diff --git a/Makefile b/Makefile index 3d04ba10..2ee5b04a 100644 --- a/Makefile +++ b/Makefile @@ -131,6 +131,12 @@ test: manifests generate fmt vet envtest ## Run tests. We need LOCALBIN=$(LOCALB mkdir -p $(LOCALBIN)/default-config && cp config/manager/$(CONF_DIR)/* $(LOCALBIN)/default-config LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $(PKGS) -coverprofile cover.out +.PHONY: integration-test +integration-test: ginkgo manifests generate fmt vet envtest ## Run integration_tests. We need LOCALBIN=$(LOCALBIN) to get correct default-config path + mkdir -p $(LOCALBIN)/default-config && cp config/manager/$(CONF_DIR)/* $(LOCALBIN)/default-config + LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" $(GINKGO) -v -r integration_tests + + ##@ Build .PHONY: build @@ -195,6 +201,12 @@ deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) $(KUSTOMIZE) build config/default | kubectl apply -f - +.PHONY: deployment-manifest +deployment-manifest: manifests kustomize ## Generate manifest to deploy operator. + cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) + $(KUSTOMIZE) build config/default > rhdh-operator-${VERSION}.yaml + @echo "Generated operator script rhdh-operator-${VERSION}.yaml" + .PHONY: undeploy undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. $(KUSTOMIZE) build config/default | kubectl delete --ignore-not-found=$(ignore-not-found) -f - @@ -402,9 +414,4 @@ show-img: show-container-engine: @echo -n $(CONTAINER_ENGINE) -.PHONY: deployment-manifest -deployment-manifest: manifests kustomize ## Generate manifest to deploy operator. - cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) - $(KUSTOMIZE) build config/default > rhdh-operator-${VERSION}.yaml - @echo "Generated operator script rhdh-operator-${VERSION}.yaml" diff --git a/README.md b/README.md index 26d65d7d..64080d3c 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Output: ## License -Copyright 2023 Red Hat Inc.. +Copyright 2023 Red Hat Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/v1alpha1/backstage_types.go b/api/v1alpha1/backstage_types.go index 9c09e822..5fd7d794 100644 --- a/api/v1alpha1/backstage_types.go +++ b/api/v1alpha1/backstage_types.go @@ -16,15 +16,19 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" ) -// Constants for status conditions +type BackstageConditionReason string + +type BackstageConditionType string + const ( - // TODO: RuntimeConditionRunning string = "RuntimeRunning" - ConditionDeployed string = "Deployed" - DeployOK string = "DeployOK" - DeployFailed string = "DeployFailed" - DeployInProgress string = "DeployInProgress" + BackstageConditionTypeDeployed BackstageConditionType = "Deployed" + + BackstageConditionReasonDeployed BackstageConditionReason = "Deployed" + BackstageConditionReasonFailed BackstageConditionReason = "DeployFailed" + BackstageConditionReasonInProgress BackstageConditionReason = "DeployInProgress" ) // BackstageSpec defines the desired state of Backstage @@ -32,11 +36,18 @@ type BackstageSpec struct { // Configuration for Backstage. Optional. Application *Application `json:"application,omitempty"` - // Raw Runtime Objects configuration. For Advanced scenarios. - RawRuntimeConfig RuntimeConfig `json:"rawRuntimeConfig,omitempty"` + // Raw Runtime RuntimeObjects configuration. For Advanced scenarios. + RawRuntimeConfig *RuntimeConfig `json:"rawRuntimeConfig,omitempty"` // Configuration for database access. Optional. - Database Database `json:"database,omitempty"` + Database *Database `json:"database,omitempty"` +} + +type RuntimeConfig struct { + // Name of ConfigMap containing Backstage runtime objects configuration + BackstageConfigName string `json:"backstageConfig,omitempty"` + // Name of ConfigMap containing LocalDb (PostgreSQL) runtime objects configuration + LocalDbConfigName string `json:"localDbConfig,omitempty"` } type Database struct { @@ -98,7 +109,7 @@ type Application struct { // Image Pull Secrets to use in all containers (including Init Containers) // +optional - ImagePullSecrets *[]string `json:"imagePullSecrets,omitempty"` + ImagePullSecrets []string `json:"imagePullSecrets,omitempty"` // Route configuration. Used for OpenShift only. Route *Route `json:"route,omitempty"` @@ -177,13 +188,6 @@ type Env struct { Value string `json:"value"` } -type RuntimeConfig struct { - // Name of ConfigMap containing Backstage runtime objects configuration - BackstageConfigName string `json:"backstageConfig,omitempty"` - // Name of ConfigMap containing LocalDb (PostgreSQL) runtime objects configuration - LocalDbConfigName string `json:"localDbConfig,omitempty"` -} - // BackstageStatus defines the observed state of Backstage type BackstageStatus struct { // Conditions is the list of conditions describing the state of the runtime @@ -268,3 +272,23 @@ type TLS struct { func init() { SchemeBuilder.Register(&Backstage{}, &BackstageList{}) } + +// IsLocalDbEnabled returns true if Local database is configured and enabled +func (s *BackstageSpec) IsLocalDbEnabled() bool { + if s.Database == nil { + return true + } + return ptr.Deref(s.Database.EnableLocalDb, true) +} + +// IsRouteEnabled returns value of Application.Route.Enabled if defined or true by default +func (s *BackstageSpec) IsRouteEnabled() bool { + if s.Application != nil && s.Application.Route != nil { + return ptr.Deref(s.Application.Route.Enabled, true) + } + return true +} + +func (s *BackstageSpec) IsAuthSecretSpecified() bool { + return s.Database != nil && s.Database.AuthSecretName != "" +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 63e6f197..da31d8b8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -76,12 +76,8 @@ func (in *Application) DeepCopyInto(out *Application) { } if in.ImagePullSecrets != nil { in, out := &in.ImagePullSecrets, &out.ImagePullSecrets - *out = new([]string) - if **in != nil { - in, out := *in, *out - *out = make([]string, len(*in)) - copy(*out, *in) - } + *out = make([]string, len(*in)) + copy(*out, *in) } if in.Route != nil { in, out := &in.Route, &out.Route @@ -167,8 +163,16 @@ func (in *BackstageSpec) DeepCopyInto(out *BackstageSpec) { *out = new(Application) (*in).DeepCopyInto(*out) } - out.RawRuntimeConfig = in.RawRuntimeConfig - in.Database.DeepCopyInto(&out.Database) + if in.RawRuntimeConfig != nil { + in, out := &in.RawRuntimeConfig, &out.RawRuntimeConfig + *out = new(RuntimeConfig) + **out = **in + } + if in.Database != nil { + in, out := &in.Database, &out.Database + *out = new(Database) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackstageSpec. diff --git a/bundle/manifests/backstage-default-config_v1_configmap.yaml b/bundle/manifests/backstage-default-config_v1_configmap.yaml index f462e9fc..3f3e7b5e 100644 --- a/bundle/manifests/backstage-default-config_v1_configmap.yaml +++ b/bundle/manifests/backstage-default-config_v1_configmap.yaml @@ -1,28 +1,33 @@ apiVersion: v1 data: - backend-auth-configmap.yaml: | + app-config.yaml: |- apiVersion: v1 kind: ConfigMap metadata: - name: # placeholder for '-backend-auth' + name: my-backstage-config-cm1 # placeholder for -default-appconfig data: - "app-config.backend-auth.default.yaml": | + default.app-config.yaml: | backend: + database: + connection: + password: ${POSTGRES_PASSWORD} + user: ${POSTGRES_USER} auth: keys: # This is a default value, which you should change by providing your own app-config - secret: "pl4s3Ch4ng3M3" - db-secret.yaml: | + db-secret.yaml: |- apiVersion: v1 kind: Secret metadata: - name: # placeholder for 'backstage-psql-secret-' - stringData: - "POSTGRES_PASSWORD": "rl4s3Fh4ng3M4" # default value, change to your own value - "POSTGRES_PORT": "5432" - "POSTGRES_USER": "postgres" - "POSTGRESQL_ADMIN_PASSWORD": "rl4s3Fh4ng3M4" # default value, change to your own value - "POSTGRES_HOST": "" # set to your Postgres DB host. If the local DB is deployed, set to 'backstage-psql-' + name: postgres-secrets # will be replaced + type: Opaque + #stringData: + # POSTGRES_PASSWORD: + # POSTGRES_PORT: "5432" + # POSTGRES_USER: postgres + # POSTGRESQL_ADMIN_PASSWORD: admin123 + # POSTGRES_HOST: bs1-db-service #placeholder -db-service db-service-hl.yaml: | apiVersion: v1 kind: Service @@ -44,7 +49,7 @@ data: rhdh.redhat.com/app: backstage-psql-cr1 # placeholder for 'backstage-psql-' ports: - port: 5432 - db-statefulset.yaml: | + db-statefulset.yaml: |- apiVersion: apps/v1 kind: StatefulSet metadata: @@ -62,6 +67,10 @@ data: rhdh.redhat.com/app: backstage-psql-cr1 # placeholder for 'backstage-psql-' name: backstage-db-cr1 # placeholder for 'backstage-psql-' spec: + # fsGroup does not work for Openshift + # AKS/EKS does not work w/o it + #securityContext: + # fsGroup: 26 automountServiceAccountToken: false ## https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/ ## The optional .spec.persistentVolumeClaimRetentionPolicy field controls if and how PVCs are deleted during the lifecycle of a StatefulSet. @@ -77,13 +86,12 @@ data: value: /var/lib/pgsql/data - name: PGDATA value: /var/lib/pgsql/data/userdata - envFrom: - - secretRef: - name: # will be replaced with 'backstage-psql-secrets-' - # image will be replaced by the value of the `RELATED_IMAGE_postgresql` env var, if set - image: quay.io/fedora/postgresql-15:latest + image: quay.io/fedora/postgresql-15:latest # will be replaced with the actual image imagePullPolicy: IfNotPresent securityContext: + # runAsUser:26 does not work for Openshift but looks work for AKS/EKS + # runAsUser: 26 + runAsGroup: 0 runAsNonRoot: true allowPrivilegeEscalation: false seccompProfile: @@ -134,8 +142,6 @@ data: - mountPath: /var/lib/pgsql/data name: data restartPolicy: Always - securityContext: {} - serviceAccount: default serviceAccountName: default volumes: - emptyDir: @@ -160,7 +166,7 @@ data: apiVersion: apps/v1 kind: Deployment metadata: - name: # placeholder for 'backstage-' + name: backstage # placeholder for 'backstage-' spec: replicas: 1 selector: @@ -172,6 +178,11 @@ data: rhdh.redhat.com/app: # placeholder for 'backstage-' spec: automountServiceAccountToken: false + # if securityContext not present in AKS/EKS, the error is like this: + #Error: EACCES: permission denied, open '/dynamic-plugins-root/backstage-plugin-scaffolder-backend-module-github-dynamic-0.2.2.tgz' + # fsGroup doesn not work for Openshift + #securityContext: + # fsGroup: 1001 volumes: - ephemeral: volumeClaimTemplate: @@ -187,18 +198,19 @@ data: defaultMode: 420 optional: true secretName: dynamic-plugins-npmrc - initContainers: - - command: + - name: install-dynamic-plugins + command: - ./install-dynamic-plugins.sh - /dynamic-plugins-root + image: quay.io/janus-idp/backstage-showcase:latest # will be replaced with the actual image quay.io/janus-idp/backstage-showcase:next + imagePullPolicy: IfNotPresent + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false env: - name: NPM_CONFIG_USERCONFIG value: /opt/app-root/src/.npmrc.dynamic-plugins - # image will be replaced by the value of the `RELATED_IMAGE_backstage` env var, if set - image: quay.io/janus-idp/backstage-showcase:latest - imagePullPolicy: IfNotPresent - name: install-dynamic-plugins volumeMounts: - mountPath: /dynamic-plugins-root name: dynamic-plugins-root @@ -208,6 +220,9 @@ data: subPath: .npmrc workingDir: /opt/app-root/src resources: + requests: + cpu: 250m + memory: 256Mi limits: cpu: 1000m memory: 2.5Gi @@ -220,6 +235,9 @@ data: args: - "--config" - "dynamic-plugins-root/app-config.dynamic-plugins.yaml" + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false readinessProbe: failureThreshold: 3 httpGet: @@ -246,24 +264,22 @@ data: env: - name: APP_CONFIG_backend_listen_port value: "7007" - envFrom: - - secretRef: - name: # will be replaced with 'backstage-psql-secrets-' - # - secretRef: - # name: backstage-secrets volumeMounts: - mountPath: /opt/app-root/src/dynamic-plugins-root name: dynamic-plugins-root resources: + requests: + cpu: 250m + memory: 256Mi limits: cpu: 1000m memory: 2.5Gi ephemeral-storage: 5Gi - dynamic-plugins-configmap.yaml: |- + dynamic-plugins.yaml: |- apiVersion: v1 kind: ConfigMap metadata: - name: # placeholder for '-dynamic-plugins' + name: default-dynamic-plugins # must be the same as (deployment.yaml).spec.template.spec.volumes.name.dynamic-plugins-conf.configMap.name data: "dynamic-plugins.yaml": | includes: @@ -273,7 +289,7 @@ data: apiVersion: route.openshift.io/v1 kind: Route metadata: - name: # placeholder for 'backstage-' + name: route # placeholder for 'backstage-' spec: port: targetPort: http-backend @@ -284,11 +300,20 @@ data: to: kind: Service name: # placeholder for 'backstage-' + secret-envs.yaml: | + apiVersion: v1 + kind: Secret + metadata: + name: backend-auth-secret + stringData: + # generated with the command below (from https://janus-idp.io/docs/auth/service-to-service-auth/#setup): + # node -p 'require("crypto").randomBytes(24).toString("base64")' + BACKEND_SECRET: "R2FxRVNrcmwzYzhhN3l0V1VRcnQ3L1pLT09WaVhDNUEK" # notsecret service.yaml: |- apiVersion: v1 kind: Service metadata: - name: # placeholder for 'backstage-' + name: backstage # placeholder for 'backstage-' spec: type: ClusterIP selector: diff --git a/bundle/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/manifests/backstage-operator.clusterserviceversion.yaml index 8b0cb0fd..a7d72242 100644 --- a/bundle/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/manifests/backstage-operator.clusterserviceversion.yaml @@ -21,7 +21,7 @@ metadata: } ] capabilities: Seamless Upgrades - createdAt: "2024-03-06T17:17:14Z" + createdAt: "2024-04-01T18:15:06Z" operatorframework.io/suggested-namespace: backstage-system operators.operatorframework.io/builder: operator-sdk-v1.33.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v3 @@ -56,6 +56,7 @@ spec: - delete - get - list + - patch - update - watch - apiGroups: @@ -74,6 +75,8 @@ spec: verbs: - create - delete + - patch + - update - apiGroups: - apps resources: @@ -83,6 +86,7 @@ spec: - delete - get - list + - patch - update - watch - apiGroups: @@ -94,6 +98,7 @@ spec: - delete - get - list + - patch - update - watch - apiGroups: @@ -132,6 +137,7 @@ spec: - delete - get - list + - patch - update - watch - apiGroups: diff --git a/bundle/manifests/rhdh.redhat.com_backstages.yaml b/bundle/manifests/rhdh.redhat.com_backstages.yaml index bf6a6fb5..ebbb9bfb 100644 --- a/bundle/manifests/rhdh.redhat.com_backstages.yaml +++ b/bundle/manifests/rhdh.redhat.com_backstages.yaml @@ -284,7 +284,8 @@ spec: type: boolean type: object rawRuntimeConfig: - description: Raw Runtime Objects configuration. For Advanced scenarios. + description: Raw Runtime RuntimeObjects configuration. For Advanced + scenarios. properties: backstageConfig: description: Name of ConfigMap containing Backstage runtime objects diff --git a/config/crd/bases/rhdh.redhat.com_backstages.yaml b/config/crd/bases/rhdh.redhat.com_backstages.yaml index 64c18360..2b990068 100644 --- a/config/crd/bases/rhdh.redhat.com_backstages.yaml +++ b/config/crd/bases/rhdh.redhat.com_backstages.yaml @@ -285,7 +285,8 @@ spec: type: boolean type: object rawRuntimeConfig: - description: Raw Runtime Objects configuration. For Advanced scenarios. + description: Raw Runtime RuntimeObjects configuration. For Advanced + scenarios. properties: backstageConfig: description: Name of ConfigMap containing Backstage runtime objects diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index 4bdce607..560cac08 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -42,7 +42,7 @@ patchesStrategicMerge: #- webhookcainjection_patch.yaml # the following config is for teaching kustomize how to do var substitution -vars: +#vars: # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. #- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR # objref: diff --git a/config/manager/default-config/app-config.yaml b/config/manager/default-config/app-config.yaml new file mode 100644 index 00000000..ccfe93e8 --- /dev/null +++ b/config/manager/default-config/app-config.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-backstage-config-cm1 # placeholder for -default-appconfig +data: + default.app-config.yaml: | + backend: + database: + connection: + password: ${POSTGRES_PASSWORD} + user: ${POSTGRES_USER} + auth: + keys: + # This is a default value, which you should change by providing your own app-config + - secret: "pl4s3Ch4ng3M3" \ No newline at end of file diff --git a/config/manager/default-config/backend-auth-configmap.yaml b/config/manager/default-config/backend-auth-configmap.yaml deleted file mode 100644 index b862592d..00000000 --- a/config/manager/default-config/backend-auth-configmap.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: # placeholder for '-backend-auth' -data: - "app-config.backend-auth.default.yaml": | - backend: - auth: - keys: - # This is a default value, which you should change by providing your own app-config - - secret: "pl4s3Ch4ng3M3" diff --git a/config/manager/default-config/configmap-envs.yaml.sample b/config/manager/default-config/configmap-envs.yaml.sample new file mode 100644 index 00000000..36c9bf07 --- /dev/null +++ b/config/manager/default-config/configmap-envs.yaml.sample @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-env-cm-1 +data: + CM_ENV1: "cm env 1" + CM_ENV2: "cm env 2" \ No newline at end of file diff --git a/config/manager/default-config/dynamic-plugins-configmap.yaml b/config/manager/default-config/configmap-files.yaml.sample similarity index 77% rename from config/manager/default-config/dynamic-plugins-configmap.yaml rename to config/manager/default-config/configmap-files.yaml.sample index 492543c6..14d95c4a 100644 --- a/config/manager/default-config/dynamic-plugins-configmap.yaml +++ b/config/manager/default-config/configmap-files.yaml.sample @@ -3,7 +3,7 @@ kind: ConfigMap metadata: name: # placeholder for '-dynamic-plugins' data: - "dynamic-plugins.yaml": | + "config-plugins123.yaml": | includes: - dynamic-plugins.default.yaml plugins: [] \ No newline at end of file diff --git a/config/manager/default-config/db-secret.yaml b/config/manager/default-config/db-secret.yaml index 88b18a96..e5e384a6 100644 --- a/config/manager/default-config/db-secret.yaml +++ b/config/manager/default-config/db-secret.yaml @@ -1,10 +1,11 @@ apiVersion: v1 kind: Secret metadata: - name: # placeholder for 'backstage-psql-secret-' -stringData: - "POSTGRES_PASSWORD": "rl4s3Fh4ng3M4" # default value, change to your own value - "POSTGRES_PORT": "5432" - "POSTGRES_USER": "postgres" - "POSTGRESQL_ADMIN_PASSWORD": "rl4s3Fh4ng3M4" # default value, change to your own value - "POSTGRES_HOST": "" # set to your Postgres DB host. If the local DB is deployed, set to 'backstage-psql-' + name: postgres-secrets # will be replaced +type: Opaque +#stringData: +# POSTGRES_PASSWORD: +# POSTGRES_PORT: "5432" +# POSTGRES_USER: postgres +# POSTGRESQL_ADMIN_PASSWORD: admin123 +# POSTGRES_HOST: bs1-db-service #placeholder -db-service \ No newline at end of file diff --git a/config/manager/default-config/db-statefulset.yaml b/config/manager/default-config/db-statefulset.yaml index 16e1f5bd..2b21d2f3 100644 --- a/config/manager/default-config/db-statefulset.yaml +++ b/config/manager/default-config/db-statefulset.yaml @@ -15,6 +15,10 @@ spec: rhdh.redhat.com/app: backstage-psql-cr1 # placeholder for 'backstage-psql-' name: backstage-db-cr1 # placeholder for 'backstage-psql-' spec: + # fsGroup does not work for Openshift + # AKS/EKS does not work w/o it + #securityContext: + # fsGroup: 26 automountServiceAccountToken: false ## https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/ ## The optional .spec.persistentVolumeClaimRetentionPolicy field controls if and how PVCs are deleted during the lifecycle of a StatefulSet. @@ -30,13 +34,12 @@ spec: value: /var/lib/pgsql/data - name: PGDATA value: /var/lib/pgsql/data/userdata - envFrom: - - secretRef: - name: # will be replaced with 'backstage-psql-secrets-' - # image will be replaced by the value of the `RELATED_IMAGE_postgresql` env var, if set - image: quay.io/fedora/postgresql-15:latest + image: quay.io/fedora/postgresql-15:latest # will be replaced with the actual image imagePullPolicy: IfNotPresent securityContext: + # runAsUser:26 does not work for Openshift but looks work for AKS/EKS + # runAsUser: 26 + runAsGroup: 0 runAsNonRoot: true allowPrivilegeEscalation: false seccompProfile: @@ -87,8 +90,6 @@ spec: - mountPath: /var/lib/pgsql/data name: data restartPolicy: Always - securityContext: {} - serviceAccount: default serviceAccountName: default volumes: - emptyDir: @@ -108,4 +109,4 @@ spec: - ReadWriteOnce resources: requests: - storage: 1Gi + storage: 1Gi \ No newline at end of file diff --git a/config/manager/default-config/deployment.yaml b/config/manager/default-config/deployment.yaml index fbe4b05d..20d25f96 100644 --- a/config/manager/default-config/deployment.yaml +++ b/config/manager/default-config/deployment.yaml @@ -1,7 +1,7 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: # placeholder for 'backstage-' + name: backstage # placeholder for 'backstage-' spec: replicas: 1 selector: @@ -13,6 +13,11 @@ spec: rhdh.redhat.com/app: # placeholder for 'backstage-' spec: automountServiceAccountToken: false + # if securityContext not present in AKS/EKS, the error is like this: + #Error: EACCES: permission denied, open '/dynamic-plugins-root/backstage-plugin-scaffolder-backend-module-github-dynamic-0.2.2.tgz' + # fsGroup doesn not work for Openshift + #securityContext: + # fsGroup: 1001 volumes: - ephemeral: volumeClaimTemplate: @@ -28,18 +33,19 @@ spec: defaultMode: 420 optional: true secretName: dynamic-plugins-npmrc - initContainers: - - command: + - name: install-dynamic-plugins + command: - ./install-dynamic-plugins.sh - /dynamic-plugins-root + image: quay.io/janus-idp/backstage-showcase:latest # will be replaced with the actual image quay.io/janus-idp/backstage-showcase:next + imagePullPolicy: IfNotPresent + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false env: - name: NPM_CONFIG_USERCONFIG value: /opt/app-root/src/.npmrc.dynamic-plugins - # image will be replaced by the value of the `RELATED_IMAGE_backstage` env var, if set - image: quay.io/janus-idp/backstage-showcase:latest - imagePullPolicy: IfNotPresent - name: install-dynamic-plugins volumeMounts: - mountPath: /dynamic-plugins-root name: dynamic-plugins-root @@ -49,6 +55,9 @@ spec: subPath: .npmrc workingDir: /opt/app-root/src resources: + requests: + cpu: 250m + memory: 256Mi limits: cpu: 1000m memory: 2.5Gi @@ -61,6 +70,9 @@ spec: args: - "--config" - "dynamic-plugins-root/app-config.dynamic-plugins.yaml" + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false readinessProbe: failureThreshold: 3 httpGet: @@ -87,15 +99,13 @@ spec: env: - name: APP_CONFIG_backend_listen_port value: "7007" - envFrom: - - secretRef: - name: # will be replaced with 'backstage-psql-secrets-' - # - secretRef: - # name: backstage-secrets volumeMounts: - mountPath: /opt/app-root/src/dynamic-plugins-root name: dynamic-plugins-root resources: + requests: + cpu: 250m + memory: 256Mi limits: cpu: 1000m memory: 2.5Gi diff --git a/config/manager/default-config/dynamic-plugins.yaml b/config/manager/default-config/dynamic-plugins.yaml new file mode 100644 index 00000000..fb466757 --- /dev/null +++ b/config/manager/default-config/dynamic-plugins.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: default-dynamic-plugins # must be the same as (deployment.yaml).spec.template.spec.volumes.name.dynamic-plugins-conf.configMap.name +data: + "dynamic-plugins.yaml": | + includes: + - dynamic-plugins.default.yaml + plugins: [] \ No newline at end of file diff --git a/config/manager/default-config/route.yaml b/config/manager/default-config/route.yaml index cce91dba..7dd5a719 100644 --- a/config/manager/default-config/route.yaml +++ b/config/manager/default-config/route.yaml @@ -1,7 +1,7 @@ apiVersion: route.openshift.io/v1 kind: Route metadata: - name: # placeholder for 'backstage-' + name: route # placeholder for 'backstage-' spec: port: targetPort: http-backend diff --git a/config/manager/default-config/secret-envs.yaml b/config/manager/default-config/secret-envs.yaml new file mode 100644 index 00000000..d196e301 --- /dev/null +++ b/config/manager/default-config/secret-envs.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: backend-auth-secret +stringData: + # generated with the command below (from https://janus-idp.io/docs/auth/service-to-service-auth/#setup): + # node -p 'require("crypto").randomBytes(24).toString("base64")' + BACKEND_SECRET: "R2FxRVNrcmwzYzhhN3l0V1VRcnQ3L1pLT09WaVhDNUEK" # notsecret diff --git a/config/manager/default-config/secret-files.yaml.sample b/config/manager/default-config/secret-files.yaml.sample new file mode 100644 index 00000000..2397428b --- /dev/null +++ b/config/manager/default-config/secret-files.yaml.sample @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: my-backstage-extra-files-secret1 +stringData: + secret_file1.txt: | + # From Secret + Lorem Ipsum + Dolor Sit Amet \ No newline at end of file diff --git a/config/manager/default-config/service.yaml b/config/manager/default-config/service.yaml index 6c8b24de..f468e130 100644 --- a/config/manager/default-config/service.yaml +++ b/config/manager/default-config/service.yaml @@ -1,7 +1,7 @@ apiVersion: v1 kind: Service metadata: - name: # placeholder for 'backstage-' + name: backstage # placeholder for 'backstage-' spec: type: ClusterIP selector: diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index 07f84115..ceec4e07 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -12,13 +12,14 @@ generatorOptions: configMapGenerator: - files: - - default-config/deployment.yaml - - default-config/service.yaml - - default-config/route.yaml - - default-config/db-statefulset.yaml + - default-config/app-config.yaml + - default-config/db-secret.yaml - default-config/db-service.yaml - default-config/db-service-hl.yaml - - default-config/db-secret.yaml - - default-config/backend-auth-configmap.yaml - - default-config/dynamic-plugins-configmap.yaml + - default-config/db-statefulset.yaml + - default-config/deployment.yaml + - default-config/dynamic-plugins.yaml + - default-config/route.yaml + - default-config/secret-envs.yaml + - default-config/service.yaml name: default-config diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 9ae16550..78c50678 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -15,6 +15,7 @@ rules: - delete - get - list + - patch - update - watch - apiGroups: @@ -33,6 +34,8 @@ rules: verbs: - create - delete + - patch + - update - apiGroups: - apps resources: @@ -42,6 +45,7 @@ rules: - delete - get - list + - patch - update - watch - apiGroups: @@ -53,6 +57,7 @@ rules: - delete - get - list + - patch - update - watch - apiGroups: @@ -91,5 +96,6 @@ rules: - delete - get - list + - patch - update - watch diff --git a/controllers/backstage_app_config.go b/controllers/backstage_app_config.go deleted file mode 100644 index 66236be7..00000000 --- a/controllers/backstage_app_config.go +++ /dev/null @@ -1,173 +0,0 @@ -// -// Copyright (c) 2023 Red Hat, Inc. -// 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 controller - -import ( - "context" - "fmt" - - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" - - appsv1 "k8s.io/api/apps/v1" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" -) - -type appConfigData struct { - ref string - files []string -} - -func (r *BackstageReconciler) appConfigsToVolumes(backstage bs.Backstage, backendAuthAppConfig *bs.ObjectKeyRef) (result []v1.Volume) { - var cms []bs.ObjectKeyRef - if backendAuthAppConfig != nil { - cms = append(cms, *backendAuthAppConfig) - } - if backstage.Spec.Application != nil && backstage.Spec.Application.AppConfig != nil { - cms = append(cms, backstage.Spec.Application.AppConfig.ConfigMaps...) - } - - for _, cm := range cms { - volumeSource := v1.VolumeSource{ - ConfigMap: &v1.ConfigMapVolumeSource{ - DefaultMode: ptr.To[int32](420), - LocalObjectReference: v1.LocalObjectReference{Name: cm.Name}, - }, - } - result = append(result, - v1.Volume{ - Name: cm.Name, - VolumeSource: volumeSource, - }, - ) - } - - return result -} - -func (r *BackstageReconciler) addAppConfigsVolumeMounts( - ctx context.Context, - backstage bs.Backstage, - ns string, - deployment *appsv1.Deployment, - backendAuthAppConfig *bs.ObjectKeyRef, -) error { - var ( - mountPath = _containersWorkingDir - cms []bs.ObjectKeyRef - ) - if backendAuthAppConfig != nil { - cms = append(cms, *backendAuthAppConfig) - } - if backstage.Spec.Application != nil && backstage.Spec.Application.AppConfig != nil { - cms = append(cms, backstage.Spec.Application.AppConfig.ConfigMaps...) - mountPath = backstage.Spec.Application.AppConfig.MountPath - } - - appConfigFilenamesList, err := r.extractAppConfigFileNames(ctx, ns, cms) - if err != nil { - return err - } - - for i, c := range deployment.Spec.Template.Spec.Containers { - if c.Name == _defaultBackstageMainContainerName { - for _, appConfigFilenames := range appConfigFilenamesList { - for _, f := range appConfigFilenames.files { - deployment.Spec.Template.Spec.Containers[i].VolumeMounts = append(deployment.Spec.Template.Spec.Containers[i].VolumeMounts, - v1.VolumeMount{ - Name: appConfigFilenames.ref, - MountPath: fmt.Sprintf("%s/%s", mountPath, f), - SubPath: f, - }) - } - } - break - } - } - return nil -} - -func (r *BackstageReconciler) addAppConfigsContainerArgs( - ctx context.Context, - backstage bs.Backstage, - ns string, - deployment *appsv1.Deployment, - backendAuthAppConfig *bs.ObjectKeyRef, -) error { - var ( - mountPath = _containersWorkingDir - cms []bs.ObjectKeyRef - ) - if backendAuthAppConfig != nil { - cms = append(cms, *backendAuthAppConfig) - } - if backstage.Spec.Application != nil && backstage.Spec.Application.AppConfig != nil { - cms = append(cms, backstage.Spec.Application.AppConfig.ConfigMaps...) - mountPath = backstage.Spec.Application.AppConfig.MountPath - } - - appConfigFilenamesList, err := r.extractAppConfigFileNames(ctx, ns, cms) - if err != nil { - return err - } - - for i, c := range deployment.Spec.Template.Spec.Containers { - if c.Name == _defaultBackstageMainContainerName { - for _, appConfigFilenames := range appConfigFilenamesList { - // Args - for _, fileName := range appConfigFilenames.files { - appConfigPath := fmt.Sprintf("%s/%s", mountPath, fileName) - deployment.Spec.Template.Spec.Containers[i].Args = - append(deployment.Spec.Template.Spec.Containers[i].Args, "--config", appConfigPath) - } - } - break - } - } - return nil -} - -// extractAppConfigFileNames returns a mapping of app-config object name and the list of files in it. -// We intentionally do not return a Map, to preserve the iteration order of the AppConfigs in the Custom Resource, -// even though we can't guarantee the iteration order of the files listed inside each ConfigMap or Secret. -func (r *BackstageReconciler) extractAppConfigFileNames(ctx context.Context, ns string, cms []bs.ObjectKeyRef) (result []appConfigData, err error) { - for _, cmRef := range cms { - var files []string - if cmRef.Key != "" { - // Limit to that file only - files = append(files, cmRef.Key) - } else { - // All keys - cm := v1.ConfigMap{} - if err = r.Get(ctx, types.NamespacedName{Name: cmRef.Name, Namespace: ns}, &cm); err != nil { - return nil, err - } - for filename := range cm.Data { - // Bear in mind that iteration order over this map is not guaranteed by Go - files = append(files, filename) - } - for filename := range cm.BinaryData { - // Bear in mind that iteration order over this map is not guaranteed by Go - files = append(files, filename) - } - } - result = append(result, appConfigData{ - ref: cmRef.Name, - files: files, - }) - } - return result, nil -} diff --git a/controllers/backstage_backend_auth.go b/controllers/backstage_backend_auth.go deleted file mode 100644 index 6157a8d8..00000000 --- a/controllers/backstage_backend_auth.go +++ /dev/null @@ -1,77 +0,0 @@ -// -// Copyright (c) 2023 Red Hat, Inc. -// 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 controller - -import ( - "context" - "fmt" - - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" - - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" -) - -func (r *BackstageReconciler) getBackendAuthAppConfig( - ctx context.Context, - backstage bs.Backstage, - ns string, -) (backendAuthAppConfig *bs.ObjectKeyRef, err error) { - if hasUserDefinedAppConfig(backstage) { - // Users are expected to fill their app-config(s) with their own backend auth key - return nil, nil - } - - var cm v1.ConfigMap - err = r.readConfigMapOrDefault(ctx, backstage.Spec.RawRuntimeConfig.BackstageConfigName, "backend-auth-configmap.yaml", ns, &cm) - if err != nil { - return nil, fmt.Errorf("failed to read config: %s", err) - } - // Create ConfigMap - backendAuthCmName := fmt.Sprintf("%s-auth-app-config", backstage.Name) - cm.SetName(backendAuthCmName) - err = r.Get(ctx, types.NamespacedName{Name: backendAuthCmName, Namespace: ns}, &cm) - if err != nil { - if !errors.IsNotFound(err) { - return nil, fmt.Errorf("failed to get ConfigMap for backend auth (%q), reason: %s", backendAuthCmName, err) - } - setBackstageAppLabel(&cm.ObjectMeta.Labels, backstage) - r.labels(&cm.ObjectMeta, backstage) - - if r.OwnsRuntime { - if err = controllerutil.SetControllerReference(&backstage, &cm, r.Scheme); err != nil { - return nil, fmt.Errorf("failed to set owner reference: %s", err) - } - } - err = r.Create(ctx, &cm) - if err != nil { - return nil, fmt.Errorf("failed to create ConfigMap for backend auth, reason: %s", err) - } - } - - return &bs.ObjectKeyRef{Name: backendAuthCmName}, nil -} - -func hasUserDefinedAppConfig(backstage bs.Backstage) bool { - if backstage.Spec.Application == nil { - return false - } - if backstage.Spec.Application.AppConfig == nil { - return false - } - return len(backstage.Spec.Application.AppConfig.ConfigMaps) != 0 -} diff --git a/controllers/backstage_controller.go b/controllers/backstage_controller.go index bafaa271..5352f6ed 100644 --- a/controllers/backstage_controller.go +++ b/controllers/backstage_controller.go @@ -15,42 +15,40 @@ package controller import ( - "bytes" "context" "fmt" - "os" - "path/filepath" + "reflect" - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "k8s.io/apimachinery/pkg/types" + + openshift "github.com/openshift/api/route/v1" + + "k8s.io/apimachinery/pkg/api/meta" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/go-logr/logr" - appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + + appsv1 "k8s.io/api/apps/v1" + + "redhat-developer/red-hat-developer-hub-operator/pkg/model" + + bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/yaml" - "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" ) -const ( - BackstageAppLabel = "rhdh.redhat.com/app" -) - -var ( - envPostgresImage string - envBackstageImage string -) +var recNumber = 0 // BackstageReconciler reconciles a Backstage object type BackstageReconciler struct { client.Client - Scheme *runtime.Scheme // If true, Backstage Controller always sync the state of runtime objects created // otherwise, runtime objects can be re-configured independently @@ -67,26 +65,22 @@ type BackstageReconciler struct { //+kubebuilder:rbac:groups=rhdh.redhat.com,resources=backstages,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=rhdh.redhat.com,resources=backstages/status,verbs=get;update;patch //+kubebuilder:rbac:groups=rhdh.redhat.com,resources=backstages/finalizers,verbs=update -//+kubebuilder:rbac:groups="",resources=configmaps;services,verbs=get;list;watch;create;update;delete +//+kubebuilder:rbac:groups="",resources=configmaps;services,verbs=get;watch;create;update;list;delete;patch //+kubebuilder:rbac:groups="",resources=persistentvolumes;persistentvolumeclaims,verbs=get;list;watch -//+kubebuilder:rbac:groups="",resources=secrets,verbs=create;delete -//+kubebuilder:rbac:groups="apps",resources=deployments,verbs=get;list;watch;create;update;delete -//+kubebuilder:rbac:groups="apps",resources=statefulsets,verbs=get;list;watch;create;update;delete -//+kubebuilder:rbac:groups="route.openshift.io",resources=routes;routes/custom-host,verbs=get;list;watch;create;update;delete +//+kubebuilder:rbac:groups="",resources=secrets,verbs=create;delete;patch;update +//+kubebuilder:rbac:groups="apps",resources=deployments,verbs=get;watch;create;update;list;delete;patch +//+kubebuilder:rbac:groups="apps",resources=statefulsets,verbs=get;watch;create;update;list;delete;patch +//+kubebuilder:rbac:groups="route.openshift.io",resources=routes;routes/custom-host,verbs=get;watch;create;update;list;delete;patch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the Backstage object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.14.4/pkg/reconcile func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { lg := log.FromContext(ctx) - lg.V(1).Info(fmt.Sprintf("starting reconciliation (namespace: %q)", req.NamespacedName)) + recNumber = recNumber + 1 + lg.V(1).Info(fmt.Sprintf("starting reconciliation (namespace: %q), number %d", req.NamespacedName, recNumber)) // Ignore requests for other namespaces, if specified. // This is mostly useful for our tests, to overcome a limitation of EnvTest about namespace deletion. @@ -108,7 +102,7 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( defer func(bs *bs.Backstage) { if err := r.Client.Status().Update(ctx, bs); err != nil { if errors.IsConflict(err) { - lg.V(1).Info("Backstage object modified, retry reconciliation", "Backstage Object", bs) + lg.V(1).Info("Backstage object modified, retry syncing status", "Backstage Object", bs) return } lg.Error(err, "Error updating the Backstage resource status", "Backstage Object", bs) @@ -116,209 +110,190 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( }(&backstage) if len(backstage.Status.Conditions) == 0 { - setStatusCondition(&backstage, bs.ConditionDeployed, v1.ConditionFalse, bs.DeployInProgress, "Deployment process started") + setStatusCondition(&backstage, bs.BackstageConditionTypeDeployed, metav1.ConditionFalse, bs.BackstageConditionReasonInProgress, "Deployment process started") } - if ptr.Deref(backstage.Spec.Database.EnableLocalDb, true) { - - /* We use default strogeclass currently, and no PV is needed in that case. - If we decide later on to support user provided storageclass we can enable pv creation. - if err := r.applyPV(ctx, backstage, req.Namespace); err != nil { - return ctrl.Result{}, err - } - */ - - err := r.reconcileLocalDbStatefulSet(ctx, &backstage, req.Namespace) - if err != nil { - return ctrl.Result{}, err - } - - err = r.reconcileLocalDbServices(ctx, &backstage, req.Namespace) - if err != nil { - return ctrl.Result{}, err - } - } else { // Clean up the deployed local db resources if any - if err := r.cleanupLocalDbResources(ctx, backstage); err != nil { - setStatusCondition(&backstage, bs.ConditionDeployed, v1.ConditionFalse, bs.DeployFailed, fmt.Sprintf("failed to delete Database Services:%s", err.Error())) - return ctrl.Result{}, fmt.Errorf("failed to delete Database Service: %w", err) - } + // 1. Preliminary read and prepare external config objects from the specs (configMaps, Secrets) + // 2. Make some validation to fail fast + externalConfig, err := r.preprocessSpec(ctx, backstage) + if err != nil { + return ctrl.Result{}, errorAndStatus(&backstage, "failed to preprocess backstage spec", err) } - err := r.reconcileBackstageDeployment(ctx, &backstage, req.Namespace) + // This creates array of model objects to be reconsiled + bsModel, err := model.InitObjects(ctx, backstage, externalConfig, r.OwnsRuntime, r.IsOpenShift, r.Scheme) if err != nil { - return ctrl.Result{}, err + return ctrl.Result{}, errorAndStatus(&backstage, "failed to initialize backstage model", err) } - if err := r.reconcileBackstageService(ctx, &backstage, req.Namespace); err != nil { - return ctrl.Result{}, err + err = r.applyObjects(ctx, bsModel.RuntimeObjects) + if err != nil { + return ctrl.Result{}, errorAndStatus(&backstage, "failed to apply backstage objects", err) } - if r.IsOpenShift { - if err := r.reconcileBackstageRoute(ctx, &backstage, req.Namespace); err != nil { - return ctrl.Result{}, err - } + if err := r.cleanObjects(ctx, backstage); err != nil { + return ctrl.Result{}, errorAndStatus(&backstage, "failed to clean backstage objects ", err) } - setStatusCondition(&backstage, bs.ConditionDeployed, v1.ConditionTrue, bs.DeployOK, "") + setStatusCondition(&backstage, bs.BackstageConditionTypeDeployed, metav1.ConditionTrue, bs.BackstageConditionReasonDeployed, "") + return ctrl.Result{}, nil } -func (r *BackstageReconciler) readConfigMapOrDefault(ctx context.Context, name string, key string, ns string, object v1.Object) error { +func errorAndStatus(backstage *bs.Backstage, msg string, err error) error { + setStatusCondition(backstage, bs.BackstageConditionTypeDeployed, metav1.ConditionFalse, bs.BackstageConditionReasonFailed, fmt.Sprintf("%s %s", msg, err)) + return fmt.Errorf("%s %w", msg, err) +} + +func (r *BackstageReconciler) applyObjects(ctx context.Context, objects []model.RuntimeObject) error { lg := log.FromContext(ctx) - if name == "" { - err := readYamlFile(defFile(key), object) - if err != nil { - return fmt.Errorf("failed to read YAML file: %w", err) - } - object.SetNamespace(ns) - return nil - } + for _, obj := range objects { + + baseObject := obj.EmptyObject() + // do not read Secrets + if _, ok := obj.Object().(*corev1.Secret); ok { + // try to create + if err := r.Create(ctx, obj.Object()); err != nil { + if !errors.IsAlreadyExists(err) { + return fmt.Errorf("failed to create secret: %w", err) + } + //if DBSecret - nothing to do, it is not for update + if _, ok := obj.(*model.DbSecret); ok { + continue + } + } else { + lg.V(1).Info("create secret ", objDispName(obj), obj.Object().GetName()) + continue + } - cm := corev1.ConfigMap{} - if err := r.Get(ctx, types.NamespacedName{Name: name, Namespace: ns}, &cm); err != nil { - return err - } + } else { + if err := r.Get(ctx, types.NamespacedName{Name: obj.Object().GetName(), Namespace: obj.Object().GetNamespace()}, baseObject); err != nil { + if !errors.IsNotFound(err) { + return fmt.Errorf("failed to get object: %w", err) + } + + if err := r.Create(ctx, obj.Object()); err != nil { + return fmt.Errorf("failed to create object %w", err) + } - val, ok := cm.Data[key] - if !ok { - // key not found, default - lg.V(1).Info("custom configuration configMap exists but no such key, applying default config", "configMap", cm.Name, "key", key) - err := readYamlFile(defFile(key), object) - if err != nil { - return fmt.Errorf("failed to read YAML file: %w", err) + lg.V(1).Info("create object ", objDispName(obj), obj.Object().GetName()) + continue + } } - } else { - lg.V(1).Info("custom configuration configMap and data exists, trying to apply it", "configMap", cm.Name, "key", key) - err := readYaml([]byte(val), object) - if err != nil { - return fmt.Errorf("failed to read YAML: %w", err) + + if err := r.patchObject(ctx, baseObject, obj); err != nil { + return fmt.Errorf("failed to patch object %s: %w", obj.Object(), err) } + + lg.V(1).Info("patch object ", objDispName(obj), obj.Object().GetName()) + } - object.SetNamespace(ns) return nil } -func readYaml(manifest []byte, object interface{}) error { - dec := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(manifest), 1000) - if err := dec.Decode(object); err != nil { - return fmt.Errorf("failed to decode YAML: %w", err) - } - return nil +func objDispName(obj model.RuntimeObject) string { + return reflect.TypeOf(obj.Object()).String() } -func readYamlFile(path string, object interface{}) error { +func (r *BackstageReconciler) patchObject(ctx context.Context, baseObject client.Object, obj model.RuntimeObject) error { - b, err := os.ReadFile(path) // #nosec G304, path is constructed internally - if err != nil { - return fmt.Errorf("failed to read YAML file: %w", err) + //lg := log.FromContext(ctx) + + // restore labels and annotations + if baseObject.GetLabels() != nil { + if obj.Object().GetLabels() == nil { + obj.Object().SetLabels(map[string]string{}) + } + for name, value := range baseObject.GetLabels() { + if obj.Object().GetLabels()[name] == "" { + obj.Object().GetLabels()[name] = value + } + } + } + if baseObject.GetAnnotations() != nil { + if obj.Object().GetAnnotations() == nil { + obj.Object().SetAnnotations(map[string]string{}) + } + for name, value := range baseObject.GetAnnotations() { + if obj.Object().GetAnnotations()[name] == "" { + obj.Object().GetAnnotations()[name] = value + } + } } - return readYaml(b, object) -} -func defFile(key string) string { - return filepath.Join(os.Getenv("LOCALBIN"), "default-config", key) -} + // needed for openshift.Route only, Openshift yells otherwise + obj.Object().SetResourceVersion(baseObject.GetResourceVersion()) + if objectKind, ok := obj.Object().(schema.ObjectKind); ok { + objectKind.SetGroupVersionKind(baseObject.GetObjectKind().GroupVersionKind()) + } -/* TODO -sets the RuntimeRunning condition -func (r *BackstageReconciler) setRunningStatus(ctx context.Context, backstage *bs.Backstage, ns string) { + if err := r.Patch(ctx, obj.Object(), client.MergeFrom(baseObject)); err != nil { + return fmt.Errorf("failed to patch object %s: %w", objDispName(obj), err) + } - meta.SetStatusCondition(&backstage.Status.Conditions, v1.Condition{ - Type: bs.RuntimeConditionRunning, - Status: "Unknown", - LastTransitionTime: v1.Time{}, - Reason: "Unknown", - Message: "Runtime in unknown status", - }) + return nil } -*/ -// sets status condition -func setStatusCondition(backstage *bs.Backstage, condType string, status v1.ConditionStatus, reason, msg string) { - meta.SetStatusCondition(&backstage.Status.Conditions, v1.Condition{ - Type: condType, - Status: status, - LastTransitionTime: v1.Time{}, - Reason: reason, - Message: msg, - }) -} +func (r *BackstageReconciler) cleanObjects(ctx context.Context, backstage bs.Backstage) error { -// cleanupResource deletes the resource that was previously deployed by the operator from the cluster -func (r *BackstageReconciler) cleanupResource(ctx context.Context, obj client.Object, backstage bs.Backstage) (bool, error) { - err := r.Get(ctx, types.NamespacedName{Name: obj.GetName(), Namespace: obj.GetNamespace()}, obj) - if err != nil { - if errors.IsNotFound(err) { - return false, nil // Nothing to delete + const failedToCleanup = "failed to cleanup runtime" + // check if local database disabled, respective objects have to deleted/unowned + if !backstage.Spec.IsLocalDbEnabled() { + if err := r.tryToDelete(ctx, &appsv1.StatefulSet{}, model.DbStatefulSetName(backstage.Name), backstage.Namespace); err != nil { + return fmt.Errorf("%s %w", failedToCleanup, err) } - return false, err // For retry - } - ownedByCR := false - for _, ownerRef := range obj.GetOwnerReferences() { - if ownerRef.APIVersion == bs.GroupVersion.String() && ownerRef.Kind == "Backstage" && ownerRef.Name == backstage.Name { - ownedByCR = true - break + if err := r.tryToDelete(ctx, &corev1.Service{}, model.DbServiceName(backstage.Name), backstage.Namespace); err != nil { + return fmt.Errorf("%s %w", failedToCleanup, err) + } + if err := r.tryToDelete(ctx, &corev1.Secret{}, model.DbSecretDefaultName(backstage.Name), backstage.Namespace); err != nil { + return fmt.Errorf("%s %w", failedToCleanup, err) } } - if !ownedByCR { // The object is not owned by the backstage CR - return false, nil - } - err = r.Delete(ctx, obj) - if err == nil { - return true, nil // Deleted + + //// check if route disabled, respective objects have to deleted/unowned + if r.IsOpenShift && !backstage.Spec.IsRouteEnabled() { + if err := r.tryToDelete(ctx, &openshift.Route{}, model.RouteName(backstage.Name), backstage.Namespace); err != nil { + return fmt.Errorf("%s %w", failedToCleanup, err) + } } - return false, err -} -// sets backstage-{Id} for labels and selectors -func setBackstageAppLabel(labels *map[string]string, backstage bs.Backstage) { - setLabel(labels, getDefaultObjName(backstage)) + return nil } -// sets backstage-psql-{Id} for labels and selectors -func setLabel(labels *map[string]string, label string) { - if *labels == nil { - *labels = map[string]string{} +// tryToDelete tries to delete the object by name and namespace, does not throw error if object not found +func (r *BackstageReconciler) tryToDelete(ctx context.Context, obj client.Object, name string, ns string) error { + obj.SetName(name) + obj.SetNamespace(ns) + if err := r.Delete(ctx, obj); err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete %s: %w", name, err) } - (*labels)[BackstageAppLabel] = label + return nil } -// sets labels on Backstage's instance resources -func (r *BackstageReconciler) labels(meta *v1.ObjectMeta, backstage bs.Backstage) { - if meta.Labels == nil { - meta.Labels = map[string]string{} - } - meta.Labels["app.kubernetes.io/name"] = "backstage" - meta.Labels["app.kubernetes.io/instance"] = backstage.Name - //meta.Labels[BackstageAppLabel] = getDefaultObjName(backstage) +func setStatusCondition(backstage *bs.Backstage, condType bs.BackstageConditionType, status metav1.ConditionStatus, reason bs.BackstageConditionReason, msg string) { + meta.SetStatusCondition(&backstage.Status.Conditions, metav1.Condition{ + Type: string(condType), + Status: status, + LastTransitionTime: metav1.Time{}, + Reason: string(reason), + Message: msg, + }) } // SetupWithManager sets up the controller with the Manager. -func (r *BackstageReconciler) SetupWithManager(mgr ctrl.Manager, log logr.Logger) error { - - var ok bool - if envPostgresImage, ok = os.LookupEnv("RELATED_IMAGE_postgresql"); !ok { - log.Info("RELATED_IMAGE_postgresql environment variable is not set, default will be used") - } - if envBackstageImage, ok = os.LookupEnv("RELATED_IMAGE_backstage"); !ok { - log.Info("RELATED_IMAGE_backstage environment variable is not set, default will be used") - } +func (r *BackstageReconciler) SetupWithManager(mgr ctrl.Manager) error { builder := ctrl.NewControllerManagedBy(mgr). For(&bs.Backstage{}) - if r.OwnsRuntime { - builder.Owns(&appsv1.Deployment{}). - Owns(&corev1.Service{}). - Owns(&corev1.PersistentVolume{}). - Owns(&corev1.PersistentVolumeClaim{}) - } + // [GA] do not remove it + //if r.OwnsRuntime { + // builder.Owns(&appsv1.Deployment{}). + // Owns(&corev1.Service{}). + // Owns(&appsv1.StatefulSet{}) + //} return builder.Complete(r) } - -func retryReconciliation(err error) error { - return fmt.Errorf("reconciliation retry needed: %v", err) -} diff --git a/controllers/backstage_controller_test.go b/controllers/backstage_controller_test.go index 17ab48b1..6e70c851 100644 --- a/controllers/backstage_controller_test.go +++ b/controllers/backstage_controller_test.go @@ -17,19 +17,21 @@ package controller import ( "context" "fmt" + "redhat-developer/red-hat-developer-hub-operator/pkg/model" "strings" "time" + "k8s.io/utils/ptr" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/reconcile" bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" @@ -38,6 +40,10 @@ import ( const ( fmtNotFound = "Expected error to be a not-found one, but got %v" ) +const ( + _defaultBackstageMainContainerName = "backstage-backend" + _defaultPsqlMainContainerName = "postgresql" +) var _ = Describe("Backstage controller", func() { var ( @@ -135,10 +141,10 @@ var _ = Describe("Backstage controller", func() { var backstage bsv1alpha1.Backstage err := k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, &backstage) g.Expect(err).NotTo(HaveOccurred()) - cond := meta.FindStatusCondition(backstage.Status.Conditions, bsv1alpha1.ConditionDeployed) + cond := meta.FindStatusCondition(backstage.Status.Conditions, string(bsv1alpha1.BackstageConditionTypeDeployed)) g.Expect(cond).NotTo(BeNil()) g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) - g.Expect(cond.Reason).To(Equal(bsv1alpha1.DeployFailed)) + g.Expect(cond.Reason).To(Equal(string(bsv1alpha1.BackstageConditionReasonFailed))) g.Expect(cond.Message).To(ContainSubstring(errMsg)) }, time.Minute, time.Second).Should(Succeed()) } @@ -218,7 +224,7 @@ var _ = Describe("Backstage controller", func() { By("Checking the Deployment's replicas is updated after replicas is updated in the custom resource") Eventually(func(g Gomega) { found := &appsv1.Deployment{} - err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: fmt.Sprintf("backstage-%s", backstageName)}, found) + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstageName)}, found) g.Expect(err).To(Not(HaveOccurred())) verify(found) // g.Expect(err).To(Not(HaveOccurred())) @@ -250,7 +256,7 @@ var _ = Describe("Backstage controller", func() { By("creating a secret for accessing the Database") Eventually(func(g Gomega) { found := &corev1.Secret{} - name := getDefaultPsqlSecretName(backstage) + name := model.DbSecretDefaultName(backstage.Name) err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, found) g.Expect(err).ShouldNot(HaveOccurred()) }, time.Minute, time.Second).Should(Succeed()) @@ -258,14 +264,15 @@ var _ = Describe("Backstage controller", func() { By("creating a StatefulSet for the Database") Eventually(func(g Gomega) { found := &appsv1.StatefulSet{} - name := getDefaultDbObjName(*backstage) + name := model.DbStatefulSetName(backstage.Name) err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, found) g.Expect(err).ShouldNot(HaveOccurred()) - secName := getSecretName(found.Spec.Template.Spec.Containers, _defaultPsqlMainContainerName) - g.Expect(secName).Should(Equal(getDefaultPsqlSecretName(backstage))) + secName := getSecretName(found.Spec.Template.Spec.Containers, _defaultPsqlMainContainerName, model.DbSecretDefaultName(backstage.Name)) + g.Expect(secName).Should(Equal(model.DbSecretDefaultName(backstage.Name))) }, time.Minute, time.Second).Should(Succeed()) - backendAuthConfigName := fmt.Sprintf("%s-auth-app-config", backstageName) + //backendAuthConfigName := fmt.Sprintf("%s-auth-app-config", backstageName) + backendAuthConfigName := model.AppConfigDefaultName(backstageName) By("Creating a ConfigMap for default backend auth key", func() { Eventually(func(g Gomega) { found := &corev1.ConfigMap{} @@ -276,7 +283,8 @@ var _ = Describe("Backstage controller", func() { }) By("Generating a ConfigMap for default config for dynamic plugins") - dynamicPluginsConfigName := fmt.Sprintf("%s-dynamic-plugins", backstageName) + dynamicPluginsConfigName := model.DynamicPluginsDefaultName(backstageName) + //fmt.Sprintf("%s-dynamic-plugins", backstageName) Eventually(func(g Gomega) { found := &corev1.ConfigMap{} err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: dynamicPluginsConfigName}, found) @@ -291,7 +299,7 @@ var _ = Describe("Backstage controller", func() { found := &appsv1.Deployment{} Eventually(func() error { // TODO to get name from default - return k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: getDefaultObjName(*backstage)}, found) + return k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstage.Name)}, found) }, time.Minute, time.Second).Should(Succeed()) By("checking the number of replicas") @@ -306,11 +314,13 @@ var _ = Describe("Backstage controller", func() { Expect(dpRootVol.Ephemeral.VolumeClaimTemplate).ShouldNot(BeNil()) storage := dpRootVol.Ephemeral.VolumeClaimTemplate.Spec.Resources.Requests.Storage() Expect(storage).ShouldNot(BeNil()) - q, pErr := resource.ParseQuantity("1Gi") - Expect(pErr).ShouldNot(HaveOccurred()) + + // Operator does NOT control dynamic-plugins-root volume + //q, pErr := resource.ParseQuantity("1Gi") + //Expect(pErr).ShouldNot(HaveOccurred()) // https://issues.redhat.com/browse/RHIDP-1332: storage size should be > 1Gi - Expect(storage.Cmp(q)).To(Equal(1), - "storage size for dynamic-plugins-root volume is currently %v, but it should be more than %v", storage, q) + //Expect(storage.Cmp(q)).To(Equal(1), + // "storage size for dynamic-plugins-root volume is currently %v, but it should be more than %v", storage, q) _, ok = findVolume(found.Spec.Template.Spec.Volumes, "dynamic-plugins-npmrc") Expect(ok).To(BeTrue(), "No volume found with name: dynamic-plugins-npmrc") @@ -369,7 +379,7 @@ var _ = Describe("Backstage controller", func() { Expect(mainCont.Args[0]).To(Equal("--config")) Expect(mainCont.Args[1]).To(Equal("dynamic-plugins-root/app-config.dynamic-plugins.yaml")) Expect(mainCont.Args[2]).To(Equal("--config")) - Expect(mainCont.Args[3]).To(Equal("/opt/app-root/src/app-config.backend-auth.default.yaml")) + Expect(mainCont.Args[3]).To(Equal("/opt/app-root/src/default.app-config.yaml")) }) By("Checking the main container Volume Mounts in the Backstage Deployment", func() { @@ -382,34 +392,35 @@ var _ = Describe("Backstage controller", func() { bsAuth := findVolumeMounts(mainCont.VolumeMounts, backendAuthConfigName) Expect(bsAuth).To(HaveLen(1), "No volume mount found with name: %s", backendAuthConfigName) - Expect(bsAuth[0].MountPath).To(Equal("/opt/app-root/src/app-config.backend-auth.default.yaml")) - Expect(bsAuth[0].SubPath).To(Equal("app-config.backend-auth.default.yaml")) + Expect(bsAuth[0].MountPath).To(Equal("/opt/app-root/src/default.app-config.yaml")) + Expect(bsAuth[0].SubPath).To(Equal("default.app-config.yaml")) }) By("Checking the db secret used by the Backstage Deployment") - secName := getSecretName(found.Spec.Template.Spec.Containers, _defaultBackstageMainContainerName) - Expect(secName).Should(Equal(getDefaultPsqlSecretName(backstage))) + secName := getSecretName(found.Spec.Template.Spec.Containers, _defaultBackstageMainContainerName, model.DbSecretDefaultName(backstage.Name)) + Expect(secName).Should(Equal(model.DbSecretDefaultName(backstage.Name))) By("Checking the latest Status added to the Backstage instance") verifyBackstageInstance(ctx) By("Checking the localdb statefulset has been created") Eventually(func(g Gomega) { - err := k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("backstage-psql-%s", backstageName), Namespace: ns}, &appsv1.StatefulSet{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: model.DbStatefulSetName(backstageName), Namespace: ns}, &appsv1.StatefulSet{}) g.Expect(err).To(Not(HaveOccurred())) }, time.Minute, time.Second).Should(Succeed()) By("Checking the localdb services have been created") Eventually(func(g Gomega) { - err := k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("backstage-psql-%s", backstageName), Namespace: ns}, &corev1.Service{}) - g.Expect(err).To(Not(HaveOccurred())) - err = k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("backstage-psql-%s-hl", backstageName), Namespace: ns}, &corev1.Service{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: model.DbServiceName(backstageName), Namespace: ns}, &corev1.Service{}) g.Expect(err).To(Not(HaveOccurred())) + + //err = k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("backstage-psql-%s-hl", backstageName), Namespace: ns}, &corev1.Service{}) + //g.Expect(err).To(Not(HaveOccurred())) }, time.Minute, time.Second).Should(Succeed()) - By("Checking the localdb secret has been gnerated") + By("Checking the localdb secret has been generated") Eventually(func(g Gomega) { - err := k8sClient.Get(ctx, types.NamespacedName{Name: getDefaultPsqlSecretName(backstage), Namespace: ns}, &corev1.Secret{}) + err := k8sClient.Get(ctx, types.NamespacedName{Name: model.DbSecretDefaultName(backstage.Name), Namespace: ns}, &corev1.Secret{}) g.Expect(err).To(Not(HaveOccurred())) }, time.Minute, time.Second).Should(Succeed()) @@ -419,6 +430,9 @@ var _ = Describe("Backstage controller", func() { toBeUpdated := &bsv1alpha1.Backstage{} err := k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, toBeUpdated) g.Expect(err).To(Not(HaveOccurred())) + if toBeUpdated.Spec.Database == nil { + toBeUpdated.Spec.Database = &bsv1alpha1.Database{} + } toBeUpdated.Spec.Database.EnableLocalDb = &enableLocalDb toBeUpdated.Spec.Database.AuthSecretName = "existing-db-secret" err = k8sClient.Update(ctx, toBeUpdated) @@ -434,7 +448,7 @@ var _ = Describe("Backstage controller", func() { By("Checking that the local db statefulset has been deleted") Eventually(func(g Gomega) { err := k8sClient.Get(ctx, - types.NamespacedName{Namespace: ns, Name: getDefaultDbObjName(*backstage)}, + types.NamespacedName{Namespace: ns, Name: model.DbStatefulSetName(backstage.Name)}, &appsv1.StatefulSet{}) g.Expect(err).Should(HaveOccurred()) g.Expect(errors.IsNotFound(err)).Should(BeTrue(), fmtNotFound, err) @@ -443,7 +457,7 @@ var _ = Describe("Backstage controller", func() { By("Checking that the local db services have been deleted") Eventually(func(g Gomega) { err := k8sClient.Get(ctx, - types.NamespacedName{Namespace: ns, Name: getDefaultDbObjName(*backstage)}, + types.NamespacedName{Namespace: ns, Name: model.DbStatefulSetName(backstage.Name)}, &corev1.Service{}) g.Expect(err).Should(HaveOccurred()) g.Expect(errors.IsNotFound(err)).Should(BeTrue(), fmtNotFound, err) @@ -457,7 +471,7 @@ var _ = Describe("Backstage controller", func() { By("Checking that the local db secret has been deleted") Eventually(func(g Gomega) { err := k8sClient.Get(ctx, - types.NamespacedName{Namespace: ns, Name: getDefaultDbObjName(*backstage)}, + types.NamespacedName{Namespace: ns, Name: model.DbStatefulSetName(backstage.Name)}, &corev1.Secret{}) g.Expect(err).Should(HaveOccurred()) g.Expect(errors.IsNotFound(err)).Should(BeTrue(), fmtNotFound, err) @@ -476,29 +490,29 @@ var _ = Describe("Backstage controller", func() { apiVersion: apps/v1 kind: Deployment metadata: - name: bs1-deployment - labels: - app: bs1 + name: bs1-deployment + labels: + app: bs1 spec: - replicas: 1 - selector: - matchLabels: - app: bs1 - template: - metadata: - labels: - app: bs1 - spec: - containers: - - name: bs1 - image: busybox + replicas: 1 + selector: + matchLabels: + app: bs1 + template: + metadata: + labels: + app: bs1 + spec: + containers: + - name: bs1 + image: busybox `, }) err := k8sClient.Create(ctx, backstageConfigMap) Expect(err).To(Not(HaveOccurred())) backstage = buildBackstageCR(bsv1alpha1.BackstageSpec{ - RawRuntimeConfig: bsv1alpha1.RuntimeConfig{ + RawRuntimeConfig: &bsv1alpha1.RuntimeConfig{ BackstageConfigName: backstageConfigMap.Name, }, }) @@ -523,7 +537,7 @@ spec: By("Checking if Deployment was successfully created in the reconciliation") Eventually(func() error { found := &appsv1.Deployment{} - return k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: getDefaultObjName(*backstage)}, found) + return k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstage.Name)}, found) }, time.Minute, time.Second).Should(Succeed()) By("Checking the latest Status added to the Backstage instance") @@ -540,27 +554,27 @@ spec: apiVersion: apps/v1 kind: StatefulSet metadata: - name: db-statefulset + name: db-statefulset spec: - replicas: 3 - selector: - matchLabels: - app: db - template: - metadata: - labels: - app: db - spec: - containers: - - name: db - image: busybox + replicas: 3 + selector: + matchLabels: + app: db + template: + metadata: + labels: + app: db + spec: + containers: + - name: db + image: busybox `, }) err := k8sClient.Create(ctx, localDbConfigMap) Expect(err).To(Not(HaveOccurred())) backstage = buildBackstageCR(bsv1alpha1.BackstageSpec{ - RawRuntimeConfig: bsv1alpha1.RuntimeConfig{ + RawRuntimeConfig: &bsv1alpha1.RuntimeConfig{ LocalDbConfigName: localDbConfigMap.Name, }, }) @@ -585,7 +599,7 @@ spec: By("Checking if StatefulSet was successfully created in the reconciliation") Eventually(func(g Gomega) { found := &appsv1.StatefulSet{} - name := getDefaultDbObjName(*backstage) + name := model.DbStatefulSetName(backstage.Name) err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, found) g.Expect(err).ShouldNot(HaveOccurred()) g.Expect(found.Spec.Replicas).Should(HaveValue(BeEquivalentTo(3))) @@ -637,14 +651,14 @@ spec: NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, }) Expect(err).To(HaveOccurred()) - errStr := fmt.Sprintf("failed to add volume mounts to Backstage deployment, reason: configmaps \"%s\" not found", cmName) + errStr := fmt.Sprintf("configmaps \"%s\" not found", cmName) Expect(err.Error()).Should(ContainSubstring(errStr)) verifyBackstageInstanceError(ctx, errStr) By("Not creating a Backstage Deployment") Consistently(func() error { // TODO to get name from default - return k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: getDefaultObjName(*backstage)}, &appsv1.Deployment{}) + return k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstage.Name)}, &appsv1.Deployment{}) }, 5*time.Second, time.Second).Should(Not(Succeed())) }) }) @@ -665,21 +679,21 @@ spec: BeforeEach(func() { appConfig1Cm := buildConfigMap(appConfig1CmName, map[string]string{ "my-app-config-11.yaml": ` -# my-app-config-11.yaml -`, + # my-app-config-11.yaml + `, "my-app-config-12.yaml": ` -# my-app-config-12.yaml -`, + # my-app-config-12.yaml + `, }) err := k8sClient.Create(ctx, appConfig1Cm) Expect(err).To(Not(HaveOccurred())) dynamicPluginsCm := buildConfigMap(dynamicPluginsConfigName, map[string]string{ "dynamic-plugins.yaml": ` -# dynamic-plugins.yaml (configmap) -includes: [dynamic-plugins.default.yaml] -plugins: [] -`, + # dynamic-plugins.yaml (configmap) + includes: [dynamic-plugins.default.yaml] + plugins: [] + `, }) err = k8sClient.Create(ctx, dynamicPluginsCm) Expect(err).To(Not(HaveOccurred())) @@ -719,12 +733,16 @@ plugins: [] found := &appsv1.Deployment{} Eventually(func(g Gomega) { // TODO to get name from default - err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: getDefaultObjName(*backstage)}, found) + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstage.Name)}, found) g.Expect(err).To(Not(HaveOccurred())) }, time.Minute, time.Second).Should(Succeed()) - + //dynamic-plugins-root + //dynamic-plugins-npmrc + //test-backstage-cqzfx-default-appconfig + //my-app-config-1-cm + //my-dynamic-plugins-config By("Checking the Volumes in the Backstage Deployment", func() { - Expect(found.Spec.Template.Spec.Volumes).To(HaveLen(4)) + Expect(found.Spec.Template.Spec.Volumes).To(HaveLen(5)) _, ok := findVolume(found.Spec.Template.Spec.Volumes, "dynamic-plugins-root") Expect(ok).To(BeTrue(), "No volume found with name: dynamic-plugins-root") @@ -789,9 +807,23 @@ plugins: [] } By("Checking the main container Args in the Backstage Deployment", func() { - nbArgs := 6 + // "--config", + // "dynamic-plugins-root/app-config.dynamic-plugins.yaml", + // "--config", + // "/opt/app-root/src/default.app-config.yaml", + // "--config", + // "/some/path/for/app-config/my-app-config-11.yaml", + // "--config", + // "/some/path/for/app-config/my-app-config-12.yaml", + nbArgs := 8 if key != "" { - nbArgs = 4 + // "--config", + // "dynamic-plugins-root/app-config.dynamic-plugins.yaml", + // "--config", + // "/opt/app-root/src/default.app-config.yaml", + // "--config", + // "/some/path/for/app-config/my-app-config-12.yaml", + nbArgs = 6 } Expect(mainCont.Args).To(HaveLen(nbArgs)) Expect(mainCont.Args[1]).To(Equal("dynamic-plugins-root/app-config.dynamic-plugins.yaml")) @@ -802,24 +834,33 @@ plugins: [] //TODO(rm3l): the order of the rest of the --config args should be the same as the order in // which the keys are listed in the ConfigMap/Secrets // But as this is returned as a map, Go does not provide any guarantee on the iteration order. - Expect(mainCont.Args[3]).To(SatisfyAny( + Expect(mainCont.Args[5]).To(SatisfyAny( Equal(expectedMountPath+"/my-app-config-11.yaml"), Equal(expectedMountPath+"/my-app-config-12.yaml"), )) - Expect(mainCont.Args[5]).To(SatisfyAny( + Expect(mainCont.Args[7]).To(SatisfyAny( Equal(expectedMountPath+"/my-app-config-11.yaml"), Equal(expectedMountPath+"/my-app-config-12.yaml"), )) Expect(mainCont.Args[3]).To(Not(Equal(mainCont.Args[5]))) } else { - Expect(mainCont.Args[3]).To(Equal(fmt.Sprintf("%s/%s", expectedMountPath, key))) + Expect(mainCont.Args[5]).To(Equal(fmt.Sprintf("%s/%s", expectedMountPath, key))) } }) By("Checking the main container Volume Mounts in the Backstage Deployment", func() { - nbMounts := 3 + //"/opt/app-root/src/dynamic-plugins-root" + //"/opt/app-root/src/default.app-config.yaml" + // /some/path/for/app-config/my-app-config-11.yaml" + //"/some/path/for/app-config/my-app-config-12.yaml" + nbMounts := 4 + nbMounts2 := 2 if key != "" { - nbMounts = 2 + //"/opt/app-root/src/dynamic-plugins-root" + //"/opt/app-root/src/default.app-config.yaml" + //"/some/path/for/app-config/my-app-config-11.yaml" + nbMounts = 3 + nbMounts2 = 1 } Expect(mainCont.VolumeMounts).To(HaveLen(nbMounts)) @@ -829,7 +870,7 @@ plugins: [] Expect(dpRoot[0].SubPath).To(BeEmpty()) appConfig1CmMounts := findVolumeMounts(mainCont.VolumeMounts, appConfig1CmName) - Expect(appConfig1CmMounts).To(HaveLen(nbMounts-1), "Wrong number of volume mounts found with name: %s", appConfig1CmName) + Expect(appConfig1CmMounts).To(HaveLen(nbMounts2), "Wrong number of volume mounts found with name: %s", appConfig1CmName) if key != "" { Expect(appConfig1CmMounts).To(HaveLen(1), "Wrong number of volume mounts found with name: %s", appConfig1CmName) Expect(appConfig1CmMounts[0].MountPath).To(Equal(fmt.Sprintf("%s/%s", expectedMountPath, key))) @@ -917,7 +958,7 @@ plugins: [] By("Not creating a Backstage Deployment") Consistently(func() error { // TODO to get name from default - return k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: getDefaultObjName(*backstage)}, &appsv1.Deployment{}) + return k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstage.Name)}, &appsv1.Deployment{}) }, 5*time.Second, time.Second).Should(Not(Succeed())) }) }) @@ -936,33 +977,33 @@ plugins: [] BeforeEach(func() { extraConfig1CmAll := buildConfigMap(extraConfig1CmNameAll, map[string]string{ "my-extra-config-11.yaml": ` -# my-extra-config-11.yaml -`, + # my-extra-config-11.yaml + `, "my-extra-config-12.yaml": ` -# my-extra-config-12.yaml -`, + # my-extra-config-12.yaml + `, }) err := k8sClient.Create(ctx, extraConfig1CmAll) Expect(err).To(Not(HaveOccurred())) extraConfig1CmSingle := buildConfigMap(extraConfig1CmNameSingle, map[string]string{ "my-extra-file-11-single.yaml": ` -# my-extra-file-11-single.yaml -`, + # my-extra-file-11-single.yaml + `, "my-extra-file-12-single.yaml": ` -# my-extra-file-12-single.yaml -`, + # my-extra-file-12-single.yaml + `, }) err = k8sClient.Create(ctx, extraConfig1CmSingle) Expect(err).To(Not(HaveOccurred())) extraConfig2SecretSingle := buildSecret(extraConfig2SecretNameSingle, map[string][]byte{ "my-extra-file-21-single.yaml": []byte(` -# my-extra-file-21-single.yaml -`), + # my-extra-file-21-single.yaml + `), "my-extra-file-22-single.yaml": []byte(` -# my-extra-file-22-single.yaml -`), + # my-extra-file-22-single.yaml + `), }) err = k8sClient.Create(ctx, extraConfig2SecretSingle) Expect(err).To(Not(HaveOccurred())) @@ -1002,11 +1043,11 @@ plugins: [] found := &appsv1.Deployment{} Eventually(func(g Gomega) { // TODO to get name from default - err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: getDefaultObjName(*backstage)}, found) + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstage.Name)}, found) g.Expect(err).To(Not(HaveOccurred())) }, time.Minute, time.Second).Should(Succeed()) - backendAuthConfigName := fmt.Sprintf("%s-auth-app-config", backstageName) + backendAuthConfigName := model.AppConfigDefaultName(backstageName) By("Creating a ConfigMap for default backend auth key", func() { Eventually(func(g Gomega) { found := &corev1.ConfigMap{} @@ -1064,8 +1105,8 @@ plugins: [] bsAuth := findVolumeMounts(mainCont.VolumeMounts, backendAuthConfigName) Expect(bsAuth).To(HaveLen(1), "No volume mount found with name: %s", backendAuthConfigName) - Expect(bsAuth[0].MountPath).To(Equal("/opt/app-root/src/app-config.backend-auth.default.yaml")) - Expect(bsAuth[0].SubPath).To(Equal("app-config.backend-auth.default.yaml")) + Expect(bsAuth[0].MountPath).To(Equal("/opt/app-root/src/default.app-config.yaml")) + Expect(bsAuth[0].SubPath).To(Equal("default.app-config.yaml")) extraConfig1CmMounts := findVolumeMounts(mainCont.VolumeMounts, extraConfig1CmNameAll) Expect(extraConfig1CmMounts).To(HaveLen(2), "No volume mounts found with name: %s", extraConfig1CmNameAll) @@ -1178,7 +1219,7 @@ plugins: [] found := &appsv1.Deployment{} Eventually(func(g Gomega) { // TODO to get name from default - err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: getDefaultObjName(*backstage)}, found) + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstage.Name)}, found) g.Expect(err).To(Not(HaveOccurred())) }, time.Minute, time.Second).Should(Succeed()) @@ -1308,12 +1349,12 @@ plugins: [] found := &appsv1.Deployment{} Eventually(func(g Gomega) { // TODO to get name from default - err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: getDefaultObjName(*backstage)}, found) + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstage.Name)}, found) g.Expect(err).To(Not(HaveOccurred())) }, time.Minute, time.Second).Should(Succeed()) By("Checking that the image was set on all containers in the Pod Spec") - visitContainers(&found.Spec.Template, func(container *corev1.Container) { + model.VisitContainers(&found.Spec.Template.Spec, func(container *corev1.Container) { By(fmt.Sprintf("Checking Image in the Backstage Deployment - container: %q", container.Name), func() { Expect(container.Image).Should(Equal(imageName)) }) @@ -1335,7 +1376,7 @@ plugins: [] BeforeEach(func() { backstage = buildBackstageCR(bsv1alpha1.BackstageSpec{ Application: &bsv1alpha1.Application{ - ImagePullSecrets: &[]string{ips1, ips2}, + ImagePullSecrets: []string{ips1, ips2}, }, }) err := k8sClient.Create(ctx, backstage) @@ -1359,7 +1400,7 @@ plugins: [] found := &appsv1.Deployment{} Eventually(func(g Gomega) { // TODO to get name from default - err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: getDefaultObjName(*backstage)}, found) + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstage.Name)}, found) g.Expect(err).To(Not(HaveOccurred())) }, time.Minute, time.Second).Should(Succeed()) @@ -1408,7 +1449,7 @@ plugins: [] found := &appsv1.Deployment{} Eventually(func(g Gomega) { // TODO to get name from default - err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: getDefaultObjName(*backstage)}, found) + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstage.Name)}, found) g.Expect(err).To(Not(HaveOccurred())) }, time.Minute, time.Second).Should(Succeed()) @@ -1439,7 +1480,7 @@ plugins: [] When("disabling PostgreSQL in the CR", func() { It("should successfully reconcile a custom resource for default Backstage with existing secret", func() { backstage := buildBackstageCR(bsv1alpha1.BackstageSpec{ - Database: bsv1alpha1.Database{ + Database: &bsv1alpha1.Database{ EnableLocalDb: ptr.To(false), AuthSecretName: "existing-secret", }, @@ -1462,7 +1503,7 @@ plugins: [] By("not creating a StatefulSet for the Database") Consistently(func(g Gomega) { err := k8sClient.Get(ctx, - types.NamespacedName{Namespace: ns, Name: getDefaultDbObjName(*backstage)}, + types.NamespacedName{Namespace: ns, Name: model.DbStatefulSetName(backstage.Name)}, &appsv1.StatefulSet{}) g.Expect(err).Should(HaveOccurred()) g.Expect(errors.IsNotFound(err)).Should(BeTrue(), fmtNotFound, err) @@ -1471,7 +1512,7 @@ plugins: [] By("Checking if Deployment was successfully created in the reconciliation") Eventually(func() error { // TODO to get name from default - return k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: getDefaultObjName(*backstage)}, &appsv1.Deployment{}) + return k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstage.Name)}, &appsv1.Deployment{}) }, time.Minute, time.Second).Should(Succeed()) By("Checking the latest Status added to the Backstage instance") @@ -1480,7 +1521,7 @@ plugins: [] }) It("should reconcile a custom resource for default Backstage without existing secret", func() { backstage := buildBackstageCR(bsv1alpha1.BackstageSpec{ - Database: bsv1alpha1.Database{ + Database: &bsv1alpha1.Database{ EnableLocalDb: ptr.To(false), }, }) @@ -1512,17 +1553,19 @@ func findElementsByPredicate[T any](l []T, predicate func(t T) bool) (result []T } func isDeployed(backstage bsv1alpha1.Backstage) bool { - if cond := meta.FindStatusCondition(backstage.Status.Conditions, bsv1alpha1.ConditionDeployed); cond != nil { + if cond := meta.FindStatusCondition(backstage.Status.Conditions, string(bsv1alpha1.BackstageConditionTypeDeployed)); cond != nil { return cond.Status == metav1.ConditionTrue } return false } -func getSecretName(containers []corev1.Container, name string) string { +func getSecretName(containers []corev1.Container, name string, secretName string) string { for _, c := range containers { if c.Name == name { for _, from := range c.EnvFrom { - return from.SecretRef.Name + if from.SecretRef.Name == secretName { + return from.SecretRef.Name + } } break } diff --git a/controllers/backstage_deployment.go b/controllers/backstage_deployment.go deleted file mode 100644 index 6a02d382..00000000 --- a/controllers/backstage_deployment.go +++ /dev/null @@ -1,238 +0,0 @@ -// -// Copyright (c) 2023 Red Hat, Inc. -// 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 controller - -import ( - "context" - "fmt" - - appsv1 "k8s.io/api/apps/v1" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" -) - -const ( - _defaultBackstageInitContainerName = "install-dynamic-plugins" - _defaultBackstageMainContainerName = "backstage-backend" - _containersWorkingDir = "/opt/app-root/src" -) - -// ContainerVisitor is called with each container -type ContainerVisitor func(container *v1.Container) - -// visitContainers invokes the visitor function for every container in the given pod template spec -func visitContainers(podTemplateSpec *v1.PodTemplateSpec, visitor ContainerVisitor) { - for i := range podTemplateSpec.Spec.InitContainers { - visitor(&podTemplateSpec.Spec.InitContainers[i]) - } - for i := range podTemplateSpec.Spec.Containers { - visitor(&podTemplateSpec.Spec.Containers[i]) - } - for i := range podTemplateSpec.Spec.EphemeralContainers { - visitor((*v1.Container)(&podTemplateSpec.Spec.EphemeralContainers[i].EphemeralContainerCommon)) - } -} - -func (r *BackstageReconciler) reconcileBackstageDeployment(ctx context.Context, backstage *bs.Backstage, ns string) error { - deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{ - Name: getDefaultObjName(*backstage), - Namespace: ns}, - } - if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, deployment, r.deploymentObjectMutFun(ctx, deployment, *backstage, ns)); err != nil { - if errors.IsConflict(err) { - return retryReconciliation(err) - } - msg := fmt.Sprintf("failed to deploy Backstage Deployment: %s", err) - setStatusCondition(backstage, bs.ConditionDeployed, metav1.ConditionFalse, bs.DeployFailed, msg) - return fmt.Errorf(msg) - } - return nil -} - -func (r *BackstageReconciler) deploymentObjectMutFun(ctx context.Context, targetDeployment *appsv1.Deployment, backstage bs.Backstage, ns string) controllerutil.MutateFn { - return func() error { - deployment := &appsv1.Deployment{} - targetDeployment.ObjectMeta.DeepCopyInto(&deployment.ObjectMeta) - - err := r.readConfigMapOrDefault(ctx, backstage.Spec.RawRuntimeConfig.BackstageConfigName, "deployment.yaml", ns, deployment) - if err != nil { - return fmt.Errorf("failed to read config: %s", err) - } - - // Override deployment name - deployment.Name = getDefaultObjName(backstage) - - r.setDefaultDeploymentImage(deployment) - - r.applyBackstageLabels(backstage, deployment) - - if err = r.addParams(ctx, backstage, ns, deployment); err != nil { - return err - } - - r.applyApplicationParamsFromCR(backstage, deployment) - - if err = r.validateAndUpdatePsqlSecretRef(backstage, deployment); err != nil { - return fmt.Errorf("failed to validate database secret, reason: %s", err) - } - - if r.OwnsRuntime { - if err = controllerutil.SetControllerReference(&backstage, deployment, r.Scheme); err != nil { - return fmt.Errorf("failed to set owner reference: %s", err) - } - } - - deployment.ObjectMeta.DeepCopyInto(&targetDeployment.ObjectMeta) - deployment.Spec.DeepCopyInto(&targetDeployment.Spec) - return nil - } -} - -func (r *BackstageReconciler) addParams(ctx context.Context, backstage bs.Backstage, ns string, deployment *appsv1.Deployment) error { - if err := r.addVolumes(ctx, backstage, ns, deployment); err != nil { - return fmt.Errorf("failed to add volumes to Backstage deployment, reason: %s", err) - } - - if err := r.addVolumeMounts(ctx, backstage, ns, deployment); err != nil { - return fmt.Errorf("failed to add volume mounts to Backstage deployment, reason: %s", err) - } - - if err := r.addContainerArgs(ctx, backstage, ns, deployment); err != nil { - return fmt.Errorf("failed to add container args to Backstage deployment, reason: %s", err) - } - - if err := r.addEnvVars(backstage, ns, deployment); err != nil { - return fmt.Errorf("failed to add env vars to Backstage deployment, reason: %s", err) - } - return nil -} - -func (r *BackstageReconciler) addVolumes(ctx context.Context, backstage bs.Backstage, ns string, deployment *appsv1.Deployment) error { - dpConfVol, err := r.getDynamicPluginsConfVolume(ctx, backstage, ns) - if err != nil { - return err - } - if dpConfVol != nil { - deployment.Spec.Template.Spec.Volumes = append(deployment.Spec.Template.Spec.Volumes, *dpConfVol) - } - - backendAuthAppConfig, err := r.getBackendAuthAppConfig(ctx, backstage, ns) - if err != nil { - return err - } - - deployment.Spec.Template.Spec.Volumes = append(deployment.Spec.Template.Spec.Volumes, r.appConfigsToVolumes(backstage, backendAuthAppConfig)...) - deployment.Spec.Template.Spec.Volumes = append(deployment.Spec.Template.Spec.Volumes, r.extraFilesToVolumes(backstage)...) - return nil -} - -func (r *BackstageReconciler) addVolumeMounts(ctx context.Context, backstage bs.Backstage, ns string, deployment *appsv1.Deployment) error { - err := r.addDynamicPluginsConfVolumeMount(ctx, backstage, ns, deployment) - if err != nil { - return err - } - backendAuthAppConfig, err := r.getBackendAuthAppConfig(ctx, backstage, ns) - if err != nil { - return err - } - err = r.addAppConfigsVolumeMounts(ctx, backstage, ns, deployment, backendAuthAppConfig) - if err != nil { - return err - } - return r.addExtraFilesVolumeMounts(ctx, backstage, ns, deployment) -} - -func (r *BackstageReconciler) addContainerArgs(ctx context.Context, backstage bs.Backstage, ns string, deployment *appsv1.Deployment) error { - backendAuthAppConfig, err := r.getBackendAuthAppConfig(ctx, backstage, ns) - if err != nil { - return err - } - return r.addAppConfigsContainerArgs(ctx, backstage, ns, deployment, backendAuthAppConfig) -} - -func (r *BackstageReconciler) addEnvVars(backstage bs.Backstage, ns string, deployment *appsv1.Deployment) error { - r.addExtraEnvs(backstage, deployment) - return nil -} - -func (r *BackstageReconciler) validateAndUpdatePsqlSecretRef(backstage bs.Backstage, deployment *appsv1.Deployment) error { - for i, c := range deployment.Spec.Template.Spec.Containers { - if c.Name != _defaultBackstageMainContainerName { - continue - } - for k, from := range deployment.Spec.Template.Spec.Containers[i].EnvFrom { - if from.SecretRef.Name != postGresSecret { - continue - } - if len(backstage.Spec.Database.AuthSecretName) == 0 { - from.SecretRef.Name = getDefaultPsqlSecretName(&backstage) - } else { - from.SecretRef.Name = backstage.Spec.Database.AuthSecretName - } - deployment.Spec.Template.Spec.Containers[i].EnvFrom[k] = from - break - } - } - - return nil -} - -func (r *BackstageReconciler) setDefaultDeploymentImage(deployment *appsv1.Deployment) { - if envBackstageImage != "" { - visitContainers(&deployment.Spec.Template, func(container *v1.Container) { - container.Image = envBackstageImage - - }) - } -} - -func (r *BackstageReconciler) applyBackstageLabels(backstage bs.Backstage, deployment *appsv1.Deployment) { - setBackstageAppLabel(&deployment.Spec.Template.ObjectMeta.Labels, backstage) - setBackstageAppLabel(&deployment.Spec.Selector.MatchLabels, backstage) - r.labels(&deployment.ObjectMeta, backstage) -} - -func (r *BackstageReconciler) applyApplicationParamsFromCR(backstage bs.Backstage, deployment *appsv1.Deployment) { - if backstage.Spec.Application != nil { - deployment.Spec.Replicas = backstage.Spec.Application.Replicas - if backstage.Spec.Application.Image != nil { - visitContainers(&deployment.Spec.Template, func(container *v1.Container) { - container.Image = *backstage.Spec.Application.Image - }) - } - if backstage.Spec.Application.ImagePullSecrets != nil { // use image pull secrets from the CR spec - deployment.Spec.Template.Spec.ImagePullSecrets = nil - if len(*backstage.Spec.Application.ImagePullSecrets) > 0 { - for _, imagePullSecret := range *backstage.Spec.Application.ImagePullSecrets { - deployment.Spec.Template.Spec.ImagePullSecrets = append(deployment.Spec.Template.Spec.ImagePullSecrets, v1.LocalObjectReference{ - Name: imagePullSecret, - }) - } - } - } - } -} - -func getDefaultObjName(backstage bs.Backstage) string { - return fmt.Sprintf("backstage-%s", backstage.Name) -} - -func getDefaultDbObjName(backstage bs.Backstage) string { - return fmt.Sprintf("backstage-psql-%s", backstage.Name) -} diff --git a/controllers/backstage_dynamic_plugins.go b/controllers/backstage_dynamic_plugins.go deleted file mode 100644 index bb4905b7..00000000 --- a/controllers/backstage_dynamic_plugins.go +++ /dev/null @@ -1,125 +0,0 @@ -// -// Copyright (c) 2023 Red Hat, Inc. -// 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 controller - -import ( - "context" - "fmt" - - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" - - appsv1 "k8s.io/api/apps/v1" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" -) - -//var ( -// defaultDynamicPluginsConfigMap = ` -//apiVersion: v1 -//kind: ConfigMap -//metadata: -// name: # placeholder for '-dynamic-plugins' -//data: -// "dynamic-plugins.yaml": | -// includes: -// - dynamic-plugins.default.yaml -// plugins: [] -//` -//) - -func (r *BackstageReconciler) getOrGenerateDynamicPluginsConf(ctx context.Context, backstage bs.Backstage, ns string) (configMap string, err error) { - if backstage.Spec.Application != nil && backstage.Spec.Application.DynamicPluginsConfigMapName != "" { - return backstage.Spec.Application.DynamicPluginsConfigMapName, nil - } - - //Create default ConfigMap for dynamic plugins - var cm v1.ConfigMap - err = r.readConfigMapOrDefault(ctx, backstage.Spec.RawRuntimeConfig.BackstageConfigName, "dynamic-plugins-configmap.yaml", ns, &cm) - if err != nil { - return "", fmt.Errorf("failed to read config: %s", err) - } - - dpConfigName := fmt.Sprintf("%s-dynamic-plugins", backstage.Name) - cm.SetName(dpConfigName) - err = r.Get(ctx, types.NamespacedName{Name: dpConfigName, Namespace: ns}, &cm) - if err != nil { - if !errors.IsNotFound(err) { - return "", fmt.Errorf("failed to get config map for dynamic plugins (%q), reason: %s", dpConfigName, err) - } - setBackstageAppLabel(&cm.ObjectMeta.Labels, backstage) - r.labels(&cm.ObjectMeta, backstage) - - if r.OwnsRuntime { - if err = controllerutil.SetControllerReference(&backstage, &cm, r.Scheme); err != nil { - return "", fmt.Errorf("failed to set owner reference: %s", err) - } - } - err = r.Create(ctx, &cm) - if err != nil { - return "", fmt.Errorf("failed to create config map for dynamic plugins, reason: %s", err) - } - } - - return dpConfigName, nil -} - -func (r *BackstageReconciler) getDynamicPluginsConfVolume(ctx context.Context, backstage bs.Backstage, ns string) (*v1.Volume, error) { - dpConf, err := r.getOrGenerateDynamicPluginsConf(ctx, backstage, ns) - if err != nil { - return nil, err - } - - if dpConf == "" { - return nil, nil - } - - return &v1.Volume{ - Name: dpConf, - VolumeSource: v1.VolumeSource{ - ConfigMap: &v1.ConfigMapVolumeSource{ - DefaultMode: ptr.To[int32](420), - LocalObjectReference: v1.LocalObjectReference{Name: dpConf}, - }, - }, - }, nil -} - -func (r *BackstageReconciler) addDynamicPluginsConfVolumeMount(ctx context.Context, backstage bs.Backstage, ns string, deployment *appsv1.Deployment) error { - dpConf, err := r.getOrGenerateDynamicPluginsConf(ctx, backstage, ns) - if err != nil { - return err - } - - if dpConf == "" { - return nil - } - - for i, c := range deployment.Spec.Template.Spec.InitContainers { - if c.Name == _defaultBackstageInitContainerName { - deployment.Spec.Template.Spec.InitContainers[i].VolumeMounts = append(deployment.Spec.Template.Spec.InitContainers[i].VolumeMounts, - v1.VolumeMount{ - Name: dpConf, - MountPath: fmt.Sprintf("%s/dynamic-plugins.yaml", _containersWorkingDir), - ReadOnly: true, - SubPath: "dynamic-plugins.yaml", - }) - break - } - } - return nil -} diff --git a/controllers/backstage_extra_envs.go b/controllers/backstage_extra_envs.go deleted file mode 100644 index c3c3eae5..00000000 --- a/controllers/backstage_extra_envs.go +++ /dev/null @@ -1,81 +0,0 @@ -// -// Copyright (c) 2023 Red Hat, Inc. -// 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 controller - -import ( - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" - - appsv1 "k8s.io/api/apps/v1" - v1 "k8s.io/api/core/v1" -) - -func (r *BackstageReconciler) addExtraEnvs(backstage bs.Backstage, deployment *appsv1.Deployment) { - if backstage.Spec.Application == nil || backstage.Spec.Application.ExtraEnvs == nil { - return - } - - for _, env := range backstage.Spec.Application.ExtraEnvs.Envs { - for i := range deployment.Spec.Template.Spec.Containers { - deployment.Spec.Template.Spec.Containers[i].Env = append(deployment.Spec.Template.Spec.Containers[i].Env, v1.EnvVar{ - Name: env.Name, - Value: env.Value, - }) - } - } - - for _, cmRef := range backstage.Spec.Application.ExtraEnvs.ConfigMaps { - for i := range deployment.Spec.Template.Spec.Containers { - if cmRef.Key != "" { - deployment.Spec.Template.Spec.Containers[i].Env = append(deployment.Spec.Template.Spec.Containers[i].Env, v1.EnvVar{ - Name: cmRef.Key, - ValueFrom: &v1.EnvVarSource{ - ConfigMapKeyRef: &v1.ConfigMapKeySelector{ - LocalObjectReference: v1.LocalObjectReference{Name: cmRef.Name}, - Key: cmRef.Key, - }, - }, - }) - } else { - deployment.Spec.Template.Spec.Containers[i].EnvFrom = append(deployment.Spec.Template.Spec.Containers[i].EnvFrom, v1.EnvFromSource{ - ConfigMapRef: &v1.ConfigMapEnvSource{ - LocalObjectReference: v1.LocalObjectReference{Name: cmRef.Name}, - }, - }) - } - } - } - - for _, secRef := range backstage.Spec.Application.ExtraEnvs.Secrets { - for i := range deployment.Spec.Template.Spec.Containers { - if secRef.Key != "" { - deployment.Spec.Template.Spec.Containers[i].Env = append(deployment.Spec.Template.Spec.Containers[i].Env, v1.EnvVar{ - Name: secRef.Key, - ValueFrom: &v1.EnvVarSource{ - SecretKeyRef: &v1.SecretKeySelector{ - LocalObjectReference: v1.LocalObjectReference{Name: secRef.Name}, - Key: secRef.Key, - }, - }, - }) - } else { - deployment.Spec.Template.Spec.Containers[i].EnvFrom = append(deployment.Spec.Template.Spec.Containers[i].EnvFrom, v1.EnvFromSource{ - SecretRef: &v1.SecretEnvSource{ - LocalObjectReference: v1.LocalObjectReference{Name: secRef.Name}, - }, - }) - } - } - } -} diff --git a/controllers/backstage_extra_files.go b/controllers/backstage_extra_files.go deleted file mode 100644 index 52c606ac..00000000 --- a/controllers/backstage_extra_files.go +++ /dev/null @@ -1,138 +0,0 @@ -// -// Copyright (c) 2023 Red Hat, Inc. -// 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 controller - -import ( - "context" - "fmt" - - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" - - appsv1 "k8s.io/api/apps/v1" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" -) - -func (r *BackstageReconciler) extraFilesToVolumes(backstage bs.Backstage) (result []v1.Volume) { - if backstage.Spec.Application == nil || backstage.Spec.Application.ExtraFiles == nil { - return nil - } - for _, cmExtraFile := range backstage.Spec.Application.ExtraFiles.ConfigMaps { - result = append(result, - v1.Volume{ - Name: cmExtraFile.Name, - VolumeSource: v1.VolumeSource{ - ConfigMap: &v1.ConfigMapVolumeSource{ - DefaultMode: ptr.To[int32](420), - LocalObjectReference: v1.LocalObjectReference{Name: cmExtraFile.Name}, - }, - }, - }, - ) - } - for _, secExtraFile := range backstage.Spec.Application.ExtraFiles.Secrets { - result = append(result, - v1.Volume{ - Name: secExtraFile.Name, - VolumeSource: v1.VolumeSource{ - Secret: &v1.SecretVolumeSource{ - DefaultMode: ptr.To[int32](420), - SecretName: secExtraFile.Name, - }, - }, - }, - ) - } - - return result -} - -func (r *BackstageReconciler) addExtraFilesVolumeMounts(ctx context.Context, backstage bs.Backstage, ns string, deployment *appsv1.Deployment) error { - if backstage.Spec.Application == nil || backstage.Spec.Application.ExtraFiles == nil { - return nil - } - - appConfigFilenamesList, err := r.extractExtraFileNames(ctx, backstage, ns) - if err != nil { - return err - } - - for i, c := range deployment.Spec.Template.Spec.Containers { - if c.Name == _defaultBackstageMainContainerName { - for _, appConfigFilenames := range appConfigFilenamesList { - for _, f := range appConfigFilenames.files { - deployment.Spec.Template.Spec.Containers[i].VolumeMounts = append(deployment.Spec.Template.Spec.Containers[i].VolumeMounts, - v1.VolumeMount{ - Name: appConfigFilenames.ref, - MountPath: fmt.Sprintf("%s/%s", backstage.Spec.Application.ExtraFiles.MountPath, f), - SubPath: f, - }) - } - } - break - } - } - return nil -} - -// extractExtraFileNames returns a mapping of extra-config object name and the list of files in it. -// We intentionally do not return a Map, to preserve the iteration order of the ExtraConfigs in the Custom Resource, -// even though we can't guarantee the iteration order of the files listed inside each ConfigMap or Secret. -func (r *BackstageReconciler) extractExtraFileNames(ctx context.Context, backstage bs.Backstage, ns string) (result []appConfigData, err error) { - if backstage.Spec.Application == nil || backstage.Spec.Application.ExtraFiles == nil { - return nil, nil - } - - for _, cmExtraFile := range backstage.Spec.Application.ExtraFiles.ConfigMaps { - var files []string - if cmExtraFile.Key != "" { - // Limit to that file only - files = append(files, cmExtraFile.Key) - } else { - cm := v1.ConfigMap{} - if err = r.Get(ctx, types.NamespacedName{Name: cmExtraFile.Name, Namespace: ns}, &cm); err != nil { - return nil, err - } - for filename := range cm.Data { - // Bear in mind that iteration order over this map is not guaranteed by Go - files = append(files, filename) - } - for filename := range cm.BinaryData { - // Bear in mind that iteration order over this map is not guaranteed by Go - files = append(files, filename) - } - } - result = append(result, appConfigData{ - ref: cmExtraFile.Name, - files: files, - }) - } - - for _, secExtraFile := range backstage.Spec.Application.ExtraFiles.Secrets { - var files []string - if secExtraFile.Key != "" { - // Limit to that file only - files = append(files, secExtraFile.Key) - } else { - return nil, fmt.Errorf("key is required to mount extra file with secret %s", secExtraFile.Name) - } - result = append(result, appConfigData{ - ref: secExtraFile.Name, - files: files, - }) - } - return result, nil -} diff --git a/controllers/backstage_route.go b/controllers/backstage_route.go deleted file mode 100644 index 2585e000..00000000 --- a/controllers/backstage_route.go +++ /dev/null @@ -1,146 +0,0 @@ -// -// Copyright (c) 2023 Red Hat, Inc. -// 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 controller - -import ( - "context" - "fmt" - - "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" - - openshift "github.com/openshift/api/route/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func (r *BackstageReconciler) reconcileBackstageRoute(ctx context.Context, backstage *bs.Backstage, ns string) error { - // Override the route and service names - name := getDefaultObjName(*backstage) - route := &openshift.Route{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: ns, - }, - } - - if !shouldCreateRoute(*backstage) { - _, err := r.cleanupResource(ctx, route, *backstage) - if err != nil { - setStatusCondition(backstage, bs.ConditionDeployed, metav1.ConditionFalse, bs.DeployFailed, fmt.Sprintf("failed to delete route: %s", err)) - return err - } - return nil - } - - if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, route, r.routeObjectMutFun(ctx, route, *backstage, ns)); err != nil { - if errors.IsConflict(err) { - return retryReconciliation(err) - } - msg := fmt.Sprintf("failed to deploy Backstage Route: %s", err) - setStatusCondition(backstage, bs.ConditionDeployed, metav1.ConditionFalse, bs.DeployFailed, msg) - return fmt.Errorf(msg) - } - return nil -} - -func (r *BackstageReconciler) routeObjectMutFun(ctx context.Context, targetRoute *openshift.Route, backstage bs.Backstage, ns string) controllerutil.MutateFn { - return func() error { - route := &openshift.Route{} - targetRoute.ObjectMeta.DeepCopyInto(&route.ObjectMeta) - - err := r.readConfigMapOrDefault(ctx, backstage.Spec.RawRuntimeConfig.BackstageConfigName, "route.yaml", ns, route) - if err != nil { - return err - } - - // Override the route and service names - name := getDefaultObjName(backstage) - route.Name = name - route.Spec.To.Name = route.Name - - r.labels(&route.ObjectMeta, backstage) - - r.applyRouteParamsFromCR(route, backstage) - - if r.OwnsRuntime { - if err := controllerutil.SetControllerReference(&backstage, route, r.Scheme); err != nil { - return fmt.Errorf("failed to set owner reference: %s", err) - } - } - - route.ObjectMeta.DeepCopyInto(&targetRoute.ObjectMeta) - route.Spec.DeepCopyInto(&targetRoute.Spec) - return nil - } -} - -func (r *BackstageReconciler) applyRouteParamsFromCR(route *openshift.Route, backstage bs.Backstage) { - if backstage.Spec.Application == nil || backstage.Spec.Application.Route == nil { - return // Nothing to override - } - routeCfg := backstage.Spec.Application.Route - if len(routeCfg.Host) > 0 { - route.Spec.Host = routeCfg.Host - } - if len(routeCfg.Subdomain) > 0 { - route.Spec.Subdomain = routeCfg.Subdomain - } - if routeCfg.TLS == nil { - return - } - if route.Spec.TLS == nil { - route.Spec.TLS = &openshift.TLSConfig{ - Termination: openshift.TLSTerminationEdge, - InsecureEdgeTerminationPolicy: openshift.InsecureEdgeTerminationPolicyRedirect, - Certificate: routeCfg.TLS.Certificate, - Key: routeCfg.TLS.Key, - CACertificate: routeCfg.TLS.CACertificate, - ExternalCertificate: &openshift.LocalObjectReference{ - Name: routeCfg.TLS.ExternalCertificateSecretName, - }, - } - return - } - if len(routeCfg.TLS.Certificate) > 0 { - route.Spec.TLS.Certificate = routeCfg.TLS.Certificate - } - if len(routeCfg.TLS.Key) > 0 { - route.Spec.TLS.Key = routeCfg.TLS.Key - } - if len(routeCfg.TLS.Certificate) > 0 { - route.Spec.TLS.Certificate = routeCfg.TLS.Certificate - } - if len(routeCfg.TLS.CACertificate) > 0 { - route.Spec.TLS.CACertificate = routeCfg.TLS.CACertificate - } - if len(routeCfg.TLS.ExternalCertificateSecretName) > 0 { - route.Spec.TLS.ExternalCertificate = &openshift.LocalObjectReference{ - Name: routeCfg.TLS.ExternalCertificateSecretName, - } - } -} - -func shouldCreateRoute(backstage bs.Backstage) bool { - if backstage.Spec.Application == nil { - return true - } - if backstage.Spec.Application.Route == nil { - return true - } - return ptr.Deref(backstage.Spec.Application.Route.Enabled, true) -} diff --git a/controllers/backstage_service.go b/controllers/backstage_service.go deleted file mode 100644 index d19a3058..00000000 --- a/controllers/backstage_service.go +++ /dev/null @@ -1,103 +0,0 @@ -// -// Copyright (c) 2023 Red Hat, Inc. -// 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 controller - -import ( - "context" - "fmt" - - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func (r *BackstageReconciler) reconcileBackstageService(ctx context.Context, backstage *bs.Backstage, ns string) error { - service := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: getDefaultObjName(*backstage), - Namespace: ns, - }, - } - - if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, service, r.serviceObjectMutFun(ctx, service, *backstage, - backstage.Spec.RawRuntimeConfig.BackstageConfigName, "service.yaml", service.Name, service.Name)); err != nil { - - if errors.IsConflict(err) { - return retryReconciliation(err) - } - msg := fmt.Sprintf("failed to deploy Backstage Service: %s", err) - - setStatusCondition(backstage, bs.ConditionDeployed, metav1.ConditionFalse, bs.DeployFailed, msg) - return fmt.Errorf("%s %w", msg, err) - } - - return nil -} - -// selector for deploy.spec.template.spec.meta.label -// targetPort: http for deploy.spec.template.spec.containers.ports.name=http -func (r *BackstageReconciler) serviceObjectMutFun(ctx context.Context, targetService *corev1.Service, backstage bs.Backstage, - configName, configKey, serviceName, label string) controllerutil.MutateFn { - return func() error { - - service := &corev1.Service{} - targetService.ObjectMeta.DeepCopyInto(&service.ObjectMeta) - - err := r.readConfigMapOrDefault(ctx, configName, configKey, backstage.Namespace, service) - if err != nil { - return err - } - - service.Name = serviceName - setLabel(&service.ObjectMeta.Labels, label) - setLabel(&service.Spec.Selector, label) - r.labels(&service.ObjectMeta, backstage) - - if r.OwnsRuntime { - if err := controllerutil.SetControllerReference(&backstage, service, r.Scheme); err != nil { - return fmt.Errorf("failed to set owner reference: %s", err) - } - } - - if err := validateServiceIPs(targetService, service); err != nil { - return err - } - service.Spec.ClusterIPs = targetService.Spec.ClusterIPs - - service.ObjectMeta.DeepCopyInto(&targetService.ObjectMeta) - service.Spec.DeepCopyInto(&targetService.Spec) - - return nil - } -} - -func validateServiceIPs(targetService *corev1.Service, service *corev1.Service) error { - if len(targetService.Spec.ClusterIP) > 0 && service.Spec.ClusterIP != "" && service.Spec.ClusterIP != "None" && service.Spec.ClusterIP != targetService.Spec.ClusterIP { - return fmt.Errorf("backstage service IP can not be updated: %s, current: %s, new: %s", targetService.Name, targetService.Spec.ClusterIP, service.Spec.ClusterIP) - } - service.Spec.ClusterIP = targetService.Spec.ClusterIP - for _, ip1 := range targetService.Spec.ClusterIPs { - for _, ip2 := range service.Spec.ClusterIPs { - if len(ip1) > 0 && ip2 != "" && ip2 != "None" && ip1 != ip2 { - return fmt.Errorf("backstage service IPs can not be updated: %s, current: %v, new: %v", targetService.Name, targetService.Spec.ClusterIPs, service.Spec.ClusterIPs) - } - } - } - return nil -} diff --git a/controllers/local_db_secret.go b/controllers/local_db_secret.go deleted file mode 100644 index acc14e6b..00000000 --- a/controllers/local_db_secret.go +++ /dev/null @@ -1,111 +0,0 @@ -// -// Copyright (c) 2023 Red Hat, Inc. -// 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 controller - -import ( - "context" - "crypto/rand" - "encoding/base64" - "fmt" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" -) - -const ( - postGresSecret = "" // #nosec G101. This is a placeholder for a secret name not an actual secret - _defaultPsqlMainContainerName = "postgresql" -) - -func (r *BackstageReconciler) handlePsqlSecret(ctx context.Context, statefulSet *appsv1.StatefulSet, backstage *bs.Backstage) (*corev1.Secret, error) { - secretName := getSecretNameForGeneration(statefulSet, backstage) - if len(secretName) == 0 { - return nil, nil - } - - var sec corev1.Secret - err := r.readConfigMapOrDefault(ctx, backstage.Spec.RawRuntimeConfig.BackstageConfigName, "db-secret.yaml", statefulSet.Namespace, &sec) - if err != nil { - return nil, fmt.Errorf("failed to read config: %s", err) - } - - //Generate the PostGresSQL secret if it does not exist - sec.SetName(secretName) - sec.SetNamespace(statefulSet.Namespace) - // Create a secret with a random value - pwd, pwdErr := generatePassword(24) - if pwdErr != nil { - return nil, fmt.Errorf("failed to generate a password for the PostgreSQL database: %w", pwdErr) - } - sec.StringData["POSTGRES_PASSWORD"] = pwd - sec.StringData["POSTGRESQL_ADMIN_PASSWORD"] = pwd - sec.StringData["POSTGRES_HOST"] = getDefaultDbObjName(*backstage) - if r.OwnsRuntime { - // Set the ownerreferences for the secret so that when the backstage CR is deleted, - // the generated secret is automatically deleted - if err := controllerutil.SetControllerReference(backstage, &sec, r.Scheme); err != nil { - return nil, fmt.Errorf(ownerRefFmt, err) - } - } - - err = r.Create(ctx, &sec) - if err != nil && !errors.IsAlreadyExists(err) { - return nil, fmt.Errorf("failed to create secret for local PostgreSQL DB, reason: %s", err) - } - return nil, nil // If the secret already exists, simply return -} - -func getDefaultPsqlSecretName(backstage *bs.Backstage) string { - return fmt.Sprintf("backstage-psql-secret-%s", backstage.Name) -} - -func generatePassword(length int) (string, error) { - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - // Encode the password to prevent special characters - return base64.StdEncoding.EncodeToString(bytes), nil -} - -func getSecretNameForGeneration(statefulSet *appsv1.StatefulSet, backstage *bs.Backstage) string { - // A secret for the Postgres DB will be autogenerated if and only if - // a) the container has an envFrom entry pointing to the secret reference with special name '', and - // b) no existingDbSecret is specified in backstage CR. - for i, c := range statefulSet.Spec.Template.Spec.Containers { - if c.Name != _defaultPsqlMainContainerName { - continue - } - for k, from := range statefulSet.Spec.Template.Spec.Containers[i].EnvFrom { - if from.SecretRef.Name == postGresSecret { - if len(backstage.Spec.Database.AuthSecretName) == 0 { - from.SecretRef.Name = getDefaultPsqlSecretName(backstage) - statefulSet.Spec.Template.Spec.Containers[i].EnvFrom[k] = from - return from.SecretRef.Name - } else { - from.SecretRef.Name = backstage.Spec.Database.AuthSecretName - statefulSet.Spec.Template.Spec.Containers[i].EnvFrom[k] = from - break - } - } - } - break - } - return "" -} diff --git a/controllers/local_db_services.go b/controllers/local_db_services.go deleted file mode 100644 index 7d4c2d2f..00000000 --- a/controllers/local_db_services.go +++ /dev/null @@ -1,92 +0,0 @@ -/* -Copyright 2023. - -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 controller - -import ( - "context" - "fmt" - - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" -) - -// var (` -// -// DefaultLocalDbService = `apiVersion: v1 -// -// kind: Service -// metadata: -// -// name: backstage-psql-cr1 # placeholder for 'backstage-psql-' -// -// spec: -// -// selector: -// rhdh.redhat.com/app: backstage-psql-cr1 # placeholder for 'backstage-psql-' -// ports: -// - port: 5432 -// -// ` -// -// DefaultLocalDbServiceHL = `apiVersion: v1 -// -// kind: Service -// metadata: -// -// name: backstage-psql-cr1-hl # placeholder for 'backstage-psql--hl' -// -// spec: -// -// selector: -// rhdh.redhat.com/app: backstage-psql-cr1 # placeholder for 'backstage-psql-' -// clusterIP: None -// ports: -// - port: 5432 -// -// ` -// ) -func (r *BackstageReconciler) reconcileLocalDbServices(ctx context.Context, backstage *bs.Backstage, ns string) error { - name := getDefaultDbObjName(*backstage) - err := r.reconcilePsqlService(ctx, backstage, name, name, "db-service.yaml", ns) - if err != nil { - return err - } - nameHL := fmt.Sprintf("backstage-psql-%s-hl", backstage.Name) - return r.reconcilePsqlService(ctx, backstage, nameHL, name, "db-service-hl.yaml", ns) - -} - -func (r *BackstageReconciler) reconcilePsqlService(ctx context.Context, backstage *bs.Backstage, serviceName, label, configKey, ns string) error { - service := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: serviceName, - Namespace: ns, - }, - } - if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, service, r.serviceObjectMutFun(ctx, service, *backstage, backstage.Spec.RawRuntimeConfig.LocalDbConfigName, configKey, serviceName, label)); err != nil { - if errors.IsConflict(err) { - return retryReconciliation(err) - } - msg := fmt.Sprintf("failed to deploy database service: %s", err) - setStatusCondition(backstage, bs.ConditionDeployed, metav1.ConditionFalse, bs.DeployFailed, msg) - return fmt.Errorf("%s %w", msg, err) - } - return nil -} diff --git a/controllers/local_db_statefulset.go b/controllers/local_db_statefulset.go deleted file mode 100644 index afb47afc..00000000 --- a/controllers/local_db_statefulset.go +++ /dev/null @@ -1,164 +0,0 @@ -/* -Copyright 2023. - -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 controller - -import ( - "context" - "fmt" - - appsv1 "k8s.io/api/apps/v1" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" -) - -const ( - ownerRefFmt = "failed to set owner reference: %s" -) - -func (r *BackstageReconciler) reconcileLocalDbStatefulSet(ctx context.Context, backstage *bs.Backstage, ns string) error { - statefulSet := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: getDefaultDbObjName(*backstage), - Namespace: ns, - }, - } - if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, statefulSet, r.localDBStatefulSetMutFun(ctx, statefulSet, *backstage, ns)); err != nil { - if errors.IsConflict(err) { - return retryReconciliation(err) - } - msg := fmt.Sprintf("failed to deploy Database StatefulSet: %s", err) - setStatusCondition(backstage, bs.ConditionDeployed, metav1.ConditionFalse, bs.DeployFailed, msg) - return fmt.Errorf(msg) - } - return nil -} - -func (r *BackstageReconciler) localDBStatefulSetMutFun(ctx context.Context, targetStatefulSet *appsv1.StatefulSet, backstage bs.Backstage, ns string) controllerutil.MutateFn { - return func() error { - statefulSet := &appsv1.StatefulSet{} - targetStatefulSet.ObjectMeta.DeepCopyInto(&statefulSet.ObjectMeta) - err := r.readConfigMapOrDefault(ctx, backstage.Spec.RawRuntimeConfig.LocalDbConfigName, "db-statefulset.yaml", ns, statefulSet) - if err != nil { - return err - } - - // Override the name - statefulSet.Name = getDefaultDbObjName(backstage) - if err = r.patchLocalDbStatefulSetObj(statefulSet, backstage); err != nil { - return err - } - r.labels(&statefulSet.ObjectMeta, backstage) - if err = r.patchLocalDbStatefulSetObj(statefulSet, backstage); err != nil { - return err - } - - r.setDefaultStatefulSetImage(statefulSet) - - _, err = r.handlePsqlSecret(ctx, statefulSet, &backstage) - if err != nil { - return err - } - - if r.OwnsRuntime { - // Set the ownerreferences for the statefulset so that when the backstage CR is deleted, - // the statefulset is automatically deleted - // Note that the PVCs associated with the statefulset are not deleted automatically - // to prevent data loss. However OpenShift v4.14 and Kubernetes v1.27 introduced an optional - // parameter persistentVolumeClaimRetentionPolicy in the statefulset spec: - // spec: - // persistentVolumeClaimRetentionPolicy: - // whenDeleted: Delete - // whenScaled: Retain - // This will allow the PVCs to get automatically deleted when the statefulset is deleted if - // the StatefulSetAutoDeletePVC feature gate is enabled on the API server. - // For more information, see https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/ - if err := controllerutil.SetControllerReference(&backstage, statefulSet, r.Scheme); err != nil { - return fmt.Errorf(ownerRefFmt, err) - } - } - - statefulSet.ObjectMeta.DeepCopyInto(&targetStatefulSet.ObjectMeta) - statefulSet.Spec.DeepCopyInto(&targetStatefulSet.Spec) - return nil - } -} - -func (r *BackstageReconciler) patchLocalDbStatefulSetObj(statefulSet *appsv1.StatefulSet, backstage bs.Backstage) error { - name := getDefaultDbObjName(backstage) - statefulSet.SetName(name) - statefulSet.Spec.Template.SetName(name) - statefulSet.Spec.ServiceName = fmt.Sprintf("%s-hl", name) - - setLabel(&statefulSet.Spec.Template.ObjectMeta.Labels, name) - setLabel(&statefulSet.Spec.Selector.MatchLabels, name) - - return nil -} - -func (r *BackstageReconciler) setDefaultStatefulSetImage(statefulSet *appsv1.StatefulSet) { - if envPostgresImage != "" { - visitContainers(&statefulSet.Spec.Template, func(container *v1.Container) { - container.Image = envPostgresImage - }) - } -} - -// cleanupLocalDbResources removes all local db related resources, including statefulset, services and generated secret. -func (r *BackstageReconciler) cleanupLocalDbResources(ctx context.Context, backstage bs.Backstage) error { - statefulSet := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: getDefaultDbObjName(backstage), - Namespace: backstage.Namespace, - }, - } - if _, err := r.cleanupResource(ctx, statefulSet, backstage); err != nil { - return fmt.Errorf("failed to delete database statefulset, reason: %s", err) - } - - service := &v1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: getDefaultDbObjName(backstage), - Namespace: backstage.Namespace, - }, - } - if _, err := r.cleanupResource(ctx, service, backstage); err != nil { - return fmt.Errorf("failed to delete database service, reason: %s", err) - } - serviceHL := &v1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("backstage-psql-%s-hl", backstage.Name), - Namespace: backstage.Namespace, - }, - } - if _, err := r.cleanupResource(ctx, serviceHL, backstage); err != nil { - return fmt.Errorf("failed to delete headless database service, reason: %s", err) - } - - secret := &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: getDefaultPsqlSecretName(&backstage), - Namespace: backstage.Namespace, - }, - } - if _, err := r.cleanupResource(ctx, secret, backstage); err != nil { - return fmt.Errorf("failed to delete generated database secret, reason: %s", err) - } - return nil -} diff --git a/controllers/local_db_storage.go b/controllers/local_db_storage.go deleted file mode 100644 index 2825e60e..00000000 --- a/controllers/local_db_storage.go +++ /dev/null @@ -1,65 +0,0 @@ -// -// Copyright (c) 2023 Red Hat, Inc. -// 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 controller - -/* -import ( - "context" - "fmt" - - bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" -) - -func (r *BackstageReconciler) applyPV(ctx context.Context, backstage bs.Backstage, ns string) error { - // Postgre PersistentVolume - //lg := log.FromContext(ctx) - - pv := &corev1.PersistentVolume{} - err := r.readConfigMapOrDefault(ctx, backstage.Spec.RawRuntimeConfig.LocalDbConfigName, "db-pv.yaml", ns, pv) - if err != nil { - return err - } - - err = r.Get(ctx, types.NamespacedName{Name: pv.Name, Namespace: ns}, pv) - - if err != nil { - if errors.IsNotFound(err) { - } else { - return fmt.Errorf("failed to get PV, reason: %s", err) - } - } else { - //lg.Info("CR update is ignored for the time") - return nil - } - - r.labels(&pv.ObjectMeta, backstage) - if r.OwnsRuntime { - if err := controllerutil.SetControllerReference(&backstage, pv, r.Scheme); err != nil { - return fmt.Errorf("failed to set owner reference: %s", err) - } - } - - err = r.Create(ctx, pv) - if err != nil { - return fmt.Errorf("failed to create postgre persistent volume, reason:%s", err) - } - - return nil -} -*/ diff --git a/controllers/spec_preprocessor.go b/controllers/spec_preprocessor.go new file mode 100644 index 00000000..37c5fbcc --- /dev/null +++ b/controllers/spec_preprocessor.go @@ -0,0 +1,115 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 controller + +import ( + "context" + "fmt" + + bs "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + "redhat-developer/red-hat-developer-hub-operator/pkg/model" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +// Add additional details to the Backstage Spec helping in making Backstage RuntimeObjects Model +// Validates Backstage Spec and fails fast if something not correct +func (r *BackstageReconciler) preprocessSpec(ctx context.Context, backstage bs.Backstage) (model.ExternalConfig, error) { + //lg := log.FromContext(ctx) + + bsSpec := backstage.Spec + ns := backstage.Namespace + + result := model.ExternalConfig{ + RawConfig: map[string]string{}, + AppConfigs: map[string]corev1.ConfigMap{}, + ExtraFileConfigMaps: map[string]corev1.ConfigMap{}, + ExtraEnvConfigMaps: map[string]corev1.ConfigMap{}, + } + + // Process RawConfig + if bsSpec.RawRuntimeConfig != nil { + if bsSpec.RawRuntimeConfig.BackstageConfigName != "" { + cm := corev1.ConfigMap{} + if err := r.Get(ctx, types.NamespacedName{Name: bsSpec.RawRuntimeConfig.BackstageConfigName, Namespace: ns}, &cm); err != nil { + return result, fmt.Errorf("failed to load rawConfig %s: %w", bsSpec.RawRuntimeConfig.BackstageConfigName, err) + } + for key, value := range cm.Data { + result.RawConfig[key] = value + } + } + if bsSpec.RawRuntimeConfig.LocalDbConfigName != "" { + cm := corev1.ConfigMap{} + if err := r.Get(ctx, types.NamespacedName{Name: bsSpec.RawRuntimeConfig.LocalDbConfigName, Namespace: ns}, &cm); err != nil { + return result, fmt.Errorf("failed to load rawConfig %s: %w", bsSpec.RawRuntimeConfig.LocalDbConfigName, err) + } + for key, value := range cm.Data { + result.RawConfig[key] = value + } + } + } + + if bsSpec.Application == nil { + bsSpec.Application = &bs.Application{} + } + + // Process AppConfigs + if bsSpec.Application.AppConfig != nil { + //mountPath := bsSpec.Application.AppConfig.MountPath + for _, ac := range bsSpec.Application.AppConfig.ConfigMaps { + cm := corev1.ConfigMap{} + if err := r.Get(ctx, types.NamespacedName{Name: ac.Name, Namespace: ns}, &cm); err != nil { + return result, fmt.Errorf("failed to get configMap %s: %w", ac.Name, err) + } + result.AppConfigs[cm.Name] = cm + } + } + + // Process ConfigMapFiles + if bsSpec.Application.ExtraFiles != nil && bsSpec.Application.ExtraFiles.ConfigMaps != nil { + for _, ef := range bsSpec.Application.ExtraFiles.ConfigMaps { + cm := corev1.ConfigMap{} + if err := r.Get(ctx, types.NamespacedName{Name: ef.Name, Namespace: ns}, &cm); err != nil { + return result, fmt.Errorf("failed to get ConfigMap %s: %w", ef.Name, err) + } + result.ExtraFileConfigMaps[cm.Name] = cm + } + } + + // Process ConfigMapEnvs + if bsSpec.Application.ExtraEnvs != nil && bsSpec.Application.ExtraEnvs.ConfigMaps != nil { + for _, ee := range bsSpec.Application.ExtraEnvs.ConfigMaps { + cm := corev1.ConfigMap{} + if err := r.Get(ctx, types.NamespacedName{Name: ee.Name, Namespace: ns}, &cm); err != nil { + return result, fmt.Errorf("failed to get configMap %s: %w", ee.Name, err) + } + result.ExtraEnvConfigMaps[cm.Name] = cm + } + } + + // Process DynamicPlugins + if bsSpec.Application.DynamicPluginsConfigMapName != "" { + cm := corev1.ConfigMap{} + if err := r.Get(ctx, types.NamespacedName{Name: bsSpec.Application.DynamicPluginsConfigMapName, + Namespace: ns}, &cm); err != nil { + return result, fmt.Errorf("failed to get ConfigMap %v: %w", cm, err) + } + result.DynamicPlugins = cm + } + + return result, nil +} diff --git a/examples/bs-existing-secret.yaml b/examples/bs-existing-secret.yaml index 38d8d0e9..51846725 100644 --- a/examples/bs-existing-secret.yaml +++ b/examples/bs-existing-secret.yaml @@ -17,4 +17,4 @@ stringData: POSTGRES_PORT: "5432" POSTGRES_USER: postgres POSTGRESQL_ADMIN_PASSWORD: admin123 - POSTGRES_HOST: backstage-psql-bs-existing-secret + POSTGRES_HOST: bs-existing-secret-backstage-db diff --git a/examples/bs1.yaml b/examples/bs1.yaml index 319742fb..a1b7a90f 100644 --- a/examples/bs1.yaml +++ b/examples/bs1.yaml @@ -2,7 +2,6 @@ apiVersion: rhdh.redhat.com/v1alpha1 kind: Backstage metadata: name: bs1 -# namespace: backstage -#spec: -# database: -# enableLocalDb: false + + + diff --git a/examples/postgres-secret.yaml b/examples/postgres-secret.yaml index ef001228..9ed82362 100644 --- a/examples/postgres-secret.yaml +++ b/examples/postgres-secret.yaml @@ -5,8 +5,9 @@ metadata: namespace: backstage type: Opaque stringData: - POSTGRES_PASSWORD: admin123 + POSTGRES_PASSWORD: admin12345 POSTGRES_PORT: "5432" - POSTGRES_USER: postgres - POSTGRESQL_ADMIN_PASSWORD: admin123 - POSTGRES_HOST: backstage-psql-bs1 \ No newline at end of file + POSTGRES_USER: postgres12345 + POSTGRESQL_ADMIN_PASSWORD: admin12345 + POSTGRES_HOST: bs1-db-service + #POSTGRES_HOST: backstage-psql-bs1 \ No newline at end of file diff --git a/examples/rhdh-cr-with-app-configs.yaml b/examples/rhdh-cr-with-app-configs.yaml index 28e62fc2..a9e67f1d 100644 --- a/examples/rhdh-cr-with-app-configs.yaml +++ b/examples/rhdh-cr-with-app-configs.yaml @@ -124,16 +124,25 @@ data: # supports ISO duration, "human duration (used below) timeout: { minutes: 3} initialDelay: { seconds: 15} - - package: '@dfatwork-pkgs/scaffolder-backend-module-http-request-wrapped-dynamic@4.0.9-0' - integrity: 'sha512-+YYESzHdg1hsk2XN+zrtXPnsQnfbzmWIvcOM0oQLS4hf8F4iGTtOXKjWnZsR/14/khGsPrzy0oq1ytJ1/4ORkQ==' - - package: '@dfatwork-pkgs/explore-backend-wrapped-dynamic@0.0.9-next.11' - integrity: 'sha512-/qUxjSedxQ0dmYqMWsZ2+OLGeovaaigRRrX1aTOz0GJMwSjOAauUUD1bMs56VPX74qWL1rf3Xr4nViiKo8rlIA==' + - package: ./dynamic-plugins/dist/backstage-plugin-techdocs-backend-dynamic pluginConfig: - proxy: - endpoints: - /explore-backend-completed: - target: 'http://localhost:7017' - + # Reference documentation http://backstage.io/docs/features/techdocs/configuration + # Note: After experimenting with basic setup, use CI/CD to generate docs + # and an external cloud storage when deploying TechDocs for production use-case. + # https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach + techdocs: + builder: local + generator: + runIn: local + publisher: + type: local + - package: ./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-gitlab-dynamic + disabled: false + pluginConfig: + catalog: + providers: + gitlab: {} + --- apiVersion: v1 kind: ConfigMap diff --git a/go.mod b/go.mod index 77ee63d8..26297f30 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,10 @@ module redhat-developer/red-hat-developer-hub-operator go 1.20 require ( - github.com/go-logr/logr v1.4.1 github.com/onsi/ginkgo/v2 v2.17.1 github.com/onsi/gomega v1.32.0 github.com/openshift/api v0.0.0-20240328182048-8bef56a2e295 + github.com/stretchr/testify v1.8.4 k8s.io/api v0.29.3 k8s.io/apimachinery v0.29.3 k8s.io/client-go v0.29.3 @@ -21,6 +21,7 @@ require ( github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch/v5 v5.8.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect @@ -43,6 +44,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.18.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.45.0 // indirect diff --git a/go.sum b/go.sum index 67baf6a6..b5c85082 100644 --- a/go.sum +++ b/go.sum @@ -16,7 +16,6 @@ github.com/evanphx/json-patch/v5 v5.8.0 h1:lRj6N9Nci7MvzrXuX6HFzU8XjmhPiXPlsKEy1 github.com/evanphx/json-patch/v5 v5.8.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= 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/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -35,14 +34,10 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -58,10 +53,8 @@ github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -81,17 +74,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/onsi/ginkgo/v2 v2.16.0 h1:7q1w9frJDzninhXxjZd+Y/x54XNjG/UlRLIYPZafsPM= -github.com/onsi/ginkgo/v2 v2.16.0/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= -github.com/onsi/gomega v1.31.1 h1:KYppCUK+bUgAZwHOu7EXVBKyQA6ILvOESHkn/tgoqvo= -github.com/onsi/gomega v1.31.1/go.mod h1:y40C95dwAD1Nz36SsEnxvfFe8FFfNxzI5eJ0EYGyAy0= github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= -github.com/openshift/api v0.0.0-20240314024039-4caef7fe3d0f h1:qkoJ3ypsx0gZaK2eJIYVijYm7uioLxoY+palQX1w60w= -github.com/openshift/api v0.0.0-20240314024039-4caef7fe3d0f/go.mod h1:CxgbWAlvu2iQB0UmKTtRu1YfepRg1/vJ64n2DlIEVz4= github.com/openshift/api v0.0.0-20240328182048-8bef56a2e295 h1:Fv47GtZvL6XvM/eHdRyb9NJezy/wY/0YtisbZyir58E= github.com/openshift/api v0.0.0-20240328182048-8bef56a2e295/go.mod h1:CxgbWAlvu2iQB0UmKTtRu1YfepRg1/vJ64n2DlIEVz4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -118,6 +104,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -173,10 +160,6 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -191,18 +174,12 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A= -k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0= k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw= k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= k8s.io/apiextensions-apiserver v0.29.0 h1:0VuspFG7Hj+SxyF/Z/2T0uFbI5gb5LRgEyUVE3Q4lV0= k8s.io/apiextensions-apiserver v0.29.0/go.mod h1:TKmpy3bTS0mr9pylH0nOt/QzQRrW7/h7yLdRForMZwc= -k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8= -k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= -k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg= -k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA= k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg= k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0= k8s.io/component-base v0.29.0 h1:T7rjd5wvLnPBV1vC4zWd/iWRbV8Mdxs+nGaoaFzGw3s= diff --git a/integration_tests/README.md b/integration_tests/README.md new file mode 100644 index 00000000..7a2cff99 --- /dev/null +++ b/integration_tests/README.md @@ -0,0 +1,24 @@ + +How to run Integration Tests + +- For development (controller will reconsile internally) + - As a part of the whole testing suite just: + + `make test` + - Standalone, just integration tests: + + `make integration-test` + +- For QE (integration/e2e testing). No OLM + There are 2 environment variables to use with `make` command + - `USE_EXISTING_CLUSTER=true` tells test suite to use externally running cluster (from the current .kube/config context) instead of envtest. + - `USE_EXISTING_CONTROLLER=true` tells test suite to use operator controller manager either deployed to the cluster OR (prevails if both) running locally with `make [install] run` command. Works only with `USE_EXISTING_CLUSTER=true` + + So, in most of the cases + - Make sure you test desirable version of Operator image, that's what + `make image-build image-push` does. See Makefile what version `` has. + - Prepare your cluster with: + - `make install deploy` this will install CR and deploy Controller to `backstage-system` + - `make integration-test USE_EXISTING_CLUSTER=true USE_EXISTING_CONTROLLER=true` + + \ No newline at end of file diff --git a/integration_tests/cr-config_test.go b/integration_tests/cr-config_test.go new file mode 100644 index 00000000..c102fc0b --- /dev/null +++ b/integration_tests/cr-config_test.go @@ -0,0 +1,218 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 integration_tests + +import ( + "context" + "time" + + "k8s.io/utils/ptr" + + corev1 "k8s.io/api/core/v1" + + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + appsv1 "k8s.io/api/apps/v1" + + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "redhat-developer/red-hat-developer-hub-operator/pkg/model" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + "k8s.io/apimachinery/pkg/types" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = When("create backstage with CR configured", func() { + + var ( + ctx context.Context + ns string + ) + + BeforeEach(func() { + ctx = context.Background() + ns = createNamespace(ctx) + }) + + AfterEach(func() { + deleteNamespace(ctx, ns) + }) + + It("creates Backstage with configuration ", func() { + + appConfig1 := generateConfigMap(ctx, k8sClient, "app-config1", ns, map[string]string{"key11": "app:", "key12": "app:"}) + appConfig2 := generateConfigMap(ctx, k8sClient, "app-config2", ns, map[string]string{"key21": "app:", "key22": "app:"}) + + cmFile1 := generateConfigMap(ctx, k8sClient, "cm-file1", ns, map[string]string{"cm11": "11", "cm12": "12"}) + cmFile2 := generateConfigMap(ctx, k8sClient, "cm-file2", ns, map[string]string{"cm21": "21", "cm22": "22"}) + + secretFile1 := generateSecret(ctx, k8sClient, "secret-file1", ns, []string{"sec11", "sec12"}) + secretFile2 := generateSecret(ctx, k8sClient, "secret-file2", ns, []string{"sec21", "sec22"}) + + cmEnv1 := generateConfigMap(ctx, k8sClient, "cm-env1", ns, map[string]string{"cm11": "11", "cm12": "12"}) + cmEnv2 := generateConfigMap(ctx, k8sClient, "cm-env2", ns, map[string]string{"cm21": "21", "cm22": "22"}) + + secretEnv1 := generateSecret(ctx, k8sClient, "secret-env1", ns, []string{"sec11", "sec12"}) + _ = generateSecret(ctx, k8sClient, "secret-env2", ns, []string{"sec21", "sec22"}) + + bs := bsv1alpha1.BackstageSpec{ + Application: &bsv1alpha1.Application{ + AppConfig: &bsv1alpha1.AppConfig{ + MountPath: "/my/mount/path", + ConfigMaps: []bsv1alpha1.ObjectKeyRef{ + {Name: appConfig1}, + {Name: appConfig2, Key: "key21"}, + }, + }, + //DynamicPluginsConfigMapName: "", + ExtraFiles: &bsv1alpha1.ExtraFiles{ + MountPath: "/my/file/path", + ConfigMaps: []bsv1alpha1.ObjectKeyRef{ + {Name: cmFile1}, + {Name: cmFile2, Key: "cm21"}, + }, + Secrets: []bsv1alpha1.ObjectKeyRef{ + {Name: secretFile1, Key: "sec11"}, + {Name: secretFile2, Key: "sec21"}, + }, + }, + ExtraEnvs: &bsv1alpha1.ExtraEnvs{ + ConfigMaps: []bsv1alpha1.ObjectKeyRef{ + {Name: cmEnv1}, + {Name: cmEnv2, Key: "cm21"}, + }, + Secrets: []bsv1alpha1.ObjectKeyRef{ + {Name: secretEnv1, Key: "sec11"}, + }, + Envs: []bsv1alpha1.Env{ + {Name: "env1", Value: "val1"}, + }, + }, + }, + } + backstageName := createAndReconcileBackstage(ctx, ns, bs) + + Eventually(func(g Gomega) { + deploy := &appsv1.Deployment{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstageName)}, deploy) + g.Expect(err).ShouldNot(HaveOccurred()) + + podSpec := deploy.Spec.Template.Spec + c := podSpec.Containers[0] + + By("checking if app-config volumes are added to PodSpec") + g.Expect(utils.GenerateVolumeNameFromCmOrSecret(appConfig1)).To(BeAddedAsVolumeToPodSpec(podSpec)) + g.Expect(utils.GenerateVolumeNameFromCmOrSecret(appConfig2)).To(BeAddedAsVolumeToPodSpec(podSpec)) + + By("checking if app-config volumes are mounted to the Backstage container") + g.Expect("/my/mount/path/key11").To(BeMountedToContainer(c)) + g.Expect("/my/mount/path/key12").To(BeMountedToContainer(c)) + g.Expect("/my/mount/path/key21").To(BeMountedToContainer(c)) + g.Expect("/my/mount/path/key22").NotTo(BeMountedToContainer(c)) + + By("checking if app-config args are added to the Backstage container") + g.Expect("/my/mount/path/key11").To(BeAddedAsArgToContainer(c)) + g.Expect("/my/mount/path/key12").To(BeAddedAsArgToContainer(c)) + g.Expect("/my/mount/path/key21").To(BeAddedAsArgToContainer(c)) + g.Expect("/my/mount/path/key22").NotTo(BeAddedAsArgToContainer(c)) + + By("checking if extra-cm-file volumes are added to PodSpec") + g.Expect(utils.GenerateVolumeNameFromCmOrSecret(cmFile1)).To(BeAddedAsVolumeToPodSpec(podSpec)) + g.Expect(utils.GenerateVolumeNameFromCmOrSecret(cmFile2)).To(BeAddedAsVolumeToPodSpec(podSpec)) + + By("checking if extra-cm-file volumes are mounted to the Backstage container") + g.Expect("/my/file/path/cm11").To(BeMountedToContainer(c)) + g.Expect("/my/file/path/cm12").To(BeMountedToContainer(c)) + g.Expect("/my/file/path/cm21").To(BeMountedToContainer(c)) + g.Expect("/my/file/path/cm22").NotTo(BeMountedToContainer(c)) + + By("checking if extra-secret-file volumes are added to PodSpec") + g.Expect(utils.GenerateVolumeNameFromCmOrSecret("secret-file1")).To(BeAddedAsVolumeToPodSpec(podSpec)) + g.Expect(utils.GenerateVolumeNameFromCmOrSecret("secret-file2")).To(BeAddedAsVolumeToPodSpec(podSpec)) + + By("checking if extra-secret-file volumes are mounted to the Backstage container") + g.Expect("/my/file/path/sec11").To(BeMountedToContainer(c)) + g.Expect("/my/file/path/sec12").NotTo(BeMountedToContainer(c)) + g.Expect("/my/file/path/sec21").To(BeMountedToContainer(c)) + g.Expect("/my/file/path/sec22").NotTo(BeMountedToContainer(c)) + + By("checking if extra-envvars are injected to the Backstage container as EnvFrom") + g.Expect("cm-env1").To(BeEnvFromForContainer(c)) + + By("checking if environment variables are injected to the Backstage container as EnvVar") + g.Expect("env1").To(BeEnvVarForContainer(c)) + g.Expect("cm21").To(BeEnvVarForContainer(c)) + g.Expect("sec11").To(BeEnvVarForContainer(c)) + + for _, cond := range deploy.Status.Conditions { + if cond.Type == "Available" { + g.Expect(cond.Status).To(Equal(corev1.ConditionTrue)) + } + } + }, 5*time.Minute, time.Second).Should(Succeed(), controllerMessage()) + }) + + It("creates default Backstage and then update CR ", func() { + + backstageName := createAndReconcileBackstage(ctx, ns, bsv1alpha1.BackstageSpec{}) + + Eventually(func(g Gomega) { + By("creating Deployment with replicas=1 by default") + deploy := &appsv1.Deployment{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstageName)}, deploy) + g.Expect(err).To(Not(HaveOccurred())) + + }, time.Minute, time.Second).Should(Succeed()) + + By("updating Backstage") + update := &bsv1alpha1.Backstage{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, update) + Expect(err).To(Not(HaveOccurred())) + update.Spec.Application = &bsv1alpha1.Application{} + update.Spec.Application.Replicas = ptr.To(int32(2)) + err = k8sClient.Update(ctx, update) + Expect(err).To(Not(HaveOccurred())) + _, err = NewTestBackstageReconciler(ns).ReconcileAny(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, + }) + Expect(err).To(Not(HaveOccurred())) + + Eventually(func(g Gomega) { + + deploy := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstageName)}, deploy) + g.Expect(err).To(Not(HaveOccurred())) + g.Expect(deploy.Spec.Replicas).To(HaveValue(BeEquivalentTo(2))) + + }, time.Minute, time.Second).Should(Succeed()) + + }) +}) + +// Duplicated files in different CMs +// Message: "Deployment.apps \"test-backstage-ro86g-deployment\" is invalid: spec.template.spec.containers[0].volumeMounts[4].mountPath: Invalid value: \"/my/mount/path/key12\": must be unique", + +// No CM configured +//failed to preprocess backstage spec app-configs failed to get configMap app-config3: configmaps "app-config3" not found + +// If no such a key - no reaction, it is just not included + +// mounting/injecting secret by key only + +// TODO test for Raw Config https://github.com/janus-idp/operator/issues/202 diff --git a/integration_tests/default-config_test.go b/integration_tests/default-config_test.go new file mode 100644 index 00000000..d97e713e --- /dev/null +++ b/integration_tests/default-config_test.go @@ -0,0 +1,149 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 integration_tests + +import ( + "context" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + "time" + + appsv1 "k8s.io/api/apps/v1" + + "redhat-developer/red-hat-developer-hub-operator/pkg/model" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + corev1 "k8s.io/api/core/v1" + + "k8s.io/apimachinery/pkg/types" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = When("create default backstage", func() { + + var ( + ctx context.Context + ns string + ) + + BeforeEach(func() { + ctx = context.Background() + ns = createNamespace(ctx) + }) + + AfterEach(func() { + deleteNamespace(ctx, ns) + }) + + It("creates runtime objects", func() { + + backstageName := createAndReconcileBackstage(ctx, ns, bsv1alpha1.BackstageSpec{}) + + Eventually(func(g Gomega) { + By("creating a secret for accessing the Database") + secret := &corev1.Secret{} + secretName := model.DbSecretDefaultName(backstageName) + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: secretName}, secret) + g.Expect(err).ShouldNot(HaveOccurred(), controllerMessage()) + g.Expect(len(secret.Data)).To(Equal(5)) + g.Expect(secret.Data).To(HaveKeyWithValue("POSTGRES_USER", []uint8("postgres"))) + + By("creating a StatefulSet for the Database") + ss := &appsv1.StatefulSet{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DbStatefulSetName(backstageName)}, ss) + g.Expect(err).ShouldNot(HaveOccurred()) + + By("injecting default DB Secret as an env var for Db container") + g.Expect(model.DbSecretDefaultName(backstageName)).To(BeEnvFromForContainer(ss.Spec.Template.Spec.Containers[0])) + g.Expect(ss.GetOwnerReferences()).To(HaveLen(1)) + + By("creating a Service for the Database") + err = k8sClient.Get(ctx, types.NamespacedName{Name: model.DbServiceName(backstageName), Namespace: ns}, &corev1.Service{}) + g.Expect(err).To(Not(HaveOccurred())) + + By("creating Deployment") + deploy := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstageName)}, deploy) + g.Expect(err).ShouldNot(HaveOccurred()) + Expect(deploy.Spec.Replicas).To(HaveValue(BeEquivalentTo(1))) + + By("creating OwnerReference to all the runtime objects") + or := deploy.GetOwnerReferences() + g.Expect(or).To(HaveLen(1)) + g.Expect(or[0].Name).To(Equal(backstageName)) + + By("creating default app-config") + appConfig := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.AppConfigDefaultName(backstageName)}, appConfig) + g.Expect(err).ShouldNot(HaveOccurred()) + + By("mounting Volume defined in default app-config") + g.Expect(utils.GenerateVolumeNameFromCmOrSecret(model.AppConfigDefaultName(backstageName))). + To(BeAddedAsVolumeToPodSpec(deploy.Spec.Template.Spec)) + + By("setting Backstage status") + bs := &bsv1alpha1.Backstage{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: backstageName}, bs) + g.Expect(err).ShouldNot(HaveOccurred()) + // TODO better matcher for Conditions + g.Expect(bs.Status.Conditions[0].Reason).To(Equal("Deployed")) + + for _, cond := range deploy.Status.Conditions { + if cond.Type == "Available" { + g.Expect(cond.Status).To(Equal(corev1.ConditionTrue)) + } + } + + }, 5*time.Minute, time.Second).Should(Succeed()) + }) + + It("creates runtime object using raw configuration ", func() { + + bsConf := map[string]string{"deployment.yaml": readTestYamlFile("raw-deployment.yaml")} + dbConf := map[string]string{"db-statefulset.yaml": readTestYamlFile("raw-statefulset.yaml")} + + bsRaw := generateConfigMap(ctx, k8sClient, "bsraw", ns, bsConf) + dbRaw := generateConfigMap(ctx, k8sClient, "dbraw", ns, dbConf) + + backstageName := createAndReconcileBackstage(ctx, ns, bsv1alpha1.BackstageSpec{ + RawRuntimeConfig: &bsv1alpha1.RuntimeConfig{ + BackstageConfigName: bsRaw, + LocalDbConfigName: dbRaw, + }, + }) + + Eventually(func(g Gomega) { + By("creating Deployment") + deploy := &appsv1.Deployment{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstageName)}, deploy) + g.Expect(err).ShouldNot(HaveOccurred()) + g.Expect(deploy.Spec.Replicas).To(HaveValue(BeEquivalentTo(1))) + g.Expect(deploy.Spec.Template.Spec.Containers).To(HaveLen(1)) + g.Expect(deploy.Spec.Template.Spec.Containers[0].Image).To(Equal("busybox")) + + By("creating StatefulSet") + ss := &appsv1.StatefulSet{} + name := model.DbStatefulSetName(backstageName) + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, ss) + g.Expect(err).ShouldNot(HaveOccurred()) + g.Expect(ss.Spec.Template.Spec.Containers).To(HaveLen(1)) + g.Expect(ss.Spec.Template.Spec.Containers[0].Image).To(Equal("busybox")) + }, time.Minute, time.Second).Should(Succeed()) + + }) + +}) diff --git a/integration_tests/matchers.go b/integration_tests/matchers.go new file mode 100644 index 00000000..0c98dd33 --- /dev/null +++ b/integration_tests/matchers.go @@ -0,0 +1,185 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 integration_tests + +import ( + "fmt" + + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" + corev1 "k8s.io/api/core/v1" +) + +// Matcher for Container VolumeMounts +func BeMountedToContainer(c corev1.Container) types.GomegaMatcher { + return &BeMountedToContainerMatcher{container: c} +} + +type BeMountedToContainerMatcher struct { + container corev1.Container +} + +func (matcher *BeMountedToContainerMatcher) Match(actual interface{}) (bool, error) { + mountPath, ok := actual.(string) + if !ok { + return false, fmt.Errorf("BeMountedToContainer must be passed string. Got\n%s", format.Object(actual, 1)) + } + + for _, vm := range matcher.container.VolumeMounts { + if vm.MountPath == mountPath { + return true, nil + } + } + return false, nil +} +func (matcher *BeMountedToContainerMatcher) FailureMessage(actual interface{}) string { + mountPath, _ := actual.(string) + return fmt.Sprintf("Expected container to contain VolumeMount %s", mountPath) +} +func (matcher *BeMountedToContainerMatcher) NegatedFailureMessage(actual interface{}) string { + mountPath, _ := actual.(string) + return fmt.Sprintf("Expected container not to contain VolumeMount %s", mountPath) +} + +// Matcher for PodSpec Volumes +func BeAddedAsVolumeToPodSpec(ps corev1.PodSpec) types.GomegaMatcher { + return &BeAddedAsVolumeToPodSpecMatcher{podSpec: ps} +} + +type BeAddedAsVolumeToPodSpecMatcher struct { + podSpec corev1.PodSpec +} + +func (matcher *BeAddedAsVolumeToPodSpecMatcher) Match(actual interface{}) (bool, error) { + volumeName, ok := actual.(string) + if !ok { + return false, fmt.Errorf("BeMountedToContainer must be passed string. Got\n%s", format.Object(actual, 1)) + } + + for _, v := range matcher.podSpec.Volumes { + if v.Name == volumeName { + return true, nil + } + } + return false, nil +} +func (matcher *BeAddedAsVolumeToPodSpecMatcher) FailureMessage(actual interface{}) string { + volumeName, _ := actual.(string) + return fmt.Sprintf("Expected PodSpec to contain Volume %s", volumeName) +} +func (matcher *BeAddedAsVolumeToPodSpecMatcher) NegatedFailureMessage(actual interface{}) string { + volumeName, _ := actual.(string) + return fmt.Sprintf("Expected PodSpec not to contain Volume %s", volumeName) +} + +// Matcher for container Args +func BeAddedAsArgToContainer(c corev1.Container) types.GomegaMatcher { + return &BeMountedToContainerMatcher{container: c} +} + +type BeAddedAsArgToContainerMatcher struct { + container corev1.Container +} + +func (matcher *BeAddedAsArgToContainerMatcher) Match(actual interface{}) (bool, error) { + arg, ok := actual.(string) + if !ok { + return false, fmt.Errorf("BeAddedAsArgToContainer must be passed string. Got\n%s", format.Object(actual, 1)) + } + + for _, a := range matcher.container.Args { + if arg == a { + return true, nil + } + } + return false, nil +} +func (matcher *BeAddedAsArgToContainerMatcher) FailureMessage(actual interface{}) string { + arg, _ := actual.(string) + return fmt.Sprintf("Expected container to contain Arg %s", arg) +} +func (matcher *BeAddedAsArgToContainerMatcher) NegatedFailureMessage(actual interface{}) string { + arg, _ := actual.(string) + return fmt.Sprintf("Expected container not to contain Arg %s", arg) +} + +// Matcher for Container EnvFrom +func BeEnvFromForContainer(c corev1.Container) types.GomegaMatcher { + return &BeEnvFromForContainerMatcher{container: c} +} + +type BeEnvFromForContainerMatcher struct { + container corev1.Container +} + +func (matcher *BeEnvFromForContainerMatcher) Match(actual interface{}) (bool, error) { + objectName, ok := actual.(string) + if !ok { + return false, fmt.Errorf("BeEnvFromForContainer must be passed string. Got\n%s", format.Object(actual, 1)) + } + + for _, ef := range matcher.container.EnvFrom { + if ef.SecretRef != nil && ef.SecretRef.Name == objectName { + return true, nil + } + if ef.ConfigMapRef != nil && ef.ConfigMapRef.Name == objectName { + return true, nil + } + } + return false, nil +} + +func (matcher *BeEnvFromForContainerMatcher) FailureMessage(actual interface{}) string { + objectName, _ := actual.(string) + return fmt.Sprintf("Expected container to contain EnvFrom %s", objectName) +} + +func (matcher *BeEnvFromForContainerMatcher) NegatedFailureMessage(actual interface{}) string { + objectName, _ := actual.(string) + return fmt.Sprintf("Expected container not to contain EnvFrom %s", objectName) +} + +// Matcher for Container Env Var +func BeEnvVarForContainer(c corev1.Container) types.GomegaMatcher { + return &BeEnvVarForContainerMatcher{container: c} +} + +type BeEnvVarForContainerMatcher struct { + container corev1.Container +} + +func (matcher *BeEnvVarForContainerMatcher) Match(actual interface{}) (bool, error) { + objectName, ok := actual.(string) + if !ok { + return false, fmt.Errorf("BeEnvVarForContainer must be passed string. Got\n%s", format.Object(actual, 1)) + } + + for _, ev := range matcher.container.Env { + if ev.Name == objectName { + return true, nil + } + } + return false, nil +} + +func (matcher *BeEnvVarForContainerMatcher) FailureMessage(actual interface{}) string { + objectName, _ := actual.(string) + return fmt.Sprintf("Expected container to contain EnvVar %s", objectName) +} + +func (matcher *BeEnvVarForContainerMatcher) NegatedFailureMessage(actual interface{}) string { + objectName, _ := actual.(string) + return fmt.Sprintf("Expected container not to contain EnvVar %s", objectName) +} diff --git a/integration_tests/rhdh-config_test.go b/integration_tests/rhdh-config_test.go new file mode 100644 index 00000000..32e083e8 --- /dev/null +++ b/integration_tests/rhdh-config_test.go @@ -0,0 +1,67 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 integration_tests + +import ( + "context" + "time" + + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + appsv1 "k8s.io/api/apps/v1" + + "redhat-developer/red-hat-developer-hub-operator/pkg/model" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = When("create default backstage", func() { + + It("creates runtime objects", func() { + + ctx := context.Background() + ns := createNamespace(ctx) + backstageName := createAndReconcileBackstage(ctx, ns, bsv1alpha1.BackstageSpec{}) + + Eventually(func(g Gomega) { + deploy := &appsv1.Deployment{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DeploymentName(backstageName)}, deploy) + g.Expect(err).ShouldNot(HaveOccurred(), controllerMessage()) + + By("creating /opt/app-root/src/dynamic-plugins.xml ") + appConfig := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.DynamicPluginsDefaultName(backstageName)}, appConfig) + g.Expect(err).ShouldNot(HaveOccurred()) + + g.Expect(deploy.Spec.Template.Spec.InitContainers).To(HaveLen(1)) + // it is ok to take InitContainers[0] + initCont := deploy.Spec.Template.Spec.InitContainers[0] + g.Expect(initCont.VolumeMounts).To(HaveLen(3)) + g.Expect(initCont.VolumeMounts[2].MountPath).To(Equal("/opt/app-root/src/dynamic-plugins.yaml")) + g.Expect(initCont.VolumeMounts[2].Name). + To(Equal(utils.GenerateVolumeNameFromCmOrSecret(model.DynamicPluginsDefaultName(backstageName)))) + g.Expect(initCont.VolumeMounts[2].SubPath).To(Equal(model.DynamicPluginsFile)) + + }, time.Minute, time.Second).Should(Succeed()) + + deleteNamespace(ctx, ns) + }) +}) diff --git a/integration_tests/route_test.go b/integration_tests/route_test.go new file mode 100644 index 00000000..278a63e0 --- /dev/null +++ b/integration_tests/route_test.go @@ -0,0 +1,88 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 integration_tests + +import ( + "context" + "redhat-developer/red-hat-developer-hub-operator/pkg/model" + "time" + + openshift "github.com/openshift/api/route/v1" + + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + "k8s.io/apimachinery/pkg/types" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = When("create default backstage", func() { + + var ( + ctx context.Context + ns string + ) + + BeforeEach(func() { + ctx = context.Background() + ns = createNamespace(ctx) + }) + + AfterEach(func() { + deleteNamespace(ctx, ns) + }) + + It("creates Backstage object (on Openshift)", func() { + + if !isOpenshiftCluster() { + Skip("Skipped for non-Openshift cluster") + } + + backstageName := createAndReconcileBackstage(ctx, ns, bsv1alpha1.BackstageSpec{ + Application: &bsv1alpha1.Application{ + Route: &bsv1alpha1.Route{ + //Host: "localhost", + //Enabled: ptr.To(true), + Subdomain: "test", + }, + }, + }) + + Eventually(func() error { + found := &bsv1alpha1.Backstage{} + return k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, found) + }, time.Minute, time.Second).Should(Succeed()) + + _, err := NewTestBackstageReconciler(ns).ReconcileAny(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, + }) + Expect(err).To(Not(HaveOccurred())) + + Eventually(func(g Gomega) { + By("creating Route") + route := &openshift.Route{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: model.RouteName(backstageName)}, route) + g.Expect(err).To(Not(HaveOccurred()), controllerMessage()) + + g.Expect(route.Status.Ingress).To(HaveLen(1)) + g.Expect(route.Status.Ingress[0].Host).To(Not(BeEmpty())) + + }, 5*time.Minute, time.Second).Should(Succeed()) + + }) +}) diff --git a/integration_tests/suite_test.go b/integration_tests/suite_test.go new file mode 100644 index 00000000..e3f52ef0 --- /dev/null +++ b/integration_tests/suite_test.go @@ -0,0 +1,256 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 integration_tests + +import ( + "context" + "fmt" + "os" + "strconv" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "k8s.io/utils/ptr" + + openshift "github.com/openshift/api/route/v1" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + corev1 "k8s.io/api/core/v1" + + "path/filepath" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + controller "redhat-developer/red-hat-developer-hub-operator/controllers" + + ctrl "sigs.k8s.io/controller-runtime" + + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/util/rand" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + //+kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var cfg *rest.Config +var k8sClient client.Client +var testEnv *envtest.Environment +var useExistingController = false + +type TestBackstageReconciler struct { + rec controller.BackstageReconciler + namespace string +} + +func init() { + rand.Seed(time.Now().UnixNano()) + //testOnExistingCluster, _ = strconv.ParseBool(os.Getenv("TEST_ON_EXISTING_CLUSTER")) +} + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Integration Test Suite") +} + +var _ = BeforeSuite(func() { + //logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + logf.SetLogger(zap.New(zap.UseDevMode(true))) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + testEnv.UseExistingCluster = ptr.To(false) + if val, ok := os.LookupEnv("USE_EXISTING_CLUSTER"); ok { + boolValue, err := strconv.ParseBool(val) + if err == nil { + testEnv.UseExistingCluster = ptr.To(boolValue) + } + } + + if val, ok := os.LookupEnv("USE_EXISTING_CONTROLLER"); ok { + boolValue, err := strconv.ParseBool(val) + if err == nil { + useExistingController = boolValue + } + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = bsv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz0123456789") + +func randString(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} + +func createBackstage(ctx context.Context, spec bsv1alpha1.BackstageSpec, ns string) string { + backstageName := "test-backstage-" + randString(5) + err := k8sClient.Create(ctx, &bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: backstageName, + Namespace: ns, + }, + Spec: spec, + }) + Expect(err).To(Not(HaveOccurred())) + return backstageName +} + +func createAndReconcileBackstage(ctx context.Context, ns string, spec bsv1alpha1.BackstageSpec) string { + backstageName := createBackstage(ctx, spec, ns) + + Eventually(func() error { + found := &bsv1alpha1.Backstage{} + return k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, found) + }, time.Minute, time.Second).Should(Succeed()) + + _, err := NewTestBackstageReconciler(ns).ReconcileAny(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, + }) + Expect(err).To(Not(HaveOccurred())) + + return backstageName +} + +func createNamespace(ctx context.Context) string { + ns := fmt.Sprintf("ns-%d-%s", GinkgoParallelProcess(), randString(5)) + err := k8sClient.Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: ns}, + }) + Expect(err).To(Not(HaveOccurred())) + return ns +} + +func deleteNamespace(ctx context.Context, ns string) { + _ = k8sClient.Delete(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: ns}, + }) +} + +func NewTestBackstageReconciler(namespace string) *TestBackstageReconciler { + + sch := k8sClient.Scheme() + var ( + isOpenshift bool + err error + ) + isOpenshift = isOpenshiftCluster() + if *testEnv.UseExistingCluster { + Expect(err).To(Not(HaveOccurred())) + if isOpenshift { + utilruntime.Must(openshift.Install(sch)) + } + } else { + isOpenshift = false + } + + return &TestBackstageReconciler{rec: controller.BackstageReconciler{ + Client: k8sClient, + Scheme: sch, + OwnsRuntime: true, + // let's set it explicitly to avoid misunderstanding + IsOpenShift: isOpenshift, + }, namespace: namespace} +} + +//func (t *TestBackstageReconciler) ReconcileLocalCluster(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +// +// if !*testEnv.UseExistingCluster { +// // Ignore requests for other namespaces, if specified. +// // To overcome a limitation of EnvTest about namespace deletion. +// // More details on https://book.kubebuilder.io/reference/envtest.html#namespace-usage-limitation +// if t.namespace != "" && req.Namespace != t.namespace { +// return ctrl.Result{}, nil +// } +// return t.Reconcile(ctx, req) +// } else { +// return ctrl.Result{}, nil +// } +//} + +func (t *TestBackstageReconciler) ReconcileAny(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + // Ignore if USE_EXISTING_CLUSTER = true and USE_EXISTING_CONTROLLER=true + // Ignore requests for other namespaces, if specified. + // To overcome a limitation of EnvTest about namespace deletion. + // More details on https://book.kubebuilder.io/reference/envtest.html#namespace-usage-limitation + if (*testEnv.UseExistingCluster && useExistingController) || (t.namespace != "" && req.Namespace != t.namespace) { + return ctrl.Result{}, nil + } + return t.rec.Reconcile(ctx, req) +} + +func isOpenshiftCluster() bool { + + if *testEnv.UseExistingCluster { + isOs, err := utils.IsOpenshift() + Expect(err).To(Not(HaveOccurred())) + return isOs + } else { + return false + } +} + +func controllerMessage() string { + if useExistingController == true { + return "USE_EXISTING_CONTROLLER=true configured. Make sure Controller manager is up and running." + } + return "" +} diff --git a/integration_tests/testdata/raw-deployment.yaml b/integration_tests/testdata/raw-deployment.yaml new file mode 100644 index 00000000..6e79a42c --- /dev/null +++ b/integration_tests/testdata/raw-deployment.yaml @@ -0,0 +1,19 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bs1-deployment + labels: + app: bs1 +spec: + replicas: 1 + selector: + matchLabels: + app: bs1 + template: + metadata: + labels: + app: bs1 + spec: + containers: + - name: bs1 + image: busybox \ No newline at end of file diff --git a/integration_tests/testdata/raw-statefulset.yaml b/integration_tests/testdata/raw-statefulset.yaml new file mode 100644 index 00000000..9c1090fc --- /dev/null +++ b/integration_tests/testdata/raw-statefulset.yaml @@ -0,0 +1,18 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: db-statefulset +spec: + serviceName: "" + replicas: 1 + selector: + matchLabels: + app: db + template: + metadata: + labels: + app: db + spec: + containers: + - name: db + image: busybox \ No newline at end of file diff --git a/integration_tests/utils.go b/integration_tests/utils.go new file mode 100644 index 00000000..6dcfd858 --- /dev/null +++ b/integration_tests/utils.go @@ -0,0 +1,64 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 integration_tests + +import ( + "context" + "fmt" + "os" + "path/filepath" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + //. "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func generateConfigMap(ctx context.Context, k8sClient client.Client, name, namespace string, data map[string]string) string { + Expect(k8sClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: data, + })).To(Not(HaveOccurred())) + + return name +} + +func generateSecret(ctx context.Context, k8sClient client.Client, name, namespace string, keys []string) string { + data := map[string]string{} + for _, v := range keys { + data[v] = fmt.Sprintf("value-%s", v) + } + Expect(k8sClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + StringData: data, + })).To(Not(HaveOccurred())) + + return name +} + +func readTestYamlFile(name string) string { + + b, err := os.ReadFile(filepath.Join("testdata", name)) // #nosec G304, path is constructed internally + Expect(err).NotTo(HaveOccurred()) + return string(b) +} diff --git a/main.go b/main.go index a99c0be8..7a954211 100644 --- a/main.go +++ b/main.go @@ -115,7 +115,7 @@ func main() { Scheme: mgr.GetScheme(), OwnsRuntime: ownRuntime, IsOpenShift: isOpenShift, - }).SetupWithManager(mgr, setupLog); err != nil { + }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Backstage") os.Exit(1) } diff --git a/pkg/model/appconfig.go b/pkg/model/appconfig.go new file mode 100644 index 00000000..93eaa0e9 --- /dev/null +++ b/pkg/model/appconfig.go @@ -0,0 +1,123 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "path/filepath" + + appsv1 "k8s.io/api/apps/v1" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type AppConfigFactory struct{} + +// factory method to create App Config object +func (f AppConfigFactory) newBackstageObject() RuntimeObject { + return &AppConfig{MountPath: defaultMountDir} +} + +// structure containing ConfigMap where keys are Backstage ConfigApp file names and vaues are contents of the files +// Mount path is a patch to the follder to place the files to +type AppConfig struct { + ConfigMap *corev1.ConfigMap + MountPath string + Key string +} + +func init() { + registerConfig("app-config.yaml", AppConfigFactory{}) +} + +func AppConfigDefaultName(backstageName string) string { + return utils.GenerateRuntimeObjectName(backstageName, "backstage-appconfig") +} + +func addAppConfigs(spec bsv1alpha1.BackstageSpec, deployment *appsv1.Deployment, model *BackstageModel) { + + if spec.Application == nil || spec.Application.AppConfig == nil || spec.Application.AppConfig.ConfigMaps == nil { + return + } + + for _, configMap := range spec.Application.AppConfig.ConfigMaps { + cm := model.ExternalConfig.AppConfigs[configMap.Name] + mp := defaultMountDir + if spec.Application.AppConfig.MountPath != "" { + mp = spec.Application.AppConfig.MountPath + } + ac := AppConfig{ + ConfigMap: &cm, + MountPath: mp, + Key: configMap.Key, + } + ac.updatePod(deployment) + } +} + +// implementation of RuntimeObject interface +func (b *AppConfig) Object() client.Object { + return b.ConfigMap +} + +// implementation of RuntimeObject interface +func (b *AppConfig) setObject(obj client.Object) { + b.ConfigMap = nil + if obj != nil { + b.ConfigMap = obj.(*corev1.ConfigMap) + } +} + +// implementation of RuntimeObject interface +func (b *AppConfig) EmptyObject() client.Object { + return &corev1.ConfigMap{} +} + +// implementation of RuntimeObject interface +func (b *AppConfig) addToModel(model *BackstageModel, _ bsv1alpha1.Backstage) (bool, error) { + if b.ConfigMap != nil { + model.setRuntimeObject(b) + return true, nil + } + return false, nil +} + +// implementation of RuntimeObject interface +func (b *AppConfig) validate(_ *BackstageModel, _ bsv1alpha1.Backstage) error { + return nil +} + +func (b *AppConfig) setMetaInfo(backstageName string) { + b.ConfigMap.SetName(AppConfigDefaultName(backstageName)) +} + +// implementation of BackstagePodContributor interface +// it contrubutes to Volumes, container.VolumeMounts and contaiter.Args +func (b *AppConfig) updatePod(deployment *appsv1.Deployment) { + + utils.MountFilesFrom(&deployment.Spec.Template.Spec, &deployment.Spec.Template.Spec.Containers[0], utils.ConfigMapObjectKind, + b.ConfigMap.Name, b.MountPath, b.Key, b.ConfigMap.Data) + + fileDir := b.MountPath + for file := range b.ConfigMap.Data { + if b.Key == "" || b.Key == file { + deployment.Spec.Template.Spec.Containers[0].Args = + append(deployment.Spec.Template.Spec.Containers[0].Args, []string{"--config", filepath.Join(fileDir, file)}...) + } + } +} diff --git a/pkg/model/appconfig_test.go b/pkg/model/appconfig_test.go new file mode 100644 index 00000000..45ac7ead --- /dev/null +++ b/pkg/model/appconfig_test.go @@ -0,0 +1,161 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "context" + + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + corev1 "k8s.io/api/core/v1" + + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + appConfigTestCm = corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-config1", + Namespace: "ns123", + }, + Data: map[string]string{"conf.yaml": "conf.yaml data"}, + } + + appConfigTestCm2 = corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-config2", + Namespace: "ns123", + }, + Data: map[string]string{"conf21.yaml": "", "conf22.yaml": ""}, + } + + appConfigTestCm3 = corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-config3", + Namespace: "ns123", + }, + Data: map[string]string{"conf31.yaml": "", "conf32.yaml": ""}, + } + + appConfigTestBackstage = bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Application: &bsv1alpha1.Application{ + AppConfig: &bsv1alpha1.AppConfig{ + MountPath: "/my/path", + ConfigMaps: []bsv1alpha1.ObjectKeyRef{}, + }, + }, + }, + } +) + +func TestDefaultAppConfig(t *testing.T) { + + //bs := simpleTestBackstage() + bs := *appConfigTestBackstage.DeepCopy() + + testObj := createBackstageTest(bs).withDefaultConfig(true).addToDefaultConfig("app-config.yaml", "raw-app-config.yaml") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.True(t, len(model.RuntimeObjects) > 0) + + deployment := model.backstageDeployment + assert.NotNil(t, deployment) + + assert.Equal(t, 1, len(deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) + assert.Contains(t, deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts[0].MountPath, defaultMountDir) + assert.Equal(t, utils.GenerateVolumeNameFromCmOrSecret(AppConfigDefaultName(bs.Name)), deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name) + assert.Equal(t, 2, len(deployment.deployment.Spec.Template.Spec.Containers[0].Args)) + assert.Equal(t, 1, len(deployment.deployment.Spec.Template.Spec.Volumes)) + +} + +func TestSpecifiedAppConfig(t *testing.T) { + + bs := *appConfigTestBackstage.DeepCopy() + bs.Spec.Application.AppConfig.ConfigMaps = append(bs.Spec.Application.AppConfig.ConfigMaps, + bsv1alpha1.ObjectKeyRef{Name: appConfigTestCm.Name}) + bs.Spec.Application.AppConfig.ConfigMaps = append(bs.Spec.Application.AppConfig.ConfigMaps, + bsv1alpha1.ObjectKeyRef{Name: appConfigTestCm2.Name}) + bs.Spec.Application.AppConfig.ConfigMaps = append(bs.Spec.Application.AppConfig.ConfigMaps, + bsv1alpha1.ObjectKeyRef{Name: appConfigTestCm3.Name, Key: "conf31.yaml"}) + + testObj := createBackstageTest(bs).withDefaultConfig(true) + testObj.externalConfig.AppConfigs = map[string]corev1.ConfigMap{appConfigTestCm.Name: appConfigTestCm, appConfigTestCm2.Name: appConfigTestCm2, + appConfigTestCm3.Name: appConfigTestCm3} + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, + true, false, testObj.scheme) + + assert.NoError(t, err) + assert.True(t, len(model.RuntimeObjects) > 0) + + deployment := model.backstageDeployment + assert.NotNil(t, deployment) + + assert.Equal(t, 4, len(deployment.container().VolumeMounts)) + assert.Contains(t, deployment.container().VolumeMounts[0].MountPath, + bs.Spec.Application.AppConfig.MountPath) + assert.Equal(t, 8, len(deployment.container().Args)) + assert.Equal(t, 3, len(deployment.deployment.Spec.Template.Spec.Volumes)) + +} + +func TestDefaultAndSpecifiedAppConfig(t *testing.T) { + + bs := *appConfigTestBackstage.DeepCopy() + cms := &bs.Spec.Application.AppConfig.ConfigMaps + *cms = append(*cms, bsv1alpha1.ObjectKeyRef{Name: appConfigTestCm.Name}) + + testObj := createBackstageTest(bs).withDefaultConfig(true).addToDefaultConfig("app-config.yaml", "raw-app-config.yaml") + + //testObj.detailedSpec.AddConfigObject(&AppConfig{ConfigMap: &cm, MountPath: "/my/path"}) + testObj.externalConfig.AppConfigs[appConfigTestCm.Name] = appConfigTestCm + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.True(t, len(model.RuntimeObjects) > 0) + + deployment := model.backstageDeployment + assert.NotNil(t, deployment) + + assert.Equal(t, 2, len(deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) + assert.Equal(t, 4, len(deployment.deployment.Spec.Template.Spec.Containers[0].Args)) + assert.Equal(t, 2, len(deployment.deployment.Spec.Template.Spec.Volumes)) + + //assert.Equal(t, filepath.Dir(deployment.deployment.Spec.Template.Spec.Containers[0].Args[1]), + // deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts[0].MountPath) + + // it should be valid assertion using Volumes and VolumeMounts indexes since the order of adding is from default to specified + + //assert.Equal(t, utils.GenerateVolumeNameFromCmOrSecret()deployment.deployment.Spec.Template.Spec.Volumes[0].Name + assert.Equal(t, deployment.deployment.Spec.Template.Spec.Volumes[0].Name, + deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name) + + //t.Log(">>>>>>>>>>>>>>>>", ) + //t.Log(">>>>>>>>>>>>>>>>", ) + +} diff --git a/pkg/model/configmapenvs.go b/pkg/model/configmapenvs.go new file mode 100644 index 00000000..205aaa13 --- /dev/null +++ b/pkg/model/configmapenvs.go @@ -0,0 +1,97 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type ConfigMapEnvsFactory struct{} + +func (f ConfigMapEnvsFactory) newBackstageObject() RuntimeObject { + return &ConfigMapEnvs{} +} + +type ConfigMapEnvs struct { + ConfigMap *corev1.ConfigMap + Key string +} + +func init() { + registerConfig("configmap-envs.yaml", ConfigMapEnvsFactory{}) +} + +func addConfigMapEnvs(spec v1alpha1.BackstageSpec, deployment *appsv1.Deployment, model *BackstageModel) { + + if spec.Application == nil || spec.Application.ExtraEnvs == nil || spec.Application.ExtraEnvs.ConfigMaps == nil { + return + } + + for _, configMap := range spec.Application.ExtraEnvs.ConfigMaps { + cm := model.ExternalConfig.ExtraEnvConfigMaps[configMap.Name] + cmf := ConfigMapEnvs{ + ConfigMap: &cm, + Key: configMap.Key, + } + cmf.updatePod(deployment) + } +} + +// Object implements RuntimeObject interface +func (p *ConfigMapEnvs) Object() client.Object { + return p.ConfigMap +} + +func (p *ConfigMapEnvs) setObject(obj client.Object) { + p.ConfigMap = nil + if obj != nil { + p.ConfigMap = obj.(*corev1.ConfigMap) + } +} + +// EmptyObject implements RuntimeObject interface +func (p *ConfigMapEnvs) EmptyObject() client.Object { + return &corev1.ConfigMap{} +} + +// implementation of RuntimeObject interface +func (p *ConfigMapEnvs) addToModel(model *BackstageModel, _ v1alpha1.Backstage) (bool, error) { + if p.ConfigMap != nil { + model.setRuntimeObject(p) + return true, nil + } + return false, nil +} + +// implementation of RuntimeObject interface +func (p *ConfigMapEnvs) validate(_ *BackstageModel, _ v1alpha1.Backstage) error { + return nil +} + +func (p *ConfigMapEnvs) setMetaInfo(backstageName string) { + p.ConfigMap.SetName(utils.GenerateRuntimeObjectName(backstageName, "backstage-envs")) +} + +// implementation of BackstagePodContributor interface +func (p *ConfigMapEnvs) updatePod(deployment *appsv1.Deployment) { + + utils.AddEnvVarsFrom(&deployment.Spec.Template.Spec.Containers[0], utils.ConfigMapObjectKind, + p.ConfigMap.Name, p.Key) +} diff --git a/pkg/model/configmapenvs_test.go b/pkg/model/configmapenvs_test.go new file mode 100644 index 00000000..aa3b4846 --- /dev/null +++ b/pkg/model/configmapenvs_test.go @@ -0,0 +1,95 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "context" + "testing" + + "k8s.io/utils/ptr" + + corev1 "k8s.io/api/core/v1" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultConfigMapEnvFrom(t *testing.T) { + + bs := bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Database: &bsv1alpha1.Database{ + EnableLocalDb: ptr.To(false), + }, + }, + } + + testObj := createBackstageTest(bs).withDefaultConfig(true).addToDefaultConfig("configmap-envs.yaml", "raw-cm-envs.yaml") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.NotNil(t, model) + + bscontainer := model.backstageDeployment.deployment.Spec.Template.Spec.Containers[0] + assert.NotNil(t, bscontainer) + + assert.Equal(t, 1, len(bscontainer.EnvFrom)) + assert.Equal(t, 0, len(bscontainer.Env)) + +} + +func TestSpecifiedConfigMapEnvs(t *testing.T) { + + bs := bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Application: &bsv1alpha1.Application{ + ExtraEnvs: &bsv1alpha1.ExtraEnvs{ + ConfigMaps: []bsv1alpha1.ObjectKeyRef{}, + }, + }, + }, + } + + bs.Spec.Application.ExtraEnvs.ConfigMaps = append(bs.Spec.Application.ExtraEnvs.ConfigMaps, + bsv1alpha1.ObjectKeyRef{Name: "mapName", Key: "ENV1"}) + + testObj := createBackstageTest(bs).withDefaultConfig(true) + testObj.externalConfig.ExtraEnvConfigMaps["mapName"] = corev1.ConfigMap{Data: map[string]string{"mapName": "ENV1"}} + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.NotNil(t, model) + + bscontainer := model.backstageDeployment.container() + assert.NotNil(t, bscontainer) + assert.Equal(t, 1, len(bscontainer.Env)) + + assert.NotNil(t, bscontainer.Env[0]) + assert.Equal(t, "ENV1", bscontainer.Env[0].ValueFrom.ConfigMapKeyRef.Key) + +} diff --git a/pkg/model/configmapfiles.go b/pkg/model/configmapfiles.go new file mode 100644 index 00000000..1977987a --- /dev/null +++ b/pkg/model/configmapfiles.go @@ -0,0 +1,106 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + appsv1 "k8s.io/api/apps/v1" + + "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type ConfigMapFilesFactory struct{} + +func (f ConfigMapFilesFactory) newBackstageObject() RuntimeObject { + return &ConfigMapFiles{MountPath: defaultMountDir} +} + +type ConfigMapFiles struct { + ConfigMap *corev1.ConfigMap + MountPath string + Key string +} + +func init() { + registerConfig("configmap-files.yaml", ConfigMapFilesFactory{}) +} + +func addConfigMapFiles(spec v1alpha1.BackstageSpec, deployment *appsv1.Deployment, model *BackstageModel) { + + if spec.Application == nil || spec.Application.ExtraFiles == nil || spec.Application.ExtraFiles.ConfigMaps == nil { + return + } + mp := defaultMountDir + if spec.Application.ExtraFiles.MountPath != "" { + mp = spec.Application.ExtraFiles.MountPath + } + + for _, configMap := range spec.Application.ExtraFiles.ConfigMaps { + cm := model.ExternalConfig.ExtraFileConfigMaps[configMap.Name] + cmf := ConfigMapFiles{ + ConfigMap: &cm, + MountPath: mp, + Key: configMap.Key, + } + cmf.updatePod(deployment) + } +} + +// implementation of RuntimeObject interface +func (p *ConfigMapFiles) Object() client.Object { + return p.ConfigMap +} + +func (p *ConfigMapFiles) setObject(obj client.Object) { + p.ConfigMap = nil + if obj != nil { + p.ConfigMap = obj.(*corev1.ConfigMap) + } + +} + +// implementation of RuntimeObject interface +func (p *ConfigMapFiles) EmptyObject() client.Object { + return &corev1.ConfigMap{} +} + +// implementation of RuntimeObject interface +func (p *ConfigMapFiles) addToModel(model *BackstageModel, _ v1alpha1.Backstage) (bool, error) { + if p.ConfigMap != nil { + model.setRuntimeObject(p) + return true, nil + } + return false, nil +} + +// implementation of RuntimeObject interface +func (p *ConfigMapFiles) validate(_ *BackstageModel, _ v1alpha1.Backstage) error { + return nil +} + +func (p *ConfigMapFiles) setMetaInfo(backstageName string) { + p.ConfigMap.SetName(utils.GenerateRuntimeObjectName(backstageName, "backstage-files")) +} + +// implementation of BackstagePodContributor interface +func (p *ConfigMapFiles) updatePod(deployment *appsv1.Deployment) { + + utils.MountFilesFrom(&deployment.Spec.Template.Spec, &deployment.Spec.Template.Spec.Containers[0], utils.ConfigMapObjectKind, + p.ConfigMap.Name, p.MountPath, p.Key, p.ConfigMap.Data) + +} diff --git a/pkg/model/configmapfiles_test.go b/pkg/model/configmapfiles_test.go new file mode 100644 index 00000000..9dedb06a --- /dev/null +++ b/pkg/model/configmapfiles_test.go @@ -0,0 +1,123 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "context" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + //appConfigTestCm = corev1.ConfigMap{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "app-config1", + // Namespace: "ns123", + // }, + // Data: map[string]string{"conf.yaml": ""}, + //} + // + //appConfigTestCm2 = corev1.ConfigMap{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "app-config2", + // Namespace: "ns123", + // }, + // Data: map[string]string{"conf2.yaml": ""}, + //} + + configMapFilesTestBackstage = bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Application: &bsv1alpha1.Application{ + ExtraFiles: &bsv1alpha1.ExtraFiles{ + MountPath: "/my/path", + ConfigMaps: []bsv1alpha1.ObjectKeyRef{}, + }, + }, + }, + } +) + +func TestDefaultConfigMapFiles(t *testing.T) { + + bs := *configMapFilesTestBackstage.DeepCopy() + + testObj := createBackstageTest(bs).withDefaultConfig(true).addToDefaultConfig("configmap-files.yaml", "raw-cm-files.yaml") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + + deployment := model.backstageDeployment + assert.NotNil(t, deployment) + + assert.Equal(t, 1, len(deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) + assert.Equal(t, 1, len(deployment.deployment.Spec.Template.Spec.Volumes)) + +} + +func TestSpecifiedConfigMapFiles(t *testing.T) { + + bs := *configMapFilesTestBackstage.DeepCopy() + cmf := &bs.Spec.Application.ExtraFiles.ConfigMaps + *cmf = append(*cmf, bsv1alpha1.ObjectKeyRef{Name: appConfigTestCm.Name}) + *cmf = append(*cmf, bsv1alpha1.ObjectKeyRef{Name: appConfigTestCm2.Name}) + + testObj := createBackstageTest(bs).withDefaultConfig(true) + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.True(t, len(model.RuntimeObjects) > 0) + + deployment := model.backstageDeployment + assert.NotNil(t, deployment) + + assert.Equal(t, 2, len(deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) + assert.Equal(t, 0, len(deployment.deployment.Spec.Template.Spec.Containers[0].Args)) + assert.Equal(t, 2, len(deployment.deployment.Spec.Template.Spec.Volumes)) + +} + +func TestDefaultAndSpecifiedConfigMapFiles(t *testing.T) { + + bs := *configMapFilesTestBackstage.DeepCopy() + cmf := &bs.Spec.Application.ExtraFiles.ConfigMaps + *cmf = append(*cmf, bsv1alpha1.ObjectKeyRef{Name: appConfigTestCm.Name}) + + testObj := createBackstageTest(bs).withDefaultConfig(true).addToDefaultConfig("configmap-files.yaml", "raw-cm-files.yaml") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.True(t, len(model.RuntimeObjects) > 0) + + deployment := model.backstageDeployment + assert.NotNil(t, deployment) + + assert.Equal(t, 2, len(deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) + assert.Equal(t, 0, len(deployment.deployment.Spec.Template.Spec.Containers[0].Args)) + assert.Equal(t, 2, len(deployment.deployment.Spec.Template.Spec.Volumes)) + +} diff --git a/pkg/model/db-secret.go b/pkg/model/db-secret.go new file mode 100644 index 00000000..aff1e278 --- /dev/null +++ b/pkg/model/db-secret.go @@ -0,0 +1,100 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "strconv" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type DbSecretFactory struct{} + +func (f DbSecretFactory) newBackstageObject() RuntimeObject { + return &DbSecret{} +} + +type DbSecret struct { + secret *corev1.Secret +} + +func init() { + registerConfig("db-secret.yaml", DbSecretFactory{}) +} + +func DbSecretDefaultName(backstageName string) string { + return utils.GenerateRuntimeObjectName(backstageName, "backstage-db") +} + +// implementation of RuntimeObject interface +func (b *DbSecret) Object() client.Object { + return b.secret +} + +// implementation of RuntimeObject interface +func (b *DbSecret) setObject(obj client.Object) { + b.secret = nil + if obj != nil { + b.secret = obj.(*corev1.Secret) + } +} + +// implementation of RuntimeObject interface +func (b *DbSecret) addToModel(model *BackstageModel, backstage bsv1alpha1.Backstage) (bool, error) { + + // do not add if specified + if backstage.Spec.IsAuthSecretSpecified() { + return false, nil + } + + if b.secret != nil && model.localDbEnabled { + model.setRuntimeObject(b) + model.LocalDbSecret = b + return true, nil + } + + return false, nil +} + +// implementation of RuntimeObject interface +func (b *DbSecret) EmptyObject() client.Object { + return &corev1.Secret{} +} + +// implementation of RuntimeObject interface +func (b *DbSecret) validate(model *BackstageModel, backstage bsv1alpha1.Backstage) error { + + pswd, _ := utils.GeneratePassword(24) + + service := model.LocalDbService + + b.secret.StringData = map[string]string{ + "POSTGRES_PASSWORD": pswd, + "POSTGRESQL_ADMIN_PASSWORD": pswd, + "POSTGRES_USER": "postgres", + "POSTGRES_HOST": service.service.GetName(), + "POSTGRES_PORT": strconv.FormatInt(int64(service.service.Spec.Ports[0].Port), 10), + } + + return nil +} + +func (b *DbSecret) setMetaInfo(backstageName string) { + b.secret.SetName(DbSecretDefaultName(backstageName)) +} diff --git a/pkg/model/db-secret_test.go b/pkg/model/db-secret_test.go new file mode 100644 index 00000000..2b8e1829 --- /dev/null +++ b/pkg/model/db-secret_test.go @@ -0,0 +1,96 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "context" + "testing" + + "k8s.io/utils/ptr" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stretchr/testify/assert" +) + +var dbSecretBackstage = &bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Database: &bsv1alpha1.Database{ + EnableLocalDb: ptr.To(false), + }, + }, +} + +func TestEmptyDbSecret(t *testing.T) { + + bs := *dbSecretBackstage.DeepCopy() + + // expected generatePassword = false (default db-secret defined) will come from preprocess + testObj := createBackstageTest(bs).withDefaultConfig(true).withLocalDb().addToDefaultConfig("db-secret.yaml", "db-empty-secret.yaml") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.NotNil(t, model.LocalDbSecret) + assert.Equal(t, DbSecretDefaultName(bs.Name), model.LocalDbSecret.secret.Name) + + dbss := model.localDbStatefulSet + assert.NotNil(t, dbss) + assert.Equal(t, 1, len(dbss.container().EnvFrom)) + + assert.Equal(t, model.LocalDbSecret.secret.Name, dbss.container().EnvFrom[0].SecretRef.Name) +} + +func TestDefaultWithGeneratedSecrets(t *testing.T) { + bs := *dbSecretBackstage.DeepCopy() + + // expected generatePassword = true (no db-secret defined) will come from preprocess + testObj := createBackstageTest(bs).withDefaultConfig(true).withLocalDb().addToDefaultConfig("db-secret.yaml", "db-generated-secret.yaml") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.Equal(t, DbSecretDefaultName(bs.Name), model.LocalDbSecret.secret.Name) + //should be generated + // assert.NotEmpty(t, model.LocalDbSecret.secret.StringData["POSTGRES_USER"]) + // assert.NotEmpty(t, model.LocalDbSecret.secret.StringData["POSTGRES_PASSWORD"]) + + dbss := model.localDbStatefulSet + assert.NotNil(t, dbss) + assert.Equal(t, 1, len(dbss.container().EnvFrom)) + assert.Equal(t, model.LocalDbSecret.secret.Name, dbss.container().EnvFrom[0].SecretRef.Name) +} + +func TestSpecifiedSecret(t *testing.T) { + bs := *dbSecretBackstage.DeepCopy() + bs.Spec.Database.AuthSecretName = "custom-db-secret" + + // expected generatePassword = false (db-secret defined in the spec) will come from preprocess + testObj := createBackstageTest(bs).withDefaultConfig(true).withLocalDb().addToDefaultConfig("db-secret.yaml", "db-generated-secret.yaml") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.Nil(t, model.LocalDbSecret) + + assert.Equal(t, bs.Spec.Database.AuthSecretName, model.localDbStatefulSet.container().EnvFrom[0].SecretRef.Name) + assert.Equal(t, bs.Spec.Database.AuthSecretName, model.backstageDeployment.container().EnvFrom[0].SecretRef.Name) +} diff --git a/pkg/model/db-service.go b/pkg/model/db-service.go new file mode 100644 index 00000000..d6988565 --- /dev/null +++ b/pkg/model/db-service.go @@ -0,0 +1,89 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "fmt" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type DbServiceFactory struct{} + +func (f DbServiceFactory) newBackstageObject() RuntimeObject { + return &DbService{ /*service: &corev1.Service{}*/ } +} + +type DbService struct { + service *corev1.Service +} + +func init() { + registerConfig("db-service.yaml", DbServiceFactory{}) +} + +func DbServiceName(backstageName string) string { + return utils.GenerateRuntimeObjectName(backstageName, "backstage-db") +} + +// implementation of RuntimeObject interface +func (b *DbService) Object() client.Object { + return b.service +} + +func (b *DbService) setObject(obj client.Object) { + b.service = nil + if obj != nil { + b.service = obj.(*corev1.Service) + } +} + +// implementation of RuntimeObject interface +func (b *DbService) addToModel(model *BackstageModel, _ bsv1alpha1.Backstage) (bool, error) { + if b.service == nil { + if model.localDbEnabled { + return false, fmt.Errorf("LocalDb Service not initialized, make sure there is db-service.yaml.yaml in default or raw configuration") + } + return false, nil + } else { + if !model.localDbEnabled { + return false, nil + } + } + + model.LocalDbService = b + model.setRuntimeObject(b) + + return true, nil +} + +// implementation of RuntimeObject interface +func (b *DbService) EmptyObject() client.Object { + return &corev1.Service{} +} + +// implementation of RuntimeObject interface +func (b *DbService) validate(_ *BackstageModel, _ bsv1alpha1.Backstage) error { + return nil +} + +func (b *DbService) setMetaInfo(backstageName string) { + b.service.SetName(DbServiceName(backstageName)) + utils.GenerateLabel(&b.service.Spec.Selector, BackstageAppLabel, fmt.Sprintf("backstage-db-%s", backstageName)) +} diff --git a/pkg/model/db-statefulset.go b/pkg/model/db-statefulset.go new file mode 100644 index 00000000..c0fc3a0d --- /dev/null +++ b/pkg/model/db-statefulset.go @@ -0,0 +1,117 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "fmt" + "os" + + corev1 "k8s.io/api/core/v1" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + appsv1 "k8s.io/api/apps/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const LocalDbImageEnvVar = "RELATED_IMAGE_postgresql" + +type DbStatefulSetFactory struct{} + +func (f DbStatefulSetFactory) newBackstageObject() RuntimeObject { + return &DbStatefulSet{} +} + +type DbStatefulSet struct { + statefulSet *appsv1.StatefulSet +} + +func init() { + registerConfig("db-statefulset.yaml", DbStatefulSetFactory{}) +} + +func DbStatefulSetName(backstageName string) string { + return utils.GenerateRuntimeObjectName(backstageName, "backstage-db") +} + +// implementation of RuntimeObject interface +func (b *DbStatefulSet) Object() client.Object { + return b.statefulSet +} + +func (b *DbStatefulSet) setObject(obj client.Object) { + b.statefulSet = nil + if obj != nil { + b.statefulSet = obj.(*appsv1.StatefulSet) + } +} + +// implementation of RuntimeObject interface +func (b *DbStatefulSet) addToModel(model *BackstageModel, _ bsv1alpha1.Backstage) (bool, error) { + if b.statefulSet == nil { + if model.localDbEnabled { + return false, fmt.Errorf("LocalDb StatefulSet not configured, make sure there is db-statefulset.yaml.yaml in default or raw configuration") + } + return false, nil + } else { + if !model.localDbEnabled { + return false, nil + } + } + + model.localDbStatefulSet = b + model.setRuntimeObject(b) + + // override image with env var + // [GA] TODO Do we really need this feature? + if os.Getenv(LocalDbImageEnvVar) != "" { + b.container().Image = os.Getenv(LocalDbImageEnvVar) + } + + return true, nil +} + +// implementation of RuntimeObject interface +func (b *DbStatefulSet) EmptyObject() client.Object { + return &appsv1.StatefulSet{} +} + +// implementation of RuntimeObject interface +func (b *DbStatefulSet) validate(model *BackstageModel, backstage bsv1alpha1.Backstage) error { + + if backstage.Spec.IsAuthSecretSpecified() { + utils.SetDbSecretEnvVar(b.container(), backstage.Spec.Database.AuthSecretName) + } else if model.LocalDbSecret != nil { + utils.SetDbSecretEnvVar(b.container(), model.LocalDbSecret.secret.Name) + } + return nil +} + +func (b *DbStatefulSet) setMetaInfo(backstageName string) { + b.statefulSet.SetName(DbStatefulSetName(backstageName)) + utils.GenerateLabel(&b.statefulSet.Spec.Template.ObjectMeta.Labels, BackstageAppLabel, fmt.Sprintf("backstage-db-%s", backstageName)) + utils.GenerateLabel(&b.statefulSet.Spec.Selector.MatchLabels, BackstageAppLabel, fmt.Sprintf("backstage-db-%s", backstageName)) +} + +// returns DB container +func (b *DbStatefulSet) container() *corev1.Container { + return &b.podSpec().Containers[0] +} + +// returns DB pod +func (b *DbStatefulSet) podSpec() corev1.PodSpec { + return b.statefulSet.Spec.Template.Spec +} diff --git a/pkg/model/db-statefulset_test.go b/pkg/model/db-statefulset_test.go new file mode 100644 index 00000000..bb0af9dc --- /dev/null +++ b/pkg/model/db-statefulset_test.go @@ -0,0 +1,60 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "context" + "os" + "testing" + + "k8s.io/utils/ptr" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stretchr/testify/assert" +) + +var dbStatefulSetBackstage = &bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Database: &bsv1alpha1.Database{ + EnableLocalDb: ptr.To(false), + }, + }, +} + +// It tests the overriding image feature +// [GA] if we need this (and like this) feature +// we need to think about simple template engine +// for substitution env vars instead. +// Current implementation is not good +func TestOverrideDbImage(t *testing.T) { + bs := *dbStatefulSetBackstage.DeepCopy() + + testObj := createBackstageTest(bs).withDefaultConfig(true). + addToDefaultConfig("db-statefulset.yaml", "janus-db-statefulset.yaml").withLocalDb() + + _ = os.Setenv(LocalDbImageEnvVar, "dummy") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + assert.NoError(t, err) + + assert.Equal(t, "dummy", model.localDbStatefulSet.statefulSet.Spec.Template.Spec.Containers[0].Image) +} diff --git a/pkg/model/deployment.go b/pkg/model/deployment.go new file mode 100644 index 00000000..8d1c488c --- /dev/null +++ b/pkg/model/deployment.go @@ -0,0 +1,208 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "fmt" + "os" + + corev1 "k8s.io/api/core/v1" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + appsv1 "k8s.io/api/apps/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const BackstageImageEnvVar = "RELATED_IMAGE_backstage" +const defaultMountDir = "/opt/app-root/src" + +type BackstageDeploymentFactory struct{} + +func (f BackstageDeploymentFactory) newBackstageObject() RuntimeObject { + return &BackstageDeployment{} +} + +type BackstageDeployment struct { + deployment *appsv1.Deployment +} + +func init() { + registerConfig("deployment.yaml", BackstageDeploymentFactory{}) +} + +func DeploymentName(backstageName string) string { + return utils.GenerateRuntimeObjectName(backstageName, "backstage") +} + +// implementation of RuntimeObject interface +func (b *BackstageDeployment) Object() client.Object { + return b.deployment +} + +func (b *BackstageDeployment) setObject(obj client.Object) { + b.deployment = nil + if obj != nil { + b.deployment = obj.(*appsv1.Deployment) + } +} + +// implementation of RuntimeObject interface +func (b *BackstageDeployment) EmptyObject() client.Object { + return &appsv1.Deployment{} +} + +// implementation of RuntimeObject interface +func (b *BackstageDeployment) addToModel(model *BackstageModel, _ bsv1alpha1.Backstage) (bool, error) { + if b.deployment == nil { + return false, fmt.Errorf("Backstage Deployment is not initialized, make sure there is deployment.yaml in default or raw configuration") + } + model.backstageDeployment = b + model.setRuntimeObject(b) + + // override image with env var + // [GA] TODO Do we need this feature? + if os.Getenv(BackstageImageEnvVar) != "" { + b.deployment.Spec.Template.Spec.Containers[0].Image = os.Getenv(BackstageImageEnvVar) + // exactly the same image for initContainer and want it to be overriden + // the same way as Backstage's one + for i := range b.deployment.Spec.Template.Spec.InitContainers { + b.deployment.Spec.Template.Spec.InitContainers[i].Image = os.Getenv(BackstageImageEnvVar) + } + } + + return true, nil +} + +// implementation of RuntimeObject interface +func (b *BackstageDeployment) validate(model *BackstageModel, backstage bsv1alpha1.Backstage) error { + + if backstage.Spec.Application != nil { + b.setReplicas(backstage.Spec.Application.Replicas) + b.setImagePullSecrets(backstage.Spec.Application.ImagePullSecrets) + b.setImage(backstage.Spec.Application.Image) + b.addExtraEnvs(backstage.Spec.Application.ExtraEnvs) + } + + for _, bso := range model.RuntimeObjects { + if bs, ok := bso.(BackstagePodContributor); ok { + bs.updatePod(b.deployment) + } + } + + addAppConfigs(backstage.Spec, b.deployment, model) + + addConfigMapFiles(backstage.Spec, b.deployment, model) + + addConfigMapEnvs(backstage.Spec, b.deployment, model) + + if err := addSecretFiles(backstage.Spec, b.deployment); err != nil { + return err + } + + if err := addSecretEnvs(backstage.Spec, b.deployment); err != nil { + return err + } + if err := addDynamicPlugins(backstage.Spec, b.deployment, model); err != nil { + return err + } + + //DbSecret + if backstage.Spec.IsAuthSecretSpecified() { + utils.SetDbSecretEnvVar(b.container(), backstage.Spec.Database.AuthSecretName) + } else if model.LocalDbSecret != nil { + utils.SetDbSecretEnvVar(b.container(), model.LocalDbSecret.secret.Name) + } + + return nil +} + +func (b *BackstageDeployment) setMetaInfo(backstageName string) { + b.deployment.SetName(DeploymentName(backstageName)) + utils.GenerateLabel(&b.deployment.Spec.Template.ObjectMeta.Labels, BackstageAppLabel, fmt.Sprintf("backstage-%s", backstageName)) + utils.GenerateLabel(&b.deployment.Spec.Selector.MatchLabels, BackstageAppLabel, fmt.Sprintf("backstage-%s", backstageName)) +} + +func (b *BackstageDeployment) container() *corev1.Container { + return &b.deployment.Spec.Template.Spec.Containers[0] +} + +func (b *BackstageDeployment) podSpec() *corev1.PodSpec { + return &b.deployment.Spec.Template.Spec +} + +// sets the amount of replicas (used by CR config) +func (b *BackstageDeployment) setReplicas(replicas *int32) { + if replicas != nil { + b.deployment.Spec.Replicas = replicas + } +} + +// sets container image name of Backstage Container +func (b *BackstageDeployment) setImage(image *string) { + if image != nil { + // TODO this is a workaround for RHDH/Janus configuration + // it is not a fact that all the containers should be updated + // in general case need something smarter (probably annotation based) + // to mark/recognize containers for update + VisitContainers(b.podSpec(), func(container *corev1.Container) { + container.Image = *image + }) + } +} + +// adds environment variables to the Backstage Container +func (b *BackstageDeployment) addContainerEnvVar(env bsv1alpha1.Env) { + b.deployment.Spec.Template.Spec.Containers[0].Env = + append(b.deployment.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{ + Name: env.Name, + Value: env.Value, + }) +} + +// adds environment from source to the Backstage Container +func (b *BackstageDeployment) addExtraEnvs(extraEnvs *bsv1alpha1.ExtraEnvs) { + if extraEnvs != nil { + for _, e := range extraEnvs.Envs { + b.addContainerEnvVar(e) + } + } +} + +// sets pullSecret for Backstage Pod +func (b *BackstageDeployment) setImagePullSecrets(pullSecrets []string) { + for _, ps := range pullSecrets { + b.deployment.Spec.Template.Spec.ImagePullSecrets = append(b.deployment.Spec.Template.Spec.ImagePullSecrets, + corev1.LocalObjectReference{Name: ps}) + } +} + +// ContainerVisitor is called with each container +type ContainerVisitor func(container *corev1.Container) + +// visitContainers invokes the visitor function for every container in the given pod template spec +func VisitContainers(podTemplateSpec *corev1.PodSpec, visitor ContainerVisitor) { + for i := range podTemplateSpec.InitContainers { + visitor(&podTemplateSpec.InitContainers[i]) + } + for i := range podTemplateSpec.Containers { + visitor(&podTemplateSpec.Containers[i]) + } + for i := range podTemplateSpec.EphemeralContainers { + visitor((*corev1.Container)(&podTemplateSpec.EphemeralContainers[i].EphemeralContainerCommon)) + } +} diff --git a/pkg/model/deployment_test.go b/pkg/model/deployment_test.go new file mode 100644 index 00000000..3a1324b9 --- /dev/null +++ b/pkg/model/deployment_test.go @@ -0,0 +1,82 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "context" + "os" + "testing" + + "k8s.io/utils/ptr" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stretchr/testify/assert" +) + +var deploymentTestBackstage = bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Database: &bsv1alpha1.Database{ + EnableLocalDb: ptr.To(false), + }, + Application: &bsv1alpha1.Application{}, + }, +} + +func TestSpecs(t *testing.T) { + bs := *deploymentTestBackstage.DeepCopy() + bs.Spec.Application.Image = ptr.To("my-image:1.0.0") + bs.Spec.Application.Replicas = ptr.To(int32(3)) + bs.Spec.Application.ImagePullSecrets = []string{"my-secret"} + + testObj := createBackstageTest(bs).withDefaultConfig(true). + addToDefaultConfig("deployment.yaml", "janus-deployment.yaml") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, true, testObj.scheme) + assert.NoError(t, err) + + assert.Equal(t, "my-image:1.0.0", model.backstageDeployment.container().Image) + assert.Equal(t, int32(3), *model.backstageDeployment.deployment.Spec.Replicas) + assert.Equal(t, 1, len(model.backstageDeployment.deployment.Spec.Template.Spec.ImagePullSecrets)) + assert.Equal(t, "my-secret", model.backstageDeployment.deployment.Spec.Template.Spec.ImagePullSecrets[0].Name) + +} + +// It tests the overriding image feature +// [GA] if we need this (and like this) feature +// we need to think about simple template engine +// for substitution env vars instead. +// Janus image specific +func TestOverrideBackstageImage(t *testing.T) { + + bs := *deploymentTestBackstage.DeepCopy() + + testObj := createBackstageTest(bs).withDefaultConfig(true). + addToDefaultConfig("deployment.yaml", "janus-deployment.yaml") + + _ = os.Setenv(BackstageImageEnvVar, "dummy") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + assert.NoError(t, err) + + assert.Equal(t, "dummy", model.backstageDeployment.container().Image) + +} diff --git a/pkg/model/dynamic-plugins.go b/pkg/model/dynamic-plugins.go new file mode 100644 index 00000000..2f3400ff --- /dev/null +++ b/pkg/model/dynamic-plugins.go @@ -0,0 +1,152 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "fmt" + "os" + + appsv1 "k8s.io/api/apps/v1" + + "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const dynamicPluginInitContainerName = "install-dynamic-plugins" +const DynamicPluginsFile = "dynamic-plugins.yaml" + +type DynamicPluginsFactory struct{} + +func (f DynamicPluginsFactory) newBackstageObject() RuntimeObject { + return &DynamicPlugins{} +} + +type DynamicPlugins struct { + ConfigMap *corev1.ConfigMap +} + +func init() { + registerConfig("dynamic-plugins.yaml", DynamicPluginsFactory{}) +} + +func DynamicPluginsDefaultName(backstageName string) string { + return utils.GenerateRuntimeObjectName(backstageName, "backstage-dynamic-plugins") +} + +func addDynamicPlugins(spec v1alpha1.BackstageSpec, deployment *appsv1.Deployment, model *BackstageModel) error { + + if spec.Application == nil || spec.Application.DynamicPluginsConfigMapName == "" { + return nil + } + + if dynamicPluginsInitContainer(deployment.Spec.Template.Spec.InitContainers) == nil { + return fmt.Errorf("deployment validation failed, dynamic plugin name configured but no InitContainer %s defined", dynamicPluginInitContainerName) + } + + dp := DynamicPlugins{ConfigMap: &model.ExternalConfig.DynamicPlugins} + dp.updatePod(deployment) + return nil + +} + +// implementation of RuntimeObject interface +func (p *DynamicPlugins) Object() client.Object { + return p.ConfigMap +} + +func (p *DynamicPlugins) setObject(obj client.Object) { + p.ConfigMap = nil + if obj != nil { + p.ConfigMap = obj.(*corev1.ConfigMap) + } + +} + +// implementation of RuntimeObject interface +func (p *DynamicPlugins) EmptyObject() client.Object { + return &corev1.ConfigMap{} +} + +// implementation of RuntimeObject interface +func (p *DynamicPlugins) addToModel(model *BackstageModel, backstage v1alpha1.Backstage) (bool, error) { + + if p.ConfigMap == nil || (backstage.Spec.Application != nil && backstage.Spec.Application.DynamicPluginsConfigMapName != "") { + return false, nil + } + model.setRuntimeObject(p) + return true, nil +} + +// implementation of BackstagePodContributor interface +func (p *DynamicPlugins) updatePod(deployment *appsv1.Deployment) { + + //it relies on implementation where dynamic-plugin initContainer + //uses specified ConfigMap for producing app-config with dynamic-plugins + //For this implementation: + //- backstage contaier and dynamic-plugin initContainer must share a volume + // where initContainer writes and backstage container reads produced app-config + //- app-config path should be set as a --config parameter of backstage container + //in the deployment manifest + + //it creates a volume with dynamic-plugins ConfigMap (there should be a key named "dynamic-plugins.yaml") + //and mount it to the dynamic-plugin initContainer's WorkingDir (what if not specified?) + + initContainer := dynamicPluginsInitContainer(deployment.Spec.Template.Spec.InitContainers) + if initContainer == nil { + // it will fail on validate + return + } + + utils.MountFilesFrom(&deployment.Spec.Template.Spec, &deployment.Spec.Template.Spec.InitContainers[0], utils.ConfigMapObjectKind, + p.ConfigMap.Name, initContainer.WorkingDir, DynamicPluginsFile, p.ConfigMap.Data) + +} + +// implementation of RuntimeObject interface +// ConfigMap name must be the same as (deployment.yaml).spec.template.spec.volumes.name.dynamic-plugins-conf.ConfigMap.name +func (p *DynamicPlugins) validate(model *BackstageModel, _ v1alpha1.Backstage) error { + + initContainer := dynamicPluginsInitContainer(model.backstageDeployment.deployment.Spec.Template.Spec.InitContainers) + if initContainer == nil { + return fmt.Errorf("failed to find initContainer named %s", dynamicPluginInitContainerName) + } + // override image with env var + // [GA] Do we need this feature? + if os.Getenv(BackstageImageEnvVar) != "" { + // TODO workaround for the (janus-idp, rhdh) case where we have + // exactly the same image for initContainer and want it to be overriden + // the same way as Backstage's one + initContainer.Image = os.Getenv(BackstageImageEnvVar) + } + return nil +} + +func (p *DynamicPlugins) setMetaInfo(backstageName string) { + p.ConfigMap.SetName(DynamicPluginsDefaultName(backstageName)) +} + +// returns initContainer supposed to initialize DynamicPlugins +// TODO consider to use a label to identify instead +func dynamicPluginsInitContainer(initContainers []corev1.Container) *corev1.Container { + for _, ic := range initContainers { + if ic.Name == dynamicPluginInitContainerName { + return &ic + } + } + return nil +} diff --git a/pkg/model/dynamic-plugins_test.go b/pkg/model/dynamic-plugins_test.go new file mode 100644 index 00000000..8037c4af --- /dev/null +++ b/pkg/model/dynamic-plugins_test.go @@ -0,0 +1,132 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "context" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + "testing" + + "k8s.io/utils/ptr" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stretchr/testify/assert" +) + +var testDynamicPluginsBackstage = bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Database: &bsv1alpha1.Database{ + EnableLocalDb: ptr.To(false), + }, + Application: &bsv1alpha1.Application{}, + }, +} + +func TestDynamicPluginsValidationFailed(t *testing.T) { + + bs := testDynamicPluginsBackstage.DeepCopy() + + testObj := createBackstageTest(*bs).withDefaultConfig(true). + addToDefaultConfig("dynamic-plugins.yaml", "raw-dynamic-plugins.yaml") + + _, err := InitObjects(context.TODO(), *bs, testObj.externalConfig, true, false, testObj.scheme) + + //"failed object validation, reason: failed to find initContainer named install-dynamic-plugins") + assert.Error(t, err) + +} + +// Janus pecific test +func TestDefaultDynamicPlugins(t *testing.T) { + + bs := testDynamicPluginsBackstage.DeepCopy() + + testObj := createBackstageTest(*bs).withDefaultConfig(true). + addToDefaultConfig("dynamic-plugins.yaml", "raw-dynamic-plugins.yaml"). + addToDefaultConfig("deployment.yaml", "janus-deployment.yaml") + + model, err := InitObjects(context.TODO(), *bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.NotNil(t, model.backstageDeployment) + //dynamic-plugins-root + //dynamic-plugins-npmrc + //vol-default-dynamic-plugins + assert.Equal(t, 3, len(model.backstageDeployment.deployment.Spec.Template.Spec.Volumes)) + + ic := initContainer(model) + assert.NotNil(t, ic) + //dynamic-plugins-root + //dynamic-plugins-npmrc + //vol-default-dynamic-plugins + assert.Equal(t, 3, len(ic.VolumeMounts)) + +} + +func TestDefaultAndSpecifiedDynamicPlugins(t *testing.T) { + + bs := testDynamicPluginsBackstage.DeepCopy() + bs.Spec.Application.DynamicPluginsConfigMapName = "dplugin" + + testObj := createBackstageTest(*bs).withDefaultConfig(true). + addToDefaultConfig("dynamic-plugins.yaml", "raw-dynamic-plugins.yaml"). + addToDefaultConfig("deployment.yaml", "janus-deployment.yaml") + + testObj.externalConfig.DynamicPlugins = corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dplugin"}} + + model, err := InitObjects(context.TODO(), *bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.NotNil(t, model) + + ic := initContainer(model) + assert.NotNil(t, ic) + //dynamic-plugins-root + //dynamic-plugins-npmrc + //vol-dplugin + assert.Equal(t, 3, len(ic.VolumeMounts)) + assert.Equal(t, utils.GenerateVolumeNameFromCmOrSecret("dplugin"), ic.VolumeMounts[2].Name) + //t.Log(">>>>>>>>>>>>>>>>", ic.VolumeMounts) +} + +func TestDynamicPluginsFailOnArbitraryDepl(t *testing.T) { + + bs := testDynamicPluginsBackstage.DeepCopy() + //bs.Spec.Application.DynamicPluginsConfigMapName = "dplugin" + + testObj := createBackstageTest(*bs).withDefaultConfig(true). + addToDefaultConfig("dynamic-plugins.yaml", "raw-dynamic-plugins.yaml") + + _, err := InitObjects(context.TODO(), *bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.Error(t, err) +} + +func initContainer(model *BackstageModel) *corev1.Container { + for _, v := range model.backstageDeployment.deployment.Spec.Template.Spec.InitContainers { + if v.Name == dynamicPluginInitContainerName { + return &v + } + } + return nil +} diff --git a/pkg/model/interfaces.go b/pkg/model/interfaces.go new file mode 100644 index 00000000..859ce115 --- /dev/null +++ b/pkg/model/interfaces.go @@ -0,0 +1,61 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + appsv1 "k8s.io/api/apps/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Registered Object configuring Backstage runtime model +type ObjectConfig struct { + // Factory to create the object + ObjectFactory ObjectFactory + // Unique key identifying the "kind" of Object which also is the name of config file. + // For example: "deployment.yaml" containing configuration of Backstage Deployment + Key string +} + +// Interface for Runtime Objects factory method +type ObjectFactory interface { + newBackstageObject() RuntimeObject +} + +// Abstraction for the model Backstage object taking part in deployment +type RuntimeObject interface { + // Object underlying Kubernetes object + Object() client.Object + // setObject sets object + setObject(obj client.Object) + // EmptyObject an empty object the same kind as Object + EmptyObject() client.Object + // adds runtime object to the model + // returns false if the object was not added to the model (not configured) + addToModel(model *BackstageModel, backstage bsv1alpha1.Backstage) (bool, error) + // at this stage all the information is updated + // set the final references validates the object at the end of initialization + validate(model *BackstageModel, backstage bsv1alpha1.Backstage) error + // sets object name, labels and other necessary meta information + setMetaInfo(backstageName string) +} + +// BackstagePodContributor contributing to the pod as an Environment variables or mounting file/directory. +// Usually app-config related +type BackstagePodContributor interface { + RuntimeObject + updatePod(deployment *appsv1.Deployment) +} diff --git a/pkg/model/model_tests.go b/pkg/model/model_tests.go new file mode 100644 index 00000000..3948d2c8 --- /dev/null +++ b/pkg/model/model_tests.go @@ -0,0 +1,96 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "fmt" + "os" + "path/filepath" + + "k8s.io/utils/ptr" + + corev1 "k8s.io/api/core/v1" + + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + + "k8s.io/apimachinery/pkg/runtime" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" +) + +// testBackstageObject it is a helper object to simplify testing model component allowing to customize and isolate testing configuration +// usual sequence of creating testBackstageObject contains such a steps: +// createBackstageTest(bsv1alpha1.Backstage). +// withDefaultConfig(useDef bool) +// addToDefaultConfig(key, fileName) +type testBackstageObject struct { + backstage bsv1alpha1.Backstage + externalConfig ExternalConfig + scheme *runtime.Scheme +} + +// initialises testBackstageObject object +func createBackstageTest(bs bsv1alpha1.Backstage) *testBackstageObject { + ec := ExternalConfig{ + RawConfig: map[string]string{}, + AppConfigs: map[string]corev1.ConfigMap{}, + ExtraFileConfigMaps: map[string]corev1.ConfigMap{}, + ExtraEnvConfigMaps: map[string]corev1.ConfigMap{}, + } + b := &testBackstageObject{backstage: bs, externalConfig: ec, scheme: runtime.NewScheme()} + utilruntime.Must(bsv1alpha1.AddToScheme(b.scheme)) + return b +} + +// enables LocalDB +func (b *testBackstageObject) withLocalDb() *testBackstageObject { + b.backstage.Spec.Database.EnableLocalDb = ptr.To(true) + return b +} + +// tells if object should use default Backstage Deployment/Service configuration from ./testdata/default-config or not +func (b *testBackstageObject) withDefaultConfig(useDef bool) *testBackstageObject { + if useDef { + // here we have default-config folder + _ = os.Setenv("LOCALBIN", "./testdata") + } else { + _ = os.Setenv("LOCALBIN", ".") + } + return b +} + +// adds particular part of configuration pointing to configuration key +// where key is configuration key (such as "deployment.yaml" and fileName is a name of additional conf file in ./testdata +func (b *testBackstageObject) addToDefaultConfig(key string, fileName string) *testBackstageObject { + + yaml, err := readTestYamlFile(fileName) + if err != nil { + panic(err) + } + + b.externalConfig.RawConfig[key] = string(yaml) + + return b +} + +// reads file from ./testdata +func readTestYamlFile(name string) ([]byte, error) { + + b, err := os.ReadFile(filepath.Join("testdata", name)) // #nosec G304, path is constructed internally + if err != nil { + return nil, fmt.Errorf("failed to read YAML file: %w", err) + } + return b, nil +} diff --git a/pkg/model/route.go b/pkg/model/route.go new file mode 100644 index 00000000..56601949 --- /dev/null +++ b/pkg/model/route.go @@ -0,0 +1,147 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + openshift "github.com/openshift/api/route/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type BackstageRouteFactory struct{} + +func (f BackstageRouteFactory) newBackstageObject() RuntimeObject { + return &BackstageRoute{} +} + +type BackstageRoute struct { + route *openshift.Route +} + +func RouteName(backstageName string) string { + return utils.GenerateRuntimeObjectName(backstageName, "backstage") +} + +func (b *BackstageRoute) setRoute(specified *bsv1alpha1.Route) { + + if len(specified.Host) > 0 { + b.route.Spec.Host = specified.Host + } + if len(specified.Subdomain) > 0 { + b.route.Spec.Subdomain = specified.Subdomain + } + if specified.TLS == nil { + return + } + if b.route.Spec.TLS == nil { + b.route.Spec.TLS = &openshift.TLSConfig{ + Termination: openshift.TLSTerminationEdge, + InsecureEdgeTerminationPolicy: openshift.InsecureEdgeTerminationPolicyRedirect, + Certificate: specified.TLS.Certificate, + Key: specified.TLS.Key, + CACertificate: specified.TLS.CACertificate, + ExternalCertificate: &openshift.LocalObjectReference{ + Name: specified.TLS.ExternalCertificateSecretName, + }, + } + return + } + if len(specified.TLS.Certificate) > 0 { + b.route.Spec.TLS.Certificate = specified.TLS.Certificate + } + if len(specified.TLS.Key) > 0 { + b.route.Spec.TLS.Key = specified.TLS.Key + } + if len(specified.TLS.Certificate) > 0 { + b.route.Spec.TLS.Certificate = specified.TLS.Certificate + } + if len(specified.TLS.CACertificate) > 0 { + b.route.Spec.TLS.CACertificate = specified.TLS.CACertificate + } + if len(specified.TLS.ExternalCertificateSecretName) > 0 { + b.route.Spec.TLS.ExternalCertificate = &openshift.LocalObjectReference{ + Name: specified.TLS.ExternalCertificateSecretName, + } + } +} + +func init() { + registerConfig("route.yaml", BackstageRouteFactory{}) +} + +// implementation of RuntimeObject interface +func (b *BackstageRoute) Object() client.Object { + return b.route +} + +func (b *BackstageRoute) setObject(obj client.Object) { + b.route = nil + if obj != nil { + b.route = obj.(*openshift.Route) + } +} + +// implementation of RuntimeObject interface +func (b *BackstageRoute) EmptyObject() client.Object { + return &openshift.Route{} +} + +// implementation of RuntimeObject interface +func (b *BackstageRoute) addToModel(model *BackstageModel, backstage bsv1alpha1.Backstage) (bool, error) { + + // not Openshift + if !model.isOpenshift { + return false, nil + } + + // route explicitly disabled + if !backstage.Spec.IsRouteEnabled() { + return false, nil + } + + specDefined := backstage.Spec.Application != nil && backstage.Spec.Application.Route != nil + + // no default route and not defined + if b.route == nil && !specDefined { + return false, nil + } + + // no default route but defined in the spec -> create default + if b.route == nil { + b.route = &openshift.Route{} + } + + // merge with specified (pieces) if any + if specDefined { + b.setRoute(backstage.Spec.Application.Route) + } + + model.route = b + model.setRuntimeObject(b) + + return true, nil +} + +// implementation of RuntimeObject interface +func (b *BackstageRoute) validate(model *BackstageModel, _ bsv1alpha1.Backstage) error { + b.route.Spec.To.Name = model.backstageService.service.Name + return nil +} + +func (b *BackstageRoute) setMetaInfo(backstageName string) { + b.route.SetName(RouteName(backstageName)) +} diff --git a/pkg/model/route_test.go b/pkg/model/route_test.go new file mode 100644 index 00000000..c035af39 --- /dev/null +++ b/pkg/model/route_test.go @@ -0,0 +1,192 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "context" + "testing" + + openshift "github.com/openshift/api/route/v1" + + "k8s.io/utils/ptr" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultRoute(t *testing.T) { + bs := bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "TestSpecifiedRoute", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Application: &bsv1alpha1.Application{ + Route: &bsv1alpha1.Route{}, + }, + }, + } + assert.True(t, bs.Spec.IsRouteEnabled()) + + testObj := createBackstageTest(bs).withDefaultConfig(true).addToDefaultConfig("route.yaml", "raw-route.yaml") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, true, testObj.scheme) + + assert.NoError(t, err) + + assert.NotNil(t, model.route) + + assert.Equal(t, RouteName(bs.Name), model.route.route.Name) + assert.Equal(t, model.backstageService.service.Name, model.route.route.Spec.To.Name) + // from spec + assert.Equal(t, "/default", model.route.route.Spec.Path) + // from default + assert.NotNil(t, model.route.route.Spec.TLS) + assert.NotEmpty(t, model.route.route.Spec.TLS.Termination) + + // assert.Empty(t, model.route.route.Spec.Host) +} + +func TestSpecifiedRoute(t *testing.T) { + bs := bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "TestSpecifiedRoute", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Application: &bsv1alpha1.Application{ + Route: &bsv1alpha1.Route{ + Enabled: ptr.To(true), + Host: "TestSpecifiedRoute", + //TLS: nil, + }, + }, + }, + } + + assert.True(t, bs.Spec.IsRouteEnabled()) + + // Test w/o default route configured + testObjNoDef := createBackstageTest(bs).withDefaultConfig(true) + model, err := InitObjects(context.TODO(), bs, testObjNoDef.externalConfig, true, true, testObjNoDef.scheme) + + assert.NoError(t, err) + assert.NotNil(t, model.route) + + // check if what we have is what we specified in bs + assert.Equal(t, RouteName(bs.Name), model.route.route.Name) + assert.Equal(t, bs.Spec.Application.Route.Host, model.route.route.Spec.Host) + + // Test with default route configured + testObjWithDef := testObjNoDef.addToDefaultConfig("route.yaml", "raw-route.yaml") + model, err = InitObjects(context.TODO(), bs, testObjWithDef.externalConfig, true, true, testObjWithDef.scheme) + + assert.NoError(t, err) + assert.NotNil(t, model.route) + + // check if what we have is default route merged with fields defined in bs + assert.Equal(t, RouteName(bs.Name), model.route.route.Name) + assert.Equal(t, bs.Spec.Application.Route.Host, model.route.route.Spec.Host) + assert.NotNil(t, model.route.route.Spec.TLS) + assert.Equal(t, openshift.TLSTerminationEdge, model.route.route.Spec.TLS.Termination) +} + +func TestDisabledRoute(t *testing.T) { + + // Route.Enabled = false + bs := bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "TestSpecifiedRoute", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Application: &bsv1alpha1.Application{ + Route: &bsv1alpha1.Route{ + Enabled: ptr.To(false), + Host: "TestSpecifiedRoute", + //TLS: nil, + }, + }, + }, + } + + // With def route config + testObj := createBackstageTest(bs).withDefaultConfig(true).addToDefaultConfig("route.yaml", "raw-route.yaml") + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, true, testObj.scheme) + assert.NoError(t, err) + assert.Nil(t, model.route) + + // W/o def route config + testObj = createBackstageTest(bs).withDefaultConfig(true) + model, err = InitObjects(context.TODO(), bs, testObj.externalConfig, true, true, testObj.scheme) + assert.NoError(t, err) + assert.Nil(t, model.route) + +} + +func TestExcludedRoute(t *testing.T) { + // No route configured + bs := bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "TestSpecifiedRoute", + Namespace: "ns123", + }, + //Spec: bsv1alpha1.BackstageSpec{ // //Application: &bsv1alpha1.Application{}, + //}, + } + + // With def route config - create default route + testObj := createBackstageTest(bs).withDefaultConfig(true).addToDefaultConfig("route.yaml", "raw-route.yaml") + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, true, testObj.scheme) + assert.NoError(t, err) + assert.NotNil(t, model.route) + + // W/o def route config - do not create route + testObj = createBackstageTest(bs).withDefaultConfig(true) + model, err = InitObjects(context.TODO(), bs, testObj.externalConfig, true, true, testObj.scheme) + assert.NoError(t, err) + assert.Nil(t, model.route) +} + +func TestEnabledRoute(t *testing.T) { + // Route is enabled by default if configured + bs := bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "TestSpecifiedRoute", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Application: &bsv1alpha1.Application{ + Route: &bsv1alpha1.Route{}, + }, + }, + } + + // With def route config + testObj := createBackstageTest(bs).withDefaultConfig(true).addToDefaultConfig("route.yaml", "raw-route.yaml") + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, true, testObj.scheme) + assert.NoError(t, err) + assert.NotNil(t, model.route) + + // W/o def route config + testObj = createBackstageTest(bs).withDefaultConfig(true) + model, err = InitObjects(context.TODO(), bs, testObj.externalConfig, true, true, testObj.scheme) + assert.NoError(t, err) + assert.NotNil(t, model.route) + +} diff --git a/pkg/model/runtime.go b/pkg/model/runtime.go new file mode 100644 index 00000000..671288ed --- /dev/null +++ b/pkg/model/runtime.go @@ -0,0 +1,198 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "context" + "errors" + "fmt" + "os" + "reflect" + "sort" + + corev1 "k8s.io/api/core/v1" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "sigs.k8s.io/controller-runtime/pkg/log" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" +) + +const BackstageAppLabel = "rhdh.redhat.com/app" + +// Backstage configuration scaffolding with empty BackstageObjects. +// There are all possible objects for configuration, can be: +// Mandatory - Backstage Deployment (Pod), Service +// Optional - mostly (but not only) Backstage Pod configuration objects (AppConfig, ExtraConfig) +// ForLocalDatabase - mandatory if EnabledLocalDb, ignored otherwise +// ForOpenshift - if configured, used for Openshift deployment, ignored otherwise +var runtimeConfig []ObjectConfig + +// BackstageModel represents internal object model +type BackstageModel struct { + localDbEnabled bool + isOpenshift bool + + backstageDeployment *BackstageDeployment + backstageService *BackstageService + + localDbStatefulSet *DbStatefulSet + LocalDbService *DbService + LocalDbSecret *DbSecret + + route *BackstageRoute + + RuntimeObjects []RuntimeObject + + ExternalConfig ExternalConfig + + //appConfigs []SpecifiedConfigMap +} + +type SpecifiedConfigMap struct { + ConfigMap corev1.ConfigMap + Key string +} + +type ExternalConfig struct { + RawConfig map[string]string + AppConfigs map[string]corev1.ConfigMap + ExtraFileConfigMaps map[string]corev1.ConfigMap + ExtraEnvConfigMaps map[string]corev1.ConfigMap + DynamicPlugins corev1.ConfigMap +} + +func (m *BackstageModel) setRuntimeObject(object RuntimeObject) { + for i, obj := range m.RuntimeObjects { + if reflect.TypeOf(obj) == reflect.TypeOf(object) { + m.RuntimeObjects[i] = object + return + } + } + m.RuntimeObjects = append(m.RuntimeObjects, object) +} + +func (m *BackstageModel) sortRuntimeObjects() { + // works with Go 1.18+ + sort.Slice(m.RuntimeObjects, func(i, j int) bool { + _, ok1 := m.RuntimeObjects[i].(*DbStatefulSet) + _, ok2 := m.RuntimeObjects[j].(*BackstageDeployment) + if ok1 || ok2 { + return false + } + return true + + }) + + // this does not work for Go 1.20 + // so image-build fails + //slices.SortFunc(m.RuntimeObjects, + // func(a, b RuntimeObject) int { + // _, ok1 := b.(*DbStatefulSet) + // _, ok2 := b.(*BackstageDeployment) + // if ok1 || ok2 { + // return -1 + // } + // return 1 + // }) +} + +// Registers config object +func registerConfig(key string, factory ObjectFactory) { + runtimeConfig = append(runtimeConfig, ObjectConfig{Key: key, ObjectFactory: factory /*, need: need*/}) +} + +// InitObjects performs a main loop for configuring and making the array of objects to reconcile +func InitObjects(ctx context.Context, backstage bsv1alpha1.Backstage, externalConfig ExternalConfig, ownsRuntime bool, isOpenshift bool, scheme *runtime.Scheme) (*BackstageModel, error) { + + // 3 phases of Backstage configuration: + // 1- load from Operator defaults, modify metadata (labels, selectors..) and namespace as needed + // 2- overlay some/all objects with Backstage.spec.rawRuntimeConfig CM + // 3- override some parameters defined in Backstage.spec.application + // At the end there should be an array of runtime RuntimeObjects to apply (order optimized) + + lg := log.FromContext(ctx) + lg.V(1) + + model := &BackstageModel{RuntimeObjects: make([]RuntimeObject, 0), ExternalConfig: externalConfig, localDbEnabled: backstage.Spec.IsLocalDbEnabled(), isOpenshift: isOpenshift} + + // looping through the registered runtimeConfig objects initializing the model + for _, conf := range runtimeConfig { + + // creating the instance of backstageObject + backstageObject := conf.ObjectFactory.newBackstageObject() + + var obj = backstageObject.EmptyObject() + if err := utils.ReadYamlFile(utils.DefFile(conf.Key), obj); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("failed to read default value for the key %s, reason: %s", conf.Key, err) + } + } else { + backstageObject.setObject(obj) + } + + // reading configuration defined in BackstageCR.Spec.RawConfigContent ConfigMap + // if present, backstageObject's default configuration will be overridden + overlay, overlayExist := externalConfig.RawConfig[conf.Key] + if overlayExist { + if err := utils.ReadYaml([]byte(overlay), obj); err != nil { + return nil, fmt.Errorf("failed to read overlay value for the key %s, reason: %s", conf.Key, err) + } else { + backstageObject.setObject(obj) + } + } + + // apply spec and add the object to the model and list + if added, err := backstageObject.addToModel(model, backstage); err != nil { + return nil, fmt.Errorf("failed to initialize %s reason: %s", backstageObject, err) + } else if added { + setMetaInfo(backstageObject, backstage, ownsRuntime, scheme) + } + } + + // set generic metainfo and validate all + for _, v := range model.RuntimeObjects { + err := v.validate(model, backstage) + if err != nil { + return nil, fmt.Errorf("failed object validation, reason: %s", err) + } + } + + // sort for reconciliation number optimization + model.sortRuntimeObjects() + + return model, nil +} + +// Every RuntimeObject.setMetaInfo should as minimum call this +func setMetaInfo(modelObject RuntimeObject, backstage bsv1alpha1.Backstage, ownsRuntime bool, scheme *runtime.Scheme) { + modelObject.setMetaInfo(backstage.Name) + modelObject.Object().SetNamespace(backstage.Namespace) + modelObject.Object().SetLabels(utils.SetKubeLabels(modelObject.Object().GetLabels(), backstage.Name)) + + if ownsRuntime { + if err := controllerutil.SetControllerReference(&backstage, modelObject.Object(), scheme); err != nil { + //error should never have happened, + //otherwise the Operator has invalid (not a runtime.Object) or non-registered type. + //In both cases it will fail before this place + panic(err) + } + } + +} diff --git a/pkg/model/runtime_test.go b/pkg/model/runtime_test.go new file mode 100644 index 00000000..5d41d301 --- /dev/null +++ b/pkg/model/runtime_test.go @@ -0,0 +1,138 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "context" + "fmt" + "testing" + + "k8s.io/utils/ptr" + + "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stretchr/testify/assert" +) + +//const backstageContainerName = "backstage-backend" + +// NOTE: to make it work locally env var LOCALBIN should point to the directory where default-config folder located +func TestInitDefaultDeploy(t *testing.T) { + + bs := v1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: v1alpha1.BackstageSpec{ + Database: &v1alpha1.Database{ + EnableLocalDb: ptr.To(false), + }, + }, + } + + testObj := createBackstageTest(bs).withDefaultConfig(true) + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.True(t, len(model.RuntimeObjects) > 0) + assert.Equal(t, DeploymentName(bs.Name), model.backstageDeployment.Object().GetName()) + assert.Equal(t, "ns123", model.backstageDeployment.Object().GetNamespace()) + assert.Equal(t, 2, len(model.backstageDeployment.Object().GetLabels())) + + bsDeployment := model.backstageDeployment + assert.NotNil(t, bsDeployment.deployment.Spec.Template.Spec.Containers[0]) + + bsService := model.backstageService + assert.Equal(t, ServiceName(bs.Name), bsService.service.Name) + assert.True(t, len(bsService.service.Spec.Ports) > 0) + + assert.Equal(t, fmt.Sprintf("backstage-%s", "bs"), bsDeployment.deployment.Spec.Template.ObjectMeta.Labels[BackstageAppLabel]) + assert.Equal(t, fmt.Sprintf("backstage-%s", "bs"), bsService.service.Spec.Selector[BackstageAppLabel]) + +} + +func TestIfEmptyObjectIsValid(t *testing.T) { + + bs := bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Database: &bsv1alpha1.Database{ + EnableLocalDb: ptr.To(false), + }, + }, + } + + testObj := createBackstageTest(bs).withDefaultConfig(true) + + assert.False(t, bs.Spec.IsLocalDbEnabled()) + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + assert.NoError(t, err) + + assert.Equal(t, 2, len(model.RuntimeObjects)) + +} + +func TestAddToModel(t *testing.T) { + + bs := v1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: v1alpha1.BackstageSpec{ + Database: &v1alpha1.Database{ + EnableLocalDb: ptr.To(false), + }, + }, + } + testObj := createBackstageTest(bs).withDefaultConfig(true) + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + assert.NoError(t, err) + assert.NotNil(t, model) + assert.NotNil(t, model.RuntimeObjects) + assert.Equal(t, 2, len(model.RuntimeObjects)) + + found := false + for _, bd := range model.RuntimeObjects { + if bd, ok := bd.(*BackstageDeployment); ok { + found = true + assert.Equal(t, bd, model.backstageDeployment) + } + } + assert.True(t, found) + + // another empty model to test + rm := BackstageModel{RuntimeObjects: []RuntimeObject{}} + assert.Equal(t, 0, len(rm.RuntimeObjects)) + testService := *model.backstageService + + // add to rm + _, _ = testService.addToModel(&rm, bs) + assert.Equal(t, 1, len(rm.RuntimeObjects)) + assert.NotNil(t, rm.backstageService) + assert.Nil(t, rm.backstageDeployment) + assert.Equal(t, testService, *rm.backstageService) + assert.Equal(t, testService, *rm.RuntimeObjects[0].(*BackstageService)) +} diff --git a/pkg/model/secretenvs.go b/pkg/model/secretenvs.go new file mode 100644 index 00000000..a6277351 --- /dev/null +++ b/pkg/model/secretenvs.go @@ -0,0 +1,98 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type SecretEnvsFactory struct{} + +func (f SecretEnvsFactory) newBackstageObject() RuntimeObject { + return &SecretEnvs{} +} + +type SecretEnvs struct { + Secret *corev1.Secret + Key string +} + +func init() { + registerConfig("secret-envs.yaml", SecretEnvsFactory{}) +} + +// implementation of RuntimeObject interface +func (p *SecretEnvs) Object() client.Object { + return p.Secret +} + +func addSecretEnvs(spec v1alpha1.BackstageSpec, deployment *appsv1.Deployment) error { + + if spec.Application == nil || spec.Application.ExtraEnvs == nil || spec.Application.ExtraEnvs.Secrets == nil { + return nil + } + + for _, sec := range spec.Application.ExtraEnvs.Secrets { + se := SecretEnvs{ + Secret: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: sec.Name}}, + Key: sec.Key, + } + se.updatePod(deployment) + } + return nil +} + +func (p *SecretEnvs) setObject(obj client.Object) { + p.Secret = nil + if obj != nil { + p.Secret = obj.(*corev1.Secret) + } +} + +// implementation of RuntimeObject interface +func (p *SecretEnvs) EmptyObject() client.Object { + return &corev1.Secret{} +} + +// implementation of RuntimeObject interface +func (p *SecretEnvs) addToModel(model *BackstageModel, _ v1alpha1.Backstage) (bool, error) { + if p.Secret != nil { + model.setRuntimeObject(p) + return true, nil + } + return false, nil +} + +// implementation of RuntimeObject interface +func (p *SecretEnvs) validate(_ *BackstageModel, _ v1alpha1.Backstage) error { + return nil +} + +func (p *SecretEnvs) setMetaInfo(backstageName string) { + p.Secret.SetName(utils.GenerateRuntimeObjectName(backstageName, "backstage-envs")) +} + +// implementation of BackstagePodContributor interface +func (p *SecretEnvs) updatePod(deployment *appsv1.Deployment) { + + utils.AddEnvVarsFrom(&deployment.Spec.Template.Spec.Containers[0], utils.SecretObjectKind, + p.Secret.Name, p.Key) +} diff --git a/pkg/model/secretfiles.go b/pkg/model/secretfiles.go new file mode 100644 index 00000000..17430ab3 --- /dev/null +++ b/pkg/model/secretfiles.go @@ -0,0 +1,115 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "fmt" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type SecretFilesFactory struct{} + +func (f SecretFilesFactory) newBackstageObject() RuntimeObject { + return &SecretFiles{MountPath: defaultMountDir} +} + +type SecretFiles struct { + Secret *corev1.Secret + MountPath string + Key string +} + +func init() { + registerConfig("secret-files.yaml", SecretFilesFactory{}) +} + +func addSecretFiles(spec v1alpha1.BackstageSpec, deployment *appsv1.Deployment) error { + + if spec.Application == nil || spec.Application.ExtraFiles == nil || spec.Application.ExtraFiles.Secrets == nil { + return nil + } + mp := defaultMountDir + if spec.Application.ExtraFiles.MountPath != "" { + mp = spec.Application.ExtraFiles.MountPath + } + + for _, sec := range spec.Application.ExtraFiles.Secrets { + if sec.Key == "" { + return fmt.Errorf("key is required to mount extra file with secret %s", sec.Name) + } + sf := SecretFiles{ + Secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: sec.Name}, + // TODO it is not correct, there may not be such a secret key + // it is done for 0.1.0 compatibility only + StringData: map[string]string{sec.Key: ""}, + }, + MountPath: mp, + Key: sec.Key, + } + sf.updatePod(deployment) + } + return nil +} + +// implementation of RuntimeObject interface +func (p *SecretFiles) Object() client.Object { + return p.Secret +} + +func (p *SecretFiles) setObject(obj client.Object) { + p.Secret = nil + if obj != nil { + p.Secret = obj.(*corev1.Secret) + } +} + +// implementation of RuntimeObject interface +func (p *SecretFiles) EmptyObject() client.Object { + return &corev1.Secret{} +} + +// implementation of RuntimeObject interface +func (p *SecretFiles) addToModel(model *BackstageModel, _ v1alpha1.Backstage) (bool, error) { + if p.Secret != nil { + model.setRuntimeObject(p) + return true, nil + } + return false, nil +} + +// implementation of RuntimeObject interface +func (p *SecretFiles) validate(_ *BackstageModel, _ v1alpha1.Backstage) error { + return nil +} + +func (p *SecretFiles) setMetaInfo(backstageName string) { + p.Secret.SetName(utils.GenerateRuntimeObjectName(backstageName, "backstage-files")) +} + +// implementation of BackstagePodContributor interface +func (p *SecretFiles) updatePod(depoyment *appsv1.Deployment) { + + utils.MountFilesFrom(&depoyment.Spec.Template.Spec, &depoyment.Spec.Template.Spec.Containers[0], utils.SecretObjectKind, + p.Secret.Name, p.MountPath, p.Key, p.Secret.StringData) +} diff --git a/pkg/model/secretfiles_test.go b/pkg/model/secretfiles_test.go new file mode 100644 index 00000000..c463d470 --- /dev/null +++ b/pkg/model/secretfiles_test.go @@ -0,0 +1,127 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "context" + + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + //secretFilesTestSecret = corev1.Secret{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "secret1", + // Namespace: "ns123", + // }, + // StringData: map[string]string{"conf.yaml": ""}, + //} + // + //secretFilesTestSecret2 = corev1.Secret{ + // ObjectMeta: metav1.ObjectMeta{ + // Name: "secret2", + // Namespace: "ns123", + // }, + // StringData: map[string]string{"conf2.yaml": ""}, + //} + + secretFilesTestBackstage = bsv1alpha1.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bs", + Namespace: "ns123", + }, + Spec: bsv1alpha1.BackstageSpec{ + Application: &bsv1alpha1.Application{ + ExtraFiles: &bsv1alpha1.ExtraFiles{ + MountPath: "/my/path", + Secrets: []bsv1alpha1.ObjectKeyRef{}, + }, + }, + }, + } +) + +func TestDefaultSecretFiles(t *testing.T) { + + bs := *secretFilesTestBackstage.DeepCopy() + + testObj := createBackstageTest(bs).withDefaultConfig(true).addToDefaultConfig("secret-files.yaml", "raw-secret-files.yaml") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + + deployment := model.backstageDeployment + assert.NotNil(t, deployment) + + assert.Equal(t, 1, len(deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) + assert.Equal(t, 1, len(deployment.deployment.Spec.Template.Spec.Volumes)) + +} + +func TestSpecifiedSecretFiles(t *testing.T) { + + bs := *secretFilesTestBackstage.DeepCopy() + sf := &bs.Spec.Application.ExtraFiles.Secrets + *sf = append(*sf, bsv1alpha1.ObjectKeyRef{Name: "secret1", Key: "conf.yaml"}) + *sf = append(*sf, bsv1alpha1.ObjectKeyRef{Name: "secret2", Key: "conf.yaml"}) + + testObj := createBackstageTest(bs).withDefaultConfig(true) + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.True(t, len(model.RuntimeObjects) > 0) + + deployment := model.backstageDeployment + assert.NotNil(t, deployment) + + assert.Equal(t, 2, len(deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) + assert.Equal(t, 0, len(deployment.deployment.Spec.Template.Spec.Containers[0].Args)) + assert.Equal(t, 2, len(deployment.deployment.Spec.Template.Spec.Volumes)) + + assert.Equal(t, utils.GenerateVolumeNameFromCmOrSecret("secret1"), deployment.podSpec().Volumes[0].Name) + +} + +func TestDefaultAndSpecifiedSecretFiles(t *testing.T) { + + bs := *secretFilesTestBackstage.DeepCopy() + sf := &bs.Spec.Application.ExtraFiles.Secrets + *sf = append(*sf, bsv1alpha1.ObjectKeyRef{Name: "secret1", Key: "conf.yaml"}) + testObj := createBackstageTest(bs).withDefaultConfig(true).addToDefaultConfig("secret-files.yaml", "raw-secret-files.yaml") + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, true, false, testObj.scheme) + + assert.NoError(t, err) + assert.True(t, len(model.RuntimeObjects) > 0) + + deployment := model.backstageDeployment + assert.NotNil(t, deployment) + + assert.Equal(t, 2, len(deployment.deployment.Spec.Template.Spec.Containers[0].VolumeMounts)) + assert.Equal(t, 0, len(deployment.deployment.Spec.Template.Spec.Containers[0].Args)) + assert.Equal(t, 2, len(deployment.deployment.Spec.Template.Spec.Volumes)) + assert.Equal(t, utils.GenerateVolumeNameFromCmOrSecret("secret1"), deployment.podSpec().Volumes[1].Name) + +} diff --git a/pkg/model/service.go b/pkg/model/service.go new file mode 100644 index 00000000..da1b3a1b --- /dev/null +++ b/pkg/model/service.go @@ -0,0 +1,82 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 model + +import ( + "fmt" + + bsv1alpha1 "redhat-developer/red-hat-developer-hub-operator/api/v1alpha1" + "redhat-developer/red-hat-developer-hub-operator/pkg/utils" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type BackstageServiceFactory struct{} + +func (f BackstageServiceFactory) newBackstageObject() RuntimeObject { + return &BackstageService{} +} + +type BackstageService struct { + service *corev1.Service +} + +func init() { + registerConfig("service.yaml", BackstageServiceFactory{}) +} + +func ServiceName(backstageName string) string { + return utils.GenerateRuntimeObjectName(backstageName, "backstage") +} + +// implementation of RuntimeObject interface +func (b *BackstageService) Object() client.Object { + return b.service +} + +func (b *BackstageService) setObject(obj client.Object) { + b.service = nil + if obj != nil { + b.service = obj.(*corev1.Service) + } +} + +// implementation of RuntimeObject interface +func (b *BackstageService) addToModel(model *BackstageModel, _ bsv1alpha1.Backstage) (bool, error) { + if b.service == nil { + return false, fmt.Errorf("Backstage Service is not initialized, make sure there is service.yaml in default or raw configuration") + } + model.backstageService = b + model.setRuntimeObject(b) + + return true, nil + +} + +// implementation of RuntimeObject interface +func (b *BackstageService) EmptyObject() client.Object { + return &corev1.Service{} +} + +// implementation of RuntimeObject interface +func (b *BackstageService) validate(_ *BackstageModel, _ bsv1alpha1.Backstage) error { + return nil +} + +func (b *BackstageService) setMetaInfo(backstageName string) { + b.service.SetName(ServiceName(backstageName)) + utils.GenerateLabel(&b.service.Spec.Selector, BackstageAppLabel, fmt.Sprintf("backstage-%s", backstageName)) +} diff --git a/pkg/model/testdata/db-defined-secret.yaml b/pkg/model/testdata/db-defined-secret.yaml new file mode 100644 index 00000000..f2bd2eb8 --- /dev/null +++ b/pkg/model/testdata/db-defined-secret.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: postgres-secrets # will be replaced + namespace: backstage +type: Opaque +stringData: + POSTGRES_PASSWORD: admin123 + POSTGRES_PORT: "5432" + POSTGRES_USER: postgres + POSTGRESQL_ADMIN_PASSWORD: admin123 + POSTGRES_HOST: bs1-db-service #placeholder -db-service \ No newline at end of file diff --git a/pkg/model/testdata/db-empty-secret.yaml b/pkg/model/testdata/db-empty-secret.yaml new file mode 100644 index 00000000..bc0cb1dc --- /dev/null +++ b/pkg/model/testdata/db-empty-secret.yaml @@ -0,0 +1,2 @@ +apiVersion: v1 +kind: Secret diff --git a/pkg/model/testdata/db-generated-secret.yaml b/pkg/model/testdata/db-generated-secret.yaml new file mode 100644 index 00000000..4ccb53e9 --- /dev/null +++ b/pkg/model/testdata/db-generated-secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: postgres-secrets # will be replaced + namespace: backstage +type: Opaque +stringData: + POSTGRES_PASSWORD: "postgres" + POSTGRES_PORT: "5432" + POSTGRES_USER: "postgres" + POSTGRES_HOST: bs1-db-service #placeholder -db-service \ No newline at end of file diff --git a/pkg/model/testdata/default-config/db-secret.yaml b/pkg/model/testdata/default-config/db-secret.yaml new file mode 100644 index 00000000..c55dd111 --- /dev/null +++ b/pkg/model/testdata/default-config/db-secret.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: # will be replaced + namespace: backstage +type: Opaque +#stringData: +# POSTGRES_PASSWORD: #wrgd5688 #admin123 # leave it empty to make it autogenerated +# POSTGRES_PORT: "5432" +# POSTGRES_USER: postgres +# POSTGRESQL_ADMIN_PASSWORD: admin123 +# POSTGRES_HOST: bs1-db-service #placeholder -db-service \ No newline at end of file diff --git a/pkg/model/testdata/default-config/db-service.yaml b/pkg/model/testdata/default-config/db-service.yaml new file mode 100644 index 00000000..754b849d --- /dev/null +++ b/pkg/model/testdata/default-config/db-service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +metadata: + name: backstage-psql # placeholder for 'backstage-psql-' .NOTE: For the time it is static and linked to Secret-> postgres-secrets -> OSTGRES_HOST +spec: + selector: + rhdh.redhat.com/app: backstage-psql-cr1 # placeholder for 'backstage-psql-' + ports: + - port: 5432 diff --git a/pkg/model/testdata/default-config/db-statefulset.yaml b/pkg/model/testdata/default-config/db-statefulset.yaml new file mode 100644 index 00000000..f95020ab --- /dev/null +++ b/pkg/model/testdata/default-config/db-statefulset.yaml @@ -0,0 +1,101 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: backstage-psql-cr1 # placeholder for 'backstage-psql-' +spec: + podManagementPolicy: OrderedReady + replicas: 1 + selector: + matchLabels: + rhdh.redhat.com/app: backstage-psql-cr1 # placeholder for 'backstage-psql-' + serviceName: backstage-psql-cr1-hl # placeholder for 'backstage-psql--hl' + template: + metadata: + labels: + rhdh.redhat.com/app: backstage-psql-cr1 # placeholder for 'backstage-psql-' + name: backstage-db-cr1 # placeholder for 'backstage-psql-' + spec: + automountServiceAccountToken: false + containers: + - env: + - name: POSTGRESQL_PORT_NUMBER + value: "5432" + - name: POSTGRESQL_VOLUME_DIR + value: /var/lib/pgsql/data + - name: PGDATA + value: /var/lib/pgsql/data/userdata + image: quay.io/fedora/postgresql-15:latest + imagePullPolicy: IfNotPresent + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + seccompProfile: + type: RuntimeDefault + capabilities: + drop: + - ALL + livenessProbe: + exec: + command: + - /bin/sh + - -c + - exec pg_isready -U ${POSTGRES_USER} -h 127.0.0.1 -p 5432 + failureThreshold: 6 + initialDelaySeconds: 30 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 5 + name: postgresql + ports: + - containerPort: 5432 + name: tcp-postgresql + protocol: TCP + readinessProbe: + exec: + command: + - /bin/sh + - -c + - -e + - | + exec pg_isready -U ${POSTGRES_USER} -h 127.0.0.1 -p 5432 + failureThreshold: 6 + initialDelaySeconds: 5 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 5 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 250m + memory: 1024Mi + ephemeral-storage: 20Mi + volumeMounts: + - mountPath: /dev/shm + name: dshm + - mountPath: /var/lib/pgsql/data + name: data + restartPolicy: Always + securityContext: {} + serviceAccount: default + serviceAccountName: default + volumes: + - emptyDir: + medium: Memory + name: dshm + updateStrategy: + rollingUpdate: + partition: 0 + type: RollingUpdate + volumeClaimTemplates: + - apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi \ No newline at end of file diff --git a/pkg/model/testdata/default-config/deployment.yaml b/pkg/model/testdata/default-config/deployment.yaml new file mode 100644 index 00000000..6a6dba71 --- /dev/null +++ b/pkg/model/testdata/default-config/deployment.yaml @@ -0,0 +1,25 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backstage +spec: + replicas: 1 + selector: + matchLabels: + backstage.io/app: # placeholder for 'backstage-' + template: + metadata: + labels: + backstage.io/app: # placeholder for 'backstage-' + spec: + containers: + - name: backstage-backend # placeholder for 'backstage-backend' + image: ghcr.io/backstage/backstage + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 7007 + + + + diff --git a/pkg/model/testdata/default-config/service.yaml b/pkg/model/testdata/default-config/service.yaml new file mode 100644 index 00000000..e2c04838 --- /dev/null +++ b/pkg/model/testdata/default-config/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: backstage +spec: + type: NodePort + selector: + backstage.io/app: # placeholder for 'backstage-' + ports: + - name: http + port: 80 + targetPort: http \ No newline at end of file diff --git a/pkg/model/testdata/janus-db-statefulset.yaml b/pkg/model/testdata/janus-db-statefulset.yaml new file mode 100644 index 00000000..df262551 --- /dev/null +++ b/pkg/model/testdata/janus-db-statefulset.yaml @@ -0,0 +1,98 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: backstage-psql-cr1 # placeholder for 'backstage-psql-' +spec: + podManagementPolicy: OrderedReady + replicas: 1 + selector: + matchLabels: + rhdh.redhat.com/app: backstage-psql-cr1 # placeholder for 'backstage-psql-' + serviceName: backstage-psql-cr1-hl # placeholder for 'backstage-psql--hl' + template: + metadata: + labels: + rhdh.redhat.com/app: backstage-psql-cr1 # placeholder for 'backstage-psql-' + name: backstage-db-cr1 # placeholder for 'backstage-psql-' + spec: + containers: + - env: + - name: POSTGRESQL_PORT_NUMBER + value: "5432" + - name: POSTGRESQL_VOLUME_DIR + value: /var/lib/pgsql/data + - name: PGDATA + value: /var/lib/pgsql/data/userdata + image: quay.io/fedora/postgresql-15:latest # will be replaced with the actual image + imagePullPolicy: IfNotPresent + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + seccompProfile: + type: RuntimeDefault + capabilities: + drop: + - ALL + livenessProbe: + exec: + command: + - /bin/sh + - -c + - exec pg_isready -U ${POSTGRES_USER} -h 127.0.0.1 -p 5432 + failureThreshold: 6 + initialDelaySeconds: 30 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 5 + name: postgresql + ports: + - containerPort: 5432 + name: tcp-postgresql + protocol: TCP + readinessProbe: + exec: + command: + - /bin/sh + - -c + - -e + - | + exec pg_isready -U ${POSTGRES_USER} -h 127.0.0.1 -p 5432 + failureThreshold: 6 + initialDelaySeconds: 5 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 5 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + memory: 1024Mi + volumeMounts: + - mountPath: /dev/shm + name: dshm + - mountPath: /var/lib/pgsql/data + name: data + restartPolicy: Always + securityContext: {} + serviceAccount: default + serviceAccountName: default + volumes: + - emptyDir: + medium: Memory + name: dshm + updateStrategy: + rollingUpdate: + partition: 0 + type: RollingUpdate + volumeClaimTemplates: + - apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi \ No newline at end of file diff --git a/pkg/model/testdata/janus-deployment.yaml b/pkg/model/testdata/janus-deployment.yaml new file mode 100644 index 00000000..698c47b3 --- /dev/null +++ b/pkg/model/testdata/janus-deployment.yaml @@ -0,0 +1,99 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: # placeholder for 'backstage-' +spec: + replicas: 1 + selector: + matchLabels: + rhdh.redhat.com/app: # placeholder for 'backstage-' + template: + metadata: + labels: + rhdh.redhat.com/app: # placeholder for 'backstage-' + spec: + # serviceAccountName: default + volumes: + - ephemeral: + volumeClaimTemplate: + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + name: dynamic-plugins-root + - name: dynamic-plugins-npmrc + secret: + defaultMode: 420 + optional: true + secretName: dynamic-plugins-npmrc +# - name: dynamic-plugins-conf +# configMap: +# name: default-dynamic-plugins +# optional: true +# items: +# - key: dynamic-plugins.yaml +# path: dynamic-plugins.yaml + + initContainers: + - command: + - ./install-dynamic-plugins.sh + - /dynamic-plugins-root + env: + - name: NPM_CONFIG_USERCONFIG + value: /opt/app-root/src/.npmrc.dynamic-plugins + image: 'quay.io/janus-idp/backstage-showcase:next' + imagePullPolicy: IfNotPresent + name: install-dynamic-plugins + volumeMounts: + - mountPath: /dynamic-plugins-root + name: dynamic-plugins-root + - mountPath: /opt/app-root/src/.npmrc.dynamic-plugins + name: dynamic-plugins-npmrc + readOnly: true + subPath: .npmrc +# - mountPath: /opt/app-root/src/dynamic-plugins.yaml +# subPath: dynamic-plugins.yaml +# name: dynamic-plugins-conf + workingDir: /opt/app-root/src + + containers: + - name: backstage-backend # placeholder for 'backstage-backend' + image: quay.io/janus-idp/backstage-showcase:next + imagePullPolicy: IfNotPresent + args: + - "--config" + - "dynamic-plugins-root/app-config.dynamic-plugins.yaml" + readinessProbe: + failureThreshold: 3 + httpGet: + path: /healthcheck + port: 7007 + scheme: HTTP + initialDelaySeconds: 30 + periodSeconds: 10 + successThreshold: 2 + timeoutSeconds: 2 + livenessProbe: + failureThreshold: 3 + httpGet: + path: /healthcheck + port: 7007 + scheme: HTTP + initialDelaySeconds: 60 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 2 + ports: + - name: backend + containerPort: 7007 + env: + - name: APP_CONFIG_backend_listen_port + value: "7007" + volumeMounts: + - mountPath: /opt/app-root/src/dynamic-plugins-root + name: dynamic-plugins-root + + + diff --git a/pkg/model/testdata/raw-app-config.yaml b/pkg/model/testdata/raw-app-config.yaml new file mode 100644 index 00000000..65e17c70 --- /dev/null +++ b/pkg/model/testdata/raw-app-config.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-backstage-config-cm1 +data: + default.app-config.yaml: | + backend: + database: + connection: + password: ${POSTGRES_PASSWORD} + user: ${POSTGRES_USER} + auth: + keys: + # This is a default value, which you should change by providing your own app-config + - secret: "pl4s3Ch4ng3M3" \ No newline at end of file diff --git a/pkg/model/testdata/raw-cm-envs.yaml b/pkg/model/testdata/raw-cm-envs.yaml new file mode 100644 index 00000000..cd4f39c1 --- /dev/null +++ b/pkg/model/testdata/raw-cm-envs.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: +data: + ENV1: "env var 1" + ENV2: "env var 2" \ No newline at end of file diff --git a/pkg/model/testdata/raw-cm-files.yaml b/pkg/model/testdata/raw-cm-files.yaml new file mode 100644 index 00000000..f97275e0 --- /dev/null +++ b/pkg/model/testdata/raw-cm-files.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: # placeholder for '-dynamic-plugins' +data: + "dynamic-plugins123.yaml": | + includes: + - dynamic-plugins.default.yaml + plugins: [] \ No newline at end of file diff --git a/pkg/model/testdata/raw-dynamic-plugins.yaml b/pkg/model/testdata/raw-dynamic-plugins.yaml new file mode 100644 index 00000000..fb466757 --- /dev/null +++ b/pkg/model/testdata/raw-dynamic-plugins.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: default-dynamic-plugins # must be the same as (deployment.yaml).spec.template.spec.volumes.name.dynamic-plugins-conf.configMap.name +data: + "dynamic-plugins.yaml": | + includes: + - dynamic-plugins.default.yaml + plugins: [] \ No newline at end of file diff --git a/pkg/model/testdata/raw-route.yaml b/pkg/model/testdata/raw-route.yaml new file mode 100644 index 00000000..3dcdd4a1 --- /dev/null +++ b/pkg/model/testdata/raw-route.yaml @@ -0,0 +1,14 @@ +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: # placeholder for 'backstage-' +spec: + port: + targetPort: default + path: /default + tls: + insecureEdgeTerminationPolicy: Redirect + termination: edge + to: + kind: Service + name: # placeholder for 'backstage-' \ No newline at end of file diff --git a/pkg/model/testdata/raw-secret-files.yaml b/pkg/model/testdata/raw-secret-files.yaml new file mode 100644 index 00000000..68bda294 --- /dev/null +++ b/pkg/model/testdata/raw-secret-files.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: # to be generaated +stringData: + "dynamic-plugins321.yaml": | + includes: + - dynamic-plugins.default.yaml + plugins: [] \ No newline at end of file diff --git a/pkg/utils/pod-mutator.go b/pkg/utils/pod-mutator.go new file mode 100644 index 00000000..b9c5e133 --- /dev/null +++ b/pkg/utils/pod-mutator.go @@ -0,0 +1,118 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 utils + +import ( + "path/filepath" + + "k8s.io/utils/ptr" + + corev1 "k8s.io/api/core/v1" +) + +const ( + SecretObjectKind = "Secret" + ConfigMapObjectKind = "ConfigMap" +) + +type ObjectKind string + +type PodMutator struct { + PodSpec *corev1.PodSpec + Container *corev1.Container +} + +// MountFilesFrom adds Volume to specified podSpec and related VolumeMounts to specified belonging to this podSpec container +// from ConfigMap or Secret volume source +// podSpec - PodSpec to add Volume to +// container - container to add VolumeMount(s) to +// kind - kind of source, can be ConfigMap or Secret +// object name - name of source object +// mountPath - mount path, default one or as it specified in BackstageCR.spec.Application.AppConfig|ExtraFiles +// fileName - file name which fits one of the object's key, otherwise error will be returned. +// data - key:value pairs from the object. should be specified if fileName specified +func MountFilesFrom(podSpec *corev1.PodSpec, container *corev1.Container, kind ObjectKind, objectName, mountPath, fileName string, data map[string]string) { + + volName := GenerateVolumeNameFromCmOrSecret(objectName) + volSrc := corev1.VolumeSource{} + if kind == ConfigMapObjectKind { + volSrc.ConfigMap = &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: objectName}, + DefaultMode: ptr.To(int32(420)), + Optional: ptr.To(false), + } + } else if kind == SecretObjectKind { + volSrc.Secret = &corev1.SecretVolumeSource{ + SecretName: objectName, + DefaultMode: ptr.To(int32(420)), + Optional: ptr.To(false), + } + } + + podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{Name: volName, VolumeSource: volSrc}) + + if data != nil { + for file := range data { + if fileName == "" || fileName == file { + vm := corev1.VolumeMount{Name: volName, MountPath: filepath.Join(mountPath, file), SubPath: file, ReadOnly: true} + container.VolumeMounts = append(container.VolumeMounts, vm) + } + } + } else { + vm := corev1.VolumeMount{Name: volName, MountPath: filepath.Join(mountPath, objectName), ReadOnly: true} + container.VolumeMounts = append(container.VolumeMounts, vm) + } + +} + +func AddEnvVarsFrom(container *corev1.Container, kind ObjectKind, objectName, varName string) { + + if varName == "" { + envFromSrc := corev1.EnvFromSource{} + if kind == ConfigMapObjectKind { + envFromSrc.ConfigMapRef = &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: objectName}} + } else if kind == SecretObjectKind { + envFromSrc.SecretRef = &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: objectName}} + } + container.EnvFrom = append(container.EnvFrom, envFromSrc) + } else { + envVarSrc := &corev1.EnvVarSource{} + if kind == ConfigMapObjectKind { + envVarSrc.ConfigMapKeyRef = &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: objectName, + }, + Key: varName, + } + } else if kind == SecretObjectKind { + envVarSrc.SecretKeyRef = &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: objectName, + }, + Key: varName, + } + } + container.Env = append(container.Env, corev1.EnvVar{ + Name: varName, + ValueFrom: envVarSrc, + }) + } +} + +func SetDbSecretEnvVar(container *corev1.Container, secretName string) { + AddEnvVarsFrom(container, SecretObjectKind, secretName, "") +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 00000000..7aa3534c --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,114 @@ +// +// Copyright (c) 2023 Red Hat, Inc. +// 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 utils + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + + "k8s.io/client-go/discovery" + ctrl "sigs.k8s.io/controller-runtime" + + "k8s.io/apimachinery/pkg/util/yaml" +) + +func SetKubeLabels(labels map[string]string, backstageName string) map[string]string { + if labels == nil { + labels = map[string]string{} + } + labels["app.kubernetes.io/name"] = "backstage" + labels["app.kubernetes.io/instance"] = backstageName + + return labels +} + +// GenerateLabel generates backstage-{Id} for labels or selectors +func GenerateLabel(labels *map[string]string, name string, value string) { + if *labels == nil { + *labels = map[string]string{} + } + (*labels)[name] = value +} + +// GenerateRuntimeObjectName generates name using BackstageCR name and objectType which is ConfigObject Key without '.yaml' (like 'deployment') +func GenerateRuntimeObjectName(backstageCRName string, objectType string) string { + return fmt.Sprintf("%s-%s", backstageCRName, objectType) +} + +// GenerateVolumeNameFromCmOrSecret generates volume name for mounting ConfigMap or Secret +func GenerateVolumeNameFromCmOrSecret(cmOrSecretName string) string { + //return fmt.Sprintf("vol-%s", cmOrSecretName) + return cmOrSecretName +} + +func ReadYaml(manifest []byte, object interface{}) error { + dec := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(manifest), 1000) + if err := dec.Decode(object); err != nil { + return fmt.Errorf("failed to decode YAML: %w", err) + } + return nil +} + +func ReadYamlFile(path string, object interface{}) error { + fpath := filepath.Clean(path) + if _, err := os.Stat(fpath); err != nil { + return err + } + b, err := os.ReadFile(fpath) + if err != nil { + return fmt.Errorf("failed to read YAML file: %w", err) + } + return ReadYaml(b, object) +} + +func DefFile(key string) string { + return filepath.Join(os.Getenv("LOCALBIN"), "default-config", key) +} + +func GeneratePassword(length int) (string, error) { + buff := make([]byte, length) + if _, err := rand.Read(buff); err != nil { + return "", err + } + // Encode the password to prevent special characters + return base64.StdEncoding.EncodeToString(buff), nil +} + +// Automatically detects if the cluster the operator running on is OpenShift +func IsOpenshift() (bool, error) { + restConfig := ctrl.GetConfigOrDie() + dcl, err := discovery.NewDiscoveryClientForConfig(restConfig) + if err != nil { + return false, err + } + + apiList, err := dcl.ServerGroups() + if err != nil { + return false, err + } + + apiGroups := apiList.Groups + for i := 0; i < len(apiGroups); i++ { + if apiGroups[i].Name == "route.openshift.io" { + return true, nil + } + } + + return false, nil +} diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index a22cd10c..b2db9d4f 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -82,12 +82,16 @@ var _ = Describe("Backstage Operator E2E", func() { ExpectedHttpStatusCode: 200, BodyMatcher: SatisfyAll( ContainSubstring("backstage-plugin-catalog-backend-module-github-dynamic"), - ContainSubstring("@dfatwork-pkgs/scaffolder-backend-module-http-request-wrapped-dynamic"), - ContainSubstring("@dfatwork-pkgs/explore-backend-wrapped-dynamic"), - ), + ContainSubstring("backstage-plugin-techdocs-backend-dynamic"), + ContainSubstring("backstage-plugin-catalog-backend-module-gitlab-dynamic")), }, }, }, + { + name: "with custom DB auth secret", + crFilePath: filepath.Join("examples", "bs-existing-secret.yaml"), + crName: "bs-existing-secret", + }, } { tt := tt When(fmt.Sprintf("applying %s (%s)", tt.name, tt.crFilePath), func() { diff --git a/tests/helper/helper_backstage.go b/tests/helper/helper_backstage.go index 65622da3..94532e11 100644 --- a/tests/helper/helper_backstage.go +++ b/tests/helper/helper_backstage.go @@ -20,6 +20,7 @@ import ( "io" "net/http" "os/exec" + "redhat-developer/red-hat-developer-hub-operator/pkg/model" "strings" . "github.com/onsi/ginkgo/v2" @@ -65,7 +66,7 @@ func PatchBackstageCR(ns string, crName string, jsonPatch string, patchType stri } func DoesBackstageRouteExist(ns string, crName string) (bool, error) { - routeName := "backstage-" + crName + routeName := model.RouteName(crName) out, err := Run(exec.Command(GetPlatformTool(), "get", "route", routeName, "-n", ns)) // #nosec G204 if err != nil { if strings.Contains(string(out), fmt.Sprintf("%q not found", routeName)) { @@ -77,7 +78,7 @@ func DoesBackstageRouteExist(ns string, crName string) (bool, error) { } func GetBackstageRouteHost(ns string, crName string) (string, error) { - routeName := "backstage-" + crName + routeName := model.RouteName(crName) hostBytes, err := Run(exec.Command( GetPlatformTool(), "get", "route", routeName, "-o", "go-template={{if .spec.host}}{{.spec.host}}{{end}}", "-n", ns)) // #nosec G204 @@ -142,6 +143,7 @@ func VerifyBackstageRoute(g Gomega, ns string, crName string, tests []ApiEndpoin performTest := func(tt ApiEndpointTest) { url := fmt.Sprintf("https://%s/%s", host, strings.TrimPrefix(tt.Endpoint, "/")) + fmt.Fprintf(GinkgoWriter, "--> GET %q\n", url) resp, rErr := httpClient.Get(url) g.Expect(rErr).ShouldNot(HaveOccurred(), fmt.Sprintf("error while trying to GET %q", url)) defer resp.Body.Close()