diff --git a/.gitignore b/.gitignore index 19d5607..8978e19 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ dist/ .github/release-notes.md # Binaries for programs and plugins -appcat +appcat-apiserver # But don't ignore the appcat APIS! !apis/appcat diff --git a/Makefile b/Makefile index f7fdb58..b5413e9 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Image URL to use all building/pushing image targets IMG_TAG ?= latest -GHCR_IMG ?= ghcr.io/vshn/appcat:$(IMG_TAG) +GHCR_IMG ?= ghcr.io/vshn/appcat-apiserver:$(IMG_TAG) DOCKER_CMD ?= docker # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) @@ -23,11 +23,11 @@ DOCKER_IMAGE_GOOS = linux DOCKER_IMAGE_GOARCH = amd64 PROJECT_ROOT_DIR = . -PROJECT_NAME ?= appcat +PROJECT_NAME ?= appcat-apiserver PROJECT_OWNER ?= vshn PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) -BIN_FILENAME ?= $(PROJECT_DIR)/appcat +BIN_FILENAME ?= $(PROJECT_DIR)/appcat-apiserver ## Stackgres CRDs STACKGRES_VERSION ?= 1.4.3 @@ -64,43 +64,12 @@ help: ## Display this help. .PHONY: generate generate: export PATH := $(go_bin):$(PATH) -generate: $(protoc_bin) generate-stackgres-crds ## Generate code with controller-gen and protobuf. +generate: $(protoc_bin) ## Generate code with controller-gen and protobuf. go version rm -rf apis/generated go run sigs.k8s.io/controller-tools/cmd/controller-gen paths=./apis/... object crd:crdVersions=v1,allowDangerousTypes=true output:artifacts:config=./apis/generated - go generate ./... - # Because yaml is such a fun and easy specification, we need to hack some things here. - # Depending on the yaml parser implementation the equal sign (=) has special meaning, or not... - # So we make it explicitly a string. - $(sed) -i ':a;N;$$!ba;s/- =\n/- "="\n/g' apis/generated/vshn.appcat.vshn.io_vshnpostgresqls.yaml - rm -rf crds && cp -r apis/generated crds go run sigs.k8s.io/controller-tools/cmd/controller-gen rbac:roleName=appcat paths="{./apis/...,./pkg/apiserver/...}" output:artifacts:config=config/apiserver - go run sigs.k8s.io/controller-tools/cmd/controller-gen rbac:roleName=appcat-sli-exporter paths="{./pkg/sliexporter/...}" output:artifacts:config=config/sliexporter/rbac - go run k8s.io/code-generator/cmd/go-to-protobuf \ - --packages=github.com/vshn/appcat-apiserver/apis/appcat/v1 \ - --output-base=./.work/tmp \ - --go-header-file=./pkg/apiserver/hack/boilerplate.txt \ - --apimachinery-packages='-k8s.io/apimachinery/pkg/util/intstr,-k8s.io/apimachinery/pkg/api/resource,-k8s.io/apimachinery/pkg/runtime/schema,-k8s.io/apimachinery/pkg/runtime,-k8s.io/apimachinery/pkg/apis/meta/v1,-k8s.io/apimachinery/pkg/apis/meta/v1beta1,-k8s.io/api/core/v1,-k8s.io/api/rbac/v1' \ - --proto-import=./.work/kubernetes/vendor/ && \ - mv ./.work/tmp/github.com/vshn/appcat-apiserver/apis/appcat/v1/generated.pb.go ./apis/appcat/v1/ && \ - rm -rf ./.work/tmp - -.PHONY: generate-stackgres-crds -generate-stackgres-crds: - curl ${STACKGRES_CRD_URL}/SGDbOps.yaml?inline=false -o apis/stackgres/v1/sgdbops_crd.yaml - yq -i e apis/stackgres/v1/sgdbops.yaml --expression ".components.schemas.SGDbOpsSpec=load(\"apis/stackgres/v1/sgdbops_crd.yaml\").spec.versions[0].schema.openAPIV3Schema.properties.spec" - yq -i e apis/stackgres/v1/sgdbops.yaml --expression ".components.schemas.SGDbOpsStatus=load(\"apis/stackgres/v1/sgdbops_crd.yaml\").spec.versions[0].schema.openAPIV3Schema.properties.status" - go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen --package=v1 -generate=types -o apis/stackgres/v1/sgdbops.gen.go apis/stackgres/v1/sgdbops.yaml - perl -i -0pe 's/\*struct\s\{\n\s\sAdditionalProperties\smap\[string\]string\s`json:"-"`\n\s}/map\[string\]string/gms' apis/stackgres/v1/sgdbops.gen.go - - curl ${STACKGRES_CRD_URL}/SGCluster.yaml?inline=false -o apis/stackgres/v1/sgcluster_crd.yaml - yq -i e apis/stackgres/v1/sgcluster.yaml --expression ".components.schemas.SGClusterSpec=load(\"apis/stackgres/v1/sgcluster_crd.yaml\").spec.versions[0].schema.openAPIV3Schema.properties.spec" - yq -i e apis/stackgres/v1/sgcluster.yaml --expression ".components.schemas.SGClusterStatus=load(\"apis/stackgres/v1/sgcluster_crd.yaml\").spec.versions[0].schema.openAPIV3Schema.properties.status" - go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen --package=v1 -generate=types -o apis/stackgres/v1/sgcluster.gen.go apis/stackgres/v1/sgcluster.yaml - perl -i -0pe 's/\*struct\s\{\n\s\sAdditionalProperties\smap\[string\]string\s`json:"-"`\n\s}/map\[string\]string/gms' apis/stackgres/v1/sgcluster.gen.go - - go run sigs.k8s.io/controller-tools/cmd/controller-gen object paths=./apis/stackgres/v1/... - rm apis/stackgres/v1/*_crd.yaml + go generate ./... .PHONY: fmt fmt: ## Run go fmt against code. @@ -148,49 +117,6 @@ kind-load-branch-tag: ## load docker image with current branch tag into kind docker-push: docker-build ## Push docker image with the manager. docker push ${GHCR_IMG} -# Generate webhook certificates. -# This is only relevant when debugging. -# Component-appcat installs a proper certificate for this. -.PHONY: webhook-cert -webhook_key = .work/webhook/tls.key -webhook_cert = .work/webhook/tls.crt -webhook-cert: $(webhook_cert) ## Generate webhook certificates for out-of-cluster debugging in an IDE - -$(webhook_key): - mkdir -p .work/webhook - ipsan="" && \ - if [[ $(webhook_service_name) =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$$ ]]; then \ - ipsan=", IP:$(webhook_service_name)"; \ - fi; \ - openssl req -x509 -newkey rsa:4096 -nodes -keyout $@ --noout -days 3650 -subj "/CN=$(webhook_service_name)" -addext "subjectAltName = DNS:$(webhook_service_name)$$ipsan" - -$(webhook_cert): $(webhook_key) - ipsan="" && \ - if [[ $(webhook_service_name) =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$$ ]]; then \ - ipsan=", IP:$(webhook_service_name)"; \ - fi; \ - openssl req -x509 -key $(webhook_key) -nodes -out $@ -days 3650 -subj "/CN=$(webhook_service_name)" -addext "subjectAltName = DNS:$(webhook_service_name)$$ipsan" - - -.PHONY: webhook-debug -webhook_service_name = host.docker.internal - -webhook-debug: $(webhook_cert) ## Creates certificates, patches the webhook registrations and applies everything to the given kube cluster -webhook-debug: - kubectl -n syn-appcat scale deployment appcat-controller --replicas 0 - cabundle=$$(cat .work/webhook/tls.crt | base64) && \ - HOSTIP=$(webhook_service_name) && \ - kubectl annotate validatingwebhookconfigurations.admissionregistration.k8s.io appcat-pg-validation cert-manager.io/inject-ca-from- && \ - kubectl get validatingwebhookconfigurations.admissionregistration.k8s.io appcat-pg-validation -oyaml | \ - yq e "del(.webhooks[0].clientConfig.service) | .webhooks[0].clientConfig.caBundle |= \"$$cabundle\" | .webhooks[0].clientConfig.url |= \"https://$$HOSTIP:9443/validate-vshn-appcat-vshn-io-v1-vshnpostgresql\"" - | \ - kubectl apply -f - && \ - kubectl annotate validatingwebhookconfigurations.admissionregistration.k8s.io appcat-redis-validation cert-manager.io/inject-ca-from- && \ - kubectl annotate validatingwebhookconfigurations.admissionregistration.k8s.io appcat-pg-validation kubectl.kubernetes.io/last-applied-configuration- && \ - kubectl get validatingwebhookconfigurations.admissionregistration.k8s.io appcat-redis-validation -oyaml | \ - yq e "del(.webhooks[0].clientConfig.service) | .webhooks[0].clientConfig.caBundle |= \"$$cabundle\" | .webhooks[0].clientConfig.url |= \"https://$$HOSTIP:9443/validate-vshn-appcat-vshn-io-v1-vshnredis\"" - | \ - kubectl apply -f - && \ - kubectl annotate validatingwebhookconfigurations.admissionregistration.k8s.io appcat-redis-validation kubectl.kubernetes.io/last-applied-configuration- - .PHONY: clean clean: rm -rf bin/ appcat .work/ docs/node_modules $docs_out_dir .public .cache apiserver.local.config apis/generated default.sock diff --git a/pkg/apiserver/appcat/appcat.go b/pkg/apiserver/appcat/appcat.go new file mode 100644 index 0000000..3eab31f --- /dev/null +++ b/pkg/apiserver/appcat/appcat.go @@ -0,0 +1,57 @@ +package appcat + +import ( + crossplane "github.com/crossplane/crossplane/apis/apiextensions/v1" + v1 "github.com/vshn/appcat-apiserver/apis/appcat/v1" + "k8s.io/apimachinery/pkg/runtime" + genericregistry "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + restbuilder "sigs.k8s.io/apiserver-runtime/pkg/builder/rest" + "sigs.k8s.io/apiserver-runtime/pkg/util/loopback" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch,resourceNames=extension-apiserver-authentication +// +kubebuilder:rbac:groups="admissionregistration.k8s.io",resources=mutatingwebhookconfigurations;validatingwebhookconfigurations,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create;delete;update +// +kubebuilder:rbac:groups="authorization.k8s.io",resources=subjectaccessreviews,verbs=get;list;watch;create;delete;update + +// New returns a new storage provider for AppCat +func New() restbuilder.ResourceHandlerProvider { + return func(s *runtime.Scheme, gasdf genericregistry.RESTOptionsGetter) (rest.Storage, error) { + c, err := client.NewWithWatch(loopback.GetLoopbackMasterClientConfig(), client.Options{}) + if err != nil { + return nil, err + } + err = v1.AddToScheme(c.Scheme()) + if err != nil { + return nil, err + } + err = crossplane.AddToScheme(c.Scheme()) + if err != nil { + return nil, err + } + return &appcatStorage{ + compositions: &kubeCompositionProvider{ + Client: c, + }, + }, nil + } +} + +type appcatStorage struct { + compositions compositionProvider +} + +func (s *appcatStorage) New() runtime.Object { + return &v1.AppCat{} +} + +func (s *appcatStorage) Destroy() {} + +var _ rest.Scoper = &appcatStorage{} +var _ rest.Storage = &appcatStorage{} + +func (s *appcatStorage) NamespaceScoped() bool { + return false +} diff --git a/pkg/apiserver/appcat/appcat_test.go b/pkg/apiserver/appcat/appcat_test.go new file mode 100644 index 0000000..ee9c595 --- /dev/null +++ b/pkg/apiserver/appcat/appcat_test.go @@ -0,0 +1,92 @@ +package appcat + +import ( + "testing" + + crossplanev1 "github.com/crossplane/crossplane/apis/apiextensions/v1" + v1 "github.com/vshn/appcat-apiserver/apis/appcat/v1" + "github.com/vshn/appcat-apiserver/test/mocks" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/golang/mock/gomock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// newMockedAppCatStorage is a mocked instance of AppCatStorage +func newMockedAppCatStorage(t *testing.T, ctrl *gomock.Controller) (rest.StandardStorage, *mocks.MockcompositionProvider) { + t.Helper() + comp := mocks.NewMockcompositionProvider(ctrl) + stor := &appcatStorage{ + compositions: comp, + } + return rest.Storage(stor).(rest.StandardStorage), comp +} + +// Test AppCat instances +var ( + appCatOne = &v1.AppCat{ + ObjectMeta: metav1.ObjectMeta{ + Name: "one", + }, + + Details: map[string]string{ + "zone": "rma1", + "displayname": "one", + "docs": "https://docs.com", + }, + + Status: v1.AppCatStatus{ + CompositionName: "one", + }, + } + compositionOne = &crossplanev1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "one", + Labels: map[string]string{ + v1.OfferedKey: v1.OfferedValue, + }, + Annotations: map[string]string{ + v1.PrefixAppCatKey + "/zone": "rma1", + v1.PrefixAppCatKey + "/displayname": "one", + v1.PrefixAppCatKey + "/docs": "https://docs.com", + }, + }, + } + appCatTwo = &v1.AppCat{ + ObjectMeta: metav1.ObjectMeta{ + Name: "two", + }, + + Details: map[string]string{ + "zone": "lpg", + "displayname": "two", + "docs": "https://docs.com", + "productDescription": "product desc", + }, + + Status: v1.AppCatStatus{ + CompositionName: "two", + }, + } + compositionTwo = &crossplanev1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "two", + Labels: map[string]string{ + v1.OfferedKey: v1.OfferedValue, + }, + Annotations: map[string]string{ + v1.PrefixAppCatKey + "/zone": "lpg", + v1.PrefixAppCatKey + "/displayname": "two", + v1.PrefixAppCatKey + "/docs": "https://docs.com", + v1.PrefixAppCatKey + "/product-description": "product desc", + }, + }, + } + compositionNonOffered = &crossplanev1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + v1.OfferedKey: "false", + }, + }, + } +) diff --git a/pkg/apiserver/appcat/composition.go b/pkg/apiserver/appcat/composition.go new file mode 100644 index 0000000..07491c1 --- /dev/null +++ b/pkg/apiserver/appcat/composition.go @@ -0,0 +1,51 @@ +package appcat + +import ( + "context" + v1 "github.com/crossplane/crossplane/apis/apiextensions/v1" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// compositionProvider is an abstraction to interact with the K8s API +type compositionProvider interface { + GetComposition(ctx context.Context, name string, options *metav1.GetOptions) (*v1.Composition, error) + ListCompositions(ctx context.Context, options *metainternalversion.ListOptions) (*v1.CompositionList, error) + WatchCompositions(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error) +} + +type kubeCompositionProvider struct { + Client client.WithWatch +} + +func (k *kubeCompositionProvider) GetComposition(ctx context.Context, name string, options *metav1.GetOptions) (*v1.Composition, error) { + c := v1.Composition{} + err := k.Client.Get(ctx, client.ObjectKey{Namespace: "", Name: name}, &c) + return &c, err +} + +func (k *kubeCompositionProvider) ListCompositions(ctx context.Context, options *metainternalversion.ListOptions) (*v1.CompositionList, error) { + cl := v1.CompositionList{} + err := k.Client.List(ctx, &cl, &client.ListOptions{ + LabelSelector: options.LabelSelector, + FieldSelector: options.FieldSelector, + Limit: options.Limit, + Continue: options.Continue, + }) + if err != nil { + return nil, err + } + return &cl, nil +} + +func (k *kubeCompositionProvider) WatchCompositions(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error) { + cl := v1.CompositionList{} + return k.Client.Watch(ctx, &cl, &client.ListOptions{ + LabelSelector: options.LabelSelector, + FieldSelector: options.FieldSelector, + Limit: options.Limit, + Continue: options.Continue, + }) +} diff --git a/pkg/apiserver/appcat/create.go b/pkg/apiserver/appcat/create.go new file mode 100644 index 0000000..b01d562 --- /dev/null +++ b/pkg/apiserver/appcat/create.go @@ -0,0 +1,15 @@ +package appcat + +import ( + "context" + "fmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" +) + +var _ rest.Creater = &appcatStorage{} + +func (s *appcatStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + return nil, fmt.Errorf("method not implemented") +} diff --git a/pkg/apiserver/appcat/delete.go b/pkg/apiserver/appcat/delete.go new file mode 100644 index 0000000..266448f --- /dev/null +++ b/pkg/apiserver/appcat/delete.go @@ -0,0 +1,29 @@ +package appcat + +import ( + "context" + + v1 "github.com/vshn/appcat-apiserver/apis/appcat/v1" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" +) + +var _ rest.GracefulDeleter = &appcatStorage{} +var _ rest.CollectionDeleter = &appcatStorage{} + +func (s *appcatStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + return &v1.AppCat{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + }, false, nil +} + +func (s *appcatStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *metainternalversion.ListOptions) (runtime.Object, error) { + return &v1.AppCatList{ + Items: []v1.AppCat{}, + }, nil +} diff --git a/pkg/apiserver/appcat/get.go b/pkg/apiserver/appcat/get.go new file mode 100644 index 0000000..02df13b --- /dev/null +++ b/pkg/apiserver/appcat/get.go @@ -0,0 +1,30 @@ +package appcat + +import ( + "context" + + v1 "github.com/vshn/appcat-apiserver/apis/appcat/v1" + "github.com/vshn/appcat-apiserver/pkg/apiserver" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" +) + +var _ rest.Getter = &appcatStorage{} + +// Get returns an AppCat service based on its composition +func (s *appcatStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + composition, err := s.compositions.GetComposition(ctx, name, options) + if err != nil { + return nil, apiserver.ResolveError(v1.GetGroupResource(v1.Resource), err) + } + + appcat := v1.NewAppCatFromComposition(composition) + if appcat == nil { + // This composition is not an AppCat service + return nil, apierrors.NewNotFound(appcat.GetGroupVersionResource().GroupResource(), name) + } + + return appcat, nil +} diff --git a/pkg/apiserver/appcat/get_test.go b/pkg/apiserver/appcat/get_test.go new file mode 100644 index 0000000..ca5e35a --- /dev/null +++ b/pkg/apiserver/appcat/get_test.go @@ -0,0 +1,86 @@ +package appcat + +import ( + "testing" + + crossplanev1 "github.com/crossplane/crossplane/apis/apiextensions/v1" + v1 "github.com/vshn/appcat-apiserver/apis/appcat/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "k8s.io/apiserver/pkg/endpoints/request" +) + +func TestAppCatStorage_Get(t *testing.T) { + tests := map[string]struct { + name string + composition *crossplanev1.Composition + compErr error + appcat *v1.AppCat + err error + }{ + "GivenAComposition_ThenAppCat": { + name: "one", + composition: compositionOne, + appcat: appCatOne, + }, + "GivenErrNotFound_ThenErrNotFound": { + name: "not-found", + compErr: apierrors.NewNotFound(schema.GroupResource{ + Resource: "compositions", + }, "not-found"), + err: apierrors.NewNotFound(schema.GroupResource{ + Group: v1.GroupVersion.Group, + Resource: "appcats", + }, "not-found"), + }, + "GivenNonAppCatComp_ThenErrNotFound": { + name: "appcat-not-found", + composition: &crossplanev1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + v1.OfferedKey: "false", + }, + }, + }, + err: apierrors.NewNotFound(schema.GroupResource{ + Group: v1.GroupVersion.Group, + Resource: "appcats", + }, "appcat-not-found"), + }, + } + + for n, tc := range tests { + + t.Run(n, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + stor, compProvider := newMockedAppCatStorage(t, ctrl) + + compProvider.EXPECT(). + GetComposition(gomock.Any(), tc.name, gomock.Any()). + Return(tc.composition, tc.compErr). + Times(1) + + appcat, err := stor.Get(request.WithRequestInfo(request.NewContext(), + &request.RequestInfo{ + Verb: "get", + APIGroup: v1.GroupVersion.Group, + Resource: "appcats", + Name: tc.name, + }), + tc.name, nil) + if tc.err != nil { + assert.EqualError(t, err, tc.err.Error()) + return + } + require.NoError(t, err) + assert.Equal(t, tc.appcat, appcat) + }) + } +} diff --git a/pkg/apiserver/appcat/list.go b/pkg/apiserver/appcat/list.go new file mode 100644 index 0000000..23a9954 --- /dev/null +++ b/pkg/apiserver/appcat/list.go @@ -0,0 +1,89 @@ +package appcat + +import ( + "context" + + crossplanev1 "github.com/crossplane/crossplane/apis/apiextensions/v1" + v1 "github.com/vshn/appcat-apiserver/apis/appcat/v1" + "github.com/vshn/appcat-apiserver/pkg/apiserver" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/registry/rest" +) + +var _ rest.Lister = &appcatStorage{} + +func (s *appcatStorage) NewList() runtime.Object { + return &v1.AppCatList{} +} + +// List returns a list of AppCat services based on their compositions +func (s *appcatStorage) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) { + cl, err := s.compositions.ListCompositions(ctx, addOfferedLabelSelector(options)) + if err != nil { + return nil, apiserver.ResolveError(v1.GetGroupResource(v1.Resource), err) + } + + res := v1.AppCatList{ + ListMeta: cl.ListMeta, + } + + for _, v := range cl.Items { + appCat := v1.NewAppCatFromComposition(&v) + if appCat != nil { + res.Items = append(res.Items, *appCat) + } + } + + return &res, nil +} + +var _ rest.Watcher = &appcatStorage{} + +// Watch returns a watched list of AppCat services based on their compositions +func (s *appcatStorage) Watch(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error) { + compWatcher, err := s.compositions.WatchCompositions(ctx, addOfferedLabelSelector(options)) + if err != nil { + return nil, apiserver.ResolveError(v1.GetGroupResource(v1.Resource), err) + } + + return watch.Filter(compWatcher, func(in watch.Event) (out watch.Event, keep bool) { + if in.Object == nil { + // This should never happen, let downstream deal with it + return in, true + } + comp, ok := in.Object.(*crossplanev1.Composition) + if !ok { + // We received a non Composition object + // This is most likely an error so we pass it on + return in, true + } + + in.Object = v1.NewAppCatFromComposition(comp) + if in.Object.(*v1.AppCat) == nil { + return in, false + } + + return in, true + }), nil +} + +func addOfferedLabelSelector(options *metainternalversion.ListOptions) *metainternalversion.ListOptions { + offeredComposition, err := labels.NewRequirement(v1.OfferedKey, selection.Equals, []string{v1.OfferedValue}) + if err != nil { + // The input is static. This call will only fail during development. + panic(err) + } + if options == nil { + options = &metainternalversion.ListOptions{} + } + if options.LabelSelector == nil { + options.LabelSelector = labels.NewSelector() + } + options.LabelSelector = options.LabelSelector.Add(*offeredComposition) + + return options +} diff --git a/pkg/apiserver/appcat/list_test.go b/pkg/apiserver/appcat/list_test.go new file mode 100644 index 0000000..2413dc3 --- /dev/null +++ b/pkg/apiserver/appcat/list_test.go @@ -0,0 +1,205 @@ +package appcat + +import ( + "testing" + + crossplanev1 "github.com/crossplane/crossplane/apis/apiextensions/v1" + v1 "github.com/vshn/appcat-apiserver/apis/appcat/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "k8s.io/apiserver/pkg/endpoints/request" +) + +func TestAppcatStorage_List(t *testing.T) { + tests := map[string]struct { + compositions *crossplanev1.CompositionList + compositionErr error + + appcats *v1.AppCatList + err error + }{ + "GivenListOfCompositions_ThenReturnAppCats": { + compositions: &crossplanev1.CompositionList{ + Items: []crossplanev1.Composition{ + *compositionOne, + *compositionTwo, + }, + }, + appcats: &v1.AppCatList{ + Items: []v1.AppCat{ + *appCatOne, + *appCatTwo, + }, + }, + }, + "GivenErrNotFound_ThenErrNotFound": { + compositionErr: apierrors.NewNotFound(schema.GroupResource{ + Resource: "compositions", + }, "not-found"), + err: apierrors.NewNotFound(schema.GroupResource{ + Group: v1.GroupVersion.Group, + Resource: v1.Resource, + }, "not-found"), + }, + "GivenList_ThenFilter": { + compositions: &crossplanev1.CompositionList{ + Items: []crossplanev1.Composition{ + *compositionOne, + *compositionNonOffered, + }, + }, + appcats: &v1.AppCatList{ + Items: []v1.AppCat{ + *appCatOne, + }, + }, + }, + } + for n, tc := range tests { + t.Run(n, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + stor, compProvider := newMockedAppCatStorage(t, ctrl) + + compProvider.EXPECT(). + ListCompositions(gomock.Any(), gomock.Any()). + Return(tc.compositions, tc.compositionErr). + Times(1) + + appcats, err := stor.List(request.WithRequestInfo(request.NewContext(), + &request.RequestInfo{ + Verb: "list", + APIGroup: v1.GroupVersion.Group, + Resource: v1.Resource, + }), nil) + if tc.err != nil { + assert.EqualError(t, err, tc.err.Error()) + return + } + require.NoError(t, err) + assert.Equal(t, tc.appcats, appcats) + }) + } +} + +type testWatcher struct { + events chan watch.Event +} + +func (w testWatcher) Stop() {} + +func (w testWatcher) ResultChan() <-chan watch.Event { + return w.events +} + +func TestAppCatsStorage_Watch(t *testing.T) { + tests := map[string]struct { + compositionEvents []watch.Event + compositionErr error + + appcatEvents []watch.Event + err error + }{ + "GivenCompositionEvents_ThenAppCatEvents": { + compositionEvents: []watch.Event{ + { + Type: watch.Added, + Object: compositionOne, + }, + { + Type: watch.Modified, + Object: compositionTwo, + }, + }, + appcatEvents: []watch.Event{ + { + Type: watch.Added, + Object: appCatOne, + }, + { + Type: watch.Modified, + Object: appCatTwo, + }, + }, + }, + "GivenErrNotFound_ThenErrNotFound": { + compositionErr: apierrors.NewNotFound(schema.GroupResource{ + Resource: "compositions", + }, "not-found"), + err: apierrors.NewNotFound(schema.GroupResource{ + Group: v1.GroupVersion.Group, + Resource: v1.Resource, + }, "not-found"), + }, + "GivenVariousCompositionEvents_ThenFilter": { + compositionEvents: []watch.Event{ + { + Type: watch.Added, + Object: compositionOne, + }, + { + Type: watch.Modified, + Object: compositionNonOffered, + }, + { + Type: watch.Modified, + Object: compositionTwo, + }, + }, + appcatEvents: []watch.Event{ + { + Type: watch.Added, + Object: appCatOne, + }, + { + Type: watch.Modified, + Object: appCatTwo, + }, + }, + }, + } + + for n, tc := range tests { + t.Run(n, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + stor, compProvider := newMockedAppCatStorage(t, ctrl) + + compWatcher := testWatcher{ + events: make(chan watch.Event, len(tc.compositionEvents)), + } + for _, e := range tc.compositionEvents { + compWatcher.events <- e + } + close(compWatcher.events) + + compProvider.EXPECT(). + WatchCompositions(gomock.Any(), gomock.Any()). + Return(compWatcher, tc.compositionErr). + AnyTimes() + + appcatWatch, err := stor.Watch(request.WithRequestInfo(request.NewContext(), + &request.RequestInfo{ + Verb: "watch", + APIGroup: v1.GroupVersion.Group, + Resource: v1.Resource, + }), nil) + if tc.err != nil { + assert.EqualError(t, err, tc.err.Error()) + return + } + require.NoError(t, err) + appcatEvents := []watch.Event{} + for e := range appcatWatch.ResultChan() { + appcatEvents = append(appcatEvents, e) + } + assert.Equal(t, tc.appcatEvents, appcatEvents) + }) + } +} diff --git a/pkg/apiserver/appcat/table.go b/pkg/apiserver/appcat/table.go new file mode 100644 index 0000000..d35f0dc --- /dev/null +++ b/pkg/apiserver/appcat/table.go @@ -0,0 +1,70 @@ +package appcat + +import ( + "context" + "fmt" + "time" + + v1 "github.com/vshn/appcat-apiserver/apis/appcat/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/duration" + "k8s.io/apiserver/pkg/registry/rest" +) + +var _ rest.TableConvertor = &appcatStorage{} + +var ( + appCatDisplayname = "displayname" + appCatZone = "zone" + appCatDocs = "endUserDocsUrl" +) + +// ConvertToTable translates the given object to a table for kubectl printing +func (s *appcatStorage) ConvertToTable(_ context.Context, obj runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + var table metav1.Table + + appcats := []v1.AppCat{} + if meta.IsListType(obj) { + appcatList, ok := obj.(*v1.AppCatList) + if !ok { + return nil, fmt.Errorf("not an appcat: %#v", obj) + } + appcats = appcatList.Items + } else { + appcat, ok := obj.(*v1.AppCat) + if !ok { + return nil, fmt.Errorf("not an appcat: %#v", obj) + } + appcats = append(appcats, *appcat) + } + + for _, appcat := range appcats { + table.Rows = append(table.Rows, appcatToTableRow(&appcat)) + } + + if opt, ok := tableOptions.(*metav1.TableOptions); !ok || !opt.NoHeaders { + desc := metav1.ObjectMeta{}.SwaggerDoc() + table.ColumnDefinitions = []metav1.TableColumnDefinition{ + {Name: "AppCat Name", Type: "string", Format: "name", Description: desc["name"]}, + {Name: "AppCat Display Name", Type: "string", Format: "name", Description: "The display name of the service"}, + {Name: "Service Zone", Type: "string", Description: "Available zones of the service"}, + {Name: "User Docs", Type: "string", Description: "The user documentation of the service"}, + {Name: "Age", Type: "date", Description: desc["creationTimestamp"]}, + } + } + return &table, nil +} + +func appcatToTableRow(appcat *v1.AppCat) metav1.TableRow { + return metav1.TableRow{ + Cells: []interface{}{ + appcat.GetName(), + appcat.Details[appCatDisplayname], + appcat.Details[appCatZone], + appcat.Details[appCatDocs], + duration.HumanDuration(time.Since(appcat.GetCreationTimestamp().Time))}, + Object: runtime.RawExtension{Object: appcat}, + } +} diff --git a/pkg/apiserver/appcat/table_test.go b/pkg/apiserver/appcat/table_test.go new file mode 100644 index 0000000..d90d6ff --- /dev/null +++ b/pkg/apiserver/appcat/table_test.go @@ -0,0 +1,74 @@ +package appcat + +import ( + "context" + "testing" + + v1 "github.com/vshn/appcat-apiserver/apis/appcat/v1" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestAppCatStorage_ConvertToTable(t *testing.T) { + tests := map[string]struct { + obj runtime.Object + tableOptions runtime.Object + fail bool + nrRows int + }{ + "GivenEmptyAppCat_ThenSingleRow": { + obj: &v1.AppCat{}, + nrRows: 1, + }, + "GivenAppCat_ThenSingleRow": { + obj: &v1.AppCat{ + ObjectMeta: metav1.ObjectMeta{Name: "pippo"}, + + Details: map[string]string{ + "zone": "rma1", + "displayname": "ObjectStorage", + }, + }, + nrRows: 1, + }, + "GivenAppCatList_ThenMultipleRow": { + obj: &v1.AppCatList{ + Items: []v1.AppCat{ + {}, + {}, + {}, + }, + }, + nrRows: 3, + }, + "GivenNil_ThenFail": { + obj: nil, + fail: true, + }, + "GivenNonAppCat_ThenFail": { + obj: &corev1.Pod{}, + fail: true, + }, + "GivenNonAppCatList_ThenFail": { + obj: &corev1.PodList{}, + fail: true, + }, + } + appcatStore := &appcatStorage{} + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + table, err := appcatStore.ConvertToTable(context.TODO(), tc.obj, tc.tableOptions) + if tc.fail { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Len(t, table.Rows, tc.nrRows) + }) + } +} diff --git a/pkg/apiserver/appcat/update.go b/pkg/apiserver/appcat/update.go new file mode 100644 index 0000000..5a062b8 --- /dev/null +++ b/pkg/apiserver/appcat/update.go @@ -0,0 +1,21 @@ +package appcat + +import ( + "context" + + v1 "github.com/vshn/appcat-apiserver/apis/appcat/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" +) + +var _ rest.Updater = &appcatStorage{} +var _ rest.CreaterUpdater = &appcatStorage{} + +func (s *appcatStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + return &v1.AppCat{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + }, false, nil +}