From 0a8f41f7be18fc9f284ebdf624f3a76eed113d22 Mon Sep 17 00:00:00 2001 From: Gennady Azarenkov Date: Wed, 3 Apr 2024 19:14:36 +0300 Subject: [PATCH] Layered operator (#272) * yaml/configMap default configuration * fix make test * fix with new objects * fix with new objects * config small fixes * fix for https://github.com/janus-idp/operator/issues/51 * fix for https://github.com/janus-idp/operator/issues/58 * init next (design improvement) * initial model * initial model * initial * initial * initial * format and license * factory and pswd generator * delete onCreate handler * support configmapfiles, dynamic-plugins * initial model test framework * configurations * more comments and tests * add more tests, remove old logic of object creation * add more tests, remove old logic of object creation * add support of keys, integration tests passed * add support of keys, integration tests passed * fix npe * cm envs * tmp * maintain images env var * fix lint * remove unused params in status * fix make release-build * fix default images * several fixes * fix route.Spec.To.Name * fix image env vars * fix dynamic plugins * fix * remove ownership of depl, ss, service * DbSecret and Route * clean up * make test * clean db * status * fix gosec * cleanup * route fix * patch and route * fix lint * fix * working... * temp * refactor runtime * temp * temp * tmp * temp * dbsecret * fix * fix * operator-script * fix * test * fix * rename module * types * ctrl test fixed * ctrl test fixed * fix * container permissions * chore: gosec check is looking for a build stage, so give it one (#163) Signed-off-by: Nick Boldt * chore: only generate PR previews and next... (#161) * chore: only generate PR previews and next builds for paths listed in the GH action (exclude changes to doc, etc.) Signed-off-by: Nick Boldt * indent Signed-off-by: Nick Boldt * use a check-changes stage to set an env.CHANGES with either a list of changed files or a nullstring; if null, don't build anything Signed-off-by: Nick Boldt * run 'PR Publish' stage for all PRs, but if no changes, skip the subsequent setup/build/publish stages Signed-off-by: Nick Boldt --------- Signed-off-by: Nick Boldt * no-op to test if new PR check will skip... (#164) * no-op to test if new PR check will skip building container images for a readme update Signed-off-by: Nick Boldt must checkout before we can git diff, obviously Signed-off-by: Nick Boldt must checkout before we can git diff, obviously Signed-off-by: Nick Boldt * Update README.md --------- Signed-off-by: Nick Boldt * chore: multiline env var; explicitly check diff against HEAD~1 (#167) Signed-off-by: Nick Boldt * chore: skip the golang build if there's no... (#168) * chore: skip the golang build if there's no changes to the golang files (see regex) Signed-off-by: Nick Boldt * don't fail if nothing returned by grep Signed-off-by: Nick Boldt --------- Signed-off-by: Nick Boldt * chore: use multiline github env; check HEAD~1 for diff; reorder regexes (#170) Signed-off-by: Nick Boldt * chore: no auth needed to run tests (#171) Signed-off-by: Nick Boldt * move env.CHANGES check to substages as that's where env is defined (#173) Signed-off-by: Nick Boldt * bump to latest actions (node 16 -> 20) (#172) Signed-off-by: Nick Boldt * chore: move commit check into the same job as the build as it seems env vars do not cross job boundaries (#174) Signed-off-by: Nick Boldt * chore: fix: remove dep on other job (#175) Signed-off-by: Nick Boldt * chore: move commit check into the same job as the build as it seems env vars do not cross job boundaries; remove dep on other job (#176) Signed-off-by: Nick Boldt * Security mitigation: remove secret get from RBAC (#160) * Security mitigation: remove secret get from RBAC * Security migtigation: update the description for the custom image and extraFile secrets in the CRD * Security compliance: remove create and update from RBAC for PV and PVC * Code cleanup: remove unused clientset * chore: label every new issue with jira label (#181) * chore: bump csv to 1.2 in main Signed-off-by: Nick Boldt * chore: RHIDP-855 tweak csv/operator/subscription descriptions Signed-off-by: Nick Boldt * Add instructions for installing CI Builds and move install scripts here (#184) * Move CI Builds install script from personal gist to upstream repo * Add instructions for installing CI Builds of the RHDH operator * Reference the CI Builds instructions from the main install doc * Use single script rather than 2 nearly identical ones This is largely inspired from the installCatalogSourceFromIIB.sh script in the internal GitLab repo. Co-authored-by: Nick Boldt * Update .rhdh/scripts/install-rhdh-catalog-source.sh * Apply suggestions from code review Co-authored-by: Nick Boldt * Fix undeclared var: INSTALL_PLAN_APPROVAL Co-authored-by: Nick Boldt * Update install script help output * Update .rhdh/scripts/install-rhdh-catalog-source.sh * Apply suggestions from code review Co-authored-by: Nick Boldt --------- Co-authored-by: Nick Boldt * chore: RHIDP-855 rename the operator to append 'Operator' on it; relabel the CRD/Backstage instance as 'Red Hat Developer Hub' with a more detailed description too (#189) Signed-off-by: Nick Boldt * Documentation for security mitigation (#182) * Documemtation for security mitigation * rename openshift-rhdh-operator to rhdh-operator for suggested namespace * Update docs/admin.md --------- Co-authored-by: Armel Soro * Add script and docs for air-gapped/restricted env setup (#183) * feat: new script for restricted env setup - fetch dev hub images and related images from the index, and mirror to a cluster's internal registry TODO: fix the skopeo copy step - not working :( Signed-off-by: Nick Boldt * Add script to deploy and expose mirror registry into the cluster * 'skopeo copy' now working with deployed mirror registry * Replace 'registry.redhat.io/rhdh/*' with 'quay.io/rhdh/*', as those images are not public yet? * Add steps for deploying mirror registry in the same prepare-restricted-environment.sh script, using a 'use_existing_mirror_registry' option Co-authored-by: Nick Boldt * Delete previous deploy-mirror-registry.sh script * Update .gitignore * Move prepare-restricted-environment.sh to .rhdh/scripts * Make helper mirror registry storage capacity configurable This is to allow running it on CRC, where storage might depend on CRC VM. * Use right OCP major version for release image * Change condition for replacing non-public CI images with quay.io This script should work for customers installing GA version (1.1+) to their airgapped environment. We also do the replacement only for rhdh images, and only if the image manifest does not exist, which would likely mean that the image is not public yet. * Force-recreate the helper mirror registry Deployment Generated registry password will change if we run the script twice. So we won't be able to login using the new password. * Clean prepare-restricted-environment.sh script * Add docs * fixup! Add docs * Update .rhdh/scripts/prepare-restricted-environment.sh Co-authored-by: Jianrong Zhang Co-authored-by: Nick Boldt --------- Signed-off-by: Nick Boldt Co-authored-by: Armel Soro * Fix sonarlint vulnerabilities (initial) (#185) * fix sonarlint issues (initial) * increase limits * Update config/manager/manager.yaml --------- Co-authored-by: Armel Soro * Avoid hardcoded images (#187) * remove hardcoded images * fix image * Update examples/janus-cr-with-app-configs.yaml Co-authored-by: Armel Soro * change lookup * Update config/manager/default-config/db-statefulset.yaml Co-authored-by: Armel Soro * Update config/manager/default-config/deployment.yaml Co-authored-by: Armel Soro * change lookup * change lookup * Update config/manager/default-config/deployment.yaml Co-authored-by: Armel Soro * add generated files * fix image --------- Co-authored-by: Armel Soro * Port latest changes (automountServiceAccountToken and ephemeral storage limit) to downstream CSV for RHDH (#197) This is an addendum commit to https://github.com/janus-idp/operator/pull/185 * Fix service raw configuration (#203) * remove hardcoded images * fix image * Update examples/janus-cr-with-app-configs.yaml Co-authored-by: Armel Soro * change lookup * Update config/manager/default-config/db-statefulset.yaml Co-authored-by: Armel Soro * Update config/manager/default-config/deployment.yaml Co-authored-by: Armel Soro * change lookup * change lookup * Update config/manager/default-config/deployment.yaml Co-authored-by: Armel Soro * add generated files * fix image * fix service raw config --------- Co-authored-by: Armel Soro * Set `VERSION` to `0.1.0-dev` in Makefile for `main` branch (#207) As discussed in [1], it would make sense to use different `VERSION` on `main` and release branches. [1] https://github.com/janus-idp/operator/pull/200#discussion_r1489312876 * Fix tags for images built for main and release branches (#208) As discussed in [1], this would allow to run `make deploy` out of the box, as the image corresponding to the VERSION in Makefile would be present. [1] https://github.com/janus-idp/operator/pull/200#discussion_r1489312876 * Replace operator API group janus-idp.io with rhdh.redhat.com (#201) * Replace operator API group janus-idp.io with rhdh.redhat.com * change to use module redhat-developer/red-hat-developer-hub-operator * Remove files that were checked in by mistake * Update examples/rhdh-cr.yaml Co-authored-by: Armel Soro * Update examples/rhdh-cr-with-app-configs.yaml Co-authored-by: Armel Soro * Update config/manifests/bases/backstage-operator.clusterserviceversion.yaml Co-authored-by: Armel Soro --------- Co-authored-by: Armel Soro * Add warning note in install docs about OpenShift clusters with hosted control planes * Fix diff computation for PR container builds If a PR branch contained several commits but its HEAD had changes to some files not relevant for container build, the no image would be built completely for that PR * Fix generated CSV (#212) * Set `VERSION` to `0.2.0` in Makefile for `main` branch (#213) It makes sense to align to the product version at this time: ``` upstream main == 0.2.0 upstream 1.1.x branch == 0.1.0 downstream rhdh-1-rhel-9 branch == 1.2.0 downstream rhdh-1.1-rhel-9 branch == 1.1.0 ``` * Fix typo (#214) Signed-off-by: Moti Asayag * update dependencies (#215) * update dependencies Signed-off-by: Kim Tsao * address review comments Signed-off-by: Kim Tsao --------- Signed-off-by: Kim Tsao * [ci skip] chore: enable renovate for dockerfile and golang updates (#216) Signed-off-by: Nick Boldt * chore(deps): update actions/cache action to v4 (#220) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update docker/login-action action to v3 (#223) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update actions/github-script action to v7 (#222) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * bump dockerfiles per renovate bot PR #219 (#224) Signed-off-by: Nick Boldt * chore: enable digest pinning and major updates in dockerfiles; attempt to split go and docker into separate updates (different branch prefixes) (#225) Signed-off-by: Nick Boldt * Update renovate.json - remove non-working code (#227) * Update renovate.json - don't pin digests in dockerfile as it creates something that skopeo can't read (and likely breaks OSBS) (#230) * chore(deps): pin dependencies (#228) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update github/codeql-action digest to 47b3d88 (#234) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * feat(seamless) chore: add `skipranges` and `replaces` logic TODOs to CSV (#231) * feat(seamless) chore: add skipranges and replaces logic TODOs, which we can enable when 0.1 and 1.1 are live alternatively, we could enable this sooner but then to install 1.2 you have to FIRST install 1.1, etc. Signed-off-by: Nick Boldt * apply same change to config/manifests/bases/backstage-operator.clusterserviceversion.yaml Signed-off-by: Nick Boldt --------- Signed-off-by: Nick Boldt * Add E2E tests using our examples against real clusters (#204) * Add E2E tests against our examples on real clusters - Do not error out when deleting a non-existing namespace - Stream command output to the GinkgoWriter in real-time as well This allows following what happens when calling potentially long-running commands - Implement airgap test mode - Ignore error when creating a namespace that already exists - Allow to use existing mirror registry in airgap scenario - Extract constants for test modes - Add documentation - Find an easier way to determine the IMG variable, using the Makefile - Add more examples to README.md - Add note about clusters with hosted control planes - Support k3d clusters - Support Minikube clusters - Load image into local clusters using an archive instead This allows this logic to be agnostic to the container engine used to build the image. We rely on the container image to export the image to an archive ('{podman,docker} image save'). - Run E2E test nightly on main and release branch * Try running E2E tests on PRs by leveraging the already built operator image * Revert "Try running E2E tests on PRs by leveraging the already built operator image" This reverts commit fc87e04ee419a9b4a27002ede9c5972128ea832a. * Check if image exists locally before trying to export an archive If not, try to pull it automatically. This would avoid having to manually pull it. * Update README.md Co-authored-by: Gennady Azarenkov * Ignore gosec warnings in test code Those are not used in production * Clarify in README that a connection to a cluster in the current kubeconfig is needed * Increase timeout when waiting for controller to be up On fresh clusters, 1 minute might be too short * fixup! Clarify in README that a connection to a cluster in the current kubeconfig is needed --------- Co-authored-by: Gennady Azarenkov * chore(deps): pin actions/checkout action to b4ffde6 (#235) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update docker/setup-buildx-action digest to 0d103c3 (#239) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * fix: increase default size of the dynamic-plugins-root volume from 1Gi to 2Gi (#238) * fix: increase default size of the dynamic-plugins-root volume from 1Gi to 2Gi This applies the same fix done in the Helm Chart [1]. As depicted in [2], the init container might fail with insufficient space error: ``` ======= Installing dynamic plugin ./dynamic-plugins/dist/backstage-plugin-scaffolder-backend-module-github-dynamic ==> Grabbing package archive through `npm pack` Traceback (most recent call last): File "/opt/app-root/src/install-dynamic-plugins.py", line 304, in main() File "/opt/app-root/src/install-dynamic-plugins.py", line 230, in main raise InstallException(f'Error while installing plugin \{ package } with \'npm pack\' : ' + completed.stderr.decode('utf-8')) __main__.InstallException: Error while installing plugin /opt/app-root/src/dynamic-plugins/dist/backstage-plugin-scaffolder-backend-module-github-dynamic with 'npm pack' : npm notice npm notice New major version of npm available! 9.8.1 -> 10.4.0 npm notice Changelog: npm notice Run `npm install -g npm@10.4.0` to update! npm notice npm ERR! code ENOSPC npm ERR! syscall open npm ERR! path /dynamic-plugins-root/backstage-plugin-scaffolder-backend-module-github-dynamic-0.2.0-next.3.tgz npm ERR! errno -28 npm ERR! nospc ENOSPC: no space left on device, open '/dynamic-plugins-root/backstage-plugin-scaffolder-backend-module-github-dynamic-0.2.0-next.3.tgz' npm ERR! nospc There appears to be insufficient space on your system to finish. npm ERR! nospc Clear up some disk space and try again. ``` [1] https://github.com/redhat-developer/rhdh-chart/pull/5 [2] https://issues.redhat.com/browse/RHIDP-1332 * Add test * chore: RHIDP-1105 fix bundle annotations to be version agnostic; transform downstream (#244) Signed-off-by: Nick Boldt * Generate deployment manifest (#242) * remove hardcoded images * fix image * Update examples/janus-cr-with-app-configs.yaml Co-authored-by: Armel Soro * change lookup * Update config/manager/default-config/db-statefulset.yaml Co-authored-by: Armel Soro * Update config/manager/default-config/deployment.yaml Co-authored-by: Armel Soro * change lookup * change lookup * Update config/manager/default-config/deployment.yaml Co-authored-by: Armel Soro * add generated files * fix image * fix service raw config * operator-script * Update Makefile Co-authored-by: Armel Soro * fix * Apply suggestions from code review --------- Co-authored-by: Armel Soro * chore: RHIDP-1105 switch annotations.yaml back to use fast channels; clean up comments (#246) * chore: RHIDP-1105 switch annotations.yaml back to use fast channels Signed-off-by: RHDH Build (rhdh-bot) * clean up comments Signed-off-by: RHDH Build (rhdh-bot) --------- Signed-off-by: RHDH Build (rhdh-bot) Co-authored-by: RHDH Build (rhdh-bot) * chore(deps): update actions/cache digest to ab5e6d0 (#248) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update github/codeql-action digest to 8a470fd (#247) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Auto-push bundle manifests changes to PR branch if needed (#195) * Make PR checks fail if bundle or manifests are not up-to-date This is so that PR authors do not forget to regenerate those manifests. * Update developer guide * Save diff as patch file, so it can be downloaded and applied with Git * Fix step names in PR Validation job * Apply suggestions from code review Co-authored-by: Jianrong Zhang * Do not error out if bundle manifests are outdated Display warnings instead. Also comment on the PR so that authors/reviewers are aware of that fact. Co-authored-by: Gennady Azarenkov * Update .github/workflows/pr.yaml Co-authored-by: Nick Boldt * Revert "Do not error out if bundle manifests are outdated" This reverts commit ab2c12a64975ec258d95198b3431cfbf8df80a8d. * Auto-push any changes to the bundle manifests This will alleviate the burden on contributors and maintainers. * Run bundle diff checker in separate workflow triggered on 'pull_request_target' events This is required to be able to write to fork PR branches Similar to what we do already with the pull_request_target workflows, we also require manual authorization for unknown external forks, to prevent PWN requests * Update PR template to think about eventually updating the rhdh-operator.csv.yaml file * Update .github/workflows/pr-bundle-diff-checks.yaml * Update docs/developer.md Co-authored-by: Gennady Azarenkov --------- Co-authored-by: Jianrong Zhang Co-authored-by: Gennady Azarenkov Co-authored-by: Nick Boldt Co-authored-by: Gennady Azarenkov * chore(CI): Fix PR Bundle diff checker GH workflow * chore(deps): pin dependencies (#249) * chore(deps): pin dependencies Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Regenerate bundle manifests Co-authored-by: renovate[bot] --------- Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: renovate[bot] * fix(deps): update k8s.io/utils digest to e7106e6 (#232) * fix(deps): update k8s.io/utils digest to e7106e6 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Replace deprecated usage of "k8s.io/utils/pointer" with "k8s.io/utils/ptr" --------- Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Armel Soro * chore(deps): update docker/build-push-action digest to af5a7ed (#250) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * fix(deps): update k8s.io/utils digest to 4693a02 (#253) * fix(deps): update k8s.io/utils digest to 4693a02 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Clean-up go.sum with 'go mod tidy' --------- Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Armel Soro * layered * layered * chore(deps): update actions/checkout digest to 9bb5618 (#255) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update actions/checkout digest to b4ffde6 (#256) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update github/codeql-action digest to 3ab4101 (#257) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Bump Ginkgo to v2.16.0 (#251) * chore(deps): update docker/login-action digest to e92390c (#258) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update docker/build-push-action digest to 2cdde99 (#259) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update docker/setup-buildx-action digest to 2b51285 (#260) Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * fix(deps): update all non-major dependencies (#233) * fix(deps): update all non-major dependencies Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Fix breaking changes from sigs.k8s.io/controller-runtime update - `ctrl.Options#MetricsBindAddress` (TCP address that the controller should bind to for serving prometheus metrics) was deprecated and has been replaced with `metricsserver.Options#BindAddress` (in a `Metrics` struct) [1] - `crl.Options#Port` (port that the webhook server serves at) was deprecated and has been replaced with `webhook.Options#Port` (in a `WebhookServer` field) [2] [1] https://github.com/kubernetes-sigs/controller-runtime/commit/e59161ee8f41b2f24953419533dca84d30262c21#diff-d500fbd6a2aa620607ca5e2a7c3ac4f1a4c82309d1a549561e92abfcb18f2f0eL222-L225 [2] https://github.com/kubernetes-sigs/controller-runtime/commit/e92eadb0d7f5b0d11aa782ecefaf83057243abc5#diff-d500fbd6a2aa620607ca5e2a7c3ac4f1a4c82309d1a549561e92abfcb18f2f0eL282-L286 --------- Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Armel Soro * fix(deps): update github.com/openshift/api digest to 4caef7f (#229) * fix(deps): update github.com/openshift/api digest to 4caef7f Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Tidy up dependencies with 'go mod tidy' --------- Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Armel Soro * gomod * gomod * nextv2 * Regenerate bundle manifests Co-authored-by: gazarenkov * fix lint * fix lint * fix sonar issues * fix minor sonar issues * fix e2e tests * fix e2e and add external db secret test * small fixes * small fixes * merge * Regenerate bundle manifests Co-authored-by: gazarenkov * Update examples/rhdh-cr-with-app-configs.yaml Co-authored-by: Armel Soro * Update Makefile Co-authored-by: Armel Soro * Update Makefile --------- Signed-off-by: Nick Boldt Signed-off-by: Moti Asayag Signed-off-by: Kim Tsao Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: RHDH Build (rhdh-bot) Co-authored-by: Nick Boldt Co-authored-by: Jianrong Zhang Co-authored-by: Tomas Kral Co-authored-by: Armel Soro Co-authored-by: Armel Soro Co-authored-by: Moti Asayag Co-authored-by: Kim Tsao <84398375+kim-tsao@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: RHDH Build (rhdh-bot) Co-authored-by: Jianrong Zhang Co-authored-by: Gennady Azarenkov Co-authored-by: github-actions[bot] Co-authored-by: renovate[bot] Co-authored-by: gazarenkov --- Makefile | 17 +- README.md | 2 +- api/v1alpha1/backstage_types.go | 58 ++- api/v1alpha1/zz_generated.deepcopy.go | 20 +- ...backstage-default-config_v1_configmap.yaml | 95 +++-- ...kstage-operator.clusterserviceversion.yaml | 8 +- .../manifests/rhdh.redhat.com_backstages.yaml | 3 +- .../crd/bases/rhdh.redhat.com_backstages.yaml | 3 +- config/default/kustomization.yaml | 2 +- config/manager/default-config/app-config.yaml | 15 + .../backend-auth-configmap.yaml | 11 - .../default-config/configmap-envs.yaml.sample | 7 + ...igmap.yaml => configmap-files.yaml.sample} | 2 +- config/manager/default-config/db-secret.yaml | 15 +- .../default-config/db-statefulset.yaml | 17 +- config/manager/default-config/deployment.yaml | 34 +- .../default-config/dynamic-plugins.yaml | 9 + config/manager/default-config/route.yaml | 2 +- .../manager/default-config/secret-envs.yaml | 8 + .../default-config/secret-files.yaml.sample | 9 + config/manager/default-config/service.yaml | 2 +- config/manager/kustomization.yaml | 15 +- config/rbac/role.yaml | 6 + controllers/backstage_app_config.go | 173 --------- controllers/backstage_backend_auth.go | 77 ---- controllers/backstage_controller.go | 341 ++++++++---------- controllers/backstage_controller_test.go | 269 ++++++++------ controllers/backstage_deployment.go | 238 ------------ controllers/backstage_dynamic_plugins.go | 125 ------- controllers/backstage_extra_envs.go | 81 ----- controllers/backstage_extra_files.go | 138 ------- controllers/backstage_route.go | 146 -------- controllers/backstage_service.go | 103 ------ controllers/local_db_secret.go | 111 ------ controllers/local_db_services.go | 92 ----- controllers/local_db_statefulset.go | 164 --------- controllers/local_db_storage.go | 65 ---- controllers/spec_preprocessor.go | 115 ++++++ examples/bs-existing-secret.yaml | 2 +- examples/bs1.yaml | 7 +- examples/postgres-secret.yaml | 9 +- examples/rhdh-cr-with-app-configs.yaml | 27 +- go.mod | 4 +- go.sum | 1 + integration_tests/README.md | 24 ++ integration_tests/cr-config_test.go | 218 +++++++++++ integration_tests/default-config_test.go | 149 ++++++++ integration_tests/matchers.go | 185 ++++++++++ integration_tests/rhdh-config_test.go | 67 ++++ integration_tests/route_test.go | 88 +++++ integration_tests/suite_test.go | 256 +++++++++++++ .../testdata/raw-deployment.yaml | 19 + .../testdata/raw-statefulset.yaml | 18 + integration_tests/utils.go | 64 ++++ main.go | 2 +- pkg/model/appconfig.go | 123 +++++++ pkg/model/appconfig_test.go | 161 +++++++++ pkg/model/configmapenvs.go | 97 +++++ pkg/model/configmapenvs_test.go | 95 +++++ pkg/model/configmapfiles.go | 106 ++++++ pkg/model/configmapfiles_test.go | 123 +++++++ pkg/model/db-secret.go | 100 +++++ pkg/model/db-secret_test.go | 96 +++++ pkg/model/db-service.go | 89 +++++ pkg/model/db-statefulset.go | 117 ++++++ pkg/model/db-statefulset_test.go | 60 +++ pkg/model/deployment.go | 208 +++++++++++ pkg/model/deployment_test.go | 82 +++++ pkg/model/dynamic-plugins.go | 152 ++++++++ pkg/model/dynamic-plugins_test.go | 132 +++++++ pkg/model/interfaces.go | 61 ++++ pkg/model/model_tests.go | 96 +++++ pkg/model/route.go | 147 ++++++++ pkg/model/route_test.go | 192 ++++++++++ pkg/model/runtime.go | 198 ++++++++++ pkg/model/runtime_test.go | 138 +++++++ pkg/model/secretenvs.go | 98 +++++ pkg/model/secretfiles.go | 115 ++++++ pkg/model/secretfiles_test.go | 127 +++++++ pkg/model/service.go | 82 +++++ pkg/model/testdata/db-defined-secret.yaml | 12 + pkg/model/testdata/db-empty-secret.yaml | 2 + pkg/model/testdata/db-generated-secret.yaml | 11 + .../testdata/default-config/db-secret.yaml | 12 + .../testdata/default-config/db-service.yaml | 9 + .../default-config/db-statefulset.yaml | 101 ++++++ .../testdata/default-config/deployment.yaml | 25 ++ .../testdata/default-config/service.yaml | 12 + pkg/model/testdata/janus-db-statefulset.yaml | 98 +++++ pkg/model/testdata/janus-deployment.yaml | 99 +++++ pkg/model/testdata/raw-app-config.yaml | 15 + pkg/model/testdata/raw-cm-envs.yaml | 7 + pkg/model/testdata/raw-cm-files.yaml | 9 + pkg/model/testdata/raw-dynamic-plugins.yaml | 9 + pkg/model/testdata/raw-route.yaml | 14 + pkg/model/testdata/raw-secret-files.yaml | 9 + pkg/utils/pod-mutator.go | 118 ++++++ pkg/utils/utils.go | 114 ++++++ tests/e2e/e2e_test.go | 10 +- tests/helper/helper_backstage.go | 6 +- 100 files changed, 5473 insertions(+), 1952 deletions(-) create mode 100644 config/manager/default-config/app-config.yaml delete mode 100644 config/manager/default-config/backend-auth-configmap.yaml create mode 100644 config/manager/default-config/configmap-envs.yaml.sample rename config/manager/default-config/{dynamic-plugins-configmap.yaml => configmap-files.yaml.sample} (77%) create mode 100644 config/manager/default-config/dynamic-plugins.yaml create mode 100644 config/manager/default-config/secret-envs.yaml create mode 100644 config/manager/default-config/secret-files.yaml.sample delete mode 100644 controllers/backstage_app_config.go delete mode 100644 controllers/backstage_backend_auth.go delete mode 100644 controllers/backstage_deployment.go delete mode 100644 controllers/backstage_dynamic_plugins.go delete mode 100644 controllers/backstage_extra_envs.go delete mode 100644 controllers/backstage_extra_files.go delete mode 100644 controllers/backstage_route.go delete mode 100644 controllers/backstage_service.go delete mode 100644 controllers/local_db_secret.go delete mode 100644 controllers/local_db_services.go delete mode 100644 controllers/local_db_statefulset.go delete mode 100644 controllers/local_db_storage.go create mode 100644 controllers/spec_preprocessor.go create mode 100644 integration_tests/README.md create mode 100644 integration_tests/cr-config_test.go create mode 100644 integration_tests/default-config_test.go create mode 100644 integration_tests/matchers.go create mode 100644 integration_tests/rhdh-config_test.go create mode 100644 integration_tests/route_test.go create mode 100644 integration_tests/suite_test.go create mode 100644 integration_tests/testdata/raw-deployment.yaml create mode 100644 integration_tests/testdata/raw-statefulset.yaml create mode 100644 integration_tests/utils.go create mode 100644 pkg/model/appconfig.go create mode 100644 pkg/model/appconfig_test.go create mode 100644 pkg/model/configmapenvs.go create mode 100644 pkg/model/configmapenvs_test.go create mode 100644 pkg/model/configmapfiles.go create mode 100644 pkg/model/configmapfiles_test.go create mode 100644 pkg/model/db-secret.go create mode 100644 pkg/model/db-secret_test.go create mode 100644 pkg/model/db-service.go create mode 100644 pkg/model/db-statefulset.go create mode 100644 pkg/model/db-statefulset_test.go create mode 100644 pkg/model/deployment.go create mode 100644 pkg/model/deployment_test.go create mode 100644 pkg/model/dynamic-plugins.go create mode 100644 pkg/model/dynamic-plugins_test.go create mode 100644 pkg/model/interfaces.go create mode 100644 pkg/model/model_tests.go create mode 100644 pkg/model/route.go create mode 100644 pkg/model/route_test.go create mode 100644 pkg/model/runtime.go create mode 100644 pkg/model/runtime_test.go create mode 100644 pkg/model/secretenvs.go create mode 100644 pkg/model/secretfiles.go create mode 100644 pkg/model/secretfiles_test.go create mode 100644 pkg/model/service.go create mode 100644 pkg/model/testdata/db-defined-secret.yaml create mode 100644 pkg/model/testdata/db-empty-secret.yaml create mode 100644 pkg/model/testdata/db-generated-secret.yaml create mode 100644 pkg/model/testdata/default-config/db-secret.yaml create mode 100644 pkg/model/testdata/default-config/db-service.yaml create mode 100644 pkg/model/testdata/default-config/db-statefulset.yaml create mode 100644 pkg/model/testdata/default-config/deployment.yaml create mode 100644 pkg/model/testdata/default-config/service.yaml create mode 100644 pkg/model/testdata/janus-db-statefulset.yaml create mode 100644 pkg/model/testdata/janus-deployment.yaml create mode 100644 pkg/model/testdata/raw-app-config.yaml create mode 100644 pkg/model/testdata/raw-cm-envs.yaml create mode 100644 pkg/model/testdata/raw-cm-files.yaml create mode 100644 pkg/model/testdata/raw-dynamic-plugins.yaml create mode 100644 pkg/model/testdata/raw-route.yaml create mode 100644 pkg/model/testdata/raw-secret-files.yaml create mode 100644 pkg/utils/pod-mutator.go create mode 100644 pkg/utils/utils.go 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 983b6065..b5c85082 100644 --- a/go.sum +++ b/go.sum @@ -104,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= 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()