diff --git a/.github/workflows/licenses.yml b/.github/workflows/licenses.yml deleted file mode 100644 index 54f033b..0000000 --- a/.github/workflows/licenses.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: "Update License Metadata" - -on: - push: - branches: [ "main" ] - schedule: - - cron: '32 16 * * 0' - -jobs: - license: - concurrency: - group: license-${{ github.ref }} - cancel-in-progress: true - permissions: - contents: write - pull-requests: write - uses: openmfp/gha/.github/workflows/job-license-metadata.yml@main - secrets: inherit \ No newline at end of file diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 47c1798..fc57557 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -13,7 +13,7 @@ jobs: concurrency: group: ${{ github.ref }} cancel-in-progress: true - uses: openmfp/gha/.github/workflows/pipeline-golang-app.yml@main + uses: openmfp/gha/.github/workflows/pipeline-golang-app.yml@kcp secrets: inherit with: imageTagName: ghcr.io/openmfp/account-operator diff --git a/.gitignore b/.gitignore index dbfbac8..f6cd6ee 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ Dockerfile.cross .secret .env .taskenv +.kcp/* # Test binary, built with `go test -c` *.test @@ -29,4 +30,5 @@ go.work *.swp *.swo *~ -.DS_Store \ No newline at end of file +.DS_Store +kcp.log \ No newline at end of file diff --git a/.testcoverage.yml b/.testcoverage.yml index 4b60e82..4df5e53 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -4,3 +4,4 @@ exclude: - api/v1alpha1 # skipping generated files and crd type definitions - main\.go$ # skip covering main.go - ^cmd # skip covering cmd directory + - ^pkg/testing/kcpenvtest # skip covering kcpenvtest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a19699..70103fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,18 @@ You are welcome to contribute with your pull requests. These steps explain the c To let tests run locally, run `go test ./...` in the root directory of the repository. + +### Test your change in a locally running OpenMFP instance + + +```bash +docker build -t account-operator:latest . && \ +kind load docker-image account-operator:latest --name=openmfp && \ +kubectl patch deployment openmfp-account-operator -n openmfp-system --type='json' -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/imagePullPolicy", "value": "IfNotPresent"}]' && \ +kubectl patch deployment openmfp-account-operator -n openmfp-system --type='json' -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value": "account-operator:latest"}]' && \ +kubectl rollout restart deployment openmfp-account-operator -n openmfp-system && \ +kubectl rollout status deployment openmfp-account-operator -n openmfp-system +``` ## Issues We use GitHub issues to track bugs. Please ensure your description is clear and includes sufficient instructions to reproduce the issue. diff --git a/PROJECT b/PROJECT deleted file mode 100644 index c5bfe37..0000000 --- a/PROJECT +++ /dev/null @@ -1,20 +0,0 @@ -# Code generated by tool. DO NOT EDIT. -# This file is used to track the info used to scaffold your project -# and allow the plugins properly work. -# More info: https://book.kubebuilder.io/reference/project-config.html -domain: openmfp.io -layout: -- go.kubebuilder.io/v3 -projectName: account-operator -repo: github.com/openmfp/account-operator -resources: -- api: - crdVersion: v1 - namespaced: true - controller: true - domain: openmfp.io - group: core - kind: Account - path: github.com/openmfp/account-operator/api/v1alpha1 - version: v1alpha1 -version: "3" diff --git a/Taskfile.yml b/Taskfile.yml index 7ada647..5496da3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -6,18 +6,29 @@ vars: CONTROLLER_TOOLS_VERSION: v0.14.0 ENVTEST_K8S_VERSION: "1.29.0" ENVTEST_VERSION: release-0.17 - CRD_DIRECTORY: config/crd/bases + CRD_DIRECTORY: config/crd + TEST_SETUP_DIRECTORY: test/setup/01-openmfp-system KCP_APIGEN_VERSION: v0.21.0 + KCP_VERSION: 0.26.1 + GOMPLATE_VERSION: v4.3.0 + GOARCH: + sh: go env GOARCH + GOOS: + sh: go env GOOS tasks: ## Setup setup:controller-gen: internal: true cmds: - test -s {{.LOCAL_BIN}}/controller-gen || GOBIN=$(pwd)/{{.LOCAL_BIN}} go install sigs.k8s.io/controller-tools/cmd/controller-gen@{{.CONTROLLER_TOOLS_VERSION}} - setup:envtest: + setup:kcp: internal: true cmds: - - test -s {{.LOCAL_BIN}}/setup-envtest|| GOBIN=$(pwd)/{{.LOCAL_BIN}} go install sigs.k8s.io/controller-runtime/tools/setup-envtest@{{.ENVTEST_VERSION}} + - test -s {{.LOCAL_BIN}}/kcp || GOBIN=$(pwd)/{{.LOCAL_BIN}} ./hack/download-tool.sh https://github.com/kcp-dev/kcp/releases/download/v{{ .KCP_VERSION }}/kcp_{{ .KCP_VERSION }}_{{ .GOOS }}_{{ .GOARCH }}.tar.gz kcp {{.KCP_VERSION}} + setup:gomplate: + internal: true + cmds: + - test -s {{.LOCAL_BIN}}/gomplate || curl -o {{.LOCAL_BIN}}/gomplate -sSL https://github.com/hairyhenderson/gomplate/releases/download/{{ .GOMPLATE_VERSION }}/gomplate_{{ .GOOS }}-{{ .GOARCH }} && chmod +x {{.LOCAL_BIN}}/gomplate && chmod 755 {{.LOCAL_BIN}}/gomplate setup:golangci-lint: internal: true cmds: @@ -36,7 +47,8 @@ tasks: cmds: - task: manifests - "{{.LOCAL_BIN}}/controller-gen object:headerFile=hack/boilerplate.go.txt paths=./..." - - "{{.LOCAL_BIN}}/apigen --input-dir ./config/crd/bases --output-dir ./config/resources" + - "{{.LOCAL_BIN}}/apigen --input-dir {{.CRD_DIRECTORY}} --output-dir ./config/resources" + - "{{.LOCAL_BIN}}/apigen --input-dir {{.CRD_DIRECTORY}} --output-dir {{ .TEST_SETUP_DIRECTORY }}" build: cmds: - go build ./... @@ -53,18 +65,14 @@ tasks: - task: fmt - "{{.LOCAL_BIN}}/golangci-lint run --timeout 15m ./..." envtest: - env: - KUBEBUILDER_ASSETS: - sh: $(pwd)/{{.LOCAL_BIN}}/setup-envtest use {{.ENVTEST_K8S_VERSION}} --bin-dir $(pwd)/{{.LOCAL_BIN}} -p path - GO111MODULE: on cmds: - go test ./... {{.ADDITIONAL_COMMAND_ARGS}} test: - deps: [setup:envtest] + deps: [setup:kcp, setup:gomplate] cmds: - task: envtest cover: - deps: [setup:envtest] + deps: [setup:kcp, setup:gomplate] cmds: - task: envtest vars: @@ -76,4 +84,8 @@ tasks: cmds: - task: lint - task: test + start-kcp: + deps: [setup:kcp] + cmds: + - "{{ .LOCAL_BIN}}/kcp start" diff --git a/api/v1alpha1/account_info_types.go b/api/v1alpha1/account_info_types.go new file mode 100644 index 0000000..7450754 --- /dev/null +++ b/api/v1alpha1/account_info_types.go @@ -0,0 +1,79 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// AccountInfoSpec defines the desired state of Account +type AccountInfoSpec struct { + FGA FGAInfo `json:"fga"` + Account AccountLocation `json:"account"` + ParentAccount *AccountLocation `json:"parentAccount,omitempty"` + Organization AccountLocation `json:"organization"` + ClusterInfo ClusterInfo `json:"clusterInfo"` +} + +type ClusterInfo struct { + CA string `json:"ca"` +} + +type AccountLocation struct { + Name string `json:"name"` + ClusterId string `json:"clusterId"` + Path string `json:"path"` + URL string `json:"url"` + Type AccountType `json:"type"` +} + +type FGAInfo struct { + Store StoreInfo `json:"store"` +} + +type StoreInfo struct { + Id string `json:"id"` +} + +// AccountInfoStatus defines the observed state of AccountInfo +type AccountInfoStatus struct { +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=accountinfos +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster +// AccountInfo is the Schema for the accountinfo API +type AccountInfo struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AccountInfoSpec `json:"spec,omitempty"` + Status AccountInfoStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +// AccountInfoList contains a list of AccountInfos +type AccountInfoList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AccountInfo `json:"items"` +} + +func init() { + SchemeBuilder.Register(&AccountInfo{}, &AccountInfoList{}) +} diff --git a/api/v1alpha1/account_types.go b/api/v1alpha1/account_types.go index 54255bc..81786bf 100644 --- a/api/v1alpha1/account_types.go +++ b/api/v1alpha1/account_types.go @@ -24,21 +24,18 @@ import ( type AccountType string const ( - AccountTypeFolder AccountType = "folder" + AccountTypeOrg AccountType = "org" AccountTypeAccount AccountType = "account" - NamespaceAccountOwnerLabel = "account.core.openmfp.io/owner" - NamespaceAccountOwnerNamespaceLabel = "account.core.openmfp.io/owner-namespace" + NamespaceAccountOwnerLabel = "account.core.openmfp.org/owner" + NamespaceAccountOwnerNamespaceLabel = "account.core.openmfp.org/owner-namespace" ) // AccountSpec defines the desired state of Account type AccountSpec struct { // Type specifies the intended type for this Account object. - // +kubebuilder:validation:Enum=folder;account + // +kubebuilder:validation:Enum=org;account Type AccountType `json:"type"` - // Namespace is the account should take ownership of - Namespace *string `json:"namespace,omitempty"` - // The display name for this account // +kubebuilder:validation:MaxLength=255 DisplayName string `json:"displayName"` @@ -69,15 +66,14 @@ type Extension struct { // AccountStatus defines the observed state of Account type AccountStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty"` - Namespace *string `json:"namespace,omitempty"` ObservedGeneration int64 `json:"observedGeneration,omitempty" protobuf:"varint,3,opt,name=observedGeneration"` NextReconcileTime metav1.Time `json:"nextReconcileTime,omitempty"` } -//+kubebuilder:object:root=true -//+kubebuilder:subresource:status +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster // +kubebuilder:printcolumn:JSONPath=".spec.displayName",name="Display Name",type=string -// +kubebuilder:printcolumn:JSONPath=".status.namespace",name="Account Namespace",type=string // +kubebuilder:printcolumn:JSONPath=".spec.type",name="Type",type=string // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go index 5c6e126..e37221b 100644 --- a/api/v1alpha1/groupversion_info.go +++ b/api/v1alpha1/groupversion_info.go @@ -16,7 +16,7 @@ limitations under the License. // Package v1alpha1 contains API Schema definitions for the core v1alpha1 API group // +kubebuilder:object:generate=true -// +groupName=core.openmfp.io +// +groupName=core.openmfp.org package v1alpha1 import ( @@ -26,7 +26,7 @@ import ( var ( // GroupVersion is group version used to register these objects - GroupVersion = schema.GroupVersion{Group: "core.openmfp.io", Version: "v1alpha1"} + GroupVersion = schema.GroupVersion{Group: "core.openmfp.org", Version: "v1alpha1"} // SchemeBuilder is used to add go types to the GroupVersionKind scheme SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 15af229..78a198d 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -23,7 +23,7 @@ package v1alpha1 import ( "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -53,6 +53,119 @@ func (in *Account) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccountDefaulter) DeepCopyInto(out *AccountDefaulter) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccountDefaulter. +func (in *AccountDefaulter) DeepCopy() *AccountDefaulter { + if in == nil { + return nil + } + out := new(AccountDefaulter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccountInfo) DeepCopyInto(out *AccountInfo) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccountInfo. +func (in *AccountInfo) DeepCopy() *AccountInfo { + if in == nil { + return nil + } + out := new(AccountInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AccountInfo) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccountInfoList) DeepCopyInto(out *AccountInfoList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]AccountInfo, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccountInfoList. +func (in *AccountInfoList) DeepCopy() *AccountInfoList { + if in == nil { + return nil + } + out := new(AccountInfoList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AccountInfoList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccountInfoSpec) DeepCopyInto(out *AccountInfoSpec) { + *out = *in + out.FGA = in.FGA + out.Account = in.Account + if in.ParentAccount != nil { + in, out := &in.ParentAccount, &out.ParentAccount + *out = new(AccountLocation) + **out = **in + } + out.Organization = in.Organization + out.ClusterInfo = in.ClusterInfo +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccountInfoSpec. +func (in *AccountInfoSpec) DeepCopy() *AccountInfoSpec { + if in == nil { + return nil + } + out := new(AccountInfoSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccountInfoStatus) DeepCopyInto(out *AccountInfoStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccountInfoStatus. +func (in *AccountInfoStatus) DeepCopy() *AccountInfoStatus { + if in == nil { + return nil + } + out := new(AccountInfoStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AccountList) DeepCopyInto(out *AccountList) { *out = *in @@ -86,13 +199,23 @@ func (in *AccountList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AccountSpec) DeepCopyInto(out *AccountSpec) { +func (in *AccountLocation) DeepCopyInto(out *AccountLocation) { *out = *in - if in.Namespace != nil { - in, out := &in.Namespace, &out.Namespace - *out = new(string) - **out = **in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccountLocation. +func (in *AccountLocation) DeepCopy() *AccountLocation { + if in == nil { + return nil } + out := new(AccountLocation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccountSpec) DeepCopyInto(out *AccountSpec) { + *out = *in if in.Description != nil { in, out := &in.Description, &out.Description *out = new(string) @@ -137,11 +260,6 @@ func (in *AccountStatus) DeepCopyInto(out *AccountStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.Namespace != nil { - in, out := &in.Namespace, &out.Namespace - *out = new(string) - **out = **in - } in.NextReconcileTime.DeepCopyInto(&out.NextReconcileTime) } @@ -155,6 +273,21 @@ func (in *AccountStatus) DeepCopy() *AccountStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterInfo) DeepCopyInto(out *ClusterInfo) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterInfo. +func (in *ClusterInfo) DeepCopy() *ClusterInfo { + if in == nil { + return nil + } + out := new(ClusterInfo) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Extension) DeepCopyInto(out *Extension) { *out = *in @@ -177,3 +310,34 @@ func (in *Extension) DeepCopy() *Extension { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FGAInfo) DeepCopyInto(out *FGAInfo) { + *out = *in + out.Store = in.Store +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FGAInfo. +func (in *FGAInfo) DeepCopy() *FGAInfo { + if in == nil { + return nil + } + out := new(FGAInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StoreInfo) DeepCopyInto(out *StoreInfo) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StoreInfo. +func (in *StoreInfo) DeepCopy() *StoreInfo { + if in == nil { + return nil + } + out := new(StoreInfo) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/operator.go b/cmd/operator.go index 96b784e..e1f5a0f 100644 --- a/cmd/operator.go +++ b/cmd/operator.go @@ -21,10 +21,13 @@ import ( "crypto/tls" "os" + openfgav1 "github.com/openfga/api/proto/openfga/v1" openmfpcontext "github.com/openmfp/golang-commons/context" "github.com/openmfp/golang-commons/logger" "github.com/spf13/cobra" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" @@ -33,7 +36,6 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" - tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" "github.com/openmfp/account-operator/api/v1alpha1" "github.com/openmfp/account-operator/internal/config" "github.com/openmfp/account-operator/internal/controller" @@ -97,10 +99,6 @@ func RunController(_ *cobra.Command, _ []string) { // coverage-ignore tlsOpts = append(tlsOpts, disableHTTP2) } - if cfg.Kcp.Enabled { - utilruntime.Must(tenancyv1alpha1.AddToScheme(scheme)) - } - webhookServer := webhook.NewServer(webhook.Options{ TLSOpts: tlsOpts, CertDir: cfg.Webhooks.CertDir, @@ -117,26 +115,38 @@ func RunController(_ *cobra.Command, _ []string) { // coverage-ignore WebhookServer: webhookServer, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, - LeaderElectionID: "8c290d9a.openmfp.io", + LeaderElectionID: "8c290d9a.openmfp.org", LeaderElectionConfig: restCfg, LeaderElectionReleaseOnCancel: true, } var mgr ctrl.Manager var err error - if cfg.Kcp.Enabled { - mgrConfig := rest.CopyConfig(restCfg) - if len(cfg.Kcp.VirtualWorkspaceUrl) > 0 { - mgrConfig.Host = cfg.Kcp.VirtualWorkspaceUrl - } - mgr, err = kcp.NewClusterAwareManager(mgrConfig, opts) - } else { - mgr, err = ctrl.NewManager(restCfg, opts) + mgrConfig := rest.CopyConfig(restCfg) + if len(cfg.Kcp.VirtualWorkspaceUrl) > 0 { + mgrConfig.Host = cfg.Kcp.VirtualWorkspaceUrl } + mgr, err = kcp.NewClusterAwareManager(mgrConfig, opts) if err != nil { log.Fatal().Err(err).Msg("unable to start manager") } - accountReconciler := controller.NewAccountReconciler(log, mgr, cfg) + var fgaClient openfgav1.OpenFGAServiceClient + if cfg.Subroutines.FGA.Enabled { + log.Debug().Str("GrpcAddr", cfg.Subroutines.FGA.GrpcAddr).Msg("Creating FGA Client") + conn, err := grpc.NewClient(cfg.Subroutines.FGA.GrpcAddr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithStatsHandler(otelgrpc.NewClientHandler()), + ) + if err != nil { + + log.Fatal().Err(err).Msg("error when creating the grpc client") + } + log.Debug().Msg("FGA client created") + + fgaClient = openfgav1.NewOpenFGAServiceClient(conn) + } + + accountReconciler := controller.NewAccountReconciler(log, mgr, cfg, fgaClient) if err := accountReconciler.SetupWithManager(mgr, cfg, log); err != nil { log.Fatal().Err(err).Str("controller", "Account").Msg("unable to create controller") } @@ -161,10 +171,10 @@ func RunController(_ *cobra.Command, _ []string) { // coverage-ignore } func initLog() *logger.Logger { // coverage-ignore - logcfg := logger.DefaultConfig() - logcfg.Level = loglevel - logcfg.NoJSON = logNoJson - log, err := logger.New(logcfg) + cfg := logger.DefaultConfig() + cfg.Level = loglevel + cfg.NoJSON = logNoJson + log, err := logger.New(cfg) if err != nil { setupLog.Error(err, "unable to create logger") os.Exit(1) diff --git a/cmd/root.go b/cmd/root.go index 1d0ab75..b39118e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,13 +1,13 @@ package cmd import ( + tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" - corev1alpha1 "github.com/openmfp/account-operator/api/v1alpha1" + "github.com/openmfp/account-operator/api/v1alpha1" ) var ( @@ -21,11 +21,9 @@ var rootCmd = &cobra.Command{ } func init() { // coverage-ignore - utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - - utilruntime.Must(corev1alpha1.AddToScheme(scheme)) + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + utilruntime.Must(tenancyv1alpha1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme - rootCmd.AddCommand(operatorCmd) } diff --git a/config/crd/core.openmfp.org_accountinfos.yaml b/config/crd/core.openmfp.org_accountinfos.yaml new file mode 100644 index 0000000..64e5a70 --- /dev/null +++ b/config/crd/core.openmfp.org_accountinfos.yaml @@ -0,0 +1,131 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: accountinfos.core.openmfp.org +spec: + group: core.openmfp.org + names: + kind: AccountInfo + listKind: AccountInfoList + plural: accountinfos + singular: accountinfo + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: AccountInfo is the Schema for the accountinfo API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: AccountInfoSpec defines the desired state of Account + properties: + account: + properties: + clusterId: + type: string + name: + type: string + path: + type: string + type: + type: string + url: + type: string + required: + - clusterId + - name + - path + - type + - url + type: object + clusterInfo: + properties: + ca: + type: string + required: + - ca + type: object + fga: + properties: + store: + properties: + id: + type: string + required: + - id + type: object + required: + - store + type: object + organization: + properties: + clusterId: + type: string + name: + type: string + path: + type: string + type: + type: string + url: + type: string + required: + - clusterId + - name + - path + - type + - url + type: object + parentAccount: + properties: + clusterId: + type: string + name: + type: string + path: + type: string + type: + type: string + url: + type: string + required: + - clusterId + - name + - path + - type + - url + type: object + required: + - account + - clusterInfo + - fga + - organization + type: object + status: + description: AccountInfoStatus defines the observed state of AccountInfo + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/core.openmfp.io_accounts.yaml b/config/crd/core.openmfp.org_accounts.yaml similarity index 95% rename from config/crd/bases/core.openmfp.io_accounts.yaml rename to config/crd/core.openmfp.org_accounts.yaml index 10c88fc..cd20b96 100644 --- a/config/crd/bases/core.openmfp.io_accounts.yaml +++ b/config/crd/core.openmfp.org_accounts.yaml @@ -4,23 +4,20 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.14.0 - name: accounts.core.openmfp.io + name: accounts.core.openmfp.org spec: - group: core.openmfp.io + group: core.openmfp.org names: kind: Account listKind: AccountList plural: accounts singular: account - scope: Namespaced + scope: Cluster versions: - additionalPrinterColumns: - jsonPath: .spec.displayName name: Display Name type: string - - jsonPath: .status.namespace - name: Account Namespace - type: string - jsonPath: .spec.type name: Type type: string @@ -98,13 +95,10 @@ spec: - specGoTemplate type: object type: array - namespace: - description: Namespace is the account should take ownership of - type: string type: description: Type specifies the intended type for this Account object. enum: - - folder + - org - account type: string required: @@ -183,8 +177,6 @@ spec: - type type: object type: array - namespace: - type: string nextReconcileTime: format: date-time type: string diff --git a/config/resources/apiexport-core.openmfp.io.yaml b/config/resources/apiexport-core.openmfp.io.yaml deleted file mode 100644 index 4ffda7e..0000000 --- a/config/resources/apiexport-core.openmfp.io.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: apis.kcp.io/v1alpha1 -kind: APIExport -metadata: - creationTimestamp: null - name: core.openmfp.io -spec: - latestResourceSchemas: - - v241029-1a02f92.accounts.core.openmfp.io -status: {} diff --git a/config/resources/apiexport-core.openmfp.org.yaml b/config/resources/apiexport-core.openmfp.org.yaml new file mode 100644 index 0000000..cbdfd40 --- /dev/null +++ b/config/resources/apiexport-core.openmfp.org.yaml @@ -0,0 +1,21 @@ +apiVersion: apis.kcp.io/v1alpha1 +kind: APIExport +metadata: + creationTimestamp: null + name: core.openmfp.org +spec: + latestResourceSchemas: + - v250304-c26848b.accountinfos.core.openmfp.org + - v250305-70de32b.accounts.core.openmfp.org + permissionClaims: + - all: true + resource: namespaces + - all: true + group: tenancy.kcp.io + identityHash: '{{ .Values.kcp.identityHash }}' + resource: workspaces + - all: true + group: tenancy.kcp.io + identityHash: '{{ .Values.kcp.identityHash }}' + resource: workspacetypes +status: {} diff --git a/config/resources/apiresourceschema-accountinfos.core.openmfp.org.yaml b/config/resources/apiresourceschema-accountinfos.core.openmfp.org.yaml new file mode 100644 index 0000000..0be4cf7 --- /dev/null +++ b/config/resources/apiresourceschema-accountinfos.core.openmfp.org.yaml @@ -0,0 +1,128 @@ +apiVersion: apis.kcp.io/v1alpha1 +kind: APIResourceSchema +metadata: + creationTimestamp: null + name: v250304-c26848b.accountinfos.core.openmfp.org +spec: + group: core.openmfp.org + names: + kind: AccountInfo + listKind: AccountInfoList + plural: accountinfos + singular: accountinfo + scope: Cluster + versions: + - name: v1alpha1 + schema: + description: AccountInfo is the Schema for the accountinfo API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: AccountInfoSpec defines the desired state of Account + properties: + account: + properties: + clusterId: + type: string + name: + type: string + path: + type: string + type: + type: string + url: + type: string + required: + - clusterId + - name + - path + - type + - url + type: object + clusterInfo: + properties: + ca: + type: string + required: + - ca + type: object + fga: + properties: + store: + properties: + id: + type: string + required: + - id + type: object + required: + - store + type: object + organization: + properties: + clusterId: + type: string + name: + type: string + path: + type: string + type: + type: string + url: + type: string + required: + - clusterId + - name + - path + - type + - url + type: object + parentAccount: + properties: + clusterId: + type: string + name: + type: string + path: + type: string + type: + type: string + url: + type: string + required: + - clusterId + - name + - path + - type + - url + type: object + required: + - account + - clusterInfo + - fga + - organization + type: object + status: + description: AccountInfoStatus defines the observed state of AccountInfo + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/resources/apiresourceschema-accounts.core.openmfp.io.yaml b/config/resources/apiresourceschema-accounts.core.openmfp.org.yaml similarity index 95% rename from config/resources/apiresourceschema-accounts.core.openmfp.io.yaml rename to config/resources/apiresourceschema-accounts.core.openmfp.org.yaml index d16b899..a145897 100644 --- a/config/resources/apiresourceschema-accounts.core.openmfp.io.yaml +++ b/config/resources/apiresourceschema-accounts.core.openmfp.org.yaml @@ -2,23 +2,20 @@ apiVersion: apis.kcp.io/v1alpha1 kind: APIResourceSchema metadata: creationTimestamp: null - name: v241029-1a02f92.accounts.core.openmfp.io + name: v250305-70de32b.accounts.core.openmfp.org spec: - group: core.openmfp.io + group: core.openmfp.org names: kind: Account listKind: AccountList plural: accounts singular: account - scope: Namespaced + scope: Cluster versions: - additionalPrinterColumns: - jsonPath: .spec.displayName name: Display Name type: string - - jsonPath: .status.namespace - name: Account Namespace - type: string - jsonPath: .spec.type name: Type type: string @@ -94,13 +91,10 @@ spec: - specGoTemplate type: object type: array - namespace: - description: Namespace is the account should take ownership of - type: string type: description: Type specifies the intended type for this Account object. enum: - - folder + - org - account type: string required: @@ -179,8 +173,6 @@ spec: - type type: object type: array - namespace: - type: string nextReconcileTime: format: date-time type: string diff --git a/config/samples/core_v1alpha1_account.yaml b/config/samples/core_v1alpha1_account.yaml index 58ab74c..5404ef5 100644 --- a/config/samples/core_v1alpha1_account.yaml +++ b/config/samples/core_v1alpha1_account.yaml @@ -1,4 +1,4 @@ -apiVersion: core.openmfp.io/v1alpha1 +apiVersion: core.openmfp.org/v1alpha1 kind: Account metadata: name: new-account-debug1 @@ -8,7 +8,7 @@ spec: displayName: New Demo Account creator: test.user@example.com extensions: - - apiVersion: core.openmfp.io/v1alpha1 + - apiVersion: core.openmfp.org/v1alpha1 kind: AccountExtension specGoTemplate: foo: bar diff --git a/go.mod b/go.mod index 8ea0cd6..eae350d 100644 --- a/go.mod +++ b/go.mod @@ -5,17 +5,19 @@ go 1.23.2 replace sigs.k8s.io/controller-runtime => github.com/kcp-dev/controller-runtime v0.19.0-kcp.1 require ( - github.com/Masterminds/sprig/v3 v3.3.0 github.com/kcp-dev/kcp/sdk v0.26.1 github.com/kcp-dev/logicalcluster/v3 v3.0.5 github.com/openfga/api/proto v0.0.0-20241104193559-ee46d6721514 github.com/openmfp/golang-commons v0.142.4 + github.com/otiai10/copy v1.14.1 github.com/rs/zerolog v1.33.0 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 github.com/vrischmann/envconfig v1.4.1 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 + golang.org/x/sys v0.30.0 google.golang.org/grpc v1.71.0 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.32.2 k8s.io/apiextensions-apiserver v0.32.2 k8s.io/apimachinery v0.32.2 @@ -25,10 +27,7 @@ require ( ) require ( - dario.cat/mergo v1.0.1 // indirect github.com/99designs/gqlgen v0.17.66 // indirect - github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.3.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -53,7 +52,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -62,21 +60,18 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/onsi/gomega v1.35.1 // indirect + github.com/otiai10/mint v1.6.3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/shopspring/decimal v1.4.0 // indirect github.com/sosodev/duration v1.3.1 // indirect - github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/vektah/gqlparser/v2 v2.5.23 // indirect @@ -89,7 +84,7 @@ require ( golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 // indirect golang.org/x/net v0.35.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/sync v0.11.0 // indirect golang.org/x/term v0.29.0 // indirect golang.org/x/text v0.22.0 // indirect golang.org/x/time v0.7.0 // indirect @@ -99,7 +94,6 @@ require ( google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect diff --git a/go.sum b/go.sum index e0b550d..6e6bf20 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,7 @@ cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/99designs/gqlgen v0.17.66 h1:2/SRc+h3115fCOZeTtsqrB5R5gTGm+8qCAwcrZa+CXA= github.com/99designs/gqlgen v0.17.66/go.mod h1:gucrb5jK5pgCKzAGuOMMVU9C8PnReecHEHd2UxLQwCg= -github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= -github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= -github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -37,8 +29,6 @@ github.com/evanphx/json-patch v5.8.0+incompatible h1:1Av9pn2FyxPdvrWNQszj1g6D6Yt github.com/evanphx/json-patch v5.8.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -93,8 +83,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjw github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -131,10 +119,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= -github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -151,6 +135,10 @@ github.com/openfga/api/proto v0.0.0-20241104193559-ee46d6721514 h1:ebCcwMV9LTbAG github.com/openfga/api/proto v0.0.0-20241104193559-ee46d6721514/go.mod h1:gil5LBD8tSdFQbUkCQdnXsoeU9kDJdJgbGdHkgJfcd0= github.com/openmfp/golang-commons v0.142.4 h1:VEJlrFet7kSHhMKBYwPxMBhVJtFZBuGWD3l6Mb2VwbU= github.com/openmfp/golang-commons v0.142.4/go.mod h1:LZTzW3tfgWjPbz+T/6kw9eerdQgYkej+qc/pyRmGVxs= +github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= +github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= +github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= +github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -174,12 +162,8 @@ github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWR github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= -github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= -github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= diff --git a/hack/download-tool.sh b/hack/download-tool.sh new file mode 100755 index 0000000..ed419f3 --- /dev/null +++ b/hack/download-tool.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +# Copyright 2025 The KCP Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -exuo pipefail + +cd $(dirname $0)/.. + +mkdir -p bin +cd bin + +URL="$1" +BINARY="$2" +VERSION="$3" +BINARY_PATTERN="${4:-**/$BINARY}" +GO_MODULE=${GO_MODULE:-false} +UNCOMPRESSED=${UNCOMPRESSED:-false} + +# Check if and what version we installed already. +versionFile="$BINARY.version" +existingVersion="" +if [ -f "$versionFile" ]; then + existingVersion="$(cat "$versionFile")" +fi + +# If the binary exists and its version matches, we're good. +if [ -f "$BINARY" ] && [ "$VERSION" == "$existingVersion" ]; then + exit 0 +fi + +( + rm -rf tmp + mkdir -p tmp + cd tmp + + echo "Downloading $BINARY version $VERSION …" >&2 + + if $GO_MODULE; then + GOBIN=$(realpath .) go install "$URL@$VERSION" + mv * "../$BINARY" + else + curl --fail --silent -LO "$URL" + archive="$(ls)" + + if ! $UNCOMPRESSED; then + case "$archive" in + *.tar.gz | *.tgz) + tar xzf "$archive" + ;; + *.zip) + unzip "$archive" + ;; + *) + echo "Unknown file type: $archive" >&2 + exit 1 + esac + fi + pwd + mv $BINARY_PATTERN ../$BINARY + chmod +x ../$BINARY + fi +) + +rm -rf tmp +echo "$VERSION" > "$versionFile" + +echo "Installed at bin/$BINARY." >&2 diff --git a/internal/config/config.go b/internal/config/config.go index 8a11be0..f62e86d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,7 +30,10 @@ type Config struct { Enabled bool `envconfig:"default=false"` } Subroutines struct { - Namespace struct { + Workspace struct { + Enabled bool `envconfig:"default=true"` + } + AccountInfo struct { Enabled bool `envconfig:"default=true"` } FGA struct { @@ -41,17 +44,14 @@ type Config struct { ParentRelation string `envconfig:"default=parent"` CreatorRelation string `envconfig:"default=owner"` } - Extension struct { - Enabled bool `envconfig:"default=true"` - } - ExtensionReady struct { - Enabled bool `envconfig:"default=true"` - } } MaxConcurrentReconciles int `envconfig:"default=10"` Kcp struct { - Enabled bool `envconfig:"default=false"` VirtualWorkspaceUrl string `envconfig:"optional"` + ProviderWorkspace string `envconfig:"optional,default=root"` + } + FGA struct { + StoreId string `envconfig:"default=1"` } } diff --git a/internal/controller/account_controller.go b/internal/controller/account_controller.go index ff9d7e2..adc8fa1 100644 --- a/internal/controller/account_controller.go +++ b/internal/controller/account_controller.go @@ -22,16 +22,12 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/openmfp/golang-commons/controller/lifecycle" "github.com/openmfp/golang-commons/logger" - "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/kcp" "sigs.k8s.io/controller-runtime/pkg/predicate" corev1alpha1 "github.com/openmfp/account-operator/api/v1alpha1" "github.com/openmfp/account-operator/internal/config" - "github.com/openmfp/account-operator/pkg/service" "github.com/openmfp/account-operator/pkg/subroutines" ) @@ -45,37 +41,19 @@ type AccountReconciler struct { lifecycle *lifecycle.LifecycleManager } -func NewAccountReconciler(log *logger.Logger, mgr ctrl.Manager, cfg config.Config) *AccountReconciler { +func NewAccountReconciler(log *logger.Logger, mgr ctrl.Manager, cfg config.Config, fgaClient openfgav1.OpenFGAServiceClient) *AccountReconciler { var subs []lifecycle.Subroutine - if cfg.Subroutines.Namespace.Enabled { - subs = append(subs, subroutines.NewNamespaceSubroutine(mgr.GetClient())) + if cfg.Subroutines.Workspace.Enabled { + subs = append(subs, subroutines.NewWorkspaceSubroutine(mgr.GetClient())) } - if cfg.Subroutines.Extension.Enabled { - subs = append(subs, subroutines.NewExtensionSubroutine(mgr.GetClient())) - } - if cfg.Subroutines.ExtensionReady.Enabled { - subs = append(subs, subroutines.NewExtensionReadySubroutine(mgr.GetClient())) + if cfg.Subroutines.AccountInfo.Enabled { + subs = append(subs, subroutines.NewAccountInfoSubroutine(mgr.GetClient(), string(mgr.GetConfig().CAData))) } if cfg.Subroutines.FGA.Enabled { - log.Debug().Str("GrpcAddr", cfg.Subroutines.FGA.GrpcAddr).Msg("Creating FGA Client") - conn, err := grpc.NewClient(cfg.Subroutines.FGA.GrpcAddr, - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithStatsHandler(otelgrpc.NewClientHandler()), - ) - if err != nil { - - log.Fatal().Err(err).Msg("error when creating the grpc client") - } - log.Debug().Msg("FGA client created") - - srv := service.NewService(mgr.GetClient(), cfg.Subroutines.FGA.RootNamespace) - - fgaClient := openfgav1.NewOpenFGAServiceClient(conn) - - subs = append(subs, subroutines.NewFGASubroutine(mgr.GetClient(), fgaClient, srv, cfg.Subroutines.FGA.RootNamespace, cfg.Subroutines.FGA.CreatorRelation, cfg.Subroutines.FGA.ParentRelation, cfg.Subroutines.FGA.ObjectType)) + subs = append(subs, subroutines.NewFGASubroutine(mgr.GetClient(), fgaClient, cfg.Subroutines.FGA.CreatorRelation, cfg.Subroutines.FGA.ParentRelation, cfg.Subroutines.FGA.ObjectType)) } return &AccountReconciler{ - lifecycle: lifecycle.NewLifecycleManager(log, operatorName, accountReconcilerName, mgr.GetClient(), subs).WithSpreadingReconciles().WithConditionManagement(), + lifecycle: lifecycle.NewLifecycleManager(log, operatorName, accountReconcilerName, mgr.GetClient(), subs).WithConditionManagement(), } } @@ -88,8 +66,5 @@ func (r *AccountReconciler) SetupWithManager(mgr ctrl.Manager, cfg config.Config if err != nil { return err } - if cfg.Kcp.Enabled { - return builder.Complete(kcp.WithClusterInContext(r)) - } - return builder.Complete(r) + return builder.Complete(kcp.WithClusterInContext(r)) } diff --git a/internal/controller/account_controller_test.go b/internal/controller/account_controller_test.go index ada5f29..6119fef 100644 --- a/internal/controller/account_controller_test.go +++ b/internal/controller/account_controller_test.go @@ -1,34 +1,39 @@ -package controller +package controller_test import ( "context" + "fmt" "os" - "path/filepath" + "strconv" "testing" "time" + kcpcorev1alpha "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + kcptenancyv1alpha "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" openmfpcontext "github.com/openmfp/golang-commons/context" "github.com/openmfp/golang-commons/logger" - "github.com/rs/zerolog" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" v1 "k8s.io/api/core/v1" - networkv1 "k8s.io/api/networking/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" - "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/kcp" - corev1alpha1 "github.com/openmfp/account-operator/api/v1alpha1" + "github.com/openmfp/account-operator/api/v1alpha1" "github.com/openmfp/account-operator/internal/config" - "github.com/openmfp/account-operator/pkg/subroutines" + "github.com/openmfp/account-operator/internal/controller" + "github.com/openmfp/account-operator/pkg/subroutines/mocks" + "github.com/openmfp/account-operator/pkg/testing/kcpenvtest" ) const ( - defaultTestTimeout = 5 * time.Second + defaultTestTimeout = 15 * time.Second defaultTickInterval = 250 * time.Millisecond defaultNamespace = "default" ) @@ -38,9 +43,11 @@ type AccountTestSuite struct { kubernetesClient client.Client kubernetesManager ctrl.Manager - testEnv *envtest.Environment - - cancel context.CancelFunc + testEnv *kcpenvtest.Environment + log *logger.Logger + cancel context.CancelCauseFunc + rootConfig *rest.Config + scheme *runtime.Scheme } func (suite *AccountTestSuite) SetupSuite() { @@ -48,59 +55,81 @@ func (suite *AccountTestSuite) SetupSuite() { logConfig.NoJSON = true logConfig.Name = "AccountTestSuite" logConfig.Level = "debug" - // Disable color logging as vs-code does not support color logging in the test output - logConfig.Output = &zerolog.ConsoleWriter{Out: os.Stdout, NoColor: true} + log, err := logger.New(logConfig) suite.Require().NoError(err) + suite.log = log + ctrl.SetLogger(log.Logr()) cfg, err := config.NewFromEnv() suite.Require().NoError(err) - testContext, _, _ := openmfpcontext.StartContext(log, cfg, cfg.ShutdownTimeout) + testContext, cancel, _ := openmfpcontext.StartContext(log, cfg, cfg.ShutdownTimeout) + suite.cancel = cancel - testContext = logger.SetLoggerInContext(testContext, log.ComponentLogger("TestSuite")) + testEnvLogger := log.ComponentLogger("kcpenvtest") - suite.testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, - ErrorIfCRDPathMissing: true, + useExistingCluster := true + if envValue, err := strconv.ParseBool(os.Getenv("USE_EXISTING_CLUSTER")); err != nil { + useExistingCluster = envValue + } + suite.testEnv = kcpenvtest.NewEnvironment("core.openmfp.org", "openmfp-system", "../../", "bin", "test/setup", useExistingCluster, testEnvLogger) + k8sCfg, vsUrl, err := suite.testEnv.Start() + if err != nil { + stopErr := suite.testEnv.Stop(useExistingCluster) + suite.Require().NoError(stopErr) } - - k8sCfg, err := suite.testEnv.Start() suite.Require().NoError(err) + suite.Require().NotNil(k8sCfg) + suite.Require().NotEmpty(vsUrl) + suite.rootConfig = k8sCfg - utilruntime.Must(corev1alpha1.AddToScheme(scheme.Scheme)) - utilruntime.Must(v1.AddToScheme(scheme.Scheme)) + suite.scheme = runtime.NewScheme() + utilruntime.Must(v1alpha1.AddToScheme(suite.scheme)) + utilruntime.Must(v1.AddToScheme(suite.scheme)) + utilruntime.Must(kcpcorev1alpha.AddToScheme(suite.scheme)) + utilruntime.Must(kcptenancyv1alpha.AddToScheme(suite.scheme)) - // +kubebuilder:scaffold:scheme + managerCfg := rest.CopyConfig(suite.rootConfig) + managerCfg.Host = vsUrl + + testDataConfig := rest.CopyConfig(suite.rootConfig) + testDataConfig.Host = fmt.Sprintf("%s:%s", suite.rootConfig.Host, "orgs:root-org") - suite.kubernetesClient, err = client.New(k8sCfg, client.Options{ - Scheme: scheme.Scheme, + // +kubebuilder:scaffold:scheme + suite.kubernetesClient, err = client.New(testDataConfig, client.Options{ + Scheme: suite.scheme, }) suite.Require().NoError(err) - ctrl.SetLogger(log.Logr()) - suite.kubernetesManager, err = ctrl.NewManager(k8sCfg, ctrl.Options{ - Scheme: scheme.Scheme, + + suite.kubernetesManager, err = kcp.NewClusterAwareManager(managerCfg, ctrl.Options{ + Scheme: suite.scheme, + Logger: log.Logr(), BaseContext: func() context.Context { return testContext }, }) suite.Require().NoError(err) - accountReconciler := NewAccountReconciler(log, suite.kubernetesManager, cfg) + mockClient := mocks.NewOpenFGAServiceClient(suite.T()) + mockClient.On("Write", mock.Anything, mock.Anything).Return(nil, nil) + accountReconciler := controller.NewAccountReconciler(log, suite.kubernetesManager, cfg, mockClient) err = accountReconciler.SetupWithManager(suite.kubernetesManager, cfg, log) suite.Require().NoError(err) - go suite.startController() + go suite.startController(testContext) } func (suite *AccountTestSuite) TearDownSuite() { - suite.cancel() - err := suite.testEnv.Stop() + suite.cancel(fmt.Errorf("tearing down test suite")) + useExistingCluster := true + if envValue, err := strconv.ParseBool(os.Getenv("USE_EXISTING_CLUSTER")); err != nil { + useExistingCluster = envValue + } + err := suite.testEnv.Stop(useExistingCluster) suite.Nil(err) } -func (suite *AccountTestSuite) startController() { - var controllerContext context.Context - controllerContext, suite.cancel = context.WithCancel(context.Background()) - err := suite.kubernetesManager.Start(controllerContext) +func (suite *AccountTestSuite) startController(ctx context.Context) { + err := suite.kubernetesManager.Start(ctx) suite.Require().NoError(err) } @@ -109,13 +138,12 @@ func (suite *AccountTestSuite) TestAddingFinalizer() { testContext := context.Background() accountName := "test-account-finalizer" - account := &corev1alpha1.Account{ + account := &v1alpha1.Account{ ObjectMeta: metav1.ObjectMeta{ - Name: accountName, - Namespace: defaultNamespace, + Name: accountName, }, - Spec: corev1alpha1.AccountSpec{ - Type: corev1alpha1.AccountTypeFolder, + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeAccount, }} // When @@ -123,7 +151,7 @@ func (suite *AccountTestSuite) TestAddingFinalizer() { suite.Nil(err) // Then - createdAccount := corev1alpha1.Account{} + createdAccount := v1alpha1.Account{} suite.Assert().Eventually(func() bool { err := suite.kubernetesClient.Get(testContext, types.NamespacedName{ Name: accountName, @@ -132,155 +160,160 @@ func (suite *AccountTestSuite) TestAddingFinalizer() { return err == nil && createdAccount.Finalizers != nil }, defaultTestTimeout, defaultTickInterval) - suite.Equal(createdAccount.ObjectMeta.Finalizers, []string{subroutines.NamespaceSubroutineFinalizer, subroutines.ExtensionSubroutineFinalizer, "account.core.openmfp.io/fga"}) + suite.Equal([]string{"account.core.openmfp.org/finalizer", "account.core.openmfp.org/fga"}, createdAccount.ObjectMeta.Finalizers) } -func (suite *AccountTestSuite) TestNamespaceCreation() { +func (suite *AccountTestSuite) TestWorkspaceCreation() { // Given + var err error testContext := context.Background() - accountName := "test-account-ns-creation" - account := &corev1alpha1.Account{ + accountName := "test-account-ws-creation" + account := &v1alpha1.Account{ ObjectMeta: metav1.ObjectMeta{ - Name: accountName, - Namespace: defaultNamespace, + Name: accountName, }, - Spec: corev1alpha1.AccountSpec{ - Type: corev1alpha1.AccountTypeFolder, + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeAccount, }} // When - err := suite.kubernetesClient.Create(testContext, account) - suite.Nil(err) + err = suite.kubernetesClient.Create(testContext, account) + suite.Require().NoError(err) // Then - createdAccount := corev1alpha1.Account{} + + // Wait for workspace creation and ready + createdWorkspace := kcptenancyv1alpha.Workspace{} suite.Assert().Eventually(func() bool { err := suite.kubernetesClient.Get(testContext, types.NamespacedName{ - Name: accountName, - Namespace: defaultNamespace, - }, &createdAccount) - return err == nil && createdAccount.Status.Namespace != nil + Name: accountName, + }, &createdWorkspace) + return err == nil && createdWorkspace.Status.Phase == kcpcorev1alpha.LogicalClusterPhaseReady }, defaultTestTimeout, defaultTickInterval) - // Test if Namespace exists - suite.verifyNamespace(testContext, accountName, defaultNamespace, createdAccount.Status.Namespace) + // Wait for conditions update on account + updatedAccount := &v1alpha1.Account{} + suite.Assert().Eventually(func() bool { + err := suite.kubernetesClient.Get(testContext, types.NamespacedName{ + Name: accountName, + }, updatedAccount) + return err == nil && meta.IsStatusConditionTrue(updatedAccount.Status.Conditions, "WorkspaceSubroutine_Ready") + }, defaultTestTimeout, defaultTickInterval) + + // Verify workspace and account conditions + suite.verifyWorkspace(testContext, accountName) + suite.verifyCondition(updatedAccount.Status.Conditions, "WorkspaceSubroutine_Ready", metav1.ConditionTrue, "Complete") } -func (suite *AccountTestSuite) TestNamespaceUsingExisitingNamespace() { - // Given +func (suite *AccountTestSuite) TestAccountInfoCreationForOrganization() { testContext := context.Background() - accountName := "test-account-existing-namespace" - existingNamespaceName := "existing-namespace" - - account := &corev1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: accountName, - Namespace: defaultNamespace, - }, - Spec: corev1alpha1.AccountSpec{ - Type: corev1alpha1.AccountTypeFolder, - Namespace: &existingNamespaceName, - }, - } - - nsToCreate := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: existingNamespaceName}} - err := suite.kubernetesClient.Create(testContext, nsToCreate) - suite.Nil(err) - - // When - err = suite.kubernetesClient.Create(testContext, account) - suite.Nil(err) // Then - createdAccount := corev1alpha1.Account{} + accountInfo := v1alpha1.AccountInfo{} suite.Assert().Eventually(func() bool { err := suite.kubernetesClient.Get(testContext, types.NamespacedName{ - Name: accountName, - Namespace: defaultNamespace, - }, &createdAccount) - return err == nil && createdAccount.Status.Namespace != nil + Name: "account", + }, &accountInfo) + return err == nil }, defaultTestTimeout, defaultTickInterval) - suite.Assert().Equal(existingNamespaceName, *createdAccount.Status.Namespace) - // Test if Namespace exists - suite.verifyNamespace(testContext, accountName, defaultNamespace, createdAccount.Status.Namespace) + // Test if Workspace exists + suite.NotNil(accountInfo.Spec.ClusterInfo.CA) + suite.Equal("root-org", accountInfo.Spec.Account.Name) + suite.NotNil(accountInfo.Spec.Account.URL) + suite.Equal("root:orgs:root-org", accountInfo.Spec.Account.Path) + suite.Equal("root-org", accountInfo.Spec.Organization.Name) + suite.Equal("root-org", accountInfo.Spec.Organization.Name) + suite.NotNil(accountInfo.Spec.Organization.URL) + suite.Equal("root:orgs:root-org", accountInfo.Spec.Organization.Path) + suite.Nil(accountInfo.Spec.ParentAccount) } -func (suite *AccountTestSuite) TestExtensionProcessing() { - - accountName := "test-account-extension-creation" - - testExtensionResource := `{ - "podSelector": { - "matchLabels": { - "openmfp-owner": "{{ .Account.metadata.name }}" - } - } - }` - - account := &corev1alpha1.Account{ +func (suite *AccountTestSuite) TestAccountInfoCreationForAccount() { + var err error + testContext := context.Background() + accountName := "test-account-account-info-creation1" + account := &v1alpha1.Account{ ObjectMeta: metav1.ObjectMeta{ - Name: accountName, - Namespace: defaultNamespace, + Name: accountName, }, - Spec: corev1alpha1.AccountSpec{ - Type: corev1alpha1.AccountTypeAccount, - Extensions: []corev1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - APIVersion: "networking.k8s.io/v1", - Kind: "NetworkPolicy", - }, - SpecGoTemplate: apiextensionsv1.JSON{ - Raw: []byte(testExtensionResource), - }, - }, - }, - }, - } + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeAccount, + }} - err := suite.kubernetesClient.Create(context.Background(), account) - suite.Assert().NoError(err) + // When + err = suite.kubernetesClient.Create(testContext, account) + suite.Require().NoError(err) // Then - createdAccount := corev1alpha1.Account{} - createdNetworkPolicy := networkv1.NetworkPolicy{} + // Wait for Account to be ready + updatedAccount := &v1alpha1.Account{} suite.Assert().Eventually(func() bool { - err := suite.kubernetesClient.Get(context.Background(), types.NamespacedName{ - Name: accountName, - Namespace: defaultNamespace, - }, &createdAccount) - if err != nil || createdAccount.Status.Namespace == nil { - return false - } + err := suite.kubernetesClient.Get(testContext, types.NamespacedName{ + Name: accountName, + }, updatedAccount) + cond := meta.FindStatusCondition(updatedAccount.Status.Conditions, "Ready") + return err == nil && cond != nil && cond.Status == metav1.ConditionTrue + }, defaultTestTimeout, defaultTickInterval) + + // Retrieve account info from workspace + testDataConfig := rest.CopyConfig(suite.rootConfig) + testDataConfig.Host = fmt.Sprintf("%s:%s", suite.rootConfig.Host, "orgs:root-org:test-account-account-info-creation1") + testClient, err := client.New(testDataConfig, client.Options{ + Scheme: suite.scheme, + }) + suite.Require().NoError(err) - err = suite.kubernetesClient.Get(context.Background(), types.NamespacedName{ - Name: "networkpolicy", - Namespace: *createdAccount.Status.Namespace, - }, &createdNetworkPolicy) + accountInfo := v1alpha1.AccountInfo{} + suite.Assert().Eventually(func() bool { + err := testClient.Get(testContext, types.NamespacedName{ + Name: "account", + }, &accountInfo) + return err == nil + }, defaultTestTimeout, defaultTickInterval) - return err == nil && createdNetworkPolicy.Spec.PodSelector.MatchLabels["openmfp-owner"] == accountName - }, time.Second*30, time.Millisecond*250) + // Test if Workspace exists + suite.NotNil(accountInfo.Spec.ClusterInfo.CA) + // Account + suite.Equal("test-account-account-info-creation1", accountInfo.Spec.Account.Name) + suite.NotNil(accountInfo.Spec.Account.URL) + suite.Equal("root:orgs:root-org:test-account-account-info-creation1", accountInfo.Spec.Account.Path) + // Organization + suite.Equal("root-org", accountInfo.Spec.Organization.Name) + suite.Equal("root-org", accountInfo.Spec.Organization.Name) + suite.NotNil(accountInfo.Spec.Organization.URL) + // Parent Account + suite.Require().NotNil(accountInfo.Spec.ParentAccount) + suite.Equal("root:orgs:root-org", accountInfo.Spec.ParentAccount.Path) + suite.Equal("root-org", accountInfo.Spec.ParentAccount.Name) + suite.NotNil(accountInfo.Spec.ParentAccount.URL) } -func (suite *AccountTestSuite) verifyNamespace( - ctx context.Context, accName string, accNamespace string, nsName *string) { +func (suite *AccountTestSuite) verifyWorkspace(ctx context.Context, name string) { - suite.Require().NotNil(nsName, "failed to verify namespace name") - ns := &v1.Namespace{} - err := suite.kubernetesClient.Get(ctx, types.NamespacedName{Name: *nsName}, ns) + suite.Require().NotNil(name, "failed to verify namespace name") + ns := &kcptenancyv1alpha.Workspace{} + err := suite.kubernetesClient.Get(ctx, types.NamespacedName{Name: name}, ns) suite.Nil(err) - suite.Assert().Contains(ns.GetLabels(), corev1alpha1.NamespaceAccountOwnerLabel, - "failed to verify account label on namespace") - suite.Assert().Contains(ns.GetLabels(), corev1alpha1.NamespaceAccountOwnerNamespaceLabel, - "failed to verify account namespace label on namespace") + suite.Assert().Len(ns.GetOwnerReferences(), 1, "failed to verify owner reference on workspace") +} - suite.Assert().Equal(ns.GetLabels()[corev1alpha1.NamespaceAccountOwnerLabel], accName, - "failed to verify account label on namespace") - suite.Assert().Contains(ns.GetLabels()[corev1alpha1.NamespaceAccountOwnerNamespaceLabel], accNamespace, - "failed to verify account namespace label on namespace") +func (suite *AccountTestSuite) verifyCondition(conditions []metav1.Condition, conditionType string, status metav1.ConditionStatus, reason string) { + condition := getCondition(conditions, conditionType) + suite.Require().NotNil(condition) + suite.Equal(status, condition.Status) + suite.Equal(reason, condition.Reason) +} + +func getCondition(conditions []metav1.Condition, conditionType string) *metav1.Condition { + for _, condition := range conditions { + if condition.Type == conditionType { + return &condition + } + } + return nil } func TestAccountTestSuite(t *testing.T) { diff --git a/pkg/service/service.go b/pkg/service/service.go deleted file mode 100644 index f39294b..0000000 --- a/pkg/service/service.go +++ /dev/null @@ -1,94 +0,0 @@ -package service - -import ( - "context" - "errors" - - corev1 "k8s.io/api/core/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/openmfp/account-operator/api/v1alpha1" -) - -type Servicer interface { - GetFirstLevelAccountForAccount(ctx context.Context, accountKey client.ObjectKey) (*v1alpha1.Account, error) - GetFirstLevelAccountForNamespace(ctx context.Context, namespace string) (*v1alpha1.Account, error) - - GetAccount(ctx context.Context, accountKey client.ObjectKey) (*v1alpha1.Account, error) - GetAccountForNamespace(ctx context.Context, namespace string) (*v1alpha1.Account, error) -} - -var _ Servicer = (*Service)(nil) - -type Service struct { - client client.Client - rootNamespace string -} - -func NewService(client client.Client, rootNamespace string) *Service { - return &Service{ - client: client, - rootNamespace: rootNamespace, - } -} - -func (s *Service) getAccountOwnerAndNamespaceForNamespace(ctx context.Context, namespace string) (string, string, error) { - var ns corev1.Namespace - err := s.client.Get(ctx, client.ObjectKey{Name: namespace}, &ns) - if err != nil { - return "", "", err - } - - if ns.Labels == nil { - return "", "", errors.New("namespace does not have a label and therefore no connected account") - } - - accountNamespace, ok := ns.Labels[v1alpha1.NamespaceAccountOwnerNamespaceLabel] - if !ok || accountNamespace == "" { - return "", "", errors.New("namespace does not have an account-owner-namespace label and therefore no connected account") - } - - accountName, ok := ns.Labels[v1alpha1.NamespaceAccountOwnerLabel] - if !ok || accountName == "" { - return "", "", errors.New("namespace does not have an account-owner label and therefore no connected account") - } - - return accountName, accountNamespace, nil -} - -func (s *Service) GetFirstLevelAccountForAccount(ctx context.Context, accountKey client.ObjectKey) (*v1alpha1.Account, error) { - return s.GetFirstLevelAccountForNamespace(ctx, accountKey.Namespace) -} - -func (s *Service) GetFirstLevelAccountForNamespace(ctx context.Context, namespace string) (*v1alpha1.Account, error) { - - accountName, accountNamespace, err := s.getAccountOwnerAndNamespaceForNamespace(ctx, namespace) - if err != nil { - return nil, err - } - - if s.rootNamespace != accountNamespace { - return s.GetFirstLevelAccountForNamespace(ctx, accountNamespace) - } - - var account v1alpha1.Account - err = s.client.Get(ctx, client.ObjectKey{Name: accountName, Namespace: accountNamespace}, &account) - return &account, err -} - -func (s *Service) GetAccount(ctx context.Context, accountKey client.ObjectKey) (*v1alpha1.Account, error) { - var account v1alpha1.Account - err := s.client.Get(ctx, accountKey, &account) - return &account, err -} - -func (s *Service) GetAccountForNamespace(ctx context.Context, namespace string) (*v1alpha1.Account, error) { - accountName, accountNamespace, err := s.getAccountOwnerAndNamespaceForNamespace(ctx, namespace) - if err != nil { - return nil, err - } - - var account v1alpha1.Account - err = s.client.Get(ctx, client.ObjectKey{Name: accountName, Namespace: accountNamespace}, &account) - return &account, err -} diff --git a/pkg/service/service_test.go b/pkg/service/service_test.go deleted file mode 100644 index ee6cdd1..0000000 --- a/pkg/service/service_test.go +++ /dev/null @@ -1,358 +0,0 @@ -package service_test - -import ( - "context" - "path/filepath" - "slices" - "testing" - - "github.com/stretchr/testify/suite" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes/scheme" - pointer "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" - - "github.com/openmfp/account-operator/api/v1alpha1" - "github.com/openmfp/account-operator/pkg/service" -) - -type serviceTest struct { - suite.Suite - testEnv envtest.Environment - testClient client.Client -} - -func TestService(t *testing.T) { - suite.Run(t, new(serviceTest)) -} - -func (s *serviceTest) SetupSuite() { - - s.testEnv = envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, - ErrorIfCRDPathMissing: true, - } - cfg, err := s.testEnv.Start() - - s.Require().NoError(err) - - s.Require().NoError(v1alpha1.AddToScheme(scheme.Scheme)) - - s.testClient, err = client.New(cfg, client.Options{ - Scheme: scheme.Scheme, - }) - s.Require().NoError(err) - - err = s.testClient.Create(context.Background(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "root-namespace"}}) - s.Require().NoError(err) -} - -func (s *serviceTest) TearDownSuite() { - s.Require().NoError(s.testEnv.Stop()) -} - -func (s *serviceTest) TestGetAccount() { - tests := []struct { - name string - mockObjects []client.Object - objectKey client.ObjectKey - }{ - { - name: "", - mockObjects: []client.Object{ - &v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "root-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Type: v1alpha1.AccountTypeAccount, - }, - }, - }, - objectKey: types.NamespacedName{Namespace: "root-namespace", Name: "test-account"}, - }, - } - for _, test := range tests { - s.Run(test.name, func() { - svc := service.NewService(s.testClient, "root-namespace") - - for _, obj := range test.mockObjects { - err := s.testClient.Create(context.Background(), obj) - s.Require().NoError(err) - } - - account, err := svc.GetAccount(context.Background(), test.objectKey) - s.Require().NoError(err) - - s.Require().Equal(test.objectKey.Name, account.Name) - s.Require().Equal(test.objectKey.Namespace, account.Namespace) - - for _, obj := range test.mockObjects { - err := s.testClient.Delete(context.Background(), obj) - s.Require().NoError(err) - } - }) - } -} - -func (s *serviceTest) TestGetAccountForNamespace() { - tests := []struct { - name string - mockObjects []client.Object - namespace string - expectedAccount client.ObjectKey - expectError bool - }{ - { - name: "", - mockObjects: []client.Object{ - &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "account-for-namespace", - Labels: map[string]string{ - v1alpha1.NamespaceAccountOwnerNamespaceLabel: "root-namespace", - v1alpha1.NamespaceAccountOwnerLabel: "test-account", - }, - }, - }, - &v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "root-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Type: v1alpha1.AccountTypeAccount, - }, - }, - }, - namespace: "account-for-namespace", - expectedAccount: types.NamespacedName{ - Namespace: "root-namespace", - Name: "test-account", - }, - }, - { - name: "missing namespace", - mockObjects: []client.Object{ - &v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "root-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Type: v1alpha1.AccountTypeAccount, - }, - }, - }, - namespace: "account-for-namespace-4", - expectedAccount: types.NamespacedName{ - Namespace: "root-namespace", - Name: "test-account", - }, - expectError: true, - }, - { - name: "return an error due to one missing owner-namespace label", - mockObjects: []client.Object{ - &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "account-for-namespace-2", - Labels: map[string]string{ - v1alpha1.NamespaceAccountOwnerNamespaceLabel: "root-namespace", - }, - }, - }, - &v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "root-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Type: v1alpha1.AccountTypeAccount, - }, - }, - }, - namespace: "account-for-namespace-2", - expectedAccount: types.NamespacedName{ - Namespace: "root-namespace", - Name: "test-account", - }, - expectError: true, - }, - { - name: "return an error due to missing owner name label", - mockObjects: []client.Object{ - &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "account-for-namespace-3", - }, - }, - &v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "root-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Type: v1alpha1.AccountTypeAccount, - }, - }, - }, - namespace: "account-for-namespace-3", - expectedAccount: types.NamespacedName{ - Namespace: "root-namespace", - Name: "test-account", - }, - expectError: true, - }, - } - for _, test := range tests { - s.Run(test.name, func() { - svc := service.NewService(s.testClient, "root-namespace") - - for _, obj := range test.mockObjects { - err := s.testClient.Create(context.Background(), obj) - s.Require().NoError(err) - } - - defer func() { - for _, obj := range test.mockObjects { - err := s.testClient.Delete(context.Background(), obj) - s.Require().NoError(err) - } - }() - - account, err := svc.GetAccountForNamespace(context.Background(), test.namespace) - if test.expectError { - s.Require().Error(err) - return - } else { - s.Require().NoError(err) - } - - s.Require().Equal(test.expectedAccount.Name, account.Name) - s.Require().Equal(test.expectedAccount.Namespace, account.Namespace) - - }) - } -} - -func (s *serviceTest) TestGetFirstLevelAccount() { - tests := []struct { - name string - mockObjects []client.Object - expectedAccount client.ObjectKey - expectError bool - namespace string - }{ - { - name: "", - mockObjects: []client.Object{ - &v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "first-level-account", - Namespace: "root-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Type: v1alpha1.AccountTypeFolder, - }, - Status: v1alpha1.AccountStatus{ - Namespace: pointer.To("sub-namespace"), - }, - }, - &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "first-level-sub-namespace", - Labels: map[string]string{ - v1alpha1.NamespaceAccountOwnerNamespaceLabel: "root-namespace", - v1alpha1.NamespaceAccountOwnerLabel: "first-level-account", - }, - }, - }, - &v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "sub-account", - Namespace: "first-level-sub-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Type: v1alpha1.AccountTypeFolder, - }, - }, - }, - namespace: "first-level-sub-namespace", - expectedAccount: types.NamespacedName{ - Namespace: "root-namespace", - Name: "first-level-account", - }, - }, - { - name: "invalid namespace", - mockObjects: []client.Object{ - &v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "first-level-account", - Namespace: "root-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Type: v1alpha1.AccountTypeFolder, - }, - Status: v1alpha1.AccountStatus{ - Namespace: pointer.To("sub-namespace"), - }, - }, - &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "first-level-sub-namespace1", - }, - }, - &v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "sub-account", - Namespace: "first-level-sub-namespace1", - }, - Spec: v1alpha1.AccountSpec{ - Type: v1alpha1.AccountTypeFolder, - }, - }, - }, - namespace: "first-level-sub-namespace1", - expectError: true, - }, - } - for _, test := range tests { - s.Run(test.name, func() { - svc := service.NewService(s.testClient, "root-namespace") - - for _, obj := range test.mockObjects { - err := s.testClient.Create(context.Background(), obj) - s.Require().NoError(err) - } - - account, err := svc.GetFirstLevelAccountForNamespace(context.Background(), test.namespace) - if test.expectError { - s.Require().Error(err) - } else { - s.Require().NoError(err) - - s.Require().Equal(test.expectedAccount.Name, account.Name) - s.Require().Equal(test.expectedAccount.Namespace, account.Namespace) - - account, err = svc.GetFirstLevelAccountForAccount(context.Background(), types.NamespacedName{Namespace: test.namespace, Name: "sub-account"}) - s.Require().NoError(err) - - s.Require().Equal(test.expectedAccount.Name, account.Name) - s.Require().Equal(test.expectedAccount.Namespace, account.Namespace) - - slices.Reverse(test.mockObjects) - - for _, obj := range test.mockObjects { - err := s.testClient.Delete(context.Background(), obj) - s.Require().NoError(err) - } - } - }) - } -} diff --git a/pkg/subroutines/accountinfo.go b/pkg/subroutines/accountinfo.go new file mode 100644 index 0000000..bc23329 --- /dev/null +++ b/pkg/subroutines/accountinfo.go @@ -0,0 +1,156 @@ +package subroutines + +import ( + "context" + "fmt" + "strings" + + kcpcorev1alpha "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + kcptenancyv1alpha "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" + "github.com/kcp-dev/logicalcluster/v3" + commonconfig "github.com/openmfp/golang-commons/config" + "github.com/openmfp/golang-commons/controller/lifecycle" + "github.com/openmfp/golang-commons/errors" + "github.com/openmfp/golang-commons/logger" + kerrors "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/kontext" + + "github.com/openmfp/account-operator/api/v1alpha1" + "github.com/openmfp/account-operator/internal/config" +) + +var _ lifecycle.Subroutine = (*AccountInfoSubroutine)(nil) + +const ( + AccountInfoSubroutineName = "AccountInfoSubroutine" + DefaultAccountInfoName = "account" +) + +type AccountInfoSubroutine struct { + client client.Client + serverCA string +} + +func NewAccountInfoSubroutine(client client.Client, serverCA string) *AccountInfoSubroutine { + return &AccountInfoSubroutine{client: client, serverCA: serverCA} +} + +func (r *AccountInfoSubroutine) GetName() string { + return AccountInfoSubroutineName +} + +func (r *AccountInfoSubroutine) Finalize(_ context.Context, _ lifecycle.RuntimeObject) (ctrl.Result, errors.OperatorError) { + return ctrl.Result{}, nil +} + +func (r *AccountInfoSubroutine) Finalizers() []string { // coverage-ignore + return []string{} +} + +func (r *AccountInfoSubroutine) Process(ctx context.Context, runtimeObj lifecycle.RuntimeObject) (ctrl.Result, errors.OperatorError) { + instance := runtimeObj.(*v1alpha1.Account) + cfg := commonconfig.LoadConfigFromContext(ctx).(config.Config) + log := logger.LoadLoggerFromContext(ctx) + + // select workspace for account + accountWorkspace, err := retrieveWorkspace(ctx, instance, r.client, log) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(err, true, true) + } + + if accountWorkspace.Status.Phase != kcpcorev1alpha.LogicalClusterPhaseReady { + log.Info().Msg("workspace is not ready yet, retry") + return ctrl.Result{Requeue: true}, nil + } + + // Prepare context to work in workspace + wsCtx := kontext.WithCluster(ctx, logicalcluster.Name(accountWorkspace.Spec.Cluster)) + + // Retrieve logical cluster + currentWorkspacePath, currentWorkspaceUrl, err := r.retrieveCurrentWorkspacePath(accountWorkspace) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(err, true, true) + } + + selfAccountLocation := v1alpha1.AccountLocation{Name: instance.Name, ClusterId: accountWorkspace.Spec.Cluster, Type: instance.Spec.Type, Path: currentWorkspacePath, URL: currentWorkspaceUrl} + + // Get FGA Store ID + // For now this is hard coded, needs to be replaced with Store generation on Organization level + storeId := cfg.FGA.StoreId + + if instance.Spec.Type == v1alpha1.AccountTypeOrg { + accountInfo := &v1alpha1.AccountInfo{ObjectMeta: v1.ObjectMeta{Name: DefaultAccountInfoName}} + _, err = controllerutil.CreateOrUpdate(wsCtx, r.client, accountInfo, func() error { + accountInfo.Spec.FGA.Store.Id = storeId + accountInfo.Spec.Account = selfAccountLocation + accountInfo.Spec.ParentAccount = nil + accountInfo.Spec.Organization = selfAccountLocation + accountInfo.Spec.ClusterInfo.CA = r.serverCA + return nil + }) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(err, true, true) + } + + return ctrl.Result{}, nil + } + + parentAccountInfo, exists, err := r.retrieveAccountInfo(ctx, log) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(err, true, true) + } + + if !exists { + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("AccountInfo does not yet exist. Retry another time"), true, false) + } + + accountInfo := &v1alpha1.AccountInfo{ObjectMeta: v1.ObjectMeta{Name: DefaultAccountInfoName}} + _, err = controllerutil.CreateOrUpdate(wsCtx, r.client, accountInfo, func() error { + accountInfo.Spec.Account = selfAccountLocation + accountInfo.Spec.ParentAccount = &parentAccountInfo.Spec.Account + accountInfo.Spec.Organization = parentAccountInfo.Spec.Organization + accountInfo.Spec.FGA.Store.Id = storeId + accountInfo.Spec.ClusterInfo.CA = r.serverCA + return nil + }) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(err, true, true) + } + return ctrl.Result{}, nil +} + +func (r *AccountInfoSubroutine) retrieveAccountInfo(ctx context.Context, log *logger.Logger) (*v1alpha1.AccountInfo, bool, error) { + accountInfo := &v1alpha1.AccountInfo{} + err := r.client.Get(ctx, client.ObjectKey{Name: "account"}, accountInfo) + if err != nil { + if kerrors.IsNotFound(err) { + log.Info().Msg("accountInfo does not yet exist, retry") + return nil, false, nil + } + log.Error().Err(err).Msg("error retrieving accountInfo") + return nil, false, err + } + return accountInfo, true, nil +} + +func (r *AccountInfoSubroutine) retrieveCurrentWorkspacePath(ws *kcptenancyv1alpha.Workspace) (string, string, error) { + if ws.Spec.URL == "" { + return "", "", fmt.Errorf("workspace URL is empty") + } + + // Parse path from URL + split := strings.Split(ws.Spec.URL, "/") + if len(split) < 3 { + return "", "", fmt.Errorf("workspace URL is invalid") + } + + lastSegment := split[len(split)-1] + if lastSegment == "" || strings.TrimSpace(lastSegment) == "" { + return "", "", fmt.Errorf("workspace URL is empty") + } + return lastSegment, ws.Spec.URL, nil +} diff --git a/pkg/subroutines/accountinfo_test.go b/pkg/subroutines/accountinfo_test.go new file mode 100644 index 0000000..fa649f7 --- /dev/null +++ b/pkg/subroutines/accountinfo_test.go @@ -0,0 +1,449 @@ +package subroutines_test + +import ( + "context" + "fmt" + "testing" + + kcpcorev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + kcptenancyv1alpha "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" + openmfpcontext "github.com/openmfp/golang-commons/context" + "github.com/openmfp/golang-commons/logger" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openmfp/account-operator/api/v1alpha1" + "github.com/openmfp/account-operator/internal/config" + "github.com/openmfp/account-operator/pkg/subroutines" + "github.com/openmfp/account-operator/pkg/subroutines/mocks" +) + +type AccountInfoSubroutineTestSuite struct { + suite.Suite + + // Tested Object(s) + testObj *subroutines.AccountInfoSubroutine + + // Mocks + clientMock *mocks.Client + context context.Context + log *logger.Logger +} + +func (suite *AccountInfoSubroutineTestSuite) SetupTest() { + // Setup Mocks + suite.clientMock = new(mocks.Client) + + // Initialize Tested Object(s) + suite.testObj = subroutines.NewAccountInfoSubroutine(suite.clientMock, "some-ca") + + utilruntime.Must(v1alpha1.AddToScheme(scheme.Scheme)) + utilruntime.Must(corev1.AddToScheme(scheme.Scheme)) + + cfg, err := config.NewFromEnv() + suite.Require().NoError(err) + suite.log, err = logger.New(logger.DefaultConfig()) + suite.Require().NoError(err) + suite.context, _, _ = openmfpcontext.StartContext(suite.log, cfg, cfg.ShutdownTimeout) +} + +func TestAccountInfoSubroutineTestSuite(t *testing.T) { + suite.Run(t, new(AccountInfoSubroutineTestSuite)) +} + +func (suite *AccountInfoSubroutineTestSuite) TestProcessing_OK_ForOrganization() { + // Given + testAccount := &v1alpha1.Account{ + ObjectMeta: v1.ObjectMeta{ + Name: "root-org", + }, + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeOrg, + }, + } + expectedAccountInfo := v1alpha1.AccountInfo{ + ObjectMeta: v1.ObjectMeta{ + Name: "account", + }, + Spec: v1alpha1.AccountInfoSpec{ + ClusterInfo: v1alpha1.ClusterInfo{ + CA: "some-ca", + }, + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + ClusterId: "some-cluster-id-root-org", + Path: "root:openmfp:orgs:root-org", + URL: "https://example.com/root:openmfp:orgs:root-org", + Type: "org", + }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + ClusterId: "some-cluster-id-root-org", + Path: "root:openmfp:orgs:root-org", + URL: "https://example.com/root:openmfp:orgs:root-org", + Type: "org", + }, + FGA: v1alpha1.FGAInfo{ + Store: v1alpha1.StoreInfo{ + Id: "1", + }, + }, + }, + } + + suite.mockGetWorkspaceByName(kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org") + suite.mockGetAccountInfoCallNotFound() + suite.mockCreateAccountInfoCall(expectedAccountInfo) + + // When + res, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.Nil(err) + suite.False(res.Requeue) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *AccountInfoSubroutineTestSuite) TestProcessing_ForOrganization_Workspace_Not_Ready() { + // Given + testAccount := &v1alpha1.Account{ + ObjectMeta: v1.ObjectMeta{ + Name: "root-org", + }, + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeOrg, + }, + } + + suite.mockGetWorkspaceByName(kcpcorev1alpha1.LogicalClusterPhaseInitializing, "root:openmfp:orgs") + + // When + res, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.Nil(err) + suite.True(res.Requeue) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *AccountInfoSubroutineTestSuite) TestProcessing_ForOrganization_No_Workspace() { + // Given + testAccount := &v1alpha1.Account{ + ObjectMeta: v1.ObjectMeta{ + Name: "root-org", + }, + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeOrg, + }, + } + + suite.mockGetWorkspaceNotFound() + + // When + _, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.NotNil(err) + suite.Equal("workspace does not exist: \"\" not found", err.Err().Error()) + suite.Error(err.Err()) + suite.True(err.Retry()) + suite.True(err.Sentry()) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *AccountInfoSubroutineTestSuite) TestProcessing_OK_No_Path() { + // Given + testAccount := &v1alpha1.Account{ + ObjectMeta: v1.ObjectMeta{ + Name: "root-org", + }, + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeOrg, + }, + } + suite.mockGetWorkspaceByName(kcpcorev1alpha1.LogicalClusterPhaseReady, "") + + // When + _, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.NotNil(err) + suite.Equal("workspace URL is empty", err.Err().Error()) + suite.Error(err.Err()) + suite.True(err.Retry()) + suite.True(err.Sentry()) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *AccountInfoSubroutineTestSuite) TestProcessing_OK_Empty_Path() { + // Given + testAccount := &v1alpha1.Account{ + ObjectMeta: v1.ObjectMeta{ + Name: "root-org", + }, + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeOrg, + }, + } + suite.mockGetWorkspaceByName(kcpcorev1alpha1.LogicalClusterPhaseReady, " ") + + // When + _, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.NotNil(err) + suite.Equal("workspace URL is empty", err.Err().Error()) + suite.Error(err.Err()) + suite.True(err.Retry()) + suite.True(err.Sentry()) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *AccountInfoSubroutineTestSuite) TestProcessing_OK_Invalid_Path() { + // Given + testAccount := &v1alpha1.Account{ + ObjectMeta: v1.ObjectMeta{ + Name: "root-org", + }, + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeOrg, + }, + } + suite.mockGetWorkspaceByWrongPath(kcpcorev1alpha1.LogicalClusterPhaseReady) + + // When + _, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.NotNil(err) + suite.Equal("workspace URL is invalid", err.Err().Error()) + suite.Error(err.Err()) + suite.True(err.Retry()) + suite.True(err.Sentry()) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *AccountInfoSubroutineTestSuite) TestProcessing_OK_ForAccount() { + // Given + testAccount := &v1alpha1.Account{ + ObjectMeta: v1.ObjectMeta{ + Name: "example-account", + }, + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeAccount, + }, + } + expectedAccountInfo := v1alpha1.AccountInfo{ + ObjectMeta: v1.ObjectMeta{ + Name: "account", + }, + Spec: v1alpha1.AccountInfoSpec{ + ClusterInfo: v1alpha1.ClusterInfo{CA: "some-ca"}, + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + ClusterId: "some-cluster-id-root-org", + Path: "root:openmfp:orgs:root-org", + Type: "org", + URL: "https://example.com/root:openmfp:orgs:root-org", + }, + Account: v1alpha1.AccountLocation{ + Name: "example-account", + ClusterId: "some-cluster-id-example-account", + Path: "root:openmfp:orgs:root-org:example-account", + Type: "account", + URL: "https://example.com/root:openmfp:orgs:root-org:example-account", + }, + ParentAccount: &v1alpha1.AccountLocation{ + Name: "root-org", + ClusterId: "some-cluster-id-root-org", + Path: "root:openmfp:orgs:root-org", + URL: "https://example.com/root:openmfp:orgs:root-org", + Type: "org", + }, + FGA: v1alpha1.FGAInfo{ + Store: v1alpha1.StoreInfo{ + Id: "1", + }, + }, + }, + } + + suite.mockGetWorkspaceByName(kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org:example-account") + parentAccountInfoSpec := v1alpha1.AccountInfoSpec{ + Organization: expectedAccountInfo.Spec.Organization, + ParentAccount: nil, + Account: expectedAccountInfo.Spec.Organization, + } + suite.mockGetAccountInfo(parentAccountInfoSpec).Once() + suite.mockGetAccountInfoCallNotFound() + suite.mockCreateAccountInfoCall(expectedAccountInfo) + + // When + _, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.Nil(err) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *AccountInfoSubroutineTestSuite) TestProcessing_ForAccount_No_Parent() { + // Given + testAccount := &v1alpha1.Account{ + ObjectMeta: v1.ObjectMeta{ + Name: "example-account", + }, + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeAccount, + }, + } + + suite.mockGetWorkspaceByName(kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org") + suite.mockGetAccountInfoCallNotFound() + + // When + _, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.NotNil(err) + suite.Equal("AccountInfo does not yet exist. Retry another time", err.Err().Error()) + suite.Error(err.Err()) + suite.True(err.Retry()) + suite.False(err.Sentry()) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *AccountInfoSubroutineTestSuite) TestProcessing_ForAccount_Parent_Lookup_Failed() { + // Given + testAccount := &v1alpha1.Account{ + ObjectMeta: v1.ObjectMeta{ + Name: "example-account", + }, + Spec: v1alpha1.AccountSpec{ + Type: v1alpha1.AccountTypeAccount, + }, + } + + suite.mockGetWorkspaceByName(kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org") + suite.mockGetAccountInfoCallFailed() + + // When + _, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.NotNil(err) + suite.Equal("Internal error occurred: failed", err.Err().Error()) + suite.Error(err.Err()) + suite.True(err.Retry()) + suite.True(err.Sentry()) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *AccountInfoSubroutineTestSuite) TestGetName_OK() { + // When + result := suite.testObj.GetName() + + // Then + suite.Equal(subroutines.AccountInfoSubroutineName, result) +} + +func (suite *AccountInfoSubroutineTestSuite) TestGetFinalizerName() { + // When + finalizers := suite.testObj.Finalizers() + + // Then + suite.Len(finalizers, 0) +} + +func (suite *AccountInfoSubroutineTestSuite) TestFinalize() { + // When + res, err := suite.testObj.Finalize(context.Background(), &v1alpha1.Account{}) + + // Then + suite.Nil(err) + suite.Equal(ctrl.Result{}, res) +} + +func (suite *AccountInfoSubroutineTestSuite) mockGetAccountInfoCallNotFound() *mocks.Client_Get_Call { + return suite.clientMock.EXPECT(). + Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.AccountInfo")). + Return(kerrors.NewNotFound(schema.GroupResource{}, "")) +} + +func (suite *AccountInfoSubroutineTestSuite) mockGetAccountInfoCallFailed() *mocks.Client_Get_Call { + return suite.clientMock.EXPECT(). + Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.AccountInfo")). + Return(kerrors.NewInternalError(fmt.Errorf("failed"))) +} + +func (suite *AccountInfoSubroutineTestSuite) mockCreateAccountInfoCall(info v1alpha1.AccountInfo) *mocks.Client_Create_Call { + return suite.clientMock.EXPECT(). + Create(mock.Anything, mock.Anything). + Run(func(ctx context.Context, obj client.Object, opts ...client.CreateOption) { + actual, _ := obj.(*v1alpha1.AccountInfo) + if !suite.Equal(info, *actual) { + suite.log.Info().Msgf("Expected: %+v", actual) + } + suite.Assert().Equal(info, *actual) + }). + Return(nil) +} + +func (suite *AccountInfoSubroutineTestSuite) mockGetWorkspaceByName(ready kcpcorev1alpha1.LogicalClusterPhaseType, path string) *mocks.Client_Get_Call { + return suite.clientMock.EXPECT(). + Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Workspace")). + Run(func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) { + wsPath := "" + if path != "" { + wsPath = "https://example.com/" + path + } + actual, _ := obj.(*kcptenancyv1alpha.Workspace) + actual.Name = key.Name + actual.Spec = kcptenancyv1alpha.WorkspaceSpec{ + Cluster: "some-cluster-id-" + key.Name, + URL: wsPath, + } + actual.Status.Phase = ready + }). + Return(nil) +} + +func (suite *AccountInfoSubroutineTestSuite) mockGetWorkspaceByWrongPath(ready kcpcorev1alpha1.LogicalClusterPhaseType) *mocks.Client_Get_Call { + return suite.clientMock.EXPECT(). + Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Workspace")). + Run(func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) { + actual, _ := obj.(*kcptenancyv1alpha.Workspace) + actual.Name = key.Name + actual.Spec = kcptenancyv1alpha.WorkspaceSpec{ + Cluster: "some-cluster-id-" + key.Name, + URL: "asd", + } + actual.Status.Phase = ready + }). + Return(nil) +} + +func (suite *AccountInfoSubroutineTestSuite) mockGetWorkspaceNotFound() *mocks.Client_Get_Call { + return suite.clientMock.EXPECT(). + Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Workspace")). + Return(kerrors.NewNotFound(schema.GroupResource{}, "")) +} + +func (suite *AccountInfoSubroutineTestSuite) mockGetAccountInfo(spec v1alpha1.AccountInfoSpec) *mocks.Client_Get_Call { + return suite.clientMock.EXPECT(). + Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.AccountInfo")). + Run(func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) { + actual, _ := obj.(*v1alpha1.AccountInfo) + actual.Name = key.Name + actual.Spec = spec + }). + Return(nil) +} diff --git a/pkg/subroutines/common.go b/pkg/subroutines/common.go new file mode 100644 index 0000000..a25254c --- /dev/null +++ b/pkg/subroutines/common.go @@ -0,0 +1,23 @@ +package subroutines + +import ( + "context" + + kcptenancyv1alpha "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" + "github.com/openmfp/golang-commons/errors" + "github.com/openmfp/golang-commons/logger" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openmfp/account-operator/api/v1alpha1" +) + +func retrieveWorkspace(ctx context.Context, instance *v1alpha1.Account, c client.Client, log *logger.Logger) (*kcptenancyv1alpha.Workspace, error) { + ws := &kcptenancyv1alpha.Workspace{} + err := c.Get(ctx, client.ObjectKey{Name: instance.Name}, ws) + if err != nil { + const msg = "workspace does not exist" + log.Error().Msg(msg) + return nil, errors.Wrap(err, msg) + } + return ws, nil +} diff --git a/pkg/subroutines/common_test.go b/pkg/subroutines/common_test.go new file mode 100644 index 0000000..f953aab --- /dev/null +++ b/pkg/subroutines/common_test.go @@ -0,0 +1,32 @@ +package subroutines_test + +import ( + "context" + + kcpcorev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + kcptenancyv1alpha "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" + "github.com/stretchr/testify/mock" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openmfp/account-operator/pkg/subroutines/mocks" +) + +func mockGetWorkspaceByName(clientMock *mocks.Client, ready kcpcorev1alpha1.LogicalClusterPhaseType, path string) *mocks.Client_Get_Call { + return clientMock.EXPECT(). + Get(mock.Anything, mock.Anything, mock.AnythingOfType("*v1alpha1.Workspace")). + Run(func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) { + wsPath := "" + if path != "" { + wsPath = "https://example.com/" + path + } + actual, _ := obj.(*kcptenancyv1alpha.Workspace) + actual.Name = key.Name + actual.Spec = kcptenancyv1alpha.WorkspaceSpec{ + Cluster: "some-cluster-id-" + key.Name, + URL: wsPath, + } + actual.Status.Phase = ready + }). + Return(nil) +} diff --git a/pkg/subroutines/extensions.go b/pkg/subroutines/extensions.go deleted file mode 100644 index 2b89311..0000000 --- a/pkg/subroutines/extensions.go +++ /dev/null @@ -1,304 +0,0 @@ -package subroutines - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "strings" - "text/template" - - "github.com/Masterminds/sprig/v3" - tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" - "github.com/kcp-dev/logicalcluster/v3" - "github.com/openmfp/golang-commons/controller/lifecycle" - "github.com/openmfp/golang-commons/errors" - "github.com/openmfp/golang-commons/logger" - v1 "k8s.io/api/core/v1" - kerrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/kontext" - - "github.com/openmfp/account-operator/api/v1alpha1" -) - -const ( - ExtensionSubroutineName = "ExtensionSubroutine" - ExtensionSubroutineFinalizer = "account.core.openmfp.io/ext" -) - -type ExtensionSubroutine struct { - client client.Client -} - -func NewExtensionSubroutine(cl client.Client) *ExtensionSubroutine { - return &ExtensionSubroutine{client: cl} -} - -var ( - ErrNoParentAvailable = errors.New("no parent namespace available") -) - -func (e *ExtensionSubroutine) Process(ctx context.Context, instance lifecycle.RuntimeObject) (ctrl.Result, errors.OperatorError) { - account := instance.(*v1alpha1.Account) - - lookupNamespace := account.GetNamespace() - - extensionsToApply, err := collectExtensions(ctx, e.client, lookupNamespace) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - - for _, extension := range append(extensionsToApply, account.Spec.Extensions...) { - us := unstructured.Unstructured{} - us.SetGroupVersionKind(extension.GroupVersionKind()) - - if len(extension.MetadataGoTemplate.Raw) > 0 { - var metadataKeyValues map[string]any - err := json.NewDecoder(bytes.NewReader(extension.MetadataGoTemplate.Raw)).Decode(&metadataKeyValues) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - - err = RenderExtensionSpec(ctx, metadataKeyValues, account, &us, []string{"metadata"}) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - } - - if us.GetName() == "" { - us.SetName(strings.ToLower(extension.Kind)) - } - if namespaced, err := e.client.IsObjectNamespaced(&us); err == nil && namespaced { - us.SetNamespace(*account.Status.Namespace) - } - - _, err = controllerutil.CreateOrUpdate(ctx, e.client, &us, func() error { - if len(extension.SpecGoTemplate.Raw) == 0 { - return nil - } - var keyValues map[string]any - err := json.NewDecoder(bytes.NewReader(extension.SpecGoTemplate.Raw)).Decode(&keyValues) - if err != nil { - return err - } - - path := []string{"spec"} - return RenderExtensionSpec(ctx, keyValues, account, &us, path) - }) - if kerrors.IsAlreadyExists(err) { - continue - } - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - } - - return ctrl.Result{}, nil -} - -func RenderExtensionSpec(ctx context.Context, keyValues map[string]any, account *v1alpha1.Account, us *unstructured.Unstructured, path []string) error { - for key, value := range keyValues { - switch val := value.(type) { - case string: // render string values - t, err := template.New("field").Funcs(sprig.FuncMap()).Parse(val) - if err != nil { - return err - } - - renderedAccount, err := runtime.DefaultUnstructuredConverter.ToUnstructured(account) - if err != nil { - return err - } - - var rendered bytes.Buffer - err = t.Execute(&rendered, map[string]any{ - "Account": renderedAccount, - }) - if err != nil { - return err - } - - err = unstructured.SetNestedField(us.Object, rendered.String(), append(path, key)...) - if err != nil { - return err - } - case map[string]any: - err := RenderExtensionSpec(ctx, val, account, us, append(path, key)) - if err != nil { - return err - } - default: // any other primitive type - err := unstructured.SetNestedField(us.Object, val, append(path, key)...) - if err != nil { - return err - } - } - } - - return nil -} - -func (e *ExtensionSubroutine) Finalize(ctx context.Context, instance lifecycle.RuntimeObject) (ctrl.Result, errors.OperatorError) { - log := logger.LoadLoggerFromContext(ctx) - account := instance.(*v1alpha1.Account) - - lookupNamespace := account.GetNamespace() - - extensionsToRemove, err := collectExtensions(ctx, e.client, lookupNamespace) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - - for _, extension := range append(extensionsToRemove, account.Spec.Extensions...) { - us := unstructured.Unstructured{} - us.SetGroupVersionKind(extension.GroupVersionKind()) - - if len(extension.MetadataGoTemplate.Raw) > 0 { - var metadataKeyValues map[string]any - err := json.NewDecoder(bytes.NewReader(extension.MetadataGoTemplate.Raw)).Decode(&metadataKeyValues) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - err = RenderExtensionSpec(ctx, metadataKeyValues, account, &us, []string{"metadata"}) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - } - - if us.GetName() == "" { - us.SetName(strings.ToLower(extension.Kind)) - } - if namespaced, err := e.client.IsObjectNamespaced(&us); err == nil && namespaced { - us.SetNamespace(*account.Status.Namespace) - } - - log.Info(). - Str("name", us.GetName()). - Str("kind", us.GetKind()). - Str("namespace", us.GetNamespace()). - Msg("Deleting extension") - - err := e.client.Delete(ctx, &us) - if kerrors.IsNotFound(err) { - continue - } - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - } - - return ctrl.Result{}, nil -} - -func (e *ExtensionSubroutine) GetName() string { return ExtensionSubroutineName } - -func (e *ExtensionSubroutine) Finalizers() []string { return []string{ExtensionSubroutineFinalizer} } - -func collectExtensions(ctx context.Context, cl client.Client, lookupNamespace string) ([]v1alpha1.Extension, error) { - var extensions []v1alpha1.Extension - for { - parentAccount, newClusterContext, err := getParentAccount(ctx, cl, lookupNamespace) - if errors.Is(err, ErrNoParentAvailable) { - break - } - if err != nil { - return nil, err - } - - if newClusterContext != nil { - ctx = kontext.WithCluster(ctx, logicalcluster.Name(*newClusterContext)) - } - - lookupNamespace = parentAccount.GetNamespace() - - extensions = append(extensions, parentAccount.Spec.Extensions...) - } - - return extensions, nil -} - -func getParentAccount(ctx context.Context, cl client.Client, ns string) (*v1alpha1.Account, *string, error) { - if _, ok := kontext.ClusterFrom(ctx); ok { - return getParentAccountWithKcp(ctx, cl) - } else { - return getParentAccountByNs(ctx, cl, ns) - } -} - -func getParentAccountWithKcp(ctx context.Context, cl client.Client) (*v1alpha1.Account, *string, error) { - - cluster, ok := kontext.ClusterFrom(ctx) - if !ok || cluster.Empty() { - return nil, nil, fmt.Errorf("no cluster context found, this is a configuration error") - } - - wsCtx := kontext.WithCluster(ctx, "") - list := &tenancyv1alpha1.WorkspaceList{} - - err := cl.List(wsCtx, list) - if err != nil { - return nil, nil, err - } - - for _, ws := range list.Items { - if ws.Spec.Cluster != cluster.String() { - continue - } - - clusterName := ws.Annotations[logicalcluster.AnnotationKey] - - parentCtx := kontext.WithCluster(ctx, logicalcluster.Name(clusterName)) - - parentAccount := v1alpha1.Account{} - err = cl.Get(parentCtx, types.NamespacedName{ - Name: ws.Annotations[v1alpha1.NamespaceAccountOwnerLabel], - Namespace: ws.Annotations[v1alpha1.NamespaceAccountOwnerNamespaceLabel], - }, &parentAccount) - if err != nil { - return nil, nil, err - } - - return &parentAccount, &clusterName, nil - } - - return nil, nil, ErrNoParentAvailable -} - -func getParentAccountByNs(ctx context.Context, cl client.Client, ns string) (*v1alpha1.Account, *string, error) { - - var namespace v1.Namespace - err := cl.Get(ctx, types.NamespacedName{Name: ns}, &namespace) - if kerrors.IsNotFound(err) { - return nil, nil, ErrNoParentAvailable - } - if err != nil { - return nil, nil, err - } - - accountName, ok := namespace.GetLabels()[v1alpha1.NamespaceAccountOwnerLabel] - if !ok || accountName == "" { - return nil, nil, ErrNoParentAvailable - } - - accountNamespace, ok := namespace.GetLabels()[v1alpha1.NamespaceAccountOwnerNamespaceLabel] - if !ok || accountNamespace == "" { - return nil, nil, ErrNoParentAvailable - } - - var account v1alpha1.Account - err = cl.Get(ctx, types.NamespacedName{Name: accountName, Namespace: accountNamespace}, &account) - if kerrors.IsNotFound(err) { - return nil, nil, ErrNoParentAvailable - } - if err != nil { - return nil, nil, err - } - - return &account, nil, nil -} diff --git a/pkg/subroutines/extensions_ready.go b/pkg/subroutines/extensions_ready.go deleted file mode 100644 index c9d31c3..0000000 --- a/pkg/subroutines/extensions_ready.go +++ /dev/null @@ -1,111 +0,0 @@ -package subroutines - -import ( - "bytes" - "context" - "encoding/json" - "strings" - - kerrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/openmfp/golang-commons/controller/lifecycle" - "github.com/openmfp/golang-commons/errors" - - "github.com/openmfp/account-operator/api/v1alpha1" -) - -type ExtensionReadySubroutine struct { - client client.Client -} - -func NewExtensionReadySubroutine(cl client.Client) *ExtensionReadySubroutine { - return &ExtensionReadySubroutine{client: cl} -} - -func (e *ExtensionReadySubroutine) GetName() string { return "ExtensionReadySubroutine" } - -func (e *ExtensionReadySubroutine) Finalizers() []string { return []string{} } - -func (e *ExtensionReadySubroutine) Process(ctx context.Context, instance lifecycle.RuntimeObject) (ctrl.Result, errors.OperatorError) { - - extensions, err := collectExtensions(ctx, e.client, instance.GetNamespace()) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - - account := instance.(*v1alpha1.Account) - - for _, extension := range append(extensions, account.Spec.Extensions...) { - if extension.ReadyConditionType == nil { - continue - } - - us := unstructured.Unstructured{} - us.SetGroupVersionKind(extension.GroupVersionKind()) - - if len(extension.MetadataGoTemplate.Raw) > 0 { - var metadataKeyValues map[string]any - err := json.NewDecoder(bytes.NewReader(extension.MetadataGoTemplate.Raw)).Decode(&metadataKeyValues) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - - err = RenderExtensionSpec(ctx, metadataKeyValues, account, &us, []string{"metadata"}) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - } - - if us.GetName() == "" { - us.SetName(strings.ToLower(extension.Kind)) - } - if namespaced, err := e.client.IsObjectNamespaced(&us); err == nil && namespaced { - us.SetNamespace(*account.Status.Namespace) - } - - err = e.client.Get(ctx, client.ObjectKeyFromObject(&us), &us) - if kerrors.IsNotFound(err) { - return ctrl.Result{Requeue: true}, nil - } - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - - conditions, hasField, err := unstructured.NestedSlice(us.Object, "status", "conditions") - if !hasField || err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - - parsedConditions := make([]metav1.Condition, len(conditions)) - for i, cond := range conditions { - - intermediate, err := json.Marshal(cond) - if err != nil { // coverage-ignore - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - - var parsed metav1.Condition - err = json.NewDecoder(bytes.NewReader(intermediate)).Decode(&parsed) - if err != nil { // coverage-ignore - return ctrl.Result{}, errors.NewOperatorError(err, true, false) - } - - parsedConditions[i] = parsed - } - - if meta.IsStatusConditionFalse(parsedConditions, *extension.ReadyConditionType) { - return ctrl.Result{Requeue: true}, nil - } - } - - return ctrl.Result{}, nil -} - -func (e *ExtensionReadySubroutine) Finalize(_ context.Context, _ lifecycle.RuntimeObject) (ctrl.Result, errors.OperatorError) { - return ctrl.Result{}, nil -} diff --git a/pkg/subroutines/extensions_ready_test.go b/pkg/subroutines/extensions_ready_test.go deleted file mode 100644 index b65c16e..0000000 --- a/pkg/subroutines/extensions_ready_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package subroutines_test - -import ( - "context" - "encoding/json" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - kerrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/openmfp/account-operator/api/v1alpha1" - "github.com/openmfp/account-operator/pkg/subroutines" - "github.com/openmfp/account-operator/pkg/subroutines/mocks" -) - -func TestExtensionReadyInterfaceFunction(t *testing.T) { - routine := subroutines.NewExtensionReadySubroutine(nil) - assert.Equal(t, "ExtensionReadySubroutine", routine.GetName()) - assert.Equal(t, []string{}, routine.Finalizers()) - _, err := routine.Finalize(context.Background(), nil) - assert.Nil(t, err) -} - -func TestExtensionReadySubroutine(t *testing.T) { - readyCondition := "Ready" - defaultNamespace := "default" - - tests := []struct { - name string - k8sMocks func(*mocks.Client) - account v1alpha1.Account - expectError bool - expectedResult ctrl.Result - }{ - { - name: "should respect ready condition and return successfully", - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - us := o.(*unstructured.Unstructured) - - cond := []metav1.Condition{ - { - Type: readyCondition, - Status: metav1.ConditionTrue, - }, - } - - out, err := json.Marshal(cond) - assert.NoError(t, err) - - var conditionMap []interface{} - err = json.Unmarshal(out, &conditionMap) - assert.NoError(t, err) - - us.Object["status"] = map[string]any{ - "conditions": conditionMap, - } - - return nil - }).Once() - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - }, - account: v1alpha1.Account{ - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - MetadataGoTemplate: apiextensionsv1.JSON{ - Raw: []byte(`{ - "annotations": { - "account.core.openmfp.io/owner": "{{ .Account.metadata.name }}", - "account.core.openmfp.io/owner-namespace": "{{ .Account.metadata.namespace }}" - }, - "name": "{{ .Account.metadata.name }}" - }`), - }, - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - ReadyConditionType: &readyCondition, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &defaultNamespace, - }, - }, - }, - { - name: "should respect ready condition and requeue in case the extension is not found", - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - }, - account: v1alpha1.Account{ - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - ReadyConditionType: &readyCondition, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &defaultNamespace, - }, - }, - expectError: false, - expectedResult: ctrl.Result{Requeue: true}, - }, - { - name: "should respect ready condition and requeue in case the extension is not yet ready", - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - us := o.(*unstructured.Unstructured) - - cond := []metav1.Condition{ - { - Type: readyCondition, - Status: metav1.ConditionFalse, - }, - } - - out, err := json.Marshal(cond) - assert.NoError(t, err) - - var conditionMap []interface{} - err = json.Unmarshal(out, &conditionMap) - assert.NoError(t, err) - - us.Object["status"] = map[string]any{ - "conditions": conditionMap, - } - - return nil - }).Once() - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - }, - account: v1alpha1.Account{ - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - ReadyConditionType: &readyCondition, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &defaultNamespace, - }, - }, - expectError: false, - expectedResult: ctrl.Result{Requeue: true}, - }, - { - name: "should respect ready condition and fail in case the namespace cannot be retrived", - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(errors.New("some error")) - }, - account: v1alpha1.Account{ - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - ReadyConditionType: &readyCondition, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &defaultNamespace, - }, - }, - expectError: true, - }, - { - name: "should respect ready condition and fail in case the extension retrieval failed", - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(errors.New("some error")) - }, - account: v1alpha1.Account{ - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - ReadyConditionType: &readyCondition, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &defaultNamespace, - }, - }, - expectError: true, - }, - { - name: "should respect ready condition and fail for wrong format", - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - us := o.(*unstructured.Unstructured) - - us.Object["status"] = map[string]any{ - "wrong-key": "", - } - - return nil - }) - }, - account: v1alpha1.Account{ - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - ReadyConditionType: &readyCondition, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &defaultNamespace, - }, - }, - expectError: true, - }, - { - name: "should skip processing of subroutine for extension if no readyConditionType is procided", - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - }, - account: v1alpha1.Account{ - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &defaultNamespace, - }, - }, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - k8sClient := mocks.NewClient(t) - if test.k8sMocks != nil { - test.k8sMocks(k8sClient) - } - - routine := subroutines.NewExtensionReadySubroutine(k8sClient) - - result, err := routine.Process(context.Background(), &test.account) - if test.expectError { - assert.NotNil(t, err) - } else { - assert.Nil(t, err) - } - if (test.expectedResult != ctrl.Result{}) { - assert.Equal(t, test.expectedResult, result) - } - }) - } -} diff --git a/pkg/subroutines/extensions_test.go b/pkg/subroutines/extensions_test.go deleted file mode 100644 index 3c1a654..0000000 --- a/pkg/subroutines/extensions_test.go +++ /dev/null @@ -1,792 +0,0 @@ -package subroutines_test - -import ( - "context" - "errors" - "testing" - - tenancyv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" - "github.com/kcp-dev/logicalcluster/v3" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - corev1 "k8s.io/api/core/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - kerrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/kontext" - - "github.com/openmfp/account-operator/api/v1alpha1" - corev1alpha1 "github.com/openmfp/account-operator/api/v1alpha1" - "github.com/openmfp/account-operator/pkg/subroutines" - "github.com/openmfp/account-operator/pkg/subroutines/mocks" -) - -func TestGetName(t *testing.T) { - routine := subroutines.NewExtensionSubroutine(nil) - assert.Equal(t, "ExtensionSubroutine", routine.GetName()) -} - -func TestFinalizers(t *testing.T) { - routine := subroutines.NewExtensionSubroutine(nil) - assert.Equal(t, []string{subroutines.ExtensionSubroutineFinalizer}, routine.Finalizers()) -} - -func TestExtensionSubroutine_Process(t *testing.T) { - namespace := "namespace" - - tests := []struct { - name string - account v1alpha1.Account - k8sMocks func(*mocks.Client) - contextFunc func() context.Context - expectError bool - }{ - { - name: "should work without parent accounts", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Create(mock.Anything, mock.Anything).Return(nil) - }, - }, - { - name: "should work without parent accounts and extension spec", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{ - Raw: []byte(`{"foo":"bar"}`), - }, - MetadataGoTemplate: apiextensionsv1.JSON{ - Raw: []byte(`{"annotations": {"test": "test"}}`), - }, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Create(mock.Anything, mock.Anything).Return(nil) - }, - }, - { - name: "should fail without parent accounts and extension spec due to invalid json", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{ - Raw: []byte(`{"foo":"bar"}`), - }, - MetadataGoTemplate: apiextensionsv1.JSON{ - Raw: []byte(`123jjj`), - }, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - expectError: true, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - // c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - - // c.EXPECT().Create(mock.Anything, mock.Anything).Return(nil) - }, - }, - { - name: "should fail without parent accounts due to random error", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(errors.New("")) - }, - expectError: true, - }, - { - name: "should work with 1 level parent accounts", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - - *account = v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - } - - return nil - }).Once() - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Create(mock.Anything, mock.Anything).Return(nil) - c.EXPECT().Create(mock.Anything, mock.Anything).Return(nil) - }, - }, - { - name: "should work with 1 level parent accounts but missing namespace owner label", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - }, - }, - } - return nil - }).Once() - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Create(mock.Anything, mock.Anything).Return(nil) - }, - }, - { - name: "should work with 1 level parent accounts but missing namespace namespace label", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(nil) - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Create(mock.Anything, mock.Anything).Return(nil) - }, - }, - { - name: "should work with 1 level parent accounts but missing namespace namespace label with random error", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(nil) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(errors.New("")) - }, - expectError: true, - }, - { - name: "should work with 1 level parent accounts and account not found", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Account")) - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Create(mock.Anything, mock.Anything).Return(nil) - c.EXPECT().Create(mock.Anything, mock.Anything).Return(nil) - }, - }, - { - name: "should work with 1 level parent accounts and kcp enabled", - contextFunc: func() context.Context { - return kontext.WithCluster(context.Background(), logicalcluster.Name("kcp")) - }, - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().List(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, ol client.ObjectList, lo ...client.ListOption) error { - wss := ol.(*tenancyv1alpha1.WorkspaceList) - - *wss = tenancyv1alpha1.WorkspaceList{ - Items: []tenancyv1alpha1.Workspace{ - { - Spec: tenancyv1alpha1.WorkspaceSpec{ - Cluster: "foo", - }, - }, - { - ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{ - logicalcluster.AnnotationKey: "root", - v1alpha1.NamespaceAccountOwnerLabel: "first-level", - }, - }, - Spec: tenancyv1alpha1.WorkspaceSpec{ - Cluster: "kcp", - }, - }, - }, - } - return nil - }).Once() - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - - *account = v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - } - - return nil - }).Once() - - c.EXPECT().List(mock.Anything, mock.Anything, mock.Anything).Once().Return(nil) - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Create(mock.Anything, mock.Anything).Return(nil) - c.EXPECT().Create(mock.Anything, mock.Anything).Return(nil) - }, - }, - { - name: "should fail if error during listing kcp enabled", - expectError: true, - contextFunc: func() context.Context { - return kontext.WithCluster(context.Background(), logicalcluster.Name("kcp")) - }, - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().List(mock.Anything, mock.Anything, mock.Anything).Return(errors.New("test")).Once() - }, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - k8sClient := mocks.NewClient(t) - if test.k8sMocks != nil { - test.k8sMocks(k8sClient) - } - - ctx := context.Background() - if test.contextFunc != nil { - ctx = test.contextFunc() - } - - routine := subroutines.NewExtensionSubroutine(k8sClient) - _, err := routine.Process(ctx, &test.account) - if test.expectError { - assert.NotNil(t, err) - } else { - assert.Nil(t, err) - } - }) - } -} - -func TestExtensionSubroutine_Finalize(t *testing.T) { - namespace := "namespace" - - tests := []struct { - name string - account v1alpha1.Account - k8sMocks func(*mocks.Client) - expectError bool - }{ - { - name: "should work without parent accounts", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Delete(mock.Anything, mock.Anything).Return(nil) - }, - }, - { - name: "should fail without parent accounts due to random deletion error", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Delete(mock.Anything, mock.Anything).Return(errors.New("")) - }, - expectError: true, - }, - { - name: "should work without parent accounts and already deleted extension", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Delete(mock.Anything, mock.Anything).Return(kerrors.NewNotFound(schema.GroupResource{}, "AccountExtension")) - }, - }, - { - name: "should work with 1 level parent accounts", - account: v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-account", - Namespace: "test-account-namespace", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - MetadataGoTemplate: apiextensionsv1.JSON{ - Raw: []byte(`{ - "annotations": { - "account.core.openmfp.io/owner": "{{ .Account.metadata.name }}", - "account.core.openmfp.io/owner-namespace": "{{ .Account.metadata.namespace }}" - }, - "name": "{{ .Account.metadata.name }}" - }`), - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - Status: v1alpha1.AccountStatus{ - Namespace: &namespace, - }, - }, - k8sMocks: func(c *mocks.Client) { - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - - *account = v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - } - - return nil - }).Once() - - c.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Once().Return(kerrors.NewNotFound(schema.GroupResource{}, "Namespace")) - - c.EXPECT().IsObjectNamespaced(mock.Anything).Return(true, nil) - - c.EXPECT().Delete(mock.Anything, mock.Anything).Return(nil) - c.EXPECT().Delete(mock.Anything, mock.Anything).Return(nil) - }, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - k8sClient := mocks.NewClient(t) - if test.k8sMocks != nil { - test.k8sMocks(k8sClient) - } - - routine := subroutines.NewExtensionSubroutine(k8sClient) - _, err := routine.Finalize(context.Background(), &test.account) - if test.expectError { - assert.NotNil(t, err) - } else { - assert.Nil(t, err) - } - }) - } -} - -func TestRenderExtensionSpec(t *testing.T) { - creator := "user" - us := unstructured.Unstructured{ - Object: map[string]interface{}{}, - } - err := subroutines.RenderExtensionSpec(context.Background(), map[string]any{ - "foo": "bar", - "number": int64(1), - "bool": true, - "nested": map[string]any{ - "value": "{{.Account.spec.creator}}", - }, - }, &v1alpha1.Account{ - Spec: v1alpha1.AccountSpec{ - Creator: &creator, - }, - }, &us, []string{"spec"}) - assert.NoError(t, err) - - us = unstructured.Unstructured{ - Object: map[string]interface{}{}, - } - err = subroutines.RenderExtensionSpec(context.Background(), map[string]any{ - "foo": "bar", - "number": int64(1), - "bool": true, - "nested": map[string]any{ - "value": "{{ .Account.spec.creator | upper }}", - }, - }, &v1alpha1.Account{ - Spec: v1alpha1.AccountSpec{ - Creator: &creator, - }, - }, &us, []string{"spec"}) - assert.NoError(t, err) -} - -func TestRenderExtensionSpecInvalidTemplate(t *testing.T) { - creator := "" - us := unstructured.Unstructured{ - Object: map[string]interface{}{}, - } - err := subroutines.RenderExtensionSpec(context.Background(), map[string]any{ - "foo": "{{ .Account }", - }, &v1alpha1.Account{ - Spec: v1alpha1.AccountSpec{ - Creator: &creator, - }, - }, &us, []string{"spec"}) - assert.Error(t, err) -} diff --git a/pkg/subroutines/fga.go b/pkg/subroutines/fga.go index c332ffe..2474188 100644 --- a/pkg/subroutines/fga.go +++ b/pkg/subroutines/fga.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" + kcpcorev1alpha "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" "github.com/kcp-dev/logicalcluster/v3" openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/openmfp/golang-commons/controller/lifecycle" @@ -18,25 +19,20 @@ import ( "sigs.k8s.io/controller-runtime/pkg/kontext" "github.com/openmfp/account-operator/api/v1alpha1" - "github.com/openmfp/account-operator/pkg/service" ) type FGASubroutine struct { fgaClient openfgav1.OpenFGAServiceClient client client.Client - srv service.Servicer - rootNamespace string objectType string parentRelation string creatorRelation string } -func NewFGASubroutine(cl client.Client, fgaClient openfgav1.OpenFGAServiceClient, s service.Servicer, rootNamespace, creatorRelation, parentRealtion, objectType string) *FGASubroutine { +func NewFGASubroutine(cl client.Client, fgaClient openfgav1.OpenFGAServiceClient, creatorRelation, parentRealtion, objectType string) *FGASubroutine { return &FGASubroutine{ client: cl, fgaClient: fgaClient, - srv: s, - rootNamespace: rootNamespace, creatorRelation: creatorRelation, parentRelation: parentRealtion, objectType: objectType, @@ -54,26 +50,47 @@ func (e *FGASubroutine) Process(ctx context.Context, runtimeObj lifecycle.Runtim return ctrl.Result{}, nil } - storeId, err := e.getStoreId(ctx, account) + accountWorkspace, err := retrieveWorkspace(ctx, account, e.client, log) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(err, true, true) + } + + if accountWorkspace.Status.Phase != kcpcorev1alpha.LogicalClusterPhaseReady { + log.Info().Msg("workspace is not ready yet, retry") + return ctrl.Result{Requeue: true}, nil + } + + // Prepare context to work in workspace + wsCtx := kontext.WithCluster(ctx, logicalcluster.Name(accountWorkspace.Spec.Cluster)) + + accountInfo, err := e.getAccountInfo(wsCtx) if err != nil { log.Error().Err(err).Msg("Couldn't get Store Id") return ctrl.Result{}, errors.NewOperatorError(err, true, true) } + if accountInfo.Spec.FGA.Store.Id == "" { + log.Error().Msg("FGA Store Id is empty") + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("FGA Store Id is empty"), true, true) + } + + clusterId, ok := kontext.ClusterFrom(ctx) + if !ok { + log.Error().Msg("Couldn't get Cluster Id") + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("couldn't get cluster id"), true, true) + } + writes := []*openfgav1.TupleKey{} - // Determine parent account to create parent relation - if account.GetNamespace() != e.rootNamespace { - parent, _, err := getParentAccount(ctx, e.client, account.GetNamespace()) - if err != nil { - log.Error().Err(err).Msg("Couldn't get parent account") - return ctrl.Result{}, errors.NewOperatorError(err, true, true) - } + // Parent Name + if account.Spec.Type != v1alpha1.AccountTypeOrg { + parentAccountName := accountInfo.Spec.ParentAccount.Name + // Determine parent account to create parent relation writes = append(writes, &openfgav1.TupleKey{ - Object: fmt.Sprintf("%s:%s", e.objectType, account.GetName()), + Object: fmt.Sprintf("%s:%s/%s", e.objectType, clusterId, account.GetName()), Relation: e.parentRelation, - User: fmt.Sprintf("%s:%s", e.objectType, parent.GetName()), + User: fmt.Sprintf("%s:%s/%s", e.objectType, clusterId, parentAccountName), }) } @@ -86,22 +103,21 @@ func (e *FGASubroutine) Process(ctx context.Context, runtimeObj lifecycle.Runtim creator := formatUser(*account.Spec.Creator) writes = append(writes, &openfgav1.TupleKey{ - Object: fmt.Sprintf("role:%s/%s/owner", account.Spec.Type, account.Name), + Object: fmt.Sprintf("role:%s/%s/owner", clusterId, account.Name), Relation: "assignee", User: fmt.Sprintf("user:%s", creator), }) writes = append(writes, &openfgav1.TupleKey{ - Object: fmt.Sprintf("%s:%s", e.objectType, account.Name), + Object: fmt.Sprintf("%s:%s/%s", e.objectType, clusterId, account.Name), Relation: e.creatorRelation, - User: fmt.Sprintf("role:%s/%s/owner#assignee", account.Spec.Type, account.Name), + User: fmt.Sprintf("role:%s/%s/owner#assignee", clusterId, account.Name), }) } for _, writeTuple := range writes { - _, err = e.fgaClient.Write(ctx, &openfgav1.WriteRequest{ - StoreId: storeId, + StoreId: accountInfo.Spec.FGA.Store.Id, Writes: &openfgav1.WriteRequestWrites{ TupleKeys: []*openfgav1.TupleKey{writeTuple}, }, @@ -125,38 +141,44 @@ func (e *FGASubroutine) Finalize(ctx context.Context, runtimeObj lifecycle.Runti account := runtimeObj.(*v1alpha1.Account) log := logger.LoadLoggerFromContext(ctx) - storeId, err := e.getStoreId(ctx, account) + parentAccountInfo, err := e.getAccountInfo(ctx) if err != nil { log.Error().Err(err).Msg("Couldn't get Store Id") return ctrl.Result{}, errors.NewOperatorError(err, true, true) } - deletes := []*openfgav1.TupleKeyWithoutCondition{} + if parentAccountInfo.Spec.FGA.Store.Id == "" { + log.Error().Msg("FGA Store Id is empty") + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("FGA Store Id is empty"), true, true) + } - if account.GetNamespace() != e.rootNamespace { - parent, _, err := getParentAccount(ctx, e.client, account.GetNamespace()) - if err != nil { - log.Error().Err(err).Msg("Couldn't get parent account") - return ctrl.Result{}, errors.NewOperatorError(err, true, true) - } + clusterId, ok := kontext.ClusterFrom(ctx) + if !ok { + log.Error().Msg("Couldn't get Cluster Id") + return ctrl.Result{}, errors.NewOperatorError(fmt.Errorf("couldn't get cluster id"), true, true) + } + + deletes := []*openfgav1.TupleKeyWithoutCondition{} + if account.Spec.Type != v1alpha1.AccountTypeOrg { + parentAccountName := parentAccountInfo.Spec.Account.Name deletes = append(deletes, &openfgav1.TupleKeyWithoutCondition{ - Object: fmt.Sprintf("%s:%s", e.objectType, account.GetName()), + Object: fmt.Sprintf("%s:%s/%s", e.objectType, clusterId, account.GetName()), Relation: e.parentRelation, - User: fmt.Sprintf("%s:%s", e.objectType, parent.GetName()), + User: fmt.Sprintf("%s:%s/%s", e.objectType, clusterId, parentAccountName), }) } if account.Spec.Creator != nil { creator := formatUser(*account.Spec.Creator) deletes = append(deletes, &openfgav1.TupleKeyWithoutCondition{ - Object: fmt.Sprintf("role:%s/%s/owner", account.Spec.Type, account.Name), + Object: fmt.Sprintf("role:%s/%s/owner", clusterId, account.Name), Relation: "assignee", User: fmt.Sprintf("user:%s", creator), }) deletes = append(deletes, &openfgav1.TupleKeyWithoutCondition{ - Object: fmt.Sprintf("%s:%s", e.objectType, account.Name), + Object: fmt.Sprintf("%s:%s/%s", e.objectType, clusterId, account.Name), Relation: e.creatorRelation, User: fmt.Sprintf("role:%s/%s/owner#assignee", account.Spec.Type, account.Name), }) @@ -165,7 +187,7 @@ func (e *FGASubroutine) Finalize(ctx context.Context, runtimeObj lifecycle.Runti for _, deleteTuple := range deletes { _, err = e.fgaClient.Write(ctx, &openfgav1.WriteRequest{ - StoreId: storeId, + StoreId: parentAccountInfo.Spec.FGA.Store.Id, Deletes: &openfgav1.WriteRequestDeletes{ TupleKeys: []*openfgav1.TupleKeyWithoutCondition{deleteTuple}, }, @@ -186,42 +208,19 @@ func (e *FGASubroutine) Finalize(ctx context.Context, runtimeObj lifecycle.Runti return ctrl.Result{}, nil } -func (e *FGASubroutine) getStoreId(ctx context.Context, account *v1alpha1.Account) (string, error) { - firstLevelAccountName := account.Name - - if e.rootNamespace != account.Namespace { - - lookupNamespace := account.Namespace - lookupCtx := ctx - for { - parent, newClusterContext, err := getParentAccount(lookupCtx, e.client, lookupNamespace) - if errors.Is(err, ErrNoParentAvailable) { - break - } - if err != nil { - return "", err - } - - if newClusterContext != nil { - lookupCtx = kontext.WithCluster(lookupCtx, logicalcluster.Name(*newClusterContext)) - } - - lookupNamespace = parent.GetNamespace() - firstLevelAccountName = parent.GetName() - } - } - - storeId, err := helpers.GetStoreIDForTenant(ctx, e.fgaClient, firstLevelAccountName) +func (e *FGASubroutine) getAccountInfo(ctx context.Context) (*v1alpha1.AccountInfo, error) { + // Get AccountInfo For Project + accountInfo := &v1alpha1.AccountInfo{} + err := e.client.Get(ctx, client.ObjectKey{Name: DefaultAccountInfoName}, accountInfo) if err != nil { - return "", err + return nil, err } - - return storeId, nil + return accountInfo, nil } -func (e *FGASubroutine) GetName() string { return "CreatorSubroutine" } +func (e *FGASubroutine) GetName() string { return "FGASubroutine" } -func (e *FGASubroutine) Finalizers() []string { return []string{"account.core.openmfp.io/fga"} } +func (e *FGASubroutine) Finalizers() []string { return []string{"account.core.openmfp.org/fga"} } var saRegex = regexp.MustCompile(`^system:serviceaccount:[^:]*:[^:]*$`) diff --git a/pkg/subroutines/fga_test.go b/pkg/subroutines/fga_test.go index fef705a..8de62e9 100644 --- a/pkg/subroutines/fga_test.go +++ b/pkg/subroutines/fga_test.go @@ -2,11 +2,12 @@ package subroutines_test import ( "context" - corev1 "k8s.io/api/core/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "testing" + + kcpcorev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - "testing" + "sigs.k8s.io/controller-runtime/pkg/kontext" openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/stretchr/testify/assert" @@ -17,7 +18,6 @@ import ( "k8s.io/utils/ptr" "github.com/openmfp/account-operator/api/v1alpha1" - corev1alpha1 "github.com/openmfp/account-operator/api/v1alpha1" "github.com/openmfp/account-operator/pkg/subroutines" "github.com/openmfp/account-operator/pkg/subroutines/mocks" ) @@ -39,66 +39,17 @@ func newFgaError(c openfgav1.ErrorCode, m string) *fgaError { } } -func TestCreatorSubroutine_GetName(t *testing.T) { - routine := subroutines.NewFGASubroutine(nil, nil, nil, "", "", "", "") - assert.Equal(t, "CreatorSubroutine", routine.GetName()) +func TestFGASubroutine_GetName(t *testing.T) { + routine := subroutines.NewFGASubroutine(nil, nil, "", "", "") + assert.Equal(t, "FGASubroutine", routine.GetName()) } -func TestCreatorSubroutine_Finalizers(t *testing.T) { - routine := subroutines.NewFGASubroutine(nil, nil, nil, "", "", "", "") - assert.Equal(t, []string{"account.core.openmfp.io/fga"}, routine.Finalizers()) +func TestFGASubroutine_Finalizers(t *testing.T) { + routine := subroutines.NewFGASubroutine(nil, nil, "", "", "") + assert.Equal(t, []string{"account.core.openmfp.org/fga"}, routine.Finalizers()) } -func getStoreMocks(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( - func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( - func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - - *account = v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "first-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, - }, - }, - } - - return nil - }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( - func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - return nil - }).Once() - - openFGAServiceClientMock.EXPECT().ListStores(context.Background(), mock.Anything).Return(&openfgav1.ListStoresResponse{Stores: []*openfgav1.Store{{Id: "1", Name: "tenant-first-level"}}}, nil).Maybe() -} - -func TestCreatorSubroutine_Process(t *testing.T) { - namespace := "test-openmfp-namespace" +func TestFGASubroutine_Process(t *testing.T) { creator := "test-creator" testCases := []struct { @@ -113,7 +64,7 @@ func TestCreatorSubroutine_Process(t *testing.T) { Status: v1alpha1.AccountStatus{ Conditions: []metav1.Condition{ { - Type: "CreatorSubroutine_Ready", + Type: "FGASubroutine_Ready", Status: metav1.ConditionTrue, }, }, @@ -130,44 +81,32 @@ func TestCreatorSubroutine_Process(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { + mockGetWorkspaceByName(clientMock, kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org") clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) + account := o.(*v1alpha1.AccountInfo) - *ns = corev1.Namespace{ + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, - }, - } - return nil - }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - - *account = v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, }, } return nil }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(nil) - openFGAServiceClientMock.EXPECT().ListStores(mock.Anything, mock.Anything).Return(nil, assert.AnError) }, }, { @@ -180,19 +119,7 @@ func TestCreatorSubroutine_Process(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() + mockGetWorkspaceByName(clientMock, kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org") clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(assert.AnError) }, }, @@ -206,49 +133,36 @@ func TestCreatorSubroutine_Process(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - getStoreMocks(openFGAServiceClientMock, k8ServiceMock, clientMock) - - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() + mockGetWorkspaceByName(clientMock, kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org").Once() clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - *account = v1alpha1.Account{ + account := o.(*v1alpha1.AccountInfo) + + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + ParentAccount: &v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, + Account: v1alpha1.AccountLocation{}, + FGA: v1alpha1.FGAInfo{Store: v1alpha1.StoreInfo{Id: "123123"}}, }, } return nil }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(nil) - - openFGAServiceClientMock.EXPECT(). - Write(mock.Anything, mock.Anything). - Return(nil, assert.AnError) - + openFGAServiceClientMock.EXPECT().Write(mock.Anything, mock.Anything).Return(nil, assert.AnError) }, }, { @@ -260,45 +174,40 @@ func TestCreatorSubroutine_Process(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - getStoreMocks(openFGAServiceClientMock, k8ServiceMock, clientMock) - - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() + mockGetWorkspaceByName(clientMock, kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org").Once() clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - *account = v1alpha1.Account{ + account := o.(*v1alpha1.AccountInfo) + + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + ParentAccount: &v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, + FGA: v1alpha1.FGAInfo{Store: v1alpha1.StoreInfo{Id: "123123"}}, }, } return nil }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(nil) - openFGAServiceClientMock.EXPECT(). Write(mock.Anything, mock.Anything). Return(nil, newFgaError(openfgav1.ErrorCode_write_failed_due_to_invalid_input, "error")) @@ -313,45 +222,40 @@ func TestCreatorSubroutine_Process(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - getStoreMocks(openFGAServiceClientMock, k8ServiceMock, clientMock) - + mockGetWorkspaceByName(clientMock, kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org").Once() clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - *account = v1alpha1.Account{ + account := o.(*v1alpha1.AccountInfo) + + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, + ParentAccount: &v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + FGA: v1alpha1.FGAInfo{Store: v1alpha1.StoreInfo{Id: "123123"}}, }, } return nil }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(nil) - openFGAServiceClientMock.EXPECT(). Write(mock.Anything, mock.Anything). Return(&openfgav1.WriteResponse{}, nil) @@ -369,45 +273,40 @@ func TestCreatorSubroutine_Process(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - getStoreMocks(openFGAServiceClientMock, k8ServiceMock, clientMock) - - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() + mockGetWorkspaceByName(clientMock, kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org").Once() clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - *account = v1alpha1.Account{ + account := o.(*v1alpha1.AccountInfo) + + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + ParentAccount: &v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + FGA: v1alpha1.FGAInfo{Store: v1alpha1.StoreInfo{Id: "123123"}}, }, } return nil }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(nil) - openFGAServiceClientMock.EXPECT(). Write(mock.Anything, mock.Anything). Return(&openfgav1.WriteResponse{}, nil) @@ -433,38 +332,35 @@ func TestCreatorSubroutine_Process(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - getStoreMocks(openFGAServiceClientMock, k8ServiceMock, clientMock) - + mockGetWorkspaceByName(clientMock, kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org").Once() clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - *account = v1alpha1.Account{ + account := o.(*v1alpha1.AccountInfo) + + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, + ParentAccount: &v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + FGA: v1alpha1.FGAInfo{Store: v1alpha1.StoreInfo{Id: "123123"}}, }, } @@ -485,44 +381,40 @@ func TestCreatorSubroutine_Process(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - getStoreMocks(openFGAServiceClientMock, k8ServiceMock, clientMock) - - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() + mockGetWorkspaceByName(clientMock, kcpcorev1alpha1.LogicalClusterPhaseReady, "root:openmfp:orgs:root-org").Once() clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - *account = v1alpha1.Account{ + account := o.(*v1alpha1.AccountInfo) + + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, + ParentAccount: &v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + FGA: v1alpha1.FGAInfo{Store: v1alpha1.StoreInfo{Id: "123123"}}, }, } return nil }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(nil) openFGAServiceClientMock.EXPECT(). Write(mock.Anything, mock.Anything). @@ -542,21 +434,21 @@ func TestCreatorSubroutine_Process(t *testing.T) { test.setupMocks(openFGAClient, accountClient, clientMock) } - routine := subroutines.NewFGASubroutine(clientMock, openFGAClient, accountClient, namespace, "owner", "parent", "account") - ctx := context.Background() + routine := subroutines.NewFGASubroutine(clientMock, openFGAClient, "owner", "parent", "account") + ctx := kontext.WithCluster(context.Background(), "abcdefghi") _, err := routine.Process(ctx, test.account) if test.expectedError { assert.NotNil(t, err) } else { assert.Nil(t, err) } + clientMock.AssertExpectations(t) }) } } func TestCreatorSubroutine_Finalize(t *testing.T) { - namespace := "test-openmfp-namespace" creator := "test-creator" testCases := []struct { @@ -576,43 +468,30 @@ func TestCreatorSubroutine_Finalize(t *testing.T) { }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) + account := o.(*v1alpha1.AccountInfo) - *ns = corev1.Namespace{ + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, - }, - } - return nil - }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - - *account = v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, }, } return nil }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(nil) - openFGAServiceClientMock.EXPECT().ListStores(mock.Anything, mock.Anything).Return(nil, assert.AnError) }, }, { @@ -625,19 +504,6 @@ func TestCreatorSubroutine_Finalize(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(assert.AnError) }, }, @@ -651,46 +517,36 @@ func TestCreatorSubroutine_Finalize(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { + clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - getStoreMocks(openFGAServiceClientMock, k8ServiceMock, clientMock) + account := o.(*v1alpha1.AccountInfo) - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - *ns = corev1.Namespace{ + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, - }, - } - return nil - }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - - *account = v1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, + FGA: v1alpha1.FGAInfo{Store: v1alpha1.StoreInfo{Id: "123123"}}, }, } return nil }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(nil) - openFGAServiceClientMock.EXPECT().Write(mock.Anything, mock.Anything).Return(nil, assert.AnError) + openFGAServiceClientMock.EXPECT(). + Write(mock.Anything, mock.Anything). + Return(nil, assert.AnError) }, }, @@ -703,45 +559,33 @@ func TestCreatorSubroutine_Finalize(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - getStoreMocks(openFGAServiceClientMock, k8ServiceMock, clientMock) - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - *account = v1alpha1.Account{ + account := o.(*v1alpha1.AccountInfo) + + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + FGA: v1alpha1.FGAInfo{Store: v1alpha1.StoreInfo{Id: "123123"}}, }, } return nil }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(nil) - openFGAServiceClientMock.EXPECT(). Write(mock.Anything, mock.Anything). Return(nil, newFgaError(openfgav1.ErrorCode_write_failed_due_to_invalid_input, "error")) @@ -756,44 +600,33 @@ func TestCreatorSubroutine_Finalize(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - getStoreMocks(openFGAServiceClientMock, k8ServiceMock, clientMock) - - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - *account = v1alpha1.Account{ + account := o.(*v1alpha1.AccountInfo) + + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, + FGA: v1alpha1.FGAInfo{Store: v1alpha1.StoreInfo{Id: "123123"}}, }, } return nil }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(nil) openFGAServiceClientMock.EXPECT(). Write(mock.Anything, mock.Anything). @@ -812,44 +645,33 @@ func TestCreatorSubroutine_Finalize(t *testing.T) { }, }, setupMocks: func(openFGAServiceClientMock *mocks.OpenFGAServiceClient, k8ServiceMock *mocks.K8Service, clientMock *mocks.Client) { - getStoreMocks(openFGAServiceClientMock, k8ServiceMock, clientMock) - - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - ns := o.(*corev1.Namespace) - *ns = corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "first-level", - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: "first-level", - }, - }, - } - return nil - }).Once() clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, nn types.NamespacedName, o client.Object, opts ...client.GetOption) error { - account := o.(*v1alpha1.Account) - *account = v1alpha1.Account{ + account := o.(*v1alpha1.AccountInfo) + + *account = v1alpha1.AccountInfo{ ObjectMeta: metav1.ObjectMeta{ - Name: "fist-level", - Namespace: "first-level", - }, - Spec: v1alpha1.AccountSpec{ - Extensions: []v1alpha1.Extension{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "AccountExtension", - APIVersion: "core.openmfp.io/v1alpha1", - }, - SpecGoTemplate: apiextensionsv1.JSON{}, - }, + Name: "root-org", + }, + Spec: v1alpha1.AccountInfoSpec{ + Organization: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, + }, + Account: v1alpha1.AccountLocation{ + Name: "root-org", + Path: "root:openmfp:org:root-org", + URL: "http://example.com/clusters/root:openmfp:org:root-org", + Type: v1alpha1.AccountTypeOrg, }, + FGA: v1alpha1.FGAInfo{Store: v1alpha1.StoreInfo{Id: "123123"}}, }, } return nil }).Once() - clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(nil) openFGAServiceClientMock.EXPECT(). Write(mock.Anything, mock.Anything). @@ -869,8 +691,8 @@ func TestCreatorSubroutine_Finalize(t *testing.T) { test.setupMocks(openFGAClient, accountClient, k8sClient) } - routine := subroutines.NewFGASubroutine(k8sClient, openFGAClient, accountClient, namespace, "owner", "parent", "account") - ctx := context.Background() + routine := subroutines.NewFGASubroutine(k8sClient, openFGAClient, "owner", "parent", "account") + ctx := kontext.WithCluster(context.Background(), "abcdefghi") _, err := routine.Finalize(ctx, test.account) if test.expectedError { assert.NotNil(t, err) diff --git a/pkg/subroutines/mocks/mock_OpenFGAServiceClient.go b/pkg/subroutines/mocks/mock_OpenFGAServiceClient.go index d227e8b..9d3645b 100644 --- a/pkg/subroutines/mocks/mock_OpenFGAServiceClient.go +++ b/pkg/subroutines/mocks/mock_OpenFGAServiceClient.go @@ -5,9 +5,9 @@ package mocks import ( context "context" - grpc "google.golang.org/grpc" - mock "github.com/stretchr/testify/mock" openfgav1 "github.com/openfga/api/proto/openfga/v1" + mock "github.com/stretchr/testify/mock" + grpc "google.golang.org/grpc" ) // OpenFGAServiceClient is an autogenerated mock type for the OpenFGAServiceClient type diff --git a/pkg/subroutines/namespace.go b/pkg/subroutines/namespace.go deleted file mode 100644 index aae2945..0000000 --- a/pkg/subroutines/namespace.go +++ /dev/null @@ -1,147 +0,0 @@ -package subroutines - -import ( - "context" - - v1 "k8s.io/api/core/v1" - kerrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - - "github.com/openmfp/golang-commons/controller/lifecycle" - "github.com/openmfp/golang-commons/errors" - - corev1alpha1 "github.com/openmfp/account-operator/api/v1alpha1" -) - -const ( - NamespaceSubroutineName = "NamespaceSubroutine" - NamespaceSubroutineFinalizer = "account.core.openmfp.io/finalizer" - NamespaceNamePrefix = "account-" -) - -type NamespaceSubroutine struct { - client client.Client -} - -func NewNamespaceSubroutine(client client.Client) *NamespaceSubroutine { - return &NamespaceSubroutine{client: client} -} - -func (r *NamespaceSubroutine) GetName() string { - return NamespaceSubroutineName -} - -func (r *NamespaceSubroutine) Finalize(ctx context.Context, runtimeObj lifecycle.RuntimeObject) (ctrl.Result, errors.OperatorError) { - instance := runtimeObj.(*corev1alpha1.Account) - - if instance.Status.Namespace == nil { - return ctrl.Result{}, nil - } - - ns := v1.Namespace{} - err := r.client.Get(ctx, client.ObjectKey{Name: *instance.Status.Namespace}, &ns) - if kerrors.IsNotFound(err) { - return ctrl.Result{}, nil - } - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, true) - } - - if ns.GetDeletionTimestamp() != nil { - return ctrl.Result{Requeue: true}, nil - } - - err = r.client.Delete(ctx, &ns) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, true) - } - - return ctrl.Result{Requeue: true}, nil // we need to requeue to check if the namespace was deleted -} - -func (r *NamespaceSubroutine) Finalizers() []string { // coverage-ignore - return []string{"account.core.openmfp.io/finalizer"} -} - -func (r *NamespaceSubroutine) Process(ctx context.Context, runtimeObj lifecycle.RuntimeObject) (ctrl.Result, errors.OperatorError) { - instance := runtimeObj.(*corev1alpha1.Account) - - // Test if namespace was already created based on status - createdNamespace := &v1.Namespace{} - if instance.Status.Namespace != nil { - createdNamespace = generateNamespace(instance) - _, err := controllerutil.CreateOrUpdate(ctx, r.client, createdNamespace, func() error { - return setNamespaceLabels(createdNamespace, instance) - }) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, true) - } - } else { - if instance.Spec.Namespace != nil { - createdNamespace = &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: *instance.Spec.Namespace}} - _, err := controllerutil.CreateOrUpdate(ctx, r.client, createdNamespace, func() error { - return setNamespaceLabels(createdNamespace, instance) - }) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, true) - } - } else { - // Create New Namespace - createdNamespace = generateNamespace(instance) - err := r.client.Create(ctx, createdNamespace) - if err != nil { - return ctrl.Result{}, errors.NewOperatorError(err, true, true) - } - } - } - - instance.Status.Namespace = &createdNamespace.Name - return ctrl.Result{}, nil -} - -var NamespaceOwnedByAnotherAccountErr = errors.New("Namespace already owned by another account") -var NamespaceOwnedByAnAccountInAnotherNamespaceErr = errors.New("Namespace already owned by another account in another namespace") - -func setNamespaceLabels(ns *v1.Namespace, instance *corev1alpha1.Account) error { - accountOwner, hasOwnerLabel := ns.Labels[corev1alpha1.NamespaceAccountOwnerLabel] - accountOwnerNamespace, hasOwnerNamespaceLabel := ns.Labels[corev1alpha1.NamespaceAccountOwnerNamespaceLabel] - - if hasOwnerLabel && accountOwner != instance.GetName() { - return NamespaceOwnedByAnotherAccountErr - } - - if hasOwnerNamespaceLabel && accountOwnerNamespace != instance.GetNamespace() { - return NamespaceOwnedByAnAccountInAnotherNamespaceErr - } - - if !hasOwnerLabel || !hasOwnerNamespaceLabel { - if ns.Labels == nil { - ns.Labels = make(map[string]string) - } - ns.Labels[corev1alpha1.NamespaceAccountOwnerLabel] = instance.GetName() - ns.Labels[corev1alpha1.NamespaceAccountOwnerNamespaceLabel] = instance.GetNamespace() - } - - return nil -} - -func generateNamespace(instance *corev1alpha1.Account) *v1.Namespace { - ns := &v1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: instance.GetName(), - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: instance.GetNamespace(), - }, - }, - } - - if instance.Status.Namespace != nil { - ns.Name = *instance.Status.Namespace - } else { - ns.ObjectMeta.GenerateName = NamespaceNamePrefix - } - return ns -} diff --git a/pkg/subroutines/namespace_test.go b/pkg/subroutines/namespace_test.go deleted file mode 100644 index 2799102..0000000 --- a/pkg/subroutines/namespace_test.go +++ /dev/null @@ -1,488 +0,0 @@ -package subroutines_test - -import ( - "context" - "testing" - - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/suite" - v1 "k8s.io/api/core/v1" - kerrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/openmfp/golang-commons/errors" - - corev1alpha1 "github.com/openmfp/account-operator/api/v1alpha1" - "github.com/openmfp/account-operator/pkg/subroutines" - "github.com/openmfp/account-operator/pkg/subroutines/mocks" -) - -const defaultExpectedTestNamespace = "account-test" - -type NamespaceSubroutineTestSuite struct { - suite.Suite - - // Tested Object(s) - testObj *subroutines.NamespaceSubroutine - - // Mocks - clientMock *mocks.Client -} - -func (suite *NamespaceSubroutineTestSuite) SetupTest() { - // Setup Mocks - suite.clientMock = new(mocks.Client) - - // Initialize Tested Object(s) - suite.testObj = subroutines.NewNamespaceSubroutine(suite.clientMock) -} - -func (suite *NamespaceSubroutineTestSuite) TestGetName_OK() { - // When - result := suite.testObj.GetName() - - // Then - suite.Equal(subroutines.NamespaceSubroutineName, result) -} - -func (suite *NamespaceSubroutineTestSuite) TestFinalize_OK() { - // Given - testAccount := &corev1alpha1.Account{} - - // When - res, err := suite.testObj.Finalize(context.Background(), testAccount) - - // Then - suite.False(res.Requeue) - suite.Assert().Zero(res.RequeueAfter) - suite.Nil(err) -} - -func (suite *NamespaceSubroutineTestSuite) TestProcessingNamespace_NoFinalizer_OK() { - // Given - testAccount := &corev1alpha1.Account{} - mockNewNamespaceCreateCall(suite, defaultExpectedTestNamespace) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.Require().NotNil(testAccount.Status.Namespace) - suite.Equal(defaultExpectedTestNamespace, *testAccount.Status.Namespace) - - suite.Nil(err) -} - -func (suite *NamespaceSubroutineTestSuite) TestProcessingNamespace_NoFinalizer_CreateError() { - // Given - testAccount := &corev1alpha1.Account{} - suite.clientMock.EXPECT(). - Create(mock.Anything, mock.Anything). - Return(kerrors.NewBadRequest("")) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.Nil(testAccount.Status.Namespace) - suite.NotNil(err) - suite.True(err.Retry()) - suite.True(err.Sentry()) -} - -func (suite *NamespaceSubroutineTestSuite) TestProcessingWithNamespaceInStatus() { - // Given - testAccount := &corev1alpha1.Account{ - Status: corev1alpha1.AccountStatus{ - Namespace: ptr.To(defaultExpectedTestNamespace), - }, - } - mockGetNamespaceCallWithLabels(suite, defaultExpectedTestNamespace, map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: testAccount.Name, - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: testAccount.Namespace, - }) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.Require().NotNil(testAccount.Status.Namespace) - suite.Equal(defaultExpectedTestNamespace, *testAccount.Status.Namespace) - - suite.Nil(err) -} - -func (suite *NamespaceSubroutineTestSuite) TestProcessingWithNamespaceInStatus_LookupError() { - // Given - testAccount := &corev1alpha1.Account{ - Status: corev1alpha1.AccountStatus{ - Namespace: ptr.To(defaultExpectedTestNamespace), - }, - } - suite.clientMock.EXPECT(). - Get(mock.Anything, mock.Anything, mock.Anything). - Return(kerrors.NewBadRequest("")) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.NotNil(err) - suite.True(err.Retry()) - suite.True(err.Sentry()) -} - -func (suite *NamespaceSubroutineTestSuite) TestProcessingWithNamespaceInStatusMissingLabels() { - // Given - testAccount := &corev1alpha1.Account{ - Status: corev1alpha1.AccountStatus{ - Namespace: ptr.To(defaultExpectedTestNamespace), - }, - } - mockGetNamespaceCallWithLabels(suite, defaultExpectedTestNamespace, map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: testAccount.Name, - }) - mockNewNamespaceUpdateCall(suite) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.Require().NotNil(testAccount.Status.Namespace) - suite.Equal(defaultExpectedTestNamespace, *testAccount.Status.Namespace) - - suite.Nil(err) -} - -// Test like TestProcessingWithNamespaceInStatusMissingLabels but the update call fails unexpectedly -func (suite *NamespaceSubroutineTestSuite) TestProcessingWithNamespaceInStatusMissingLabels_UpdateError() { - // Given - testAccount := &corev1alpha1.Account{ - Status: corev1alpha1.AccountStatus{ - Namespace: ptr.To(defaultExpectedTestNamespace), - }, - } - mockGetNamespaceCallWithLabels(suite, defaultExpectedTestNamespace, map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: testAccount.Name, - }) - suite.clientMock.EXPECT(). - Update(mock.Anything, mock.Anything). - Return(kerrors.NewBadRequest("")) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.NotNil(err) - suite.True(err.Retry()) - suite.True(err.Sentry()) -} - -func (suite *NamespaceSubroutineTestSuite) TestProcessingWithNamespaceInStatusMissingLabels2() { - // Given - testAccount := &corev1alpha1.Account{ - Status: corev1alpha1.AccountStatus{ - Namespace: ptr.To(defaultExpectedTestNamespace), - }, - } - mockGetNamespaceCallWithLabels(suite, defaultExpectedTestNamespace, nil) - mockNewNamespaceUpdateCall(suite) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.Require().NotNil(testAccount.Status.Namespace) - suite.Equal(defaultExpectedTestNamespace, *testAccount.Status.Namespace) - - suite.Nil(err) -} - -func (suite *NamespaceSubroutineTestSuite) TestProcessingWithNamespaceInStatusAndNotFound() { - // Given - testAccount := &corev1alpha1.Account{ - Status: corev1alpha1.AccountStatus{ - Namespace: ptr.To(defaultExpectedTestNamespace), - }, - } - mockGetNamespaceCallNotFound(suite) - mockNewNamespaceCreateCall(suite, defaultExpectedTestNamespace) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.Require().NotNil(testAccount.Status.Namespace) - suite.Equal(defaultExpectedTestNamespace, *testAccount.Status.Namespace) - - suite.Nil(err) -} - -func (suite *NamespaceSubroutineTestSuite) TestProcessingWithDeclaredNamespace_OK() { - // Given - namespaceName := "a-names-space" - testAccount := &corev1alpha1.Account{ - Spec: corev1alpha1.AccountSpec{ - Namespace: &namespaceName, - }, - } - mockGetNamespaceCallWithLabels(suite, namespaceName, map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: testAccount.Name, - corev1alpha1.NamespaceAccountOwnerNamespaceLabel: testAccount.Namespace, - }) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.Require().NotNil(testAccount.Status.Namespace) - suite.Equal(namespaceName, *testAccount.Status.Namespace) - - suite.Nil(err) - -} - -func (suite *NamespaceSubroutineTestSuite) TestProcessingWithDeclaredNamespaceNotFound() { - // Given - namespaceName := "a-names-space" - testAccount := &corev1alpha1.Account{ - Spec: corev1alpha1.AccountSpec{ - Namespace: &namespaceName, - }, - } - mockGetNamespaceCallNotFound(suite) - - mockNewNamespaceCreateCall(suite, namespaceName) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.Equal(namespaceName, *testAccount.Status.Namespace) - suite.Nil(err) -} - -func (suite *NamespaceSubroutineTestSuite) TestProcessingWithDeclaredNamespaceLookupError() { - // Given - namespaceName := "a-names-space" - testAccount := &corev1alpha1.Account{ - Spec: corev1alpha1.AccountSpec{ - Namespace: &namespaceName, - }, - } - suite.clientMock.EXPECT(). - Get(mock.Anything, mock.Anything, mock.Anything). - Return(kerrors.NewBadRequest("")) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.Nil(testAccount.Status.Namespace) - suite.NotNil(err) - suite.True(err.Retry()) - suite.True(err.Sentry()) -} - -// Test finalize function and expect no error -func (suite *NamespaceSubroutineTestSuite) TestFinalizeNamespace_OK() { - // Given - testAccount := &corev1alpha1.Account{} - - // When - res, err := suite.testObj.Finalize(context.Background(), testAccount) - - // Then - suite.False(res.Requeue) - suite.Assert().Zero(res.RequeueAfter) - suite.Nil(err) -} - -// Test an account with a namspace in the spec, where the already existing namespace has different owner labels -func (suite *NamespaceSubroutineTestSuite) TestProcessingWithDeclaredNamespaceMismatchedOwnerLabels() { - // Given - namespaceName := "a-names-space" - testAccount := &corev1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{Name: "test-account"}, - Spec: corev1alpha1.AccountSpec{ - Namespace: &namespaceName, - }, - } - mockGetNamespaceCallWithLabels(suite, namespaceName, map[string]string{ - corev1alpha1.NamespaceAccountOwnerLabel: "different-owner", - }) - - // When - _, err := suite.testObj.Process(context.Background(), testAccount) - - // Then - suite.Require().Nil(testAccount.Status.Namespace) - suite.NotNil(err) -} - -func (suite *NamespaceSubroutineTestSuite) TestFinalizationWithNamespaceInStatus() { - namespaceName := "a-names-space" - testAccount := &corev1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{Name: "test-account"}, - Status: corev1alpha1.AccountStatus{ - Namespace: &namespaceName, - }, - } - - mockGetNamespaceCallWithName(suite, namespaceName) - mockDeleteNamespaceCall(suite) - - result, err := suite.testObj.Finalize(context.Background(), testAccount) - suite.Require().Nil(err) - suite.Require().True(result.Requeue) -} - -func (suite *NamespaceSubroutineTestSuite) TestFinalizationWithNamespaceInStatus_Error() { - namespaceName := "a-names-space" - testAccount := &corev1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{Name: "test-account"}, - Status: corev1alpha1.AccountStatus{ - Namespace: &namespaceName, - }, - } - - mockGetNamespaceCallWithError(suite, errors.New("error")) - - _, err := suite.testObj.Finalize(context.Background(), testAccount) - suite.Require().NotNil(err) -} - -func (suite *NamespaceSubroutineTestSuite) TestFinalizationWithNamespaceInStatus_DeletionError() { - namespaceName := "a-names-space" - testAccount := &corev1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{Name: "test-account"}, - Status: corev1alpha1.AccountStatus{ - Namespace: &namespaceName, - }, - } - - mockGetNamespaceCallWithName(suite, namespaceName) - mockDeleteNamespaceCallWithError(suite, errors.New("error")) - - _, err := suite.testObj.Finalize(context.Background(), testAccount) - suite.Require().NotNil(err) -} - -func (suite *NamespaceSubroutineTestSuite) TestFinalizationWithNamespaceInStatus_DeletionTimestampSet() { - namespaceName := "a-names-space" - testAccount := &corev1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{Name: "test-account"}, - Status: corev1alpha1.AccountStatus{ - Namespace: &namespaceName, - }, - } - - mockGetNamespaceCallWithNameAndDeletionTimestamp(suite, namespaceName) - - result, err := suite.testObj.Finalize(context.Background(), testAccount) - suite.Require().Nil(err) - suite.Require().True(result.Requeue) -} - -func (suite *NamespaceSubroutineTestSuite) TestFinalizationWithNamespaceInStatus_NamespaceGone() { - namespaceName := "a-names-space" - testAccount := &corev1alpha1.Account{ - ObjectMeta: metav1.ObjectMeta{Name: "test-account"}, - Status: corev1alpha1.AccountStatus{ - Namespace: &namespaceName, - }, - } - - mockGetNamespaceCallNotFound(suite) - - result, err := suite.testObj.Finalize(context.Background(), testAccount) - suite.Require().Nil(err) - suite.Require().False(result.Requeue) -} - -func TestNamespaceSubroutineTestSuite(t *testing.T) { - suite.Run(t, new(NamespaceSubroutineTestSuite)) -} - -//nolint:golint,unparam -func mockNewNamespaceCreateCall(suite *NamespaceSubroutineTestSuite, generatedName string) *mocks.Client_Create_Call { - return suite.clientMock.EXPECT(). - Create(mock.Anything, mock.Anything). - Run(func(ctx context.Context, obj client.Object, opts ...client.CreateOption) { - actual, _ := obj.(*v1.Namespace) - actual.Name = generatedName - }). - Return(nil) -} - -//nolint:golint,unparam -func mockNewNamespaceUpdateCall(suite *NamespaceSubroutineTestSuite) *mocks.Client_Update_Call { - return suite.clientMock.EXPECT(). - Update(mock.Anything, mock.Anything). - Return(nil) -} - -//nolint:golint,unparam -func mockGetNamespaceCallWithLabels(suite *NamespaceSubroutineTestSuite, name string, labels map[string]string) *mocks.Client_Get_Call { - return suite.clientMock.EXPECT(). - Get(mock.Anything, mock.Anything, mock.Anything). - Run(func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) { - actual, _ := obj.(*v1.Namespace) - actual.Name = name - actual.Labels = labels - }). - Return(nil) -} - -//nolint:golint,unparam -func mockGetNamespaceCallWithName(suite *NamespaceSubroutineTestSuite, name string) *mocks.Client_Get_Call { - return suite.clientMock.EXPECT(). - Get(mock.Anything, mock.Anything, mock.Anything). - Run(func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) { - actual, _ := obj.(*v1.Namespace) - actual.Name = name - }). - Return(nil) -} - -//nolint:golint,unparam -func mockGetNamespaceCallWithNameAndDeletionTimestamp(suite *NamespaceSubroutineTestSuite, name string) *mocks.Client_Get_Call { - return suite.clientMock.EXPECT(). - Get(mock.Anything, mock.Anything, mock.Anything). - Run(func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) { - actual, _ := obj.(*v1.Namespace) - actual.Name = name - actual.DeletionTimestamp = &metav1.Time{} - }). - Return(nil) -} - -//nolint:golint,unparam -func mockDeleteNamespaceCall(suite *NamespaceSubroutineTestSuite) *mocks.Client_Delete_Call { - return suite.clientMock.EXPECT(). - Delete(mock.Anything, mock.Anything). - Return(nil) -} - -//nolint:golint,unparam -func mockDeleteNamespaceCallWithError(suite *NamespaceSubroutineTestSuite, err error) *mocks.Client_Delete_Call { - return suite.clientMock.EXPECT(). - Delete(mock.Anything, mock.Anything). - Return(err) -} - -func mockGetNamespaceCallNotFound( - suite *NamespaceSubroutineTestSuite) *mocks.Client_Get_Call { - return suite.clientMock.EXPECT(). - Get(mock.Anything, mock.Anything, mock.Anything). - Return(kerrors.NewNotFound(schema.GroupResource{}, "")) -} - -func mockGetNamespaceCallWithError(suite *NamespaceSubroutineTestSuite, err error) { - suite.clientMock.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything). - Return(err) -} diff --git a/pkg/subroutines/workspace.go b/pkg/subroutines/workspace.go new file mode 100644 index 0000000..b5fee0a --- /dev/null +++ b/pkg/subroutines/workspace.go @@ -0,0 +1,83 @@ +package subroutines + +import ( + "context" + + kcptenancyv1alpha "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" + commonconfig "github.com/openmfp/golang-commons/config" + "github.com/openmfp/golang-commons/controller/lifecycle" + "github.com/openmfp/golang-commons/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + corev1alpha1 "github.com/openmfp/account-operator/api/v1alpha1" + "github.com/openmfp/account-operator/internal/config" +) + +const ( + WorkspaceSubroutineName = "WorkspaceSubroutine" + WorkspaceSubroutineFinalizer = "account.core.openmfp.org/finalizer" +) + +type WorkspaceSubroutine struct { + client client.Client +} + +func NewWorkspaceSubroutine(client client.Client) *WorkspaceSubroutine { + return &WorkspaceSubroutine{client: client} +} + +func (r *WorkspaceSubroutine) GetName() string { + return WorkspaceSubroutineName +} + +func (r *WorkspaceSubroutine) Finalize(ctx context.Context, runtimeObj lifecycle.RuntimeObject) (ctrl.Result, errors.OperatorError) { + instance := runtimeObj.(*corev1alpha1.Account) + + ws := kcptenancyv1alpha.Workspace{} + err := r.client.Get(ctx, client.ObjectKey{Name: instance.Name}, &ws) + if kerrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(err, true, true) + } + + if ws.GetDeletionTimestamp() != nil { + return ctrl.Result{Requeue: true}, nil + } + + err = r.client.Delete(ctx, &ws) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(err, true, true) + } + + return ctrl.Result{Requeue: true}, nil // we need to requeue to check if the namespace was deleted +} + +func (r *WorkspaceSubroutine) Finalizers() []string { // coverage-ignore + return []string{"account.core.openmfp.org/finalizer"} +} + +func (r *WorkspaceSubroutine) Process(ctx context.Context, runtimeObj lifecycle.RuntimeObject) (ctrl.Result, errors.OperatorError) { + instance := runtimeObj.(*corev1alpha1.Account) + cfg := commonconfig.LoadConfigFromContext(ctx).(config.Config) + + // Test if namespace was already created based on status + createdWorkspace := &kcptenancyv1alpha.Workspace{ObjectMeta: metav1.ObjectMeta{Name: instance.Name}} + _, err := controllerutil.CreateOrUpdate(ctx, r.client, createdWorkspace, func() error { + createdWorkspace.Spec.Type = kcptenancyv1alpha.WorkspaceTypeReference{ + Name: kcptenancyv1alpha.WorkspaceTypeName(instance.Spec.Type), + Path: cfg.Kcp.ProviderWorkspace, + } + + return controllerutil.SetOwnerReference(instance, createdWorkspace, r.client.Scheme()) + }) + if err != nil { + return ctrl.Result{}, errors.NewOperatorError(err, true, true) + } + return ctrl.Result{}, nil +} diff --git a/pkg/subroutines/workspace_test.go b/pkg/subroutines/workspace_test.go new file mode 100644 index 0000000..3a79942 --- /dev/null +++ b/pkg/subroutines/workspace_test.go @@ -0,0 +1,257 @@ +package subroutines_test + +import ( + "context" + "fmt" + "testing" + + kcpcorev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + kcptenancyv1alpha "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" + openmfpcontext "github.com/openmfp/golang-commons/context" + "github.com/openmfp/golang-commons/logger" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1alpha1 "github.com/openmfp/account-operator/api/v1alpha1" + "github.com/openmfp/account-operator/internal/config" + "github.com/openmfp/account-operator/pkg/subroutines" + "github.com/openmfp/account-operator/pkg/subroutines/mocks" +) + +const defaultExpectedTestNamespace = "account-test" + +type WorkspaceSubroutineTestSuite struct { + suite.Suite + + // Tested Object(s) + testObj *subroutines.WorkspaceSubroutine + + // Mocks + clientMock *mocks.Client + + context context.Context +} + +func (suite *WorkspaceSubroutineTestSuite) SetupTest() { + // Setup Mocks + suite.clientMock = new(mocks.Client) + + // Initialize Tested Object(s) + suite.testObj = subroutines.NewWorkspaceSubroutine(suite.clientMock) + + utilruntime.Must(corev1alpha1.AddToScheme(scheme.Scheme)) + utilruntime.Must(corev1.AddToScheme(scheme.Scheme)) + + cfg, err := config.NewFromEnv() + suite.Require().NoError(err) + log, err := logger.New(logger.DefaultConfig()) + suite.Require().NoError(err) + suite.context, _, _ = openmfpcontext.StartContext(log, cfg, cfg.ShutdownTimeout) +} + +func (suite *WorkspaceSubroutineTestSuite) TestGetName_OK() { + // When + result := suite.testObj.GetName() + + // Then + suite.Equal(subroutines.WorkspaceSubroutineName, result) +} + +func (suite *WorkspaceSubroutineTestSuite) TestGetFinalizerName() { + // When + finalizers := suite.testObj.Finalizers() + + // Then + suite.Contains(finalizers, subroutines.WorkspaceSubroutineFinalizer) +} + +func (suite *WorkspaceSubroutineTestSuite) TestFinalize_OK_Workspace_NotExisting() { + // Given + testAccount := &corev1alpha1.Account{} + mockGetWorkspaceCallNotFound(suite) + + // When + res, err := suite.testObj.Finalize(context.Background(), testAccount) + + // Then + suite.False(res.Requeue) + suite.Assert().Zero(res.RequeueAfter) + suite.Nil(err) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *WorkspaceSubroutineTestSuite) TestFinalize_OK_Workspace_ExistingButInDeletion() { + // Given + testAccount := &corev1alpha1.Account{} + mockGetWorkspaceByNameInDeletion(suite) + + // When + res, err := suite.testObj.Finalize(context.Background(), testAccount) + + // Then + suite.True(res.Requeue) + suite.Assert().Zero(res.RequeueAfter) + suite.Nil(err) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *WorkspaceSubroutineTestSuite) TestFinalize_OK_Workspace_Existing() { + // Given + testAccount := &corev1alpha1.Account{} + mockGetWorkspaceByName(suite.clientMock, kcpcorev1alpha1.LogicalClusterPhaseReady, "https://example.com/") + mockDeleteWorkspaceCall(suite) + + // When + res, err := suite.testObj.Finalize(context.Background(), testAccount) + + // Then + suite.True(res.Requeue) + suite.Assert().Zero(res.RequeueAfter) + suite.Nil(err) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *WorkspaceSubroutineTestSuite) TestFinalize_Error_On_Deletion() { + // Given + testAccount := &corev1alpha1.Account{} + mockGetWorkspaceByName(suite.clientMock, kcpcorev1alpha1.LogicalClusterPhaseReady, "https://example.com/") + mockDeleteWorkspaceCallFailed(suite) + + // When + _, err := suite.testObj.Finalize(context.Background(), testAccount) + + // Then + suite.Require().NotNil(err) + suite.Error(err.Err()) + + suite.True(err.Sentry()) + suite.True(err.Retry()) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *WorkspaceSubroutineTestSuite) TestFinalize_Error_On_Get() { + // Given + testAccount := &corev1alpha1.Account{} + mockGetWorkspaceFailed(suite) + + // When + _, err := suite.testObj.Finalize(context.Background(), testAccount) + + // Then + suite.Require().NotNil(err) + suite.Error(err.Err()) + + suite.True(err.Sentry()) + suite.True(err.Retry()) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *WorkspaceSubroutineTestSuite) TestProcessing_OK() { + // Given + testAccount := &corev1alpha1.Account{} + suite.clientMock.On("Scheme").Return(scheme.Scheme) + mockGetWorkspaceCallNotFound(suite) + mockNewWorkspaceCreateCall(suite, defaultExpectedTestNamespace) + + // When + _, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.Nil(err) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *WorkspaceSubroutineTestSuite) TestProcessing_Error_On_Get() { + // Given + testAccount := &corev1alpha1.Account{} + mockGetWorkspaceFailed(suite) + + // When + _, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.Require().NotNil(err) + suite.Error(err.Err()) + suite.True(err.Sentry()) + suite.True(err.Retry()) + suite.clientMock.AssertExpectations(suite.T()) +} + +func (suite *WorkspaceSubroutineTestSuite) TestProcessing_CreateError() { + // Given + testAccount := &corev1alpha1.Account{} + suite.clientMock.On("Scheme").Return(scheme.Scheme) + mockGetWorkspaceCallNotFound(suite) + suite.clientMock.EXPECT(). + Create(mock.Anything, mock.Anything). + Return(kerrors.NewBadRequest("")) + + // When + _, err := suite.testObj.Process(suite.context, testAccount) + + // Then + suite.NotNil(err) + suite.True(err.Retry()) + suite.True(err.Sentry()) + suite.clientMock.AssertExpectations(suite.T()) +} + +func TestWorkspaceSubroutineTestSuite(t *testing.T) { + suite.Run(t, new(WorkspaceSubroutineTestSuite)) +} + +//nolint:golint,unparam +func mockNewWorkspaceCreateCall(suite *WorkspaceSubroutineTestSuite, name string) *mocks.Client_Create_Call { + return suite.clientMock.EXPECT(). + Create(mock.Anything, mock.Anything). + Run(func(ctx context.Context, obj client.Object, opts ...client.CreateOption) { + actual, _ := obj.(*kcptenancyv1alpha.Workspace) + actual.Name = name + }). + Return(nil) +} + +//nolint:golint,unparam +func mockGetWorkspaceCallNotFound(suite *WorkspaceSubroutineTestSuite) *mocks.Client_Get_Call { + return suite.clientMock.EXPECT(). + Get(mock.Anything, mock.Anything, mock.Anything). + Return(kerrors.NewNotFound(schema.GroupResource{}, "")) +} + +func mockGetWorkspaceFailed(suite *WorkspaceSubroutineTestSuite) *mocks.Client_Get_Call { + return suite.clientMock.EXPECT(). + Get(mock.Anything, types.NamespacedName{}, mock.Anything). + Return(kerrors.NewInternalError(fmt.Errorf("failed"))) +} + +func mockGetWorkspaceByNameInDeletion(suite *WorkspaceSubroutineTestSuite) *mocks.Client_Get_Call { + return suite.clientMock.EXPECT(). + Get(mock.Anything, types.NamespacedName{}, mock.Anything). + Run(func(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) { + actual, _ := obj.(*kcptenancyv1alpha.Workspace) + actual.Name = key.Name + actual.DeletionTimestamp = &metav1.Time{} + }). + Return(nil) +} + +//nolint:golint,unparam +func mockDeleteWorkspaceCall(suite *WorkspaceSubroutineTestSuite) *mocks.Client_Delete_Call { + return suite.clientMock.EXPECT(). + Delete(mock.Anything, mock.Anything). + Return(nil) +} + +func mockDeleteWorkspaceCallFailed(suite *WorkspaceSubroutineTestSuite) *mocks.Client_Delete_Call { + return suite.clientMock.EXPECT(). + Delete(mock.Anything, mock.Anything). + Return(kerrors.NewInternalError(fmt.Errorf("failed"))) +} diff --git a/pkg/testing/kcpenvtest/kcpserver.go b/pkg/testing/kcpenvtest/kcpserver.go new file mode 100644 index 0000000..dda367b --- /dev/null +++ b/pkg/testing/kcpenvtest/kcpserver.go @@ -0,0 +1,129 @@ +package kcpenvtest + +import ( + "io" + "net/url" + "os" + "path/filepath" + "time" + + "github.com/openmfp/golang-commons/logger" + + "github.com/openmfp/account-operator/pkg/testing/kcpenvtest/process" +) + +type KCPServer struct { + processState *process.State + Out io.Writer + Err io.Writer + StartTimeout time.Duration + StopTimeout time.Duration + Dir string + Binary string + Args []string + PathToRoot string + + log *logger.Logger + args *process.Arguments +} + +func NewKCPServer(baseDir string, binary string, pathToRoot string, log *logger.Logger) *KCPServer { + return &KCPServer{ + Dir: baseDir, + Binary: binary, + Args: []string{"start", "-v=1"}, + PathToRoot: pathToRoot, + log: log, + } +} + +func (s *KCPServer) Start() error { + if err := s.prepare(); err != nil { + return err + } + return s.processState.Start(s.Out, s.Err, s.log) +} + +func (s *KCPServer) prepare() error { + if s.Out == nil || s.Err == nil { + //create file writer for the logs + fileOut := filepath.Join(s.PathToRoot, "kcp.log") + out, err := os.Create(fileOut) + if err != nil { + return err + } + writer := io.Writer(out) + + if s.Out == nil { + s.Out = writer + } + if s.Err == nil { + s.Err = writer + } + } + + if err := s.setProcessState(); err != nil { + return err + } + return nil +} + +func (s *KCPServer) setProcessState() error { + var err error + + healthUrl, err := url.Parse("https://localhost:6443/clusters/root/apis/tenancy.kcp.io/v1alpha1/workspaces") + if err != nil { + return err + } + s.processState = &process.State{ + Dir: s.Dir, + Path: s.Binary, + StartTimeout: s.StartTimeout, + StopTimeout: s.StopTimeout, + HealthCheck: process.HealthCheck{ + URL: *healthUrl, + PollInterval: 2 * time.Second, + KcpAssetPath: filepath.Join(s.PathToRoot, ".kcp"), + }, + } + if err := s.processState.Init("kcp"); err != nil { + return err + } + + s.Binary = s.processState.Path + s.Dir = s.processState.Dir + s.StartTimeout = s.processState.StartTimeout + s.StopTimeout = s.processState.StopTimeout + + s.processState.Args, s.Args, err = process.TemplateAndArguments(s.Args, s.Configure(), process.TemplateDefaults{ //nolint:staticcheck + Data: s, + Defaults: s.defaultArgs(), + MinimalDefaults: map[string][]string{}, + }) + if err != nil { + return err + } + + return nil +} + +func (s *KCPServer) defaultArgs() map[string][]string { + args := map[string][]string{} + return args +} + +func (s *KCPServer) Configure() *process.Arguments { + if s.args == nil { + s.args = process.EmptyArguments() + } + return s.args +} + +func (s *KCPServer) Stop() error { + if s.processState != nil { + if err := s.processState.Stop(); err != nil { + return err + } + } + return nil +} diff --git a/pkg/testing/kcpenvtest/process/arguments.go b/pkg/testing/kcpenvtest/process/arguments.go new file mode 100644 index 0000000..391eec1 --- /dev/null +++ b/pkg/testing/kcpenvtest/process/arguments.go @@ -0,0 +1,340 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package process + +import ( + "bytes" + "html/template" + "sort" + "strings" +) + +// RenderTemplates returns an []string to render the templates +// +// Deprecated: will be removed in favor of Arguments. +func RenderTemplates(argTemplates []string, data interface{}) (args []string, err error) { + var t *template.Template + + for _, arg := range argTemplates { + t, err = template.New(arg).Parse(arg) + if err != nil { + args = nil + return + } + + buf := &bytes.Buffer{} + err = t.Execute(buf, data) + if err != nil { + args = nil + return + } + args = append(args, buf.String()) + } + + return +} + +// SliceToArguments converts a slice of arguments to structured arguments, +// appending each argument that starts with `--` and contains an `=` to the +// argument set (ignoring defaults), returning the rest. +// +// Deprecated: will be removed when RenderTemplates is removed. +func SliceToArguments(sliceArgs []string, args *Arguments) []string { + var rest []string + for i, arg := range sliceArgs { + if arg == "--" { + rest = append(rest, sliceArgs[i:]...) + return rest + } + // skip non-flag arguments, skip arguments w/o equals because we + // can't tell if the next argument should take a value + if !strings.HasPrefix(arg, "--") || !strings.Contains(arg, "=") { + rest = append(rest, arg) + continue + } + + parts := strings.SplitN(arg[2:], "=", 2) + name := parts[0] + val := parts[1] + + args.AppendNoDefaults(name, val) + } + + return rest +} + +// TemplateDefaults specifies defaults to be used for joining structured arguments with templates. +// +// Deprecated: will be removed when RenderTemplates is removed. +type TemplateDefaults struct { + // Data will be used to render the template. + Data interface{} + // Defaults will be used to default structured arguments if no template is passed. + Defaults map[string][]string + // MinimalDefaults will be used to default structured arguments if a template is passed. + // Use this for flags which *must* be present. + MinimalDefaults map[string][]string // for api server service-cluster-ip-range +} + +// TemplateAndArguments joins structured arguments and non-structured arguments, preserving existing +// behavior. Namely: +// +// 1. if templ has len > 0, it will be rendered against data +// 2. the rendered template values that look like `--foo=bar` will be split +// and appended to args, the rest will be kept around +// 3. the given args will be rendered as string form. If a template is given, +// no defaults will be used, otherwise defaults will be used +// 4. a result of [args..., rest...] will be returned +// +// It returns the resulting rendered arguments, plus the arguments that were +// not transferred to `args` during rendering. +// +// Deprecated: will be removed when RenderTemplates is removed. +func TemplateAndArguments(templ []string, args *Arguments, data TemplateDefaults) (allArgs []string, nonFlagishArgs []string, err error) { + if len(templ) == 0 { // 3 & 4 (no template case) + return args.AsStrings(data.Defaults), nil, nil + } + + // 1: render the template + rendered, err := RenderTemplates(templ, data.Data) + if err != nil { + return nil, nil, err + } + + // 2: filter out structured args and add them to args + rest := SliceToArguments(rendered, args) + + // 3 (template case): render structured args, no defaults (matching the + // legacy case where if Args was specified, no defaults were used) + res := args.AsStrings(data.MinimalDefaults) + + // 4: return the rendered structured args + all non-structured args + return append(res, rest...), rest, nil +} + +// EmptyArguments constructs an empty set of flags with no defaults. +func EmptyArguments() *Arguments { + return &Arguments{ + values: make(map[string]Arg), + } +} + +// Arguments are structured, overridable arguments. +// Each Arguments object contains some set of default arguments, which may +// be appended to, or overridden. +// +// When ready, you can serialize them to pass to exec.Command and friends using +// AsStrings. +// +// All flag-setting methods return the *same* instance of Arguments so that you +// can chain calls. +type Arguments struct { + // values contains the user-set values for the arguments. + // `values[key] = dontPass` means "don't pass this flag" + // `values[key] = passAsName` means "pass this flag without args like --key` + // `values[key] = []string{a, b, c}` means "--key=a --key=b --key=c` + // any values not explicitly set here will be copied from defaults on final rendering. + values map[string]Arg +} + +// Arg is an argument that has one or more values, +// and optionally falls back to default values. +type Arg interface { + // Append adds new values to this argument, returning + // a new instance contain the new value. The intermediate + // argument should generally be assumed to be consumed. + Append(vals ...string) Arg + // Get returns the full set of values, optionally including + // the passed in defaults. If it returns nil, this will be + // skipped. If it returns a non-nil empty slice, it'll be + // assumed that the argument should be passed as name-only. + Get(defaults []string) []string +} + +type userArg []string + +func (a userArg) Append(vals ...string) Arg { + return userArg(append(a, vals...)) //nolint:unconvert +} +func (a userArg) Get(_ []string) []string { + return []string(a) +} + +type defaultedArg []string + +func (a defaultedArg) Append(vals ...string) Arg { + return defaultedArg(append(a, vals...)) //nolint:unconvert +} +func (a defaultedArg) Get(defaults []string) []string { + res := append([]string(nil), defaults...) + return append(res, a...) +} + +type dontPassArg struct{} + +func (a dontPassArg) Append(vals ...string) Arg { + return userArg(vals) +} +func (dontPassArg) Get(_ []string) []string { + return nil +} + +type passAsNameArg struct{} + +func (a passAsNameArg) Append(_ ...string) Arg { + return passAsNameArg{} +} +func (passAsNameArg) Get(_ []string) []string { + return []string{} +} + +var ( + // DontPass indicates that the given argument will not actually be + // rendered. + DontPass Arg = dontPassArg{} + // PassAsName indicates that the given flag will be passed as `--key` + // without any value. + PassAsName Arg = passAsNameArg{} +) + +// AsStrings serializes this set of arguments to a slice of strings appropriate +// for passing to exec.Command and friends, making use of the given defaults +// as indicated for each particular argument. +// +// - Any flag in defaults that's not in Arguments will be present in the output +// - Any flag that's present in Arguments will be passed the corresponding +// defaults to do with as it will (ignore, append-to, suppress, etc). +func (a *Arguments) AsStrings(defaults map[string][]string) []string { + // sort for deterministic ordering + keysInOrder := make([]string, 0, len(defaults)+len(a.values)) + for key := range defaults { + if _, userSet := a.values[key]; userSet { + continue + } + keysInOrder = append(keysInOrder, key) + } + for key := range a.values { + keysInOrder = append(keysInOrder, key) + } + sort.Strings(keysInOrder) + + var res []string + for _, key := range keysInOrder { + vals := a.Get(key).Get(defaults[key]) + switch { + case vals == nil: // don't pass + continue + case len(vals) == 0: // pass as name + res = append(res, "--"+key) + default: + for _, val := range vals { + res = append(res, "--"+key+"="+val) + } + } + } + + return res +} + +// Get returns the value of the given flag. If nil, +// it will not be passed in AsString, otherwise: +// +// len == 0 --> `--key`, len > 0 --> `--key=val1 --key=val2 ...`. +func (a *Arguments) Get(key string) Arg { + if vals, ok := a.values[key]; ok { + return vals + } + return defaultedArg(nil) +} + +// Enable configures the given key to be passed as a "name-only" flag, +// like, `--key`. +func (a *Arguments) Enable(key string) *Arguments { + a.values[key] = PassAsName + return a +} + +// Disable prevents this flag from be passed. +func (a *Arguments) Disable(key string) *Arguments { + a.values[key] = DontPass + return a +} + +// Append adds additional values to this flag. If this flag has +// yet to be set, initial values will include defaults. If you want +// to intentionally ignore defaults/start from scratch, call AppendNoDefaults. +// +// Multiple values will look like `--key=value1 --key=value2 ...`. +func (a *Arguments) Append(key string, values ...string) *Arguments { + vals, present := a.values[key] + if !present { + vals = defaultedArg{} + } + a.values[key] = vals.Append(values...) + return a +} + +// AppendNoDefaults adds additional values to this flag. However, +// unlike Append, it will *not* copy values from defaults. +func (a *Arguments) AppendNoDefaults(key string, values ...string) *Arguments { + vals, present := a.values[key] + if !present { + vals = userArg{} + } + a.values[key] = vals.Append(values...) + return a +} + +// Set resets the given flag to the specified values, ignoring any existing +// values or defaults. +func (a *Arguments) Set(key string, values ...string) *Arguments { + a.values[key] = userArg(values) + return a +} + +// SetRaw sets the given flag to the given Arg value directly. Use this if +// you need to do some complicated deferred logic or something. +// +// Otherwise behaves like Set. +func (a *Arguments) SetRaw(key string, val Arg) *Arguments { + a.values[key] = val + return a +} + +// FuncArg is a basic implementation of Arg that can be used for custom argument logic, +// like pulling values out of APIServer, or dynamically calculating values just before +// launch. +// +// The given function will be mapped directly to Arg#Get, and will generally be +// used in conjunction with SetRaw. For example, to set `--some-flag` to the +// API server's CertDir, you could do: +// +// server.Configure().SetRaw("--some-flag", FuncArg(func(defaults []string) []string { +// return []string{server.CertDir} +// })) +// +// FuncArg ignores Appends; if you need to support appending values too, consider implementing +// Arg directly. +type FuncArg func([]string) []string + +// Append is a no-op for FuncArg, and just returns itself. +func (a FuncArg) Append(vals ...string) Arg { return a } + +// Get delegates functionality to the FuncArg function itself. +func (a FuncArg) Get(defaults []string) []string { + return a(defaults) +} diff --git a/pkg/testing/kcpenvtest/process/bin_path_finder.go b/pkg/testing/kcpenvtest/process/bin_path_finder.go new file mode 100644 index 0000000..e1428aa --- /dev/null +++ b/pkg/testing/kcpenvtest/process/bin_path_finder.go @@ -0,0 +1,70 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package process + +import ( + "os" + "path/filepath" + "regexp" + "strings" +) + +const ( + // EnvAssetsPath is the environment variable that stores the global test + // binary location override. + EnvAssetsPath = "KUBEBUILDER_ASSETS" + // EnvAssetOverridePrefix is the environment variable prefix for per-binary + // location overrides. + EnvAssetOverridePrefix = "TEST_ASSET_" + // AssetsDefaultPath is the default location to look for test binaries in, + // if no override was provided. + AssetsDefaultPath = "/usr/local/kubebuilder/bin" +) + +// BinPathFinder finds the path to the given named binary, using the following locations +// in order of precedence (highest first). Notice that the various env vars only need +// to be set -- the asset is not checked for existence on the filesystem. +// +// 1. TEST_ASSET_{tr/a-z-/A-Z_/} (if set; asset overrides -- EnvAssetOverridePrefix) +// 1. KUBEBUILDER_ASSETS (if set; global asset path -- EnvAssetsPath) +// 3. assetDirectory (if set; per-config asset directory) +// 4. /usr/local/kubebuilder/bin (AssetsDefaultPath). +func BinPathFinder(symbolicName, assetDirectory string) (binPath string) { + punctuationPattern := regexp.MustCompile("[^A-Z0-9]+") + sanitizedName := punctuationPattern.ReplaceAllString(strings.ToUpper(symbolicName), "_") + leadingNumberPattern := regexp.MustCompile("^[0-9]+") + sanitizedName = leadingNumberPattern.ReplaceAllString(sanitizedName, "") + envVar := EnvAssetOverridePrefix + sanitizedName + + // TEST_ASSET_XYZ + if val, ok := os.LookupEnv(envVar); ok { + return val + } + + // KUBEBUILDER_ASSETS + if val, ok := os.LookupEnv(EnvAssetsPath); ok { + return filepath.Join(val, symbolicName) + } + + // assetDirectory + if assetDirectory != "" { + return filepath.Join(assetDirectory, symbolicName) + } + + // default path + return filepath.Join(AssetsDefaultPath, symbolicName) +} diff --git a/pkg/testing/kcpenvtest/process/procattr_other.go b/pkg/testing/kcpenvtest/process/procattr_other.go new file mode 100644 index 0000000..df13b34 --- /dev/null +++ b/pkg/testing/kcpenvtest/process/procattr_other.go @@ -0,0 +1,28 @@ +//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !zos +// +build !aix,!darwin,!dragonfly,!freebsd,!linux,!netbsd,!openbsd,!solaris,!zos + +/* +Copyright 2016 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package process + +import "syscall" + +// GetSysProcAttr returns the SysProcAttr to use for the process, +// for non-unix systems this returns nil. +func GetSysProcAttr() *syscall.SysProcAttr { + return nil +} diff --git a/pkg/testing/kcpenvtest/process/procattr_unix.go b/pkg/testing/kcpenvtest/process/procattr_unix.go new file mode 100644 index 0000000..83ad509 --- /dev/null +++ b/pkg/testing/kcpenvtest/process/procattr_unix.go @@ -0,0 +1,33 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos +// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris zos + +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package process + +import ( + "golang.org/x/sys/unix" +) + +// GetSysProcAttr returns the SysProcAttr to use for the process, +// for unix systems this returns a SysProcAttr with Setpgid set to true, +// which inherits the parent's process group id. +func GetSysProcAttr() *unix.SysProcAttr { + return &unix.SysProcAttr{ + Setpgid: true, + } +} diff --git a/pkg/testing/kcpenvtest/process/process.go b/pkg/testing/kcpenvtest/process/process.go new file mode 100644 index 0000000..f2e7518 --- /dev/null +++ b/pkg/testing/kcpenvtest/process/process.go @@ -0,0 +1,365 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package process + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "sync" + "syscall" + "time" + + "github.com/openmfp/golang-commons/logger" + "gopkg.in/yaml.v3" +) + +// ListenAddr represents some listening address and port. +type ListenAddr struct { + Address string + Port string +} + +// URL returns a URL for this address with the given scheme and subpath. +func (l *ListenAddr) URL(scheme string, path string) *url.URL { + return &url.URL{ + Scheme: scheme, + Host: l.HostPort(), + Path: path, + } +} + +// HostPort returns the joined host-port pair for this address. +func (l *ListenAddr) HostPort() string { + return net.JoinHostPort(l.Address, l.Port) +} + +// HealthCheck describes the information needed to health-check a process via +// some health-check URL. +type HealthCheck struct { + url.URL + KcpAssetPath string + + // HealthCheckPollInterval is the interval which will be used for polling the + // endpoint described by Host, Port, and Path. + // + // If left empty it will default to 100 Milliseconds. + PollInterval time.Duration +} + +// State define the state of the process. +type State struct { + Cmd *exec.Cmd + + // HealthCheck describes how to check if this process is up. If we get an http.StatusOK, + // we assume the process is ready to operate. + // + // For example, the /healthz endpoint of the k8s API server, or the /health endpoint of etcd. + HealthCheck HealthCheck + + Args []string + + StopTimeout time.Duration + StartTimeout time.Duration + + Dir string + DirNeedsCleaning bool + Path string + + // ready holds whether the process is currently in ready state (hit the ready condition) or not. + // It will be set to true on a successful `Start()` and set to false on a successful `Stop()` + ready bool + + // waitDone is closed when our call to wait finishes up, and indicates that + // our process has terminated. + waitDone chan struct{} + errMu sync.Mutex + exitErr error + exited bool +} + +// Init sets up this process, configuring binary paths if missing, initializing +// temporary directories, etc. +// +// This defaults all defaultable fields. +func (ps *State) Init(name string) error { + if ps.Path == "" { + if name == "" { + return fmt.Errorf("must have at least one of name or path") + } + ps.Path = BinPathFinder(name, "") + } + + if ps.Dir == "" { + newDir, err := os.MkdirTemp("", "k8s_test_framework_") + if err != nil { + return err + } + ps.Dir = newDir + ps.DirNeedsCleaning = true + } + + if ps.StartTimeout == 0 { + ps.StartTimeout = 20 * time.Second + } + + if ps.StopTimeout == 0 { + ps.StopTimeout = 20 * time.Second + } + return nil +} + +type stopChannel chan struct{} + +// CheckFlag checks the help output of this command for the presence of the given flag, specified +// without the leading `--` (e.g. `CheckFlag("insecure-port")` checks for `--insecure-port`), +// returning true if the flag is present. +func (ps *State) CheckFlag(flag string) (bool, error) { + cmd := exec.Command(ps.Path, "--help") + outContents, err := cmd.CombinedOutput() + if err != nil { + return false, fmt.Errorf("unable to run command %q to check for flag %q: %w", ps.Path, flag, err) + } + pat := `(?m)^\s*--` + flag + `\b` // (m --> multi-line --> ^ matches start of line) + matched, err := regexp.Match(pat, outContents) + if err != nil { + return false, fmt.Errorf("unable to check command %q for flag %q in help output: %w", ps.Path, flag, err) + } + return matched, nil +} + +// Start starts the apiserver, waits for it to come up, and returns an error, +// if occurred. +func (ps *State) Start(stdout, stderr io.Writer, log *logger.Logger) (err error) { + if ps.ready { + return nil + } + + ps.Cmd = exec.Command(ps.Path, ps.Args...) + ps.Cmd.Dir = ps.Dir + ps.Cmd.Stdout = stdout + ps.Cmd.Stderr = stderr + ps.Cmd.SysProcAttr = GetSysProcAttr() + + ready := make(chan bool) + timedOut := time.After(ps.StartTimeout) + pollerStopCh := make(stopChannel) + go pollURLUntilOK(ps.HealthCheck.URL, ps.HealthCheck.PollInterval, ps.HealthCheck.KcpAssetPath, ready, pollerStopCh, log) + + ps.waitDone = make(chan struct{}) + + if err := ps.Cmd.Start(); err != nil { + ps.errMu.Lock() + defer ps.errMu.Unlock() + ps.exited = true + return err + } + go func() { + defer close(ps.waitDone) + err := ps.Cmd.Wait() + + ps.errMu.Lock() + defer ps.errMu.Unlock() + ps.exitErr = err + ps.exited = true + }() + + select { + case <-ready: + ps.ready = true + return nil + case <-ps.waitDone: + close(pollerStopCh) + return fmt.Errorf("timeout waiting for process %s to start successfully "+ + "(it may have failed to start, or stopped unexpectedly before becoming ready)", + path.Base(ps.Path)) + case <-timedOut: + close(pollerStopCh) + if ps.Cmd != nil { + // intentionally ignore this -- we might've crashed, failed to start, etc + ps.Cmd.Process.Signal(syscall.SIGTERM) //nolint:errcheck + } + return fmt.Errorf("timeout waiting for process %s to start", path.Base(ps.Path)) + } +} + +// Exited returns true if the process exited, and may also +// return an error (as per Cmd.Wait) if the process did not +// exit with error code 0. +func (ps *State) Exited() (bool, error) { + ps.errMu.Lock() + defer ps.errMu.Unlock() + return ps.exited, ps.exitErr +} + +func pollURLUntilOK(url url.URL, interval time.Duration, kcpAssetPath string, ready chan bool, stopCh stopChannel, log *logger.Logger) { + + if interval <= 0 { + interval = 5000 * time.Millisecond + } + for { + token, ca, err := readTokenAndCA(kcpAssetPath) + if err != nil { + log.Info().Msg("health check failed. Credentials not ready") + time.Sleep(interval) + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(ca) + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + }, + }, + } + req, err := http.NewRequest(http.MethodGet, url.String(), nil) + if err != nil { + log.Fatal().Err(err).Msg("error creating request") + } + if token != "" { + req.Header.Add("Authorization", "Bearer "+token) + } + res, err := client.Do(req) + if err == nil { + if err != nil { + fmt.Println("Error reading response body:", err) + return + } + err := res.Body.Close() + if err != nil { + fmt.Println("Error closing response body:", err) + return + } + if res.StatusCode == http.StatusOK { + log.Info().Int("status", res.StatusCode).Msg("KCP Ready (health check succeeded)") + ready <- true + return + } + log.Info().Int("status", res.StatusCode).Msg("Waiting for KCP to get ready (health check failed)") + } + + select { + case <-stopCh: + return + default: + time.Sleep(interval) + } + } +} + +type kubeconfig struct { + Users []struct { + Name string `yaml:"name"` + User struct { + Token string `yaml:"token"` + } `yaml:"user"` + } +} + +func readTokenAndCA(path string) (string, []byte, error) { + adminKubeconfigPath := filepath.Join(path, "admin.kubeconfig") + // check if file exists + if _, err := os.Stat(adminKubeconfigPath); os.IsNotExist(err) { + return "", nil, fmt.Errorf("file %s does not exist", adminKubeconfigPath) + } + file, err := os.Open(adminKubeconfigPath) + if err != nil { + return "", nil, fmt.Errorf("error opening file %s: %w", path, err) + } + defer file.Close() //nolint:errcheck + + data, err := io.ReadAll(file) + if err != nil { + return "", nil, fmt.Errorf("error reading file %s: %w", path, err) + } + + var config kubeconfig + err = yaml.Unmarshal(data, &config) + if err != nil { + return "", nil, fmt.Errorf("error unmarshalling yaml from file %s: %w", path, err) + } + + var userToken string + for _, user := range config.Users { + if user.Name == "kcp-admin" { + userToken = user.User.Token + } + } + if userToken == "" { + return "", nil, fmt.Errorf("token not found in kubeconfig file %s", path) + } + + certPath := filepath.Join(path, "apiserver.crt") + if _, err := os.Stat(certPath); os.IsNotExist(err) { + return "", nil, fmt.Errorf("file %s does not exist", certPath) + } + file, err = os.Open(certPath) + if err != nil { + return "", nil, fmt.Errorf("error opening file %s: %w", path, err) + } + defer file.Close() //nolint:errcheck + + data, err = io.ReadAll(file) + if err != nil { + return "", nil, fmt.Errorf("error reading file %s: %w", path, err) + } + + return userToken, data, nil +} + +// Stop stops this process gracefully, waits for its termination, and cleans up +// the CertDir if necessary. +func (ps *State) Stop() error { + // Always clear the directory if we need to. + defer func() { + if ps.DirNeedsCleaning { + _ = os.RemoveAll(ps.Dir) + } + }() + if ps.Cmd == nil { + return nil + } + if done, _ := ps.Exited(); done { + return nil + } + if err := ps.Cmd.Process.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("unable to signal for process %s to stop: %w", ps.Path, err) + } + + timedOut := time.After(ps.StopTimeout) + + select { + case <-ps.waitDone: + break + case <-timedOut: + if err := ps.Cmd.Process.Signal(syscall.SIGKILL); err != nil { + return fmt.Errorf("unable to kill process %s: %w", ps.Path, err) + } + return fmt.Errorf("timeout waiting for process %s to stop", path.Base(ps.Path)) + } + ps.ready = false + return nil +} diff --git a/pkg/testing/kcpenvtest/server.go b/pkg/testing/kcpenvtest/server.go new file mode 100644 index 0000000..40c521a --- /dev/null +++ b/pkg/testing/kcpenvtest/server.go @@ -0,0 +1,418 @@ +package kcpenvtest + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/openmfp/golang-commons/logger" + "github.com/otiai10/copy" + "github.com/rs/zerolog/log" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client" + + kcpapiv1alpha "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1" + kcptenancyv1alpha "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" +) + +const ( + kcpEnvStartTimeout = "KCP_SERVER_START_TIMEOUT" + kcpEnvStopTimeout = "KCP_SERVER_STOP_TIMEOUT" + defaultKCPServerTimeout = 1 * time.Minute + kcpAdminKubeconfigPath = ".kcp/admin.kubeconfig" + kcpRootNamespaceServerUrl = "https://localhost:6443/clusters/root" + dirOrderPattern = `^[0-9]*-(.*)$` +) + +type Environment struct { + kcpServer *KCPServer + + Scheme *runtime.Scheme + + ControlPlaneStartTimeout time.Duration + + ControlPlaneStopTimeout time.Duration + + Config *rest.Config + + log *logger.Logger + + RelativeSetupDirectory string + + PathToRoot string + RelativeAssetDirectory string + + ProviderWorkspace string + APIExportEndpointSliceName string + + useExistingCluster bool +} + +func NewEnvironment(apiExportEndpointSliceName string, providerWorkspaceName string, pathToRoot string, relativeAssetDirectory string, relativeSetupDirectory string, useExistingCluster bool, log *logger.Logger) *Environment { + kcpBinary := filepath.Join(relativeAssetDirectory, "kcp") + kcpServ := NewKCPServer(pathToRoot, kcpBinary, pathToRoot, log) + + //kcpServ.Out = os.Stdout + //kcpServ.Err = os.Stderr + return &Environment{ + log: log, + kcpServer: kcpServ, + APIExportEndpointSliceName: apiExportEndpointSliceName, + ProviderWorkspace: providerWorkspaceName, + RelativeSetupDirectory: relativeSetupDirectory, + RelativeAssetDirectory: relativeAssetDirectory, + PathToRoot: pathToRoot, + useExistingCluster: useExistingCluster, + } +} + +func (te *Environment) Start() (*rest.Config, string, error) { + + if !te.useExistingCluster { + // ensure clean .kcp directory + err := te.cleanDir() + if err != nil { + return nil, "", err + } + + if err := te.defaultTimeouts(); err != nil { + return nil, "", fmt.Errorf("failed to default controlplane timeouts: %w", err) + } + te.kcpServer.StartTimeout = te.ControlPlaneStartTimeout + te.kcpServer.StopTimeout = te.ControlPlaneStopTimeout + + te.log.Info().Msg("starting control plane") + if err := te.kcpServer.Start(); err != nil { + return nil, "", fmt.Errorf("unable to start control plane itself: %w", err) + } + } + + if te.Scheme == nil { + te.Scheme = scheme.Scheme + utilruntime.Must(kcpapiv1alpha.AddToScheme(te.Scheme)) + utilruntime.Must(kcptenancyv1alpha.AddToScheme(te.Scheme)) + } + //// wait for default namespace to actually be created and seen as available to the apiserver + if err := te.waitForDefaultNamespace(); err != nil { + return nil, "", fmt.Errorf("default namespace didn't register within deadline: %w", err) + } + + kubectlPath := filepath.Join(te.PathToRoot, ".kcp", "admin.kubeconfig") + var err error + te.Config, err = clientcmd.BuildConfigFromFlags("", kubectlPath) + if err != nil { + return nil, "", err + } + + if te.RelativeSetupDirectory != "" { + // Apply all yaml files in the setup directory + setupDirectory := filepath.Join(te.PathToRoot, te.RelativeSetupDirectory) + kubeconfigPath := filepath.Join(te.PathToRoot, kcpAdminKubeconfigPath) + err := te.ApplySetup(kubeconfigPath, te.Config, setupDirectory, kcpRootNamespaceServerUrl) + if err != nil { + return nil, "", err + } + } + + // Select api export + providerServerUrl := fmt.Sprintf("%s:%s", te.Config.Host, te.ProviderWorkspace) + te.Config.Host = providerServerUrl + cs, err := client.New(te.Config, client.Options{}) + if err != nil { + return nil, "", fmt.Errorf("unable to create client: %w", err) + } + + apiExportEndpointSlice := kcpapiv1alpha.APIExportEndpointSlice{} + err = cs.Get(context.Background(), types.NamespacedName{Name: te.APIExportEndpointSliceName}, &apiExportEndpointSlice) + if err != nil { + return nil, "", err + } + + if len(apiExportEndpointSlice.Status.APIExportEndpoints) == 0 { + return nil, "", fmt.Errorf("no virtual workspaces found") + } + + te.Config.Host = kcpRootNamespaceServerUrl + te.Config.QPS = 1000.0 + te.Config.Burst = 2000.0 + + return te.Config, apiExportEndpointSlice.Status.APIExportEndpoints[0].URL, nil +} + +func (te *Environment) Stop(useExistingCluster bool) error { + if !useExistingCluster { + defer te.cleanDir() //nolint:errcheck + return te.kcpServer.Stop() + } + return nil +} + +func (te *Environment) cleanDir() error { + kcpPath := filepath.Join(te.PathToRoot, ".kcp") + return os.RemoveAll(kcpPath) +} + +func (te *Environment) waitForDefaultNamespace() error { + kubectlPath := filepath.Join(te.PathToRoot, ".kcp", "admin.kubeconfig") + config, err := clientcmd.BuildConfigFromFlags("", kubectlPath) + if err != nil { + return err + } + cs, err := client.New(config, client.Options{}) + if err != nil { + return fmt.Errorf("unable to create client: %w", err) + } + // It shouldn't take longer than 5s for the default namespace to be brought up in etcd + return wait.PollUntilContextTimeout(context.TODO(), time.Millisecond*50, time.Second*10, true, func(ctx context.Context) (bool, error) { + te.log.Info().Msg("waiting for default namespace") + if err = cs.Get(ctx, types.NamespacedName{Name: "default"}, &corev1.Namespace{}); err != nil { + te.log.Info().Msg("namespace not found") + return false, nil //nolint:nilerr + } + return true, nil + }) +} + +func (te *Environment) waitForWorkspace(client client.Client, name string, log *logger.Logger) error { + // It shouldn't take longer than 5s for the default namespace to be brought up in etcd + err := wait.PollUntilContextTimeout(context.TODO(), time.Millisecond*500, time.Second*15, true, func(ctx context.Context) (bool, error) { + ws := &kcptenancyv1alpha.Workspace{} + if err := client.Get(ctx, types.NamespacedName{Name: name}, ws); err != nil { + return false, nil //nolint:nilerr + } + ready := ws.Status.Phase == "Ready" + log.Info().Str("workspace", name).Bool("ready", ready).Msg("waiting for workspace to be ready") + return ready, nil + }) + + if err != nil { + return fmt.Errorf("workspace %s did not become ready: %w", name, err) + } + return err +} + +func (te *Environment) defaultTimeouts() error { + var err error + if te.ControlPlaneStartTimeout == 0 { + if envVal := os.Getenv(kcpEnvStartTimeout); envVal != "" { + te.ControlPlaneStartTimeout, err = time.ParseDuration(envVal) + if err != nil { + return err + } + } else { + te.ControlPlaneStartTimeout = defaultKCPServerTimeout + } + } + + if te.ControlPlaneStopTimeout == 0 { + if envVal := os.Getenv(kcpEnvStopTimeout); envVal != "" { + te.ControlPlaneStopTimeout, err = time.ParseDuration(envVal) + if err != nil { + return err + } + } else { + te.ControlPlaneStopTimeout = defaultKCPServerTimeout + } + } + return nil +} + +type TemplateParameters struct { + ApiExportRootTenancyKcpIoIdentityHash string `json:"apiExportRootTenancyKcpIoIdentityHash"` + ApiExportRootTopologyKcpIoIdentityHash string `json:"apiExportRootTopologyKcpIoIdentityHash"` + ApiExportRootShardsKcpIoIdentityHash string `json:"apiExportRootShardsKcpIoIdentityHash"` +} + +func (te *Environment) ApplySetup(pathToRootConfig string, config *rest.Config, setupDirectoryPath string, serverUrl string) error { + + dataFile := filepath.Join(te.PathToRoot, ".kcp/data.json") + + err := generateTemplateDataFile(config, dataFile) + if err != nil { + return err + } + + // Copy setup dir + tmpSetupDir := filepath.Join(te.PathToRoot, ".kcp/setup") + err = os.Mkdir(tmpSetupDir, 0755) + if err != nil { + return err + } + err = copy.Copy(setupDirectoryPath, tmpSetupDir) + if err != nil { + return err + } + defer os.RemoveAll(tmpSetupDir) //nolint:errcheck + + // Apply Gomplate recursively + err = applyTemplate(te.PathToRoot, tmpSetupDir, dataFile) + if err != nil { + return err + } + + return te.ApplyYAML(pathToRootConfig, config, tmpSetupDir, serverUrl) + +} + +func applyTemplate(pathToRoot string, dir string, dataFile string) error { + gomplateBinary := filepath.Join(pathToRoot, "bin", "gomplate") + files, err := os.ReadDir(dir) + if err != nil { + return err + } + + for _, file := range files { + if file.IsDir() { + err := applyTemplate(pathToRoot, filepath.Join(dir, file.Name()), dataFile) + if err != nil { + return err + } + } else { + if strings.HasSuffix(file.Name(), ".yaml") { + filePath := filepath.Join(dir, file.Name()) + gomplateCmd := exec.Command(gomplateBinary, "-f", filePath, "-c", "data="+dataFile, "-o", filePath) + gomplateCmd.Stdout = os.Stdout + gomplateCmd.Stderr = os.Stderr + if err := gomplateCmd.Run(); err != nil { + return err + } + + } + } + } + return nil + +} + +func generateTemplateDataFile(config *rest.Config, dataFile string) error { + // Collect Variables + cs, err := client.New(config, client.Options{}) + if err != nil { + return fmt.Errorf("unable to create client: %w", err) + } + + parameters := TemplateParameters{} + apiExport := kcpapiv1alpha.APIExport{} + err = cs.Get(context.Background(), types.NamespacedName{Name: "tenancy.kcp.io"}, &apiExport) + if err != nil { + return err + } + parameters.ApiExportRootTenancyKcpIoIdentityHash = apiExport.Status.IdentityHash + + err = cs.Get(context.Background(), types.NamespacedName{Name: "shards.core.kcp.io"}, &apiExport) + if err != nil { + return err + } + parameters.ApiExportRootShardsKcpIoIdentityHash = apiExport.Status.IdentityHash + + err = cs.Get(context.Background(), types.NamespacedName{Name: "topology.kcp.io"}, &apiExport) + if err != nil { + return err + } + parameters.ApiExportRootTopologyKcpIoIdentityHash = apiExport.Status.IdentityHash + + bytes, err := json.Marshal(parameters) + if err != nil { + return err + } + + err = os.WriteFile(dataFile, bytes, 0644) + if err != nil { + return err + } + return nil +} + +func (te *Environment) ApplyYAML(pathToRootConfig string, config *rest.Config, pathToSetupDir string, serverUrl string) error { + cs, err := client.New(config, client.Options{}) + if err != nil { + return fmt.Errorf("unable to create client: %w", err) + } + + // list directory + hasManifestFiles, err := hasManifestFiles(pathToSetupDir) + if err != nil { + return err + } + if hasManifestFiles { + err = te.runTemplatedKubectlCommand(pathToRootConfig, serverUrl, fmt.Sprintf("apply -f %s", pathToSetupDir), true) + if err != nil { + return err + } + } + files, err := os.ReadDir(pathToSetupDir) + if err != nil { + return err + } + + for _, file := range files { + if file.IsDir() { + fileName := file.Name() + // check if pathToSetupDir starts with `[0-9]*-` + re := regexp.MustCompile(dirOrderPattern) + + if re.Match([]byte(fileName)) { + match := re.FindStringSubmatch(fileName) + fileName = match[1] + } + err := te.waitForWorkspace(cs, fileName, te.log) + if err != nil { + return err + } + newServerUrl := fmt.Sprintf("%s:%s", serverUrl, fileName) + wsConfig := rest.CopyConfig(config) + wsConfig.Host = newServerUrl + subDir := filepath.Join(pathToSetupDir, file.Name()) + err = te.ApplyYAML(pathToRootConfig, wsConfig, subDir, newServerUrl) + if err != nil { + return err + } + } + } + log.Info().Msg("finished applying setup") + return nil +} + +func hasManifestFiles(path string) (bool, error) { + files, err := os.ReadDir(path) + if err != nil { + return false, err + } + for _, file := range files { + if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") || strings.HasSuffix(file.Name(), ".json") { + return true, nil + } + } + return false, nil +} + +func (te *Environment) runTemplatedKubectlCommand(kubeconfig string, server string, command string, retry bool) error { + splitCommand := strings.Split(command, " ") + args := []string{fmt.Sprintf("--kubeconfig=%s", kubeconfig), fmt.Sprintf("--server=%s", server)} + args = append(args, splitCommand...) + cmd := exec.Command("kubectl", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + if retry { + time.Sleep(5 * time.Second) + return te.runTemplatedKubectlCommand(kubeconfig, server, command, false) + } + return err + } + return nil +} diff --git a/test/setup/01-openmfp-system/apiexport-core.openmfp.org.yaml b/test/setup/01-openmfp-system/apiexport-core.openmfp.org.yaml new file mode 100644 index 0000000..43e0b7d --- /dev/null +++ b/test/setup/01-openmfp-system/apiexport-core.openmfp.org.yaml @@ -0,0 +1,21 @@ +apiVersion: apis.kcp.io/v1alpha1 +kind: APIExport +metadata: + creationTimestamp: null + name: core.openmfp.org +spec: + latestResourceSchemas: + - v250226-290f38d.accountinfos.core.openmfp.org + - v250305-70de32b.accounts.core.openmfp.org + permissionClaims: + - all: true + resource: namespaces + - all: true + group: tenancy.kcp.io + identityHash: '{{ .data.apiExportRootTenancyKcpIoIdentityHash }}' + resource: workspaces + - all: true + group: tenancy.kcp.io + identityHash: '{{ .data.apiExportRootTenancyKcpIoIdentityHash }}' + resource: workspacetypes +status: {} diff --git a/test/setup/01-openmfp-system/apiexportendpointslice-core.openmfp.org.yaml b/test/setup/01-openmfp-system/apiexportendpointslice-core.openmfp.org.yaml new file mode 100644 index 0000000..022e97e --- /dev/null +++ b/test/setup/01-openmfp-system/apiexportendpointslice-core.openmfp.org.yaml @@ -0,0 +1,8 @@ +kind: APIExportEndpointSlice +apiVersion: apis.kcp.io/v1alpha1 +metadata: + name: core.openmfp.org +spec: + export: + path: root:openmfp-system + name: core.openmfp.org \ No newline at end of file diff --git a/test/setup/01-openmfp-system/apiresourceschema-accountinfos.core.openmfp.org.yaml b/test/setup/01-openmfp-system/apiresourceschema-accountinfos.core.openmfp.org.yaml new file mode 100644 index 0000000..2ed1690 --- /dev/null +++ b/test/setup/01-openmfp-system/apiresourceschema-accountinfos.core.openmfp.org.yaml @@ -0,0 +1,128 @@ +apiVersion: apis.kcp.io/v1alpha1 +kind: APIResourceSchema +metadata: + creationTimestamp: null + name: v250226-290f38d.accountinfos.core.openmfp.org +spec: + group: core.openmfp.org + names: + kind: AccountInfo + listKind: AccountInfoList + plural: accountinfos + singular: accountinfo + scope: Cluster + versions: + - name: v1alpha1 + schema: + description: AccountInfo is the Schema for the accountinfo API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: AccountInfoSpec defines the desired state of Account + properties: + account: + properties: + clusterId: + type: string + name: + type: string + path: + type: string + type: + type: string + url: + type: string + required: + - clusterId + - name + - path + - type + - url + type: object + clusterInfo: + properties: + ca: + type: string + required: + - ca + type: object + fga: + properties: + store: + properties: + id: + type: string + required: + - id + type: object + required: + - store + type: object + organization: + properties: + clusterId: + type: string + name: + type: string + path: + type: string + type: + type: string + url: + type: string + required: + - clusterId + - name + - path + - type + - url + type: object + parentAccount: + properties: + clusterId: + type: string + name: + type: string + path: + type: string + type: + type: string + url: + type: string + required: + - clusterId + - name + - path + - type + - url + type: object + required: + - account + - clusterInfo + - fga + - organization + type: object + status: + description: AccountInfoStatus defines the observed state of AccountInfo + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/test/setup/01-openmfp-system/apiresourceschema-accounts.core.openmfp.org.yaml b/test/setup/01-openmfp-system/apiresourceschema-accounts.core.openmfp.org.yaml new file mode 100644 index 0000000..a145897 --- /dev/null +++ b/test/setup/01-openmfp-system/apiresourceschema-accounts.core.openmfp.org.yaml @@ -0,0 +1,187 @@ +apiVersion: apis.kcp.io/v1alpha1 +kind: APIResourceSchema +metadata: + creationTimestamp: null + name: v250305-70de32b.accounts.core.openmfp.org +spec: + group: core.openmfp.org + names: + kind: Account + listKind: AccountList + plural: accounts + singular: account + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.displayName + name: Display Name + type: string + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + name: v1alpha1 + schema: + description: Account is the Schema for the accounts API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: AccountSpec defines the desired state of Account + properties: + creator: + description: The initial creator of this account + type: string + data: + description: Additional information that should be stored with the account + x-kubernetes-preserve-unknown-fields: true + description: + description: An optional description for this account + type: string + displayName: + description: The display name for this account + maxLength: 255 + type: string + extensions: + items: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadataGoTemplate: + x-kubernetes-preserve-unknown-fields: true + readyConditionType: + description: |- + The type of a condition that must be set to True on the Extension object + for the extension to be considered reconciled and ready. If this is empty, + the extension is considered ready. + type: string + specGoTemplate: + x-kubernetes-preserve-unknown-fields: true + required: + - specGoTemplate + type: object + type: array + type: + description: Type specifies the intended type for this Account object. + enum: + - org + - account + type: string + required: + - displayName + - type + type: object + status: + description: AccountStatus defines the observed state of Account + properties: + conditions: + items: + description: "Condition contains details for one aspect of the current + state of this API Resource.\n---\nThis struct is intended for direct + use as an array at the field path .status.conditions. For example,\n\n\n\ttype + FooStatus struct{\n\t // Represents the observations of a foo's + current state.\n\t // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\"\n\t // +patchMergeKey=type\n\t + \ // +patchStrategy=merge\n\t // +listType=map\n\t // +listMapKey=type\n\t + \ Conditions []metav1.Condition `json:\"conditions,omitempty\" + patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t + \ // other fields\n\t}" + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + --- + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + useful (see .node.status.conditions), the ability to deconflict is important. + The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + nextReconcileTime: + format: date-time + type: string + observedGeneration: + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/test/setup/02-orgs/account-root-org.yaml b/test/setup/02-orgs/account-root-org.yaml new file mode 100644 index 0000000..3b76016 --- /dev/null +++ b/test/setup/02-orgs/account-root-org.yaml @@ -0,0 +1,7 @@ +apiVersion: core.openmfp.org/v1alpha1 +kind: Account +metadata: + name: root-org +spec: + type: org + displayName: OpenMFP Org \ No newline at end of file diff --git a/test/setup/02-orgs/root-org/.keep b/test/setup/02-orgs/root-org/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/setup/02-orgs/workspace-root-org.yaml b/test/setup/02-orgs/workspace-root-org.yaml new file mode 100644 index 0000000..ad1529c --- /dev/null +++ b/test/setup/02-orgs/workspace-root-org.yaml @@ -0,0 +1,8 @@ +apiVersion: tenancy.kcp.io/v1alpha1 +kind: Workspace +metadata: + name: root-org +spec: + type: + name: org + path: root \ No newline at end of file diff --git a/test/setup/workspace-openmfp-system.yaml b/test/setup/workspace-openmfp-system.yaml new file mode 100644 index 0000000..d8d1b98 --- /dev/null +++ b/test/setup/workspace-openmfp-system.yaml @@ -0,0 +1,8 @@ +apiVersion: tenancy.kcp.io/v1alpha1 +kind: Workspace +metadata: + name: openmfp-system +spec: + type: + name: universal + path: root \ No newline at end of file diff --git a/test/setup/workspace-orgs.yaml b/test/setup/workspace-orgs.yaml new file mode 100644 index 0000000..e309732 --- /dev/null +++ b/test/setup/workspace-orgs.yaml @@ -0,0 +1,8 @@ +apiVersion: tenancy.kcp.io/v1alpha1 +kind: Workspace +metadata: + name: orgs +spec: + type: + name: orgs + path: root \ No newline at end of file diff --git a/test/setup/workspace-type-account.yaml b/test/setup/workspace-type-account.yaml new file mode 100644 index 0000000..5974eb6 --- /dev/null +++ b/test/setup/workspace-type-account.yaml @@ -0,0 +1,25 @@ +apiVersion: tenancy.kcp.io/v1alpha1 +kind: WorkspaceType +metadata: + name: account +spec: + defaultAPIBindings: + - export: core.openmfp.org + path: root:openmfp-system + - export: tenancy.kcp.io + path: root + - export: topology.kcp.io + path: root + defaultChildWorkspaceType: + name: account + path: root + limitAllowedChildren: + types: + - name: account + path: root + limitAllowedParents: + types: + - name: org + path: root + - name: account + path: root \ No newline at end of file diff --git a/test/setup/workspace-type-orgs.yaml b/test/setup/workspace-type-orgs.yaml new file mode 100644 index 0000000..860346f --- /dev/null +++ b/test/setup/workspace-type-orgs.yaml @@ -0,0 +1,19 @@ +apiVersion: tenancy.kcp.io/v1alpha1 +kind: WorkspaceType +metadata: + name: orgs +spec: + defaultAPIBindings: + - export: core.openmfp.org + path: root:openmfp-system + defaultChildWorkspaceType: + name: org + path: root + extend: + with: + - name: universal + path: root + limitAllowedChildren: + types: + - name: org + path: root \ No newline at end of file diff --git a/test/setup/workspacetype-org.yaml b/test/setup/workspacetype-org.yaml new file mode 100644 index 0000000..8e1be9d --- /dev/null +++ b/test/setup/workspacetype-org.yaml @@ -0,0 +1,23 @@ +apiVersion: tenancy.kcp.io/v1alpha1 +kind: WorkspaceType +metadata: + name: org +spec: + defaultAPIBindings: + - export: core.openmfp.org + path: root:openmfp-system + - export: tenancy.kcp.io + path: root + - export: topology.kcp.io + path: root + defaultChildWorkspaceType: + name: account + path: root + limitAllowedChildren: + types: + - name: account + path: root + limitAllowedParents: + types: + - name: orgs + path: root \ No newline at end of file