From 99a308c346ade87e84fa6171562948a38ebb2f57 Mon Sep 17 00:00:00 2001 From: moelsayed Date: Wed, 12 Mar 2025 11:40:35 +0100 Subject: [PATCH 1/6] Squashed commit of the following: Update README add release action clean up Makefile update chart and chart template remove golangci action add ci action remove unused config rename module initial merge from cloud-orchestration/control-plane-operator --- .envrc | 4 + .github/workflows/ci.yaml | 33 + .github/workflows/release.yaml | 87 ++ .gitignore | 44 + .golangci.yml | 39 + .vscode/launch.json | 17 + .vscode/settings.json | 5 + .vscode/tasks.json | 28 + Dockerfile | 9 + Makefile | 288 ++++ PROJECT | 36 + README.md | 245 ++- VERSION | 1 + api/v1beta1/btpso_component_types.go | 16 + api/v1beta1/cert_manager_component_types.go | 16 + api/v1beta1/components_types.go | 33 + api/v1beta1/controlplane_types.go | 240 +++ api/v1beta1/crossplane_component_types.go | 51 + .../crossplanepackagerestriction_types.go | 64 + api/v1beta1/eso_component_types.go | 16 + api/v1beta1/flux_component_types.go | 16 + api/v1beta1/groupversion_info.go | 36 + api/v1beta1/kyverno_component_types.go | 16 + api/v1beta1/releasechannel_types.go | 102 ++ api/v1beta1/zz_generated.deepcopy.go | 698 +++++++++ charts/control-plane-operator/.helmignore | 24 + charts/control-plane-operator/Chart.yaml | 9 + charts/control-plane-operator/Chart.yaml.tpl | 9 + .../templates/.gitignore | 2 + .../templates/_helpers.tpl | 62 + .../templates/deployment.yaml | 230 +++ .../templates/rbac.yaml | 66 + .../templates/secret-webhooks.yaml | 9 + .../templates/service-metrics.yaml | 17 + .../templates/service-webhooks.yaml | 17 + .../templates/serviceaccount.yaml | 13 + charts/control-plane-operator/values.yaml | 155 ++ charts/control-plane-operator/values.yaml.tpl | 155 ++ ...e.orchestrate.cloud.sap_controlplanes.yaml | 539 +++++++ ...oud.sap_crossplanepackagerestrictions.yaml | 108 ++ ...orchestrate.cloud.sap_releasechannels.yaml | 155 ++ cmd/main.go | 229 +++ cmd/options/options.go | 29 + ...e.orchestrate.cloud.sap_controlplanes.yaml | 539 +++++++ ...oud.sap_crossplanepackagerestrictions.yaml | 108 ++ ...orchestrate.cloud.sap_releasechannels.yaml | 155 ++ config/crd/kustomization.yaml | 28 + config/crd/kustomizeconfig.yaml | 19 + config/default/kustomization.yaml | 142 ++ config/default/manager_auth_proxy_patch.yaml | 39 + config/default/manager_config_patch.yaml | 10 + config/helm-default/kustomization.yaml | 9 + config/manager/kustomization.yaml | 8 + config/manager/manager.yaml | 102 ++ config/prometheus/kustomization.yaml | 2 + config/prometheus/monitor.yaml | 25 + .../rbac/auth_proxy_client_clusterrole.yaml | 16 + config/rbac/auth_proxy_role.yaml | 24 + config/rbac/auth_proxy_role_binding.yaml | 19 + config/rbac/auth_proxy_service.yaml | 21 + config/rbac/controlplane_editor_role.yaml | 31 + config/rbac/controlplane_viewer_role.yaml | 27 + ...ssplanepackagerestriction_editor_role.yaml | 27 + ...ssplanepackagerestriction_viewer_role.yaml | 23 + config/rbac/kustomization.yaml | 25 + config/rbac/leader_election_role.yaml | 44 + config/rbac/leader_election_role_binding.yaml | 19 + config/rbac/releasechannel_editor_role.yaml | 31 + config/rbac/releasechannel_viewer_role.yaml | 27 + config/rbac/role.yaml | 32 + config/rbac/role_binding.yaml | 19 + config/rbac/service_account.yaml | 12 + config/samples/controlplane_kubeconfig.yaml | 41 + .../samples/controlplane_kubeconfigRef.yaml | 69 + config/samples/controlplane_local.yaml | 38 + config/samples/controlplane_psat.yaml | 45 + config/samples/kustomization.yaml | 6 + config/samples/releasechannel/local.yaml | 11 + .../v1beta1_crossplanepackagerestriction.yaml | 20 + e2e.env | 1 + go.mod | 350 +++++ go.sum | 1338 +++++++++++++++++ hack/boilerplate.go.txt | 15 + hack/create-token-opt.sh | 23 + hack/goimports_version | 1 + hack/jwt-expired.sh | 23 + hack/userid.sh | 9 + .../controller/controlplane_controller.go | 463 ++++++ .../controlplane_controller_test.go | 378 +++++ internal/controller/kubeconfigs.go | 84 ++ .../controller/releasechannel_controller.go | 149 ++ .../releasechannel_controller_test.go | 272 ++++ internal/controller/remote_config_builder.go | 15 + .../controller/remote_config_builder_test.go | 58 + internal/controller/secret_controller.go | 173 +++ internal/controller/secret_controller_test.go | 268 ++++ internal/controller/utils.go | 50 + internal/controller/utils_test.go | 54 + internal/ocm/ocm.go | 210 +++ internal/ocm/ocm_test.go | 145 ++ internal/ocm/utils.go | 74 + internal/schemes/schemes.go | 56 + pkg/constants/annotation.go | 5 + pkg/constants/label.go | 8 + pkg/constants/ocm.go | 6 + .../components/btpso_component.go | 196 +++ .../components/btpso_component_test.go | 76 + .../components/cert_manager_component.go | 165 ++ .../components/cert_manager_component_test.go | 73 + .../components/clusterrole_component.go | 122 ++ .../components/clusterrole_component_test.go | 106 ++ .../components/clusterroles/clusterroles.go | 26 + .../clusterroles/clusterroles_test.go | 15 + pkg/controlplane/components/common.go | 39 + pkg/controlplane/components/crds/crds.go | 113 ++ pkg/controlplane/components/crds/crds_test.go | 53 + ...e.orchestrate.cloud.sap_controlplanes.yaml | 539 +++++++ ...oud.sap_crossplanepackagerestrictions.yaml | 108 ++ .../components/crossplane_component.go | 230 +++ .../components/crossplane_component_test.go | 72 + ...splanedeploymentruntimeconfig_component.go | 190 +++ ...edeploymentruntimeconfig_component_test.go | 222 +++ .../crossplaneprovider_component.go | 149 ++ .../crossplaneprovider_component_test.go | 226 +++ pkg/controlplane/components/eso_component.go | 178 +++ .../components/eso_component_test.go | 75 + pkg/controlplane/components/flux_component.go | 164 ++ .../components/flux_component_test.go | 75 + .../components/generic_object_component.go | 103 ++ .../generic_object_component_test.go | 113 ++ .../components/kyverno_component.go | 202 +++ .../components/kyverno_component_test.go | 75 + .../components/policies/Readme.md | 13 + .../components/policies/policies.go | 139 ++ .../components/policies/policies_test.go | 25 + .../components/secret_component.go | 139 ++ .../components/secret_component_test.go | 173 +++ pkg/controlplane/components/utils_test.go | 302 ++++ pkg/controlplane/crossplane/policies.go | 388 +++++ pkg/controlplane/crossplane/policies_test.go | 297 ++++ pkg/controlplane/crossplane/providers.go | 57 + pkg/controlplane/crossplane/providers_test.go | 113 ++ pkg/controlplane/kubeconfiggen/generate.go | 102 ++ .../kubeconfiggen/generate_test.go | 213 +++ .../secretresolver/secretresolver.go | 85 ++ .../secretresolver/secretresolver_test.go | 199 +++ pkg/controlplane/secrets/pullsecrets.go | 33 + pkg/controlplane/secrets/pullsecrets_test.go | 91 ++ pkg/controlplane/targetrbac/rbac.go | 114 ++ pkg/controlplane/targetrbac/rbac_test.go | 264 ++++ pkg/juggler/component.go | 151 ++ pkg/juggler/component_test.go | 133 ++ pkg/juggler/events.go | 89 ++ pkg/juggler/events_test.go | 48 + pkg/juggler/fakes_test.go | 160 ++ pkg/juggler/fluxcd/component_flux.go | 42 + pkg/juggler/fluxcd/fake_test.go | 143 ++ pkg/juggler/fluxcd/flux_manifesto.go | 103 ++ pkg/juggler/fluxcd/flux_manifesto_test.go | 103 ++ pkg/juggler/fluxcd/flux_reconciler.go | 272 ++++ pkg/juggler/fluxcd/flux_reconciler_test.go | 753 ++++++++++ pkg/juggler/fluxcd/flux_sources.go | 164 ++ pkg/juggler/fluxcd/flux_sources_test.go | 194 +++ pkg/juggler/fluxcd/scheme.go | 7 + pkg/juggler/hooks/orphans.go | 45 + pkg/juggler/hooks/orphans_test.go | 93 ++ pkg/juggler/juggler.go | 295 ++++ pkg/juggler/juggler_test.go | 581 +++++++ pkg/juggler/object/fake_test.go | 177 +++ pkg/juggler/object/object_component.go | 56 + pkg/juggler/object/object_reconciler.go | 206 +++ pkg/juggler/object/object_reconciler_test.go | 644 ++++++++ pkg/juggler/reconciler.go | 46 + pkg/utils/conditions.go | 29 + pkg/utils/conditions_test.go | 225 +++ pkg/utils/envtest/envtest.go | 123 ++ pkg/utils/envtest/envtest_test.go | 26 + pkg/utils/errors.go | 33 + pkg/utils/errors_test.go | 64 + pkg/utils/json.go | 13 + pkg/utils/json_test.go | 56 + pkg/utils/maps.go | 66 + pkg/utils/maps_test.go | 161 ++ pkg/utils/meta.go | 28 + pkg/utils/meta_test.go | 87 ++ pkg/utils/rcontext/rcontext.go | 67 + pkg/utils/rcontext/rcontext_test.go | 49 + test/e2e/controlplane_test.go | 36 + test/e2e/cp_btpso_test.go | 135 ++ test/e2e/cp_cert_manager_test.go | 111 ++ ...ossplane_provider_admission_policy_test.go | 53 + ...cp_crossplane_provider_not_allowed_test.go | 52 + test/e2e/cp_crossplane_providers_test.go | 173 +++ test/e2e/cp_crossplane_test.go | 117 ++ test/e2e/cp_eso_test.go | 141 ++ test/e2e/cp_kyverno_test.go | 133 ++ test/e2e/main_test.go | 197 +++ .../controlplane-minimal.yaml | 13 + .../controlplane-btp-service-operator.yaml | 16 + .../controlplane-cert-manager.yaml | 14 + .../controlplane-crossplane-provider.yaml | 32 + .../controlplane-crossplane-provider.yaml | 33 + .../controlplane-crossplane-provider.yaml | 33 + .../controlplane-crossplane.yaml | 15 + ...ontrolplane-external-secrets-operator.yaml | 14 + .../testdata/crs/cp-kyverno/cp-kyverno.yaml | 15 + test/e2e/testdata/values.yaml | 6 + test/e2e/utils_test.go | 141 ++ test/testdata/ocm_registry.tgz | Bin 0 -> 15269 bytes test/utils/ocm.go | 19 + test/utils/ocm_test.go | 35 + 211 files changed, 23524 insertions(+), 3 deletions(-) create mode 100644 .envrc create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 PROJECT create mode 100644 VERSION create mode 100644 api/v1beta1/btpso_component_types.go create mode 100644 api/v1beta1/cert_manager_component_types.go create mode 100644 api/v1beta1/components_types.go create mode 100644 api/v1beta1/controlplane_types.go create mode 100644 api/v1beta1/crossplane_component_types.go create mode 100644 api/v1beta1/crossplanepackagerestriction_types.go create mode 100644 api/v1beta1/eso_component_types.go create mode 100644 api/v1beta1/flux_component_types.go create mode 100644 api/v1beta1/groupversion_info.go create mode 100644 api/v1beta1/kyverno_component_types.go create mode 100644 api/v1beta1/releasechannel_types.go create mode 100644 api/v1beta1/zz_generated.deepcopy.go create mode 100644 charts/control-plane-operator/.helmignore create mode 100644 charts/control-plane-operator/Chart.yaml create mode 100644 charts/control-plane-operator/Chart.yaml.tpl create mode 100644 charts/control-plane-operator/templates/.gitignore create mode 100644 charts/control-plane-operator/templates/_helpers.tpl create mode 100644 charts/control-plane-operator/templates/deployment.yaml create mode 100644 charts/control-plane-operator/templates/rbac.yaml create mode 100644 charts/control-plane-operator/templates/secret-webhooks.yaml create mode 100644 charts/control-plane-operator/templates/service-metrics.yaml create mode 100644 charts/control-plane-operator/templates/service-webhooks.yaml create mode 100644 charts/control-plane-operator/templates/serviceaccount.yaml create mode 100644 charts/control-plane-operator/values.yaml create mode 100644 charts/control-plane-operator/values.yaml.tpl create mode 100644 cmd/embedded/crds/core.orchestrate.cloud.sap_controlplanes.yaml create mode 100644 cmd/embedded/crds/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml create mode 100644 cmd/embedded/crds/core.orchestrate.cloud.sap_releasechannels.yaml create mode 100644 cmd/main.go create mode 100644 cmd/options/options.go create mode 100644 config/crd/bases/core.orchestrate.cloud.sap_controlplanes.yaml create mode 100644 config/crd/bases/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml create mode 100644 config/crd/bases/core.orchestrate.cloud.sap_releasechannels.yaml create mode 100644 config/crd/kustomization.yaml create mode 100644 config/crd/kustomizeconfig.yaml create mode 100644 config/default/kustomization.yaml create mode 100644 config/default/manager_auth_proxy_patch.yaml create mode 100644 config/default/manager_config_patch.yaml create mode 100644 config/helm-default/kustomization.yaml create mode 100644 config/manager/kustomization.yaml create mode 100644 config/manager/manager.yaml create mode 100644 config/prometheus/kustomization.yaml create mode 100644 config/prometheus/monitor.yaml create mode 100644 config/rbac/auth_proxy_client_clusterrole.yaml create mode 100644 config/rbac/auth_proxy_role.yaml create mode 100644 config/rbac/auth_proxy_role_binding.yaml create mode 100644 config/rbac/auth_proxy_service.yaml create mode 100644 config/rbac/controlplane_editor_role.yaml create mode 100644 config/rbac/controlplane_viewer_role.yaml create mode 100644 config/rbac/crossplanepackagerestriction_editor_role.yaml create mode 100644 config/rbac/crossplanepackagerestriction_viewer_role.yaml create mode 100644 config/rbac/kustomization.yaml create mode 100644 config/rbac/leader_election_role.yaml create mode 100644 config/rbac/leader_election_role_binding.yaml create mode 100644 config/rbac/releasechannel_editor_role.yaml create mode 100644 config/rbac/releasechannel_viewer_role.yaml create mode 100644 config/rbac/role.yaml create mode 100644 config/rbac/role_binding.yaml create mode 100644 config/rbac/service_account.yaml create mode 100644 config/samples/controlplane_kubeconfig.yaml create mode 100644 config/samples/controlplane_kubeconfigRef.yaml create mode 100644 config/samples/controlplane_local.yaml create mode 100644 config/samples/controlplane_psat.yaml create mode 100644 config/samples/kustomization.yaml create mode 100644 config/samples/releasechannel/local.yaml create mode 100644 config/samples/v1beta1_crossplanepackagerestriction.yaml create mode 100644 e2e.env create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hack/boilerplate.go.txt create mode 100755 hack/create-token-opt.sh create mode 100644 hack/goimports_version create mode 100755 hack/jwt-expired.sh create mode 100755 hack/userid.sh create mode 100644 internal/controller/controlplane_controller.go create mode 100644 internal/controller/controlplane_controller_test.go create mode 100644 internal/controller/kubeconfigs.go create mode 100644 internal/controller/releasechannel_controller.go create mode 100644 internal/controller/releasechannel_controller_test.go create mode 100644 internal/controller/remote_config_builder.go create mode 100644 internal/controller/remote_config_builder_test.go create mode 100644 internal/controller/secret_controller.go create mode 100644 internal/controller/secret_controller_test.go create mode 100644 internal/controller/utils.go create mode 100644 internal/controller/utils_test.go create mode 100644 internal/ocm/ocm.go create mode 100644 internal/ocm/ocm_test.go create mode 100644 internal/ocm/utils.go create mode 100644 internal/schemes/schemes.go create mode 100644 pkg/constants/annotation.go create mode 100644 pkg/constants/label.go create mode 100644 pkg/constants/ocm.go create mode 100644 pkg/controlplane/components/btpso_component.go create mode 100644 pkg/controlplane/components/btpso_component_test.go create mode 100644 pkg/controlplane/components/cert_manager_component.go create mode 100644 pkg/controlplane/components/cert_manager_component_test.go create mode 100644 pkg/controlplane/components/clusterrole_component.go create mode 100644 pkg/controlplane/components/clusterrole_component_test.go create mode 100644 pkg/controlplane/components/clusterroles/clusterroles.go create mode 100644 pkg/controlplane/components/clusterroles/clusterroles_test.go create mode 100644 pkg/controlplane/components/common.go create mode 100644 pkg/controlplane/components/crds/crds.go create mode 100644 pkg/controlplane/components/crds/crds_test.go create mode 100644 pkg/controlplane/components/crds/testdata/core.orchestrate.cloud.sap_controlplanes.yaml create mode 100644 pkg/controlplane/components/crds/testdata/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml create mode 100644 pkg/controlplane/components/crossplane_component.go create mode 100644 pkg/controlplane/components/crossplane_component_test.go create mode 100644 pkg/controlplane/components/crossplanedeploymentruntimeconfig_component.go create mode 100644 pkg/controlplane/components/crossplanedeploymentruntimeconfig_component_test.go create mode 100644 pkg/controlplane/components/crossplaneprovider_component.go create mode 100644 pkg/controlplane/components/crossplaneprovider_component_test.go create mode 100644 pkg/controlplane/components/eso_component.go create mode 100644 pkg/controlplane/components/eso_component_test.go create mode 100644 pkg/controlplane/components/flux_component.go create mode 100644 pkg/controlplane/components/flux_component_test.go create mode 100644 pkg/controlplane/components/generic_object_component.go create mode 100644 pkg/controlplane/components/generic_object_component_test.go create mode 100644 pkg/controlplane/components/kyverno_component.go create mode 100644 pkg/controlplane/components/kyverno_component_test.go create mode 100644 pkg/controlplane/components/policies/Readme.md create mode 100644 pkg/controlplane/components/policies/policies.go create mode 100644 pkg/controlplane/components/policies/policies_test.go create mode 100644 pkg/controlplane/components/secret_component.go create mode 100644 pkg/controlplane/components/secret_component_test.go create mode 100644 pkg/controlplane/components/utils_test.go create mode 100644 pkg/controlplane/crossplane/policies.go create mode 100644 pkg/controlplane/crossplane/policies_test.go create mode 100644 pkg/controlplane/crossplane/providers.go create mode 100644 pkg/controlplane/crossplane/providers_test.go create mode 100644 pkg/controlplane/kubeconfiggen/generate.go create mode 100644 pkg/controlplane/kubeconfiggen/generate_test.go create mode 100644 pkg/controlplane/secretresolver/secretresolver.go create mode 100644 pkg/controlplane/secretresolver/secretresolver_test.go create mode 100644 pkg/controlplane/secrets/pullsecrets.go create mode 100644 pkg/controlplane/secrets/pullsecrets_test.go create mode 100644 pkg/controlplane/targetrbac/rbac.go create mode 100644 pkg/controlplane/targetrbac/rbac_test.go create mode 100644 pkg/juggler/component.go create mode 100644 pkg/juggler/component_test.go create mode 100644 pkg/juggler/events.go create mode 100644 pkg/juggler/events_test.go create mode 100644 pkg/juggler/fakes_test.go create mode 100644 pkg/juggler/fluxcd/component_flux.go create mode 100644 pkg/juggler/fluxcd/fake_test.go create mode 100644 pkg/juggler/fluxcd/flux_manifesto.go create mode 100644 pkg/juggler/fluxcd/flux_manifesto_test.go create mode 100644 pkg/juggler/fluxcd/flux_reconciler.go create mode 100644 pkg/juggler/fluxcd/flux_reconciler_test.go create mode 100644 pkg/juggler/fluxcd/flux_sources.go create mode 100644 pkg/juggler/fluxcd/flux_sources_test.go create mode 100644 pkg/juggler/fluxcd/scheme.go create mode 100644 pkg/juggler/hooks/orphans.go create mode 100644 pkg/juggler/hooks/orphans_test.go create mode 100644 pkg/juggler/juggler.go create mode 100644 pkg/juggler/juggler_test.go create mode 100644 pkg/juggler/object/fake_test.go create mode 100644 pkg/juggler/object/object_component.go create mode 100644 pkg/juggler/object/object_reconciler.go create mode 100644 pkg/juggler/object/object_reconciler_test.go create mode 100644 pkg/juggler/reconciler.go create mode 100644 pkg/utils/conditions.go create mode 100644 pkg/utils/conditions_test.go create mode 100644 pkg/utils/envtest/envtest.go create mode 100644 pkg/utils/envtest/envtest_test.go create mode 100644 pkg/utils/errors.go create mode 100644 pkg/utils/errors_test.go create mode 100644 pkg/utils/json.go create mode 100644 pkg/utils/json_test.go create mode 100644 pkg/utils/maps.go create mode 100644 pkg/utils/maps_test.go create mode 100644 pkg/utils/meta.go create mode 100644 pkg/utils/meta_test.go create mode 100644 pkg/utils/rcontext/rcontext.go create mode 100644 pkg/utils/rcontext/rcontext_test.go create mode 100644 test/e2e/controlplane_test.go create mode 100644 test/e2e/cp_btpso_test.go create mode 100644 test/e2e/cp_cert_manager_test.go create mode 100644 test/e2e/cp_crossplane_provider_admission_policy_test.go create mode 100644 test/e2e/cp_crossplane_provider_not_allowed_test.go create mode 100644 test/e2e/cp_crossplane_providers_test.go create mode 100644 test/e2e/cp_crossplane_test.go create mode 100644 test/e2e/cp_eso_test.go create mode 100644 test/e2e/cp_kyverno_test.go create mode 100644 test/e2e/main_test.go create mode 100644 test/e2e/testdata/crs/controlplane-only/controlplane-minimal.yaml create mode 100644 test/e2e/testdata/crs/cp-btpso/controlplane-btp-service-operator.yaml create mode 100644 test/e2e/testdata/crs/cp-cert-manager/controlplane-cert-manager.yaml create mode 100644 test/e2e/testdata/crs/cp-crossplane-provider-admission-policy/controlplane-crossplane-provider.yaml create mode 100644 test/e2e/testdata/crs/cp-crossplane-provider-not-allowed/controlplane-crossplane-provider.yaml create mode 100644 test/e2e/testdata/crs/cp-crossplane-provider/controlplane-crossplane-provider.yaml create mode 100644 test/e2e/testdata/crs/cp-crossplane/controlplane-crossplane.yaml create mode 100644 test/e2e/testdata/crs/cp-eso/controlplane-external-secrets-operator.yaml create mode 100644 test/e2e/testdata/crs/cp-kyverno/cp-kyverno.yaml create mode 100644 test/e2e/testdata/values.yaml create mode 100644 test/e2e/utils_test.go create mode 100644 test/testdata/ocm_registry.tgz create mode 100644 test/utils/ocm.go create mode 100644 test/utils/ocm_test.go diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..b1a06d3 --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +# powered by direnv +[[ -f dev.env ]] && dotenv dev.env +[[ -f secret.env ]] && dotenv secret.env +[[ -f e2e.env ]] && dotenv e2e.env diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..dd5a886 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,33 @@ +name: ci +on: + push: + tags: + - v* + branches: + - master + - main + pull_request: + +jobs: + build: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: make tidy + run: | + make tidy + git diff --exit-code + + - name: make verify + run: make verify + + - name: make test + run: make test \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8e043a5 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,87 @@ +name: Versioned Release + +on: + push: + branches: + - main + +permissions: + contents: write # we need this to be able to push tags + +jobs: + release_tag: + name: Release version + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.PUSH_KEY }} + fetch-tags: true + fetch-depth: 0 + + - name: Read and validate VERSION + id: version + run: | + VERSION=$(cat VERSION) + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-dev)?$ ]]; then + echo "Invalid version format in VERSION file: $VERSION" + exit 1 + fi + echo "New version: $VERSION" + echo "version=$VERSION" >> $GITHUB_ENV + + - name: Skip release if version is a dev version + if: contains(env.version, '-dev') + run: | + echo "Skipping development version release: ${{ env.version }}" + echo "SKIP=true" >> $GITHUB_ENV + exit 0 + + - name: Check if VERSION is already tagged + id: check_tag + run: | + if git rev-parse "refs/tags/${{ env.version }}" >/dev/null 2>&1; then + echo "Tag ${{ env.version }} already exists. Skipping release." + echo "SKIP=true" >> $GITHUB_ENV + exit 0 + fi + echo "Tag ${{ env.version }} doesn't exists. Proceeding with release." + + - name: Create Git tag + if: ${{ env.SKIP != 'true' }} + run: | + AUTHOR_NAME=$(git log -1 --pretty=format:'%an') + AUTHOR_EMAIL=$(git log -1 --pretty=format:'%ae') + echo "Tagging as $AUTHOR_NAME <$AUTHOR_EMAIL>" + + echo "AUTHOR_NAME=$AUTHOR_NAME" >> $GITHUB_ENV + echo "AUTHOR_EMAIL=$AUTHOR_EMAIL" >> $GITHUB_ENV + + git config user.name "$AUTHOR_NAME" + git config user.email "$AUTHOR_EMAIL" + + git tag -a "${{ env.version }}" -m "Release ${{ env.version }}" + git push origin "${{ env.version }}" + + - name: Create GitHub release + if: ${{ env.SKIP != 'true' }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.version }} + name: Release ${{ env.version }} + body: "Automated release for version ${{ env.version }}" + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Push dev VERSION + if: ${{ env.SKIP != 'true' }} + run: | + echo "${{ env.version }}-dev" > VERSION + git config user.name "${{ env.AUTHOR_NAME }}" + git config user.email "${{ env.AUTHOR_EMAIL }}" + git add VERSION + git commit -m "Update VERSION to ${{ env.version }}-dev" + git push origin main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ee06d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/* +Dockerfile.cross + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +cover.html + +# Kubernetes Generated files - skip generated files, except for vendored files + +!vendor/**/zz_generated.* + +# editor and IDE paraphernalia +.idea +*.swp +*.swo +*~ + +.DS_Store +/test/e2e/logs/ +/test/e2e/logs-* + +config/samples/*_untracked.yaml + +# charts +charts/*/charts/*.tgz + +/charts/*/templates-original/ +*.orig + +.generated +secret.env + +test/e2e/testdata/serect.yaml +integration-tests.xml diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..fec73d8 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,39 @@ +run: + timeout: 5m + allow-parallel-runners: true + +issues: + # don't skip warning about doc comments + # don't exclude the default set of lint + exclude-use-default: false + # restore some of the defaults + # (fill in the rest as needed) + exclude-rules: + - path: "api/*" + linters: + - lll + - path: "internal/*" + linters: + - dupl + - lll +linters: + enable: + - dupl + - errcheck + - copyloopvar + - goconst + - gocyclo + - gofmt + - goimports + - gosimple + - govet + - ineffassign + - lll + - misspell + - nakedret + - prealloc + - staticcheck + - typecheck + - unconvert + - unused + disable: [] diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3728d12 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Run operator locally", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "cmd/main.go", + "args": ["start"], + "envFile": "${workspaceFolder}/dev.env" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a5cc164 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "gopls": { + "build.buildFlags": ["-tags=e2e"] + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..72f31a9 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,28 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run make", + "command": "make", + "type": "shell", + "args": [], + "presentation": { + "reveal": "silent" + }, + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [] + }, + { + "label": "Build docker image", + "command": "make docker-build", + "type": "shell", + "group": { + "kind": "build", + "isDefault": false + } + } + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd81ce1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +# Use distroless as minimal base image to package the manager binary +# Refer to https://github.com/GoogleContainerTools/distroless for more details +FROM gcr.io/distroless/static:nonroot +ARG TARGETARCH +WORKDIR / +COPY bin/manager-linux.${TARGETARCH} /manager +USER 65532:65532 + +ENTRYPOINT ["/manager"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..33c0ef0 --- /dev/null +++ b/Makefile @@ -0,0 +1,288 @@ +PROJECT_FULL_NAME := control-plane-operator + +# Image URL to use all building/pushing image targets +IMG_VERSION ?= dev +IMG_BASE ?= $(PROJECT_FULL_NAME) +IMG ?= $(IMG_BASE):$(IMG_VERSION) +# ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. +# Pick from https://storage.googleapis.com/kubebuilder-tools +ENVTEST_K8S_VERSION = 1.30.0 + +export UUT_IMAGES = {"cloud-orchestration/control-plane-operator":"$(IMG)"} +SET_BASE_DIR := $(eval BASE_DIR=$(shell git rev-parse --show-toplevel)) +GENERATED_DIR := ${BASE_DIR}/hack/.generated + +# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) +ifeq (,$(shell go env GOBIN)) +GOBIN=$(shell go env GOPATH)/bin +else +GOBIN=$(shell go env GOBIN) +endif + +# CONTAINER_TOOL defines the container tool to be used for building images. +# Be aware that the target commands are only tested with Docker which is +# scaffolded by default. However, you might want to replace it to use other +# tools. (i.e. podman) +CONTAINER_TOOL ?= docker + +GOOS ?= linux +GOARCH ?= $(shell go env GOARCH) +CONTAINER_PLATFORM ?= $(GOOS)/$(GOARCH) + +# Setting SHELL to bash allows bash commands to be executed by recipes. +# Options are set to exit when a recipe line exits non-zero or a piped command fails. +SHELL = /usr/bin/env bash -o pipefail +.SHELLFLAGS = -ec + +.PHONY: all +all: build + +##@ General + +# The help target prints out all targets with their descriptions organized +# beneath their categories. The categories are represented by '##@' and the +# target descriptions by '##'. The awk command is responsible for reading the +# entire set of makefiles included in this invocation, looking for lines of the +# file as xyz: ## something, and then pretty-format the target and help. Then, +# if there's a line with ##@ something, that gets pretty-printed as a category. +# More info on the usage of ANSI control characters for terminal formatting: +# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +# More info on the awk command: +# http://linuxcommand.org/lc3_adv_awk.php + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Development + +.PHONY: manifests +manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. + $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases + $(CONTROLLER_GEN) crd paths="./..." output:crd:artifacts:config=cmd/embedded/crds + +.PHONY: generate +generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. + $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." + +.PHONY: fmt +fmt: ## Run go fmt against code. + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code. + go vet ./... + +.PHONY: test +test: manifests generate fmt vet envtest ## Run tests. + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./... -coverprofile cover.out + go tool cover --html=cover.out -o cover.html + go tool cover -func cover.out | tail -n 1 + +.PHONY: lint +lint: ## Run golangci-lint to lint code + golangci-lint run ./... --timeout=15m + +.PHONY: tidy +tidy: + go mod tidy -e + +.PHONY: verify +verify: lint goimports vet + +.PHONY: localbin +localbin: + @test -d $(LOCALBIN) || mkdir -p $(LOCALBIN) + +FORMATTER_VERSION ?= v0.26.0 + +.PHONY: goimports +goimports: localbin ## Download goimports locally if necessary. If wrong version is installed, it will be overwritten. + @test -s $(FORMATTER) && test -s hack/goimports_version && cat hack/goimports_version | grep -q $(FORMATTER_VERSION) || \ + ( echo "Installing goimports $(FORMATTER_VERSION) ..."; \ + GOBIN=$(LOCALBIN) go install golang.org/x/tools/cmd/goimports@$(FORMATTER_VERSION) && \ + echo $(FORMATTER_VERSION) > hack/goimports_version ) + +##@ Build + +.PHONY: build +build: manifests generate fmt vet ## Build manager binary. + go build -o bin/manager cmd/main.go + +.PHONY: run +run: manifests generate fmt vet ## Run a controller from your host. + go run ./cmd/main.go start + +# If you wish to build the manager image targeting other platforms you can use the --platform flag. +# (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. +# More info: https://docs.docker.com/develop/develop-images/build_enhancements/ +.PHONY: docker-build +docker-build: ## Build docker image with the manager. + CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -a -o bin/manager-linux.$(GOARCH) cmd/main.go + $(CONTAINER_TOOL) build --platform $(CONTAINER_PLATFORM) -t ${IMG} . + +.PHONY: docker-push +docker-push: ## Push docker image with the manager. + $(CONTAINER_TOOL) push ${IMG} + +# PLATFORMS defines the target platforms for the manager image be built to provide support to multiple +# architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: +# - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/ +# - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ +# - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) +# To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. +PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le +.PHONY: docker-buildx +docker-buildx: ## Build and push docker image for the manager for cross-platform support + # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile + sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross + - $(CONTAINER_TOOL) buildx create --name project-v3-builder + $(CONTAINER_TOOL) buildx use project-v3-builder + - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . + - $(CONTAINER_TOOL) buildx rm project-v3-builder + rm Dockerfile.cross + +##@ Deployment + +ifndef ignore-not-found + ignore-not-found = false +endif + +.PHONY: install +install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. + $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f - + +.PHONY: uninstall +uninstall: manifests kustomize ## Uninstall CRDs 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/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - + +.PHONY: deploy +deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. + cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f - + +.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 - + +.PHONY: pull-secrets +pull-secrets: + @echo "Creating Pull Secret(s)" + mkdir -p ${GENERATED_DIR} + ${BASE_DIR}/hack/create-token-opt.sh + +##@ Build Dependencies + +## Location to install dependencies to +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + mkdir -p $(LOCALBIN) + +## Tool Binaries +KUBECTL ?= kubectl +KUSTOMIZE ?= $(LOCALBIN)/kustomize +CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen +ENVTEST ?= $(LOCALBIN)/setup-envtest +GOTESTSUM ?= $(LOCALBIN)/gotestsum +KIND ?= kind # fix this to use tools + +## Tool Versions +KUSTOMIZE_VERSION ?= v5.1.1 +CONTROLLER_TOOLS_VERSION ?= v0.16.4 + +.PHONY: kustomize +kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. If wrong version is installed, it will be removed before downloading. +$(KUSTOMIZE): $(LOCALBIN) + @if test -x $(LOCALBIN)/kustomize && ! $(LOCALBIN)/kustomize version | grep -q $(KUSTOMIZE_VERSION); then \ + echo "$(LOCALBIN)/kustomize version is not expected $(KUSTOMIZE_VERSION). Removing it before installing."; \ + rm -rf $(LOCALBIN)/kustomize; \ + fi + test -s $(LOCALBIN)/kustomize || GOBIN=$(LOCALBIN) GO111MODULE=on go install sigs.k8s.io/kustomize/kustomize/v5@$(KUSTOMIZE_VERSION) + +.PHONY: controller-gen +controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. If wrong version is installed, it will be overwritten. +$(CONTROLLER_GEN): $(LOCALBIN) + test -s $(LOCALBIN)/controller-gen && $(LOCALBIN)/controller-gen --version | grep -q $(CONTROLLER_TOOLS_VERSION) || \ + GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION) + +.PHONY: gotestsum +gotestsum: $(GOTESTSUM) ## Download gotestsum locally if necessary. +$(GOTESTSUM): $(LOCALBIN) + test -s $(LOCALBIN)/gotestsum || GOBIN=$(LOCALBIN) go install gotest.tools/gotestsum@latest + +.PHONY: envtest +envtest: $(ENVTEST) ## Download envtest-setup locally if necessary. +$(ENVTEST): $(LOCALBIN) + test -s $(LOCALBIN)/setup-envtest || GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest + +### ------------------------------------ DEVELOPMENT - LOCAL ------------------------------------ ### +.PHONY: dev-build +dev-build: docker-build + @echo "Finished building docker image" ${IMG} + +.PHONY: dev-base +dev-base: manifests kustomize dev-build dev-clean dev-cluster flux-install flux-secret helm-install-local + +.PHONY: dev-cluster +dev-cluster: + $(KIND) create cluster --name=$(PROJECT_FULL_NAME)-dev + $(KIND) load docker-image ${IMG} --name=$(PROJECT_FULL_NAME)-dev + +.PHONY: dev-local +dev-local: + $(KIND) create cluster --name=$(PROJECT_FULL_NAME)-dev + $(MAKE) install + $(MAKE) flux-install + $(MAKE) flux-secret + +.PHONY: helm-install-local +helm-install-local: + helm upgrade --create-namespace --namespace co-system --install $(PROJECT_FULL_NAME) charts/$(PROJECT_FULL_NAME)/ -f test/e2e/testdata/values.yaml --set image.repository=$(IMG_BASE) --set image.tag=$(IMG_VERSION) --set image.pullPolicy=Never + +.PHONY: dev-clean +dev-clean: + $(KIND) delete cluster --name=$(PROJECT_FULL_NAME)-dev + +.PHONY: dev-run +dev-run: + ## todo: add flag --debug + go run ./cmd/main.go start + + +.PHONY: flux-install +flux-install: + kubectl apply -f https://github.com/fluxcd/flux2/releases/latest/download/install.yaml + +### ------------------------------------ HELM ------------------------------------ ### + +.PHONY: helm-chart +helm-chart: helm-templates + OPERATOR_VERSION=$(shell cat VERSION) envsubst < charts/$(PROJECT_FULL_NAME)/Chart.yaml.tpl > charts/$(PROJECT_FULL_NAME)/Chart.yaml + OPERATOR_VERSION=$(shell cat VERSION) envsubst < charts/$(PROJECT_FULL_NAME)/values.yaml.tpl > charts/$(PROJECT_FULL_NAME)/values.yaml + +### ------------------------------------------------------------------------------ ### + +# initializes pre-commit hooks using lefthook https://github.com/evilmartians/lefthook +lefthook: + @lefthook install + +### ------------------------------------ E2E - Tests ------------------------------------ ### +.PHONY: e2e +e2e: helm-chart docker-build e2e.prep run-e2e-with-report + +.PHONY: run-e2e +run-e2e: docker-build + go test -v ./... -tags=e2e + +.PHONY: run-e2e-with-report +run-e2e-with-report: docker-build run-e2e-with-report-only + +.PHONY: e2e.prep +e2e.prep: docker-build pull-secrets + echo E2E_IMAGES=$$UUT_IMAGES > e2e.env + echo PULL_SECRET_USER=$(shell cat ${GENERATED_DIR}/artifactory-user) > secret.env + -echo PULL_SECRET_PASSWORD=$(shell cat ${GENERATED_DIR}/artifactory-bearer-token.json) >> secret.env + +run-e2e-with-report-only: gotestsum + @echo "UUT_IMAGES=$$UUT_IMAGES" + $(GOTESTSUM) --debug --format standard-verbose --junitfile=integration-tests.xml -- --tags=e2e ./.../e2e -test.v -test.short -short -v -timeout 30m diff --git a/PROJECT b/PROJECT new file mode 100644 index 0000000..3b73ba5 --- /dev/null +++ b/PROJECT @@ -0,0 +1,36 @@ +# Code generated by tool. DO NOT EDIT. +# This file is used to track the info used to scaffold your project +# and allow the plugins properly work. +# More info: https://book.kubebuilder.io/reference/project-config.html +domain: core.orchestrate.cloud.sap +layout: +- go.kubebuilder.io/v4 +projectName: control-plane-operator +repo: github.com/openmcp-project/control-plane-operator +resources: +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: core.orchestrate.cloud.sap + kind: ControlPlane + path: github.com/openmcp-project/control-plane-operator/api/v1beta1 + version: v1beta1 +- controller: true + domain: core.orchestrate.cloud.sap + kind: Secret + version: v1 +- api: + crdVersion: v1 + namespaced: true + domain: core.orchestrate.cloud.sap + kind: ReleaseChannel + path: github.com/openmcp-project/control-plane-operator/api/v1beta1 + version: v1beta1 +- api: + crdVersion: v1 + domain: core.orchestrate.cloud.sap + kind: CrossplanePackageRestriction + path: github.com/openmcp-project/control-plane-operator/api/v1beta1 + version: v1beta1 +version: "3" diff --git a/README.md b/README.md index ee917f6..1ebff13 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,253 @@ [![REUSE status](https://api.reuse.software/badge/github.com/openmcp-project/control-plane-operator)](https://api.reuse.software/info/github.com/openmcp-project/control-plane-operator) -# control-plane-operator +# Control Plane Operator ## About this project +The Control Plane Operator is a universal Kubernetes Operator which bundles all necessary functionality to install so called `Components` (like Crossplane, External Secrets Operator, etc.) in a managed fashion into a Kubernetes cluster. -A Kubernetes Operator for setting up ControlPlanes. +The Control Plane Operator can be used in different flavors and scenarios: +- [Self-hosted](#self-hosted): You install the Control Plane Operator into your own Kubernetes cluster. + * You can install `Components` like Crossplane, External Secrets Operator, etc. via one central [`ControlPlane` API](./config/crd/bases/core.orchestrate.cloud.sap_controlplanes.yaml). For more information see the [Components](#components) section. + * You are responsible running and if necessary upgrading the Control Plane Operator. +- [Managed](#managed): You are using a Managed Control Plane offering, where you order a `ManagedControlPlane` either via an API or via an Onboarding system. + + +## Flavors + +### Self-hosted + +**PLEASE EXPECT SOME HICK-UPS WHEN SETTING UP THE OPERATOR IN A SELF-HOSTED SCENARIO. The following instructions might not be up-to-date.** + +If you want to use the Control Plane Operator in your own Kubernetes cluster, please follow the instructions below. +Make sure you are connected to a Kubernetes cluster that should serve as ControlPlane environment. + +```shell +# Add helm repo +helm repo add control-plane-operator-repo \ + https://helm.example.com/control-plane-operator-repo \ + --force-update \ + --username + +# Create namespace where you install the Operator and Secrets +kubectl create namespace cloud-orchestration + +# Create secret to pull Helm Charts from Artifactory +kubectl create secret docker-registry orchestrator-registry \ + --namespace cloud-orchestration \ + --docker-server="docker-registry.example.com/" \ + --docker-username= \ + --docker-password= + +# Install FluxCD +kubectl apply -f https://github.com/fluxcd/flux2/releases/latest/download/install.yaml + +# Create secret to pull Crossplane Providers from Artifactory +kubectl create secret generic artifactory-readonly-basic --type=kubernetes.io/basic-auth --from-literal=username= --from-literal=password= +kubectl label secret artifactory-readonly-basic core.orchestrate.cloud.sap/copy-to-cp-namespaces=true +kubectl annotate secret artifactory-readonly-basic core.orchestrate.cloud.sap/credentials-for-url='https://helm.example.com/control-plane-operator-repo' + +# Install the Control Plane Operator into your cluster +helm upgrade --install co-control-plane-operator control-plane-operator-repo/co-control-plane-operator --namespace cloud-orchestration --set "imagePullSecrets[0].name=orchestrator-registry" --values test/e2e/testdata/values.yaml +``` + +After the installation you will see that a Pod will spin up. + +Now, you have to install two `ReleaseChannel` resources. +A `ReleaseChannel` resource defines which version you define should be latest or stable. +(FYI: The `ReleaseChannel` feature was implemented with as a requirement in a managed setup. This may not seem suitable for a self-hosted environment. There are currently discussions about it. So this could change in the future. Feedback is much appreciated here.) + +In the [`config/samples/releasechannel/`](./config/samples/releasechannel) directory you will find two sample `ReleaseChannel` resources. +You can safely apply them to your cluster with the following command +```shell +kubectl apply -f config/samples/releasechannel/ +``` + +Make sure to keep the `latest` and `stable` `ReleaseChannel` resources up-to-date to your own needs. +In the `ControlPlane` CR you can define which `ReleaseChannel` of a Component you want to use. + +```yaml +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: controlplane-sample +spec: + target: + # use local cluster + serviceAccount: {} + fluxServiceAccount: + name: flux-deployer + namespace: default + overrides: + host: https://kubernetes.default.svc + crossplane: # Remove to disable Crossplane and Providers + version: latest + providers: + - name: provider-kubernetes # IMPORTANT: this name must match the name in the ReleaseChannel! + package: xpkg.upbound.io/crossplane-contrib/provider-kubernetes + version: latest # this will install version v0.13.0 (defined in ReleaseChannel "latest") - see above + btpServiceOperator: # Remove to disable the BTP Service Operator + version: stable + certManager: # Remove to disable Cert Manager + version: stable + externalSecretsOperator: # Remove to disable External Secrets Operator + version: stable + kyverno: # Remove to disable Kyverno + version: stable +``` +**Note:** Currently, it is only possible to install Crossplane Providers which are Open Source via the ControlPlane resource. We are working on the fix. In the meantime, you can go ahead and install them via the `Provider` CRD from Crossplane itself. + +If you apply the `ControlPlane` CR, the Control Plane Operator will start to install the `Components` into your cluster. + +### Managed + +With the Managed setup, we will use this Operator in our landscape to provide a new API called `ManagedControlPlane`. + +With the `ManagedControlPlane` you will also have two different cluster setups how the MCP will look like: +- Dedicated Cluster Setup - A standard Kubernetes cluster +- Single/Shared Cluster Setup - A Kubernetes cluster without nodes + +#### Dedicated Cluster Setup +In the Dedicated Cluster Setup, the "Managed Control Plane" will be a standard Kubernetes cluster. +The Control Plane Operator will reconcile the `ControlPlane` CR in which the necessary `Components` e.g. Crossplane, External Secrets Operator are defined. +The Control Plane Operator will then install these `Components` on the `Managed Control Plane`. +The ReleaseChannel resources (stable and latest) are watched by the Control Plane Operator. +A ReleaseChannel defines the concrete version of the `Components`. + +```mermaid +C4Context + title Dedicated Cluster Setup + Deployment_Node(core, "Core", "A Kubernetes cluster") { + Container(cpOperator, "Control Plane Operator", "Reconciles ControlPlane") + Node(clusterscoped, "Cluster Scoped Resources", "") { + SystemDb(controlplane, "ControlPlane Resource", "the ordered Control Plane", "") + SystemDb(releasechannelStable, "ReleaseChannel Resource", "stable", "") + SystemDb(releasechannelLatest, "ReleaseChannel Resource", "latest", "") + } + } + + Node(mcp, "Managed Control Plane", "A Kubernetes cluster") { + Container(crossplaneInst, "Crossplane", "Deployment") + Container(othersInst, "…Others…", "Deployment") + } + + + Rel(cpOperator, crossplaneInst, "reconciles") + Rel(cpOperator, othersInst, "reconciles") + + UpdateElementStyle(namespace, $borderColor="orange", $fontColor="red") + UpdateElementStyle(clusterscoped, $borderColor="grey") + Rel(cpOperator, releasechannelStable, "watches") + Rel(cpOperator, releasechannelLatest, "watches") + UpdateRelStyle(cpOperator, others, $textColor="white", $lineColor="white", $offsetX="5") + UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="10") +``` + +#### Single/Shared Cluster Setup +In the Single/Shared Cluster Setup, the "Managed Control Plane" will be a Kubernetes cluster without nodes e.g. a Gardener Workerless Shoot. +To run Crossplane and other Components on the MCP, the Syncer will schedule and forward the workload to the Core cluster. +That comes with cost advantages for the user of the Managed Control Plane. + +```mermaid +C4Context + title Single/Shared Cluster Setup + Deployment_Node(core, "Core", "A Kubernetes cluster") { + Container(cpOperator, "Control Plane Operator", "Reconciles ControlPlane") + Node(clusterscoped, "Cluster Scoped Resources", "") { + SystemDb(controlplane, "ControlPlane Resource", "the ordered Control Plane", "") + SystemDb(releasechannelStable, "ReleaseChannel Resource", "stable", "") + SystemDb(releasechannelLatest, "ReleaseChannel Resource", "latest", "") + } + + Node(namespace, "Managed ControlPlane Namespace", "") { + Container(syncer, "Syncer") + Container(crossplane, "Crossplane", "synced resource") + Container(others, "…Others…", "synced resource") + } + } + + Node(mcp, "Managed Control Plane", "A Kubernetes cluster without nodes") { + Container(crossplaneInst, "Crossplane", "Deployment") + Container(othersInst, "…Others…", "Deployment") + } + + + Rel(cpOperator, crossplaneInst, "reconciles") + Rel(cpOperator, othersInst, "reconciles") + + Rel(syncer, crossplaneInst, "materializes") + BiRel(crossplaneInst, crossplane , "synced") + + Rel(syncer, othersInst, "materializes") + BiRel(othersInst, others , "synced") + + UpdateElementStyle(namespace, $borderColor="orange", $fontColor="red") + UpdateElementStyle(clusterscoped, $borderColor="grey") + Rel(cpOperator, controlplane, "watches") + Rel(cpOperator, releasechannelStable, "watches") + Rel(cpOperator, releasechannelLatest, "watches") + Rel(cpOperator, syncer, "reconciles") + UpdateRelStyle(cpOperator, others, $textColor="white", $lineColor="white", $offsetX="5") + UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="10") +``` + +## Components + +You can install the following Components via the Control Plane Operator: + +| Component | Supported in Self-hosted scenario | Supported in Managed scenario | +| ------------------------------------------------------------------------------------- | :-------------------------------: | :----------------------------: | +| [Crossplane](./pkg/controlplane/components/crossplane_component.go) | ✅ | ✅ | +| [Crossplane Providers](./pkg/controlplane/components/crossplaneprovider_component.go) | ✅ (just open source providers) | ✅ (just open source providers) | +| [External Secrets Operator](./pkg/controlplane/components/eso_component.go) | ✅ | ✅ | +| [BTP Service Operator](./pkg/controlplane/components/btpso_component.go) | ✅ | ✅ | +| [Cert Manager](./pkg/controlplane/components/cert_manager_component.go) | ✅ | ✅ | +| [Kyverno](./pkg/controlplane/components/kyverno_component.go) | ✅ | ✅ | +| [Flux](./pkg/controlplane/components/flux_component.go) | ❌ | ✅ | ## Requirements and Setup -*Insert a short description what is required to get your project running...* +You’ll need a Kubernetes cluster to run against. You can use [KIND](https://sigs.k8s.io/kind) to get a local cluster for +testing, or run against a remote cluster. +**Note:** Your controller will automatically use the current context in your kubeconfig file (i.e. whatever +cluster `kubectl cluster-info` shows). + +### Simple Development Setup + +1. Run `make dev-base` to create a KIND cluster on your local machine. This will install Flux, the Control Plane Operator via Helm along with the `ControlPlane` CRD, too. +2. Apply your `ControlPlane` CR which is located under `config/samples/` via kubectl. The `controlplane_local.yaml` is a good example to start with. +3. If you want to tear down the cluster, simply execute `make dev-clean`. + +If you want to execute the Control Plane Operator outside the KIND cluster, you can use the following commands: + +1. Run `make dev-local` to create a local cluster on your machine. This will install Flux too. +2. Run the Control Plane Operator via `make dev-run` or start the `cmd/main.go` file via your IDE. +3. Apply your `ControlPlane` CR which is located under `config/samples/` via kubectl. The `controlplane_local.yaml` is a good example to start with. +4. If you want to tear down the cluster, simply execute `make dev-clean`. + +### Uninstall CRDs + +To delete the CRDs from the cluster execute: `make uninstall`. + +### Running E2E-Tests +To run E2E-Tests you can execute `make e2e`. This will execute all tests in the `test/e2e/` directory. + +### Modifying the API definitions + +If you are editing the API definitions, generate the manifests such as CRs or CRDs using `make manifests`. + +### How it works + +This project aims to follow the +Kubernetes [Operator pattern](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/) and is based on [Kubebuilder](https://book.kubebuilder.io/). + +It uses [Controllers](https://kubernetes.io/docs/concepts/architecture/controller/), +which provide a reconcile function responsible for synchronizing resources until the desired state is reached on the +cluster. + +**NOTE:** Run `make --help` for more information on all potential `make` targets + +More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html) + ## Support, Feedback, Contributing diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..b1e80bb --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.3 diff --git a/api/v1beta1/btpso_component_types.go b/api/v1beta1/btpso_component_types.go new file mode 100644 index 0000000..dba2359 --- /dev/null +++ b/api/v1beta1/btpso_component_types.go @@ -0,0 +1,16 @@ +package v1beta1 + +import apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + +// BTPServiceOperatorConfig configures the BTP Service Operator component. +type BTPServiceOperatorConfig struct { + // The Version of BTP Service Operator to install. + Version string `json:"version"` + + // Optional custom chart configuration. + Chart *ChartSpec `json:"chart,omitempty"` + + // Optional additional values that should be passed to the BTP Service Operator Helm chart. + // +kubebuilder:pruning:PreserveUnknownFields + Values *apiextensionsv1.JSON `json:"values,omitempty"` +} diff --git a/api/v1beta1/cert_manager_component_types.go b/api/v1beta1/cert_manager_component_types.go new file mode 100644 index 0000000..07e0e22 --- /dev/null +++ b/api/v1beta1/cert_manager_component_types.go @@ -0,0 +1,16 @@ +package v1beta1 + +import apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + +// CertManagerConfig configures the Cert Manager component. +type CertManagerConfig struct { + // The Version of the cert-manager to install. + Version string `json:"version"` + + // Optional custom chart configuration. + Chart *ChartSpec `json:"chart,omitempty"` + + // Optional additional values that should be passed to the cert-manager Helm chart. + // +kubebuilder:pruning:PreserveUnknownFields + Values *apiextensionsv1.JSON `json:"values,omitempty"` +} diff --git a/api/v1beta1/components_types.go b/api/v1beta1/components_types.go new file mode 100644 index 0000000..478c1c3 --- /dev/null +++ b/api/v1beta1/components_types.go @@ -0,0 +1,33 @@ +package v1beta1 + +// ComponentsConfig defines all the different Components that can be installed in a ControlPlane. +type ComponentsConfig struct { + // Configuration for the Crossplane installation of this ControlPlane. + // +kubebuilder:validation:Optional + Crossplane *CrossplaneConfig `json:"crossplane,omitempty"` + + // Configuration for the BTP Service Operator. More info: + // https://github.com/SAP/sap-btp-service-operator + // +kubebuilder:validation:Optional + BTPServiceOperator *BTPServiceOperatorConfig `json:"btpServiceOperator,omitempty"` + + // CertManager configures the cert-manager component. More info: + // https://cert-manager.io/ + // +kubebuilder:validation:Optional + CertManager *CertManagerConfig `json:"certManager,omitempty"` + + // Configuration for the External Secrets Operator. More info: + // https://external-secrets.io + // +kubebuilder:validation:Optional + ExternalSecretsOperator *ExternalSecretsOperatorConfig `json:"externalSecretsOperator,omitempty"` + + // Configuration for Kyverno. More info: + // https://kyverno.io/ + // +kubebuilder:validation:Optional + Kyverno *KyvernoConfig `json:"kyverno,omitempty"` + + // Configuration for Flux. More info: + // https://fluxcd.io/ + // +kubebuilder:validation:Optional + Flux *FluxConfig `json:"flux,omitempty"` +} diff --git a/api/v1beta1/controlplane_types.go b/api/v1beta1/controlplane_types.go new file mode 100644 index 0000000..0cdebb8 --- /dev/null +++ b/api/v1beta1/controlplane_types.go @@ -0,0 +1,240 @@ +/* +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 v1beta1 + +import ( + "github.com/openmcp-project/controller-utils/pkg/api" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // LabelControlPlane indicates to which ControlPlane a resource belongs. + LabelControlPlane = "core.orchestrate.cloud.sap/control-plane" + + // Finalizer is the default finalizer which is added to resources managed by the control-plane-operator. + Finalizer = "core.orchestrate.cloud.sap" +) + +// ControlPlaneSpec defines the desired state of ControlPlane +type ControlPlaneSpec struct { + // Reference to a core configuration + // +kubebuilder:default:={name:"default"} + CoreReference v1.LocalObjectReference `json:"coreRef,omitempty"` + + // Configuration of the ControlPlane target (local or remote cluster) + Target Target `json:"target"` + + // Configuration for the telemetry. + // +kubebuilder:validation:Optional + Telemetry *TelemetryConfig `json:"telemetry,omitempty"` + + // Pull secrets which will be used when pulling charts, providers, etc. + // +kubebuilder:validation:Optional + PullSecrets []v1.LocalObjectReference `json:"pullSecrets,omitempty"` + + ComponentsConfig `json:",inline"` +} + +type Target struct { + api.Target `json:",inline"` + + // FluxServiceAccount is a reference to a service account that should be used by Flux. + // +kubebuilder:validation:Required + FluxServiceAccount ServiceAccountReference `json:"fluxServiceAccount"` +} + +type ServiceAccountReference struct { + // Name is the name of the service account. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Namespace is the namespace of the service account. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Namespace string `json:"namespace"` + + // Overrides specifies fields that should be overwritten when a kubeconfig is generated from this ServiceAccountReference. + Overrides KubeconfigOverrides `json:"overrides,omitempty"` +} + +type KubeconfigOverrides struct { + // Host must be a host string, a host:port pair, or a URL to the base of the apiserver. + Host string `json:"host,omitempty"` +} + +// TelemetryConfig allows the toggling of telemetry data +type TelemetryConfig struct { + // Enables or disables telemetry. + Enabled bool `json:"enabled,omitempty"` +} + +// ChartSpec identifies a Helm chart. +type ChartSpec struct { + // Repository is the URL to a Helm repository + Repository string `json:"repository,omitempty"` + + // Name of the Helm chart + Name string `json:"name,omitempty"` + + // Version of the Helm chart, latest version if not set + Version string `json:"version,omitempty"` +} + +// ControlPlaneStatus defines the observed state of ControlPlane +type ControlPlaneStatus struct { + // Current service state of the ControlPlane. + Conditions []metav1.Condition `json:"conditions"` + + // Namespace that contains resources related to the ControlPlane. + Namespace string `json:"namespace"` + + // Number of enabled components. + ComponentsEnabled int `json:"componentsEnabled"` + + // Number of healthy components. + ComponentsHealthy int `json:"componentsHealthy"` +} + +// ControlPlane is the Schema for the ControlPlane API +// +kubebuilder:object:root=true +// +kubebuilder:resource:shortName=cp,scope=Cluster +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +// +kubebuilder:printcolumn:name="Components Healthy",type="integer",JSONPath=".status.componentsHealthy" +// +kubebuilder:printcolumn:name="Components Enabled",type="integer",JSONPath=".status.componentsEnabled" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +type ControlPlane struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ControlPlaneSpec `json:"spec,omitempty"` + Status ControlPlaneStatus `json:"status,omitempty"` +} + +func (cp ControlPlane) WasDeleted() bool { + return !cp.DeletionTimestamp.IsZero() +} + +//+kubebuilder:object:root=true + +// ControlPlaneList contains a list of ControlPlane +type ControlPlaneList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ControlPlane `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ControlPlane{}, &ControlPlaneList{}) +} + +// Condition types. +const ( + // TypeReady resources are believed to be ready to handle work. + TypeReady string = "Ready" + + // TypeSynced resources are believed to be in sync with the + // Kubernetes resources that manage their lifecycle. + TypeSynced string = "Synced" + TypeReconciling string = "Reconciling" +) + +// Reasons a resource is or is not ready. +const ( + ReasonAvailable string = "Available" + ReasonUnavailable string = "Unavailable" + ReasonCreating string = "Creating" + ReasonDeleting string = "Deleting" +) + +// Reasons a resource is or is not synced. +const ( + ReasonReconcileSuccess string = "ReconcileSuccess" + ReasonReconcileError string = "ReconcileError" + ReasonReconcilePaused string = "ReconcilePaused" +) + +// Creating returns a condition that indicates the resource is currently +// being created. +func Creating() metav1.Condition { + return metav1.Condition{ + Type: TypeReady, + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: ReasonCreating, + } +} + +// Deleting returns a condition that indicates the resource is currently +// being deleted. +func Deleting() metav1.Condition { + return metav1.Condition{ + Type: TypeReady, + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: ReasonDeleting, + } +} + +// Unavailable returns a condition that indicates the resource is +// currently observed NOT to be available for use. +func Unavailable() metav1.Condition { + return metav1.Condition{ + Type: TypeReady, + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: ReasonUnavailable, + } +} + +// Available returns a condition that indicates the resource is +// currently observed to be available for use. +func Available() metav1.Condition { + return metav1.Condition{ + Type: TypeReady, + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: ReasonAvailable, + } +} + +// ReconcileSuccess returns a condition indicating that reconciler successfully +// completed the most recent reconciliation of the resource. +func ReconcileSuccess() metav1.Condition { + return metav1.Condition{ + Type: TypeSynced, + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: ReasonReconcileSuccess, + } +} + +// ReconcileError returns a condition indicating that controlplane reconciler encountered an +// error while reconciling the resource. This could mean controlplane reconciler was +// unable to update the resource to reflect its desired state, or that +// it was unable to determine the current actual state of the resource. +func ReconcileError(err error) metav1.Condition { + return metav1.Condition{ + Type: TypeSynced, + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: ReasonReconcileError, + Message: err.Error(), + } +} diff --git a/api/v1beta1/crossplane_component_types.go b/api/v1beta1/crossplane_component_types.go new file mode 100644 index 0000000..3de13e0 --- /dev/null +++ b/api/v1beta1/crossplane_component_types.go @@ -0,0 +1,51 @@ +package v1beta1 + +import ( + crossplanev1 "github.com/crossplane/crossplane/apis/pkg/v1" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// CrossplaneConfig configures the Crossplane component. +type CrossplaneConfig struct { + // The Version of Crossplane to install. + Version string `json:"version"` + + // Optional custom Helm chart configuration. + Chart *ChartSpec `json:"chart,omitempty"` + + // Optional additional values that should be passed to the Crossplane Helm chart. + //+kubebuilder:pruning:PreserveUnknownFields + Values *apiextensionsv1.JSON `json:"values,omitempty"` + + // List of Crossplane providers to be installed. + // +kubebuilder:validation:Optional + Providers []*CrossplaneProviderConfig `json:"providers,omitempty"` +} + +// CrossplaneProviderConfig represents configuration for Crossplane providers in a ControlPlane. +// Primarily based on the Crossplane open source API. +type CrossplaneProviderConfig struct { + // Name of the provider. + // Using a well-known name will automatically configure the "package" field. + Name string `json:"name"` + + // Version of the provider to install. + Version string `json:"version"` + + // Provider package to be installed. + // If "name" is set to a well-known value, this field will be configured automatically. + // +kubebuilder:validation:Optional + Package string `json:"package,omitempty"` + + // Pull policy for the provider. + // One of Always, Never, IfNotPresent. + // +kubebuilder:default=IfNotPresent + // +kubebuilder:validation:Enum=Always;Never;IfNotPresent + PackagePullPolicy *corev1.PullPolicy `json:"packagePullPolicy,omitempty"` + + // PackagePullSecrets are named secrets in the same namespace that can be used to fetch packages from private registries. + PackagePullSecrets []corev1.LocalObjectReference `json:"packagePullSecrets,omitempty"` + + crossplanev1.PackageRuntimeSpec `json:",inline"` +} diff --git a/api/v1beta1/crossplanepackagerestriction_types.go b/api/v1beta1/crossplanepackagerestriction_types.go new file mode 100644 index 0000000..1239384 --- /dev/null +++ b/api/v1beta1/crossplanepackagerestriction_types.go @@ -0,0 +1,64 @@ +/* +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 v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// CrossplanePackageRestrictionSpec defines the desired state of CrossplanePackageRestriction +type CrossplanePackageRestrictionSpec struct { + Providers PackageRestriction `json:"providers"` + Configurations PackageRestriction `json:"configurations"` + Functions PackageRestriction `json:"functions"` +} + +// PackageRestriction restricts a package type (e.g. providers) to certain registries or literal packages. +// If both Registries and Packages are empty, no packages of this type will be allowed. +type PackageRestriction struct { + Registries []string `json:"registries"` + Packages []string `json:"packages"` +} + +// CrossplanePackageRestrictionStatus defines the observed state of CrossplanePackageRestriction +type CrossplanePackageRestrictionStatus struct{} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster + +// CrossplanePackageRestriction is the Schema for the crossplanepackagerestrictions API +type CrossplanePackageRestriction struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec CrossplanePackageRestrictionSpec `json:"spec,omitempty"` + Status CrossplanePackageRestrictionStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// CrossplanePackageRestrictionList contains a list of CrossplanePackageRestriction +type CrossplanePackageRestrictionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []CrossplanePackageRestriction `json:"items"` +} + +func init() { + SchemeBuilder.Register(&CrossplanePackageRestriction{}, &CrossplanePackageRestrictionList{}) +} diff --git a/api/v1beta1/eso_component_types.go b/api/v1beta1/eso_component_types.go new file mode 100644 index 0000000..a084d33 --- /dev/null +++ b/api/v1beta1/eso_component_types.go @@ -0,0 +1,16 @@ +package v1beta1 + +import apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + +// ExternalSecretsOperatorConfig configures the ExternalSecrets Operator component. +type ExternalSecretsOperatorConfig struct { + // The Version of External Secrets Operator to install. + Version string `json:"version"` + + // Optional custom chart configuration. + Chart *ChartSpec `json:"chart,omitempty"` + + // Optional additional values that should be passed to the External Secrets Operator Helm chart. + // +kubebuilder:pruning:PreserveUnknownFields + Values *apiextensionsv1.JSON `json:"values,omitempty"` +} diff --git a/api/v1beta1/flux_component_types.go b/api/v1beta1/flux_component_types.go new file mode 100644 index 0000000..10620eb --- /dev/null +++ b/api/v1beta1/flux_component_types.go @@ -0,0 +1,16 @@ +package v1beta1 + +import apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + +// FluxConfig configures the Flux component. +type FluxConfig struct { + // The Version of Flux to install. + Version string `json:"version"` + + // Optional custom chart configuration. + Chart *ChartSpec `json:"chart,omitempty"` + + // Optional additional values that should be passed to the Flux Helm chart. + // +kubebuilder:pruning:PreserveUnknownFields + Values *apiextensionsv1.JSON `json:"values,omitempty"` +} diff --git a/api/v1beta1/groupversion_info.go b/api/v1beta1/groupversion_info.go new file mode 100644 index 0000000..378736c --- /dev/null +++ b/api/v1beta1/groupversion_info.go @@ -0,0 +1,36 @@ +/* +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 v1beta1 contains API Schema definitions for the v1beta1 API group +// +kubebuilder:object:generate=true +// +groupName=core.orchestrate.cloud.sap +package v1beta1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "core.orchestrate.cloud.sap", Version: "v1beta1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1beta1/kyverno_component_types.go b/api/v1beta1/kyverno_component_types.go new file mode 100644 index 0000000..66338a9 --- /dev/null +++ b/api/v1beta1/kyverno_component_types.go @@ -0,0 +1,16 @@ +package v1beta1 + +import apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + +// KyvernoConfig configures Kyverno component. +type KyvernoConfig struct { + // The Version of Kyverno to install. + Version string `json:"version"` + + // Optional custom chart configuration. + Chart *ChartSpec `json:"chart,omitempty"` + + // Optional additional values that should be passed to the Kyverno Helm chart. + // +kubebuilder:pruning:PreserveUnknownFields + Values *apiextensionsv1.JSON `json:"values,omitempty"` +} diff --git a/api/v1beta1/releasechannel_types.go b/api/v1beta1/releasechannel_types.go new file mode 100644 index 0000000..117054a --- /dev/null +++ b/api/v1beta1/releasechannel_types.go @@ -0,0 +1,102 @@ +/* +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 v1beta1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:generate=false +type VersionResolverFn func(componentName string, version string) (ComponentVersion, error) + +// ReleaseChannelSpec defines the desired state of ReleaseChannel +type ReleaseChannelSpec struct { + // Specify a ocm registry url where the releasechannel components are uploaded + // +kubebuilder:validation:MinLength=1 + OcmRegistryUrl string `json:"ocmRegistryUrl,omitempty"` + // This should be a reference to a secret, which has the `username` and `password` keys. + // If specified, will be used when accessing the ocmRegistry specified in ocmRegistryUrl. + PullSecretRef corev1.SecretReference `json:"pullSecretRef,omitempty"` + + // This parameter can be used for a tar based ocm registry in a secret. + // The secret referenced here must contain a key where a tar based ocm registry is stored in. + OcmRegistrySecretRef corev1.SecretReference `json:"ocmRegistrySecretRef,omitempty"` + // Here you must specify the key which contains the tar based ocm registry in the referenced secret. + // Required, if ocmRegistrySecretRef is specified. + OcmRegistrySecretKey string `json:"ocmRegistrySecretKey,omitempty"` + + // When specified only components starting with this prefix will be fetched. + // Also this prefix will be cut from the componentNames in the status field. + PrefixFilter string `json:"prefixFilter,omitempty"` + + // Interval specifies the timespan when the registry is checked again + // +kubebuilder:default="15m" + Interval metav1.Duration `json:"interval"` +} + +// ReleaseChannelStatus defines the observed state of ReleaseChannel +type ReleaseChannelStatus struct { + // The components which are inside the ocm registry + Components []Component `json:"components,omitempty"` +} + +type Component struct { + // Name of the component which can be used to install it via the controlplane CR. + Name string `json:"name"` + // All available versions for that component. + Versions []ComponentVersion `json:"versions"` +} + +type ComponentVersion struct { + // The version number for that ComponentVersion + Version string `json:"version"` + // if it's a Docker Image, this specifies the Docker reference for pulling the image + DockerRef string `json:"dockerRef,omitempty"` + // if it's a helm chart, this specifies the helm repo + HelmRepo string `json:"helmRepo,omitempty"` + // if it's a helm chart, this specifies the chart name + HelmChart string `json:"helmChart,omitempty"` +} + +// ReleaseChannel is the Schema for the ReleaseChannel API +// +kubebuilder:object:root=true +// +kubebuilder:resource:shortName=rc,scope=Cluster +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:validation:XValidation:rule=(!(has(self.spec.ocmRegistryUrl) && has(self.spec.ocmRegistrySecretRef))), message="You can't specify 'ocmRegistryUrl' and 'ocmRegistrySecretRef' at the same time, either use a remote ocm registry or a secret" +// +kubebuilder:validation:XValidation:rule=(!(has(self.spec.ocmRegistrySecretRef) && !has(self.spec.ocmRegistrySecretKey))), message="You need to specify an 'ocmRegistrySecretKey' if you want to use the 'ocmRegistrySecretRef'." +// +kubebuilder:validation:XValidation:rule=(!(has(self.spec.pullSecretRef) && !has(self.spec.ocmRegistryUrl))), message="If you specify a 'pullSecretRef' you must specify an 'ocmRegistryUrl' otherwise the 'pullSecretRef' will not be used." +type ReleaseChannel struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ReleaseChannelSpec `json:"spec,omitempty"` + Status ReleaseChannelStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +// ReleaseChannelList contains a list of ReleaseChannel +type ReleaseChannelList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ReleaseChannel `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ReleaseChannel{}, &ReleaseChannelList{}) +} diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go new file mode 100644 index 0000000..72dcc78 --- /dev/null +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,698 @@ +//go:build !ignore_autogenerated + +/* +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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta1 + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BTPServiceOperatorConfig) DeepCopyInto(out *BTPServiceOperatorConfig) { + *out = *in + if in.Chart != nil { + in, out := &in.Chart, &out.Chart + *out = new(ChartSpec) + **out = **in + } + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = new(v1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BTPServiceOperatorConfig. +func (in *BTPServiceOperatorConfig) DeepCopy() *BTPServiceOperatorConfig { + if in == nil { + return nil + } + out := new(BTPServiceOperatorConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CertManagerConfig) DeepCopyInto(out *CertManagerConfig) { + *out = *in + if in.Chart != nil { + in, out := &in.Chart, &out.Chart + *out = new(ChartSpec) + **out = **in + } + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = new(v1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CertManagerConfig. +func (in *CertManagerConfig) DeepCopy() *CertManagerConfig { + if in == nil { + return nil + } + out := new(CertManagerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ChartSpec) DeepCopyInto(out *ChartSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChartSpec. +func (in *ChartSpec) DeepCopy() *ChartSpec { + if in == nil { + return nil + } + out := new(ChartSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Component) DeepCopyInto(out *Component) { + *out = *in + if in.Versions != nil { + in, out := &in.Versions, &out.Versions + *out = make([]ComponentVersion, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Component. +func (in *Component) DeepCopy() *Component { + if in == nil { + return nil + } + out := new(Component) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ComponentVersion) DeepCopyInto(out *ComponentVersion) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ComponentVersion. +func (in *ComponentVersion) DeepCopy() *ComponentVersion { + if in == nil { + return nil + } + out := new(ComponentVersion) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ComponentsConfig) DeepCopyInto(out *ComponentsConfig) { + *out = *in + if in.Crossplane != nil { + in, out := &in.Crossplane, &out.Crossplane + *out = new(CrossplaneConfig) + (*in).DeepCopyInto(*out) + } + if in.BTPServiceOperator != nil { + in, out := &in.BTPServiceOperator, &out.BTPServiceOperator + *out = new(BTPServiceOperatorConfig) + (*in).DeepCopyInto(*out) + } + if in.CertManager != nil { + in, out := &in.CertManager, &out.CertManager + *out = new(CertManagerConfig) + (*in).DeepCopyInto(*out) + } + if in.ExternalSecretsOperator != nil { + in, out := &in.ExternalSecretsOperator, &out.ExternalSecretsOperator + *out = new(ExternalSecretsOperatorConfig) + (*in).DeepCopyInto(*out) + } + if in.Kyverno != nil { + in, out := &in.Kyverno, &out.Kyverno + *out = new(KyvernoConfig) + (*in).DeepCopyInto(*out) + } + if in.Flux != nil { + in, out := &in.Flux, &out.Flux + *out = new(FluxConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ComponentsConfig. +func (in *ComponentsConfig) DeepCopy() *ComponentsConfig { + if in == nil { + return nil + } + out := new(ComponentsConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControlPlane) DeepCopyInto(out *ControlPlane) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControlPlane. +func (in *ControlPlane) DeepCopy() *ControlPlane { + if in == nil { + return nil + } + out := new(ControlPlane) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ControlPlane) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControlPlaneList) DeepCopyInto(out *ControlPlaneList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ControlPlane, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControlPlaneList. +func (in *ControlPlaneList) DeepCopy() *ControlPlaneList { + if in == nil { + return nil + } + out := new(ControlPlaneList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ControlPlaneList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControlPlaneSpec) DeepCopyInto(out *ControlPlaneSpec) { + *out = *in + out.CoreReference = in.CoreReference + in.Target.DeepCopyInto(&out.Target) + if in.Telemetry != nil { + in, out := &in.Telemetry, &out.Telemetry + *out = new(TelemetryConfig) + **out = **in + } + if in.PullSecrets != nil { + in, out := &in.PullSecrets, &out.PullSecrets + *out = make([]corev1.LocalObjectReference, len(*in)) + copy(*out, *in) + } + in.ComponentsConfig.DeepCopyInto(&out.ComponentsConfig) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControlPlaneSpec. +func (in *ControlPlaneSpec) DeepCopy() *ControlPlaneSpec { + if in == nil { + return nil + } + out := new(ControlPlaneSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControlPlaneStatus) DeepCopyInto(out *ControlPlaneStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControlPlaneStatus. +func (in *ControlPlaneStatus) DeepCopy() *ControlPlaneStatus { + if in == nil { + return nil + } + out := new(ControlPlaneStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CrossplaneConfig) DeepCopyInto(out *CrossplaneConfig) { + *out = *in + if in.Chart != nil { + in, out := &in.Chart, &out.Chart + *out = new(ChartSpec) + **out = **in + } + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = new(v1.JSON) + (*in).DeepCopyInto(*out) + } + if in.Providers != nil { + in, out := &in.Providers, &out.Providers + *out = make([]*CrossplaneProviderConfig, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(CrossplaneProviderConfig) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CrossplaneConfig. +func (in *CrossplaneConfig) DeepCopy() *CrossplaneConfig { + if in == nil { + return nil + } + out := new(CrossplaneConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CrossplanePackageRestriction) DeepCopyInto(out *CrossplanePackageRestriction) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CrossplanePackageRestriction. +func (in *CrossplanePackageRestriction) DeepCopy() *CrossplanePackageRestriction { + if in == nil { + return nil + } + out := new(CrossplanePackageRestriction) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CrossplanePackageRestriction) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CrossplanePackageRestrictionList) DeepCopyInto(out *CrossplanePackageRestrictionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CrossplanePackageRestriction, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CrossplanePackageRestrictionList. +func (in *CrossplanePackageRestrictionList) DeepCopy() *CrossplanePackageRestrictionList { + if in == nil { + return nil + } + out := new(CrossplanePackageRestrictionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CrossplanePackageRestrictionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CrossplanePackageRestrictionSpec) DeepCopyInto(out *CrossplanePackageRestrictionSpec) { + *out = *in + in.Providers.DeepCopyInto(&out.Providers) + in.Configurations.DeepCopyInto(&out.Configurations) + in.Functions.DeepCopyInto(&out.Functions) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CrossplanePackageRestrictionSpec. +func (in *CrossplanePackageRestrictionSpec) DeepCopy() *CrossplanePackageRestrictionSpec { + if in == nil { + return nil + } + out := new(CrossplanePackageRestrictionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CrossplanePackageRestrictionStatus) DeepCopyInto(out *CrossplanePackageRestrictionStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CrossplanePackageRestrictionStatus. +func (in *CrossplanePackageRestrictionStatus) DeepCopy() *CrossplanePackageRestrictionStatus { + if in == nil { + return nil + } + out := new(CrossplanePackageRestrictionStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CrossplaneProviderConfig) DeepCopyInto(out *CrossplaneProviderConfig) { + *out = *in + if in.PackagePullPolicy != nil { + in, out := &in.PackagePullPolicy, &out.PackagePullPolicy + *out = new(corev1.PullPolicy) + **out = **in + } + if in.PackagePullSecrets != nil { + in, out := &in.PackagePullSecrets, &out.PackagePullSecrets + *out = make([]corev1.LocalObjectReference, len(*in)) + copy(*out, *in) + } + in.PackageRuntimeSpec.DeepCopyInto(&out.PackageRuntimeSpec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CrossplaneProviderConfig. +func (in *CrossplaneProviderConfig) DeepCopy() *CrossplaneProviderConfig { + if in == nil { + return nil + } + out := new(CrossplaneProviderConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalSecretsOperatorConfig) DeepCopyInto(out *ExternalSecretsOperatorConfig) { + *out = *in + if in.Chart != nil { + in, out := &in.Chart, &out.Chart + *out = new(ChartSpec) + **out = **in + } + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = new(v1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalSecretsOperatorConfig. +func (in *ExternalSecretsOperatorConfig) DeepCopy() *ExternalSecretsOperatorConfig { + if in == nil { + return nil + } + out := new(ExternalSecretsOperatorConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FluxConfig) DeepCopyInto(out *FluxConfig) { + *out = *in + if in.Chart != nil { + in, out := &in.Chart, &out.Chart + *out = new(ChartSpec) + **out = **in + } + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = new(v1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FluxConfig. +func (in *FluxConfig) DeepCopy() *FluxConfig { + if in == nil { + return nil + } + out := new(FluxConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeconfigOverrides) DeepCopyInto(out *KubeconfigOverrides) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeconfigOverrides. +func (in *KubeconfigOverrides) DeepCopy() *KubeconfigOverrides { + if in == nil { + return nil + } + out := new(KubeconfigOverrides) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KyvernoConfig) DeepCopyInto(out *KyvernoConfig) { + *out = *in + if in.Chart != nil { + in, out := &in.Chart, &out.Chart + *out = new(ChartSpec) + **out = **in + } + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = new(v1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KyvernoConfig. +func (in *KyvernoConfig) DeepCopy() *KyvernoConfig { + if in == nil { + return nil + } + out := new(KyvernoConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PackageRestriction) DeepCopyInto(out *PackageRestriction) { + *out = *in + if in.Registries != nil { + in, out := &in.Registries, &out.Registries + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Packages != nil { + in, out := &in.Packages, &out.Packages + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageRestriction. +func (in *PackageRestriction) DeepCopy() *PackageRestriction { + if in == nil { + return nil + } + out := new(PackageRestriction) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseChannel) DeepCopyInto(out *ReleaseChannel) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseChannel. +func (in *ReleaseChannel) DeepCopy() *ReleaseChannel { + if in == nil { + return nil + } + out := new(ReleaseChannel) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ReleaseChannel) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseChannelList) DeepCopyInto(out *ReleaseChannelList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ReleaseChannel, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseChannelList. +func (in *ReleaseChannelList) DeepCopy() *ReleaseChannelList { + if in == nil { + return nil + } + out := new(ReleaseChannelList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ReleaseChannelList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseChannelSpec) DeepCopyInto(out *ReleaseChannelSpec) { + *out = *in + out.PullSecretRef = in.PullSecretRef + out.OcmRegistrySecretRef = in.OcmRegistrySecretRef + out.Interval = in.Interval +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseChannelSpec. +func (in *ReleaseChannelSpec) DeepCopy() *ReleaseChannelSpec { + if in == nil { + return nil + } + out := new(ReleaseChannelSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReleaseChannelStatus) DeepCopyInto(out *ReleaseChannelStatus) { + *out = *in + if in.Components != nil { + in, out := &in.Components, &out.Components + *out = make([]Component, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleaseChannelStatus. +func (in *ReleaseChannelStatus) DeepCopy() *ReleaseChannelStatus { + if in == nil { + return nil + } + out := new(ReleaseChannelStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceAccountReference) DeepCopyInto(out *ServiceAccountReference) { + *out = *in + out.Overrides = in.Overrides +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountReference. +func (in *ServiceAccountReference) DeepCopy() *ServiceAccountReference { + if in == nil { + return nil + } + out := new(ServiceAccountReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Target) DeepCopyInto(out *Target) { + *out = *in + in.Target.DeepCopyInto(&out.Target) + out.FluxServiceAccount = in.FluxServiceAccount +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Target. +func (in *Target) DeepCopy() *Target { + if in == nil { + return nil + } + out := new(Target) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TelemetryConfig) DeepCopyInto(out *TelemetryConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TelemetryConfig. +func (in *TelemetryConfig) DeepCopy() *TelemetryConfig { + if in == nil { + return nil + } + out := new(TelemetryConfig) + in.DeepCopyInto(out) + return out +} diff --git a/charts/control-plane-operator/.helmignore b/charts/control-plane-operator/.helmignore new file mode 100644 index 0000000..684b32b --- /dev/null +++ b/charts/control-plane-operator/.helmignore @@ -0,0 +1,24 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ +README.md diff --git a/charts/control-plane-operator/Chart.yaml b/charts/control-plane-operator/Chart.yaml new file mode 100644 index 0000000..ddc21d7 --- /dev/null +++ b/charts/control-plane-operator/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: co-control-plane-operator +description: A Helm chart for the Cloud Orchestration Control Plane Operator +type: application +version: 0.1.3 +appVersion: 0.1.3 +home: https://github.com/openmcp-project/control-plane-operator +sources: + - https://github.com/openmcp-project/control-plane-operator diff --git a/charts/control-plane-operator/Chart.yaml.tpl b/charts/control-plane-operator/Chart.yaml.tpl new file mode 100644 index 0000000..43e77ab --- /dev/null +++ b/charts/control-plane-operator/Chart.yaml.tpl @@ -0,0 +1,9 @@ +apiVersion: v2 +name: co-control-plane-operator +description: A Helm chart for the Cloud Orchestration Control Plane Operator +type: application +version: $OPERATOR_VERSION +appVersion: $OPERATOR_VERSION +home: https://github.com/openmcp-project/control-plane-operator +sources: + - https://github.com/openmcp-project/control-plane-operator diff --git a/charts/control-plane-operator/templates/.gitignore b/charts/control-plane-operator/templates/.gitignore new file mode 100644 index 0000000..184f2b4 --- /dev/null +++ b/charts/control-plane-operator/templates/.gitignore @@ -0,0 +1,2 @@ +.idea/* +README.md diff --git a/charts/control-plane-operator/templates/_helpers.tpl b/charts/control-plane-operator/templates/_helpers.tpl new file mode 100644 index 0000000..c57b744 --- /dev/null +++ b/charts/control-plane-operator/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "operator.labels" -}} +helm.sh/chart: {{ include "operator.chart" . }} +{{ include "operator.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "operator.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/control-plane-operator/templates/deployment.yaml b/charts/control-plane-operator/templates/deployment.yaml new file mode 100644 index 0000000..aeb883d --- /dev/null +++ b/charts/control-plane-operator/templates/deployment.yaml @@ -0,0 +1,230 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "operator.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "operator.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "operator.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- if .Values.init.enabled }} + initContainers: + - name: {{ .Chart.Name }}-init + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - init + {{- if .Values.crds.manage }} + - "--install-crds" + {{- end }} + {{- if .Values.webhooks.manage }} + - "--install-webhooks" + {{- if .Values.webhooks.url }} + - "--webhooks-base-url={{ .Values.webhooks.url }}" + - "--webhooks-without-ca" + {{- end }} + {{- end }} + {{- with .Values.init.args }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.init.extraArgs }} + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_SERVICE_ACCOUNT + valueFrom: + fieldRef: + fieldPath: spec.serviceAccountName + {{- if .Values.webhooks.manage }} + - name: WEBHOOK_SECRET_NAME + value: {{ include "operator.fullname" . }}-webhooks-tls + - name: WEBHOOK_SECRET_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: WEBHOOK_SERVICE_NAME + value: {{ include "operator.fullname" . }}-webhooks + - name: WEBHOOK_SERVICE_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + {{- end }} + {{- with .Values.init.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.init.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- range .Values.clusters }} + - name: {{ upper .name }}_CLUSTER_HOST + value: {{ quote .url }} + - name: {{ upper .name }}_CLUSTER_CONFIG_DIR + value: /var/run/secrets/{{ .name }}-cluster + {{- end }} + volumeMounts: + {{- with .Values.init.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.init.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- range .Values.clusters }} + - mountPath: /var/run/secrets/{{ .name }}-cluster + name: projected-token-{{ .name }} + {{- end }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - start + - "--metrics-bind-address={{ .Values.metrics.listen.host }}:{{ .Values.metrics.listen.port }}" + {{- if .Values.webhooks.listen }} + - "--webhooks-bind-address={{ .Values.webhooks.listen.host }}:{{ .Values.webhooks.listen.port }}" + {{- end }} + {{- with .Values.manager.args }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.manager.extraArgs }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{ if .Values.syncPeriod }} + - "--sync-period={{ .Values.syncPeriod }}" + {{ end }} + ports: + {{- if .Values.webhooks.listen }} + - name: webhooks-https + containerPort: {{ .Values.webhooks.listen.port }} + protocol: TCP + {{- end }} + - name: metrics-http + containerPort: {{ .Values.metrics.listen.port }} + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + {{- if .Values.webhooks.listen }} + - name: webhooks-tls + mountPath: /tmp/k8s-webhook-server/serving-certs + readOnly: true + {{- end }} + {{- with .Values.manager.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.manager.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- range .Values.clusters }} + - mountPath: /var/run/secrets/{{ .name }}-cluster + name: projected-token-{{ .name }} + {{- end }} + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_SERVICE_ACCOUNT + valueFrom: + fieldRef: + fieldPath: spec.serviceAccountName + {{- with .Values.manager.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.manager.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- range .Values.clusters }} + - name: {{ upper .name }}_CLUSTER_HOST + value: {{ quote .url }} + - name: {{ upper .name }}_CLUSTER_CONFIG_DIR + value: /var/run/secrets/{{ .name }}-cluster + {{- end }} + volumes: + {{- if .Values.webhooks.listen }} + - name: webhooks-tls + secret: + defaultMode: 420 + secretName: {{ include "operator.fullname" . }}-webhooks-tls + {{- end }} + {{- with .Values.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- range .Values.clusters }} + - name: projected-token-{{ .name }} + projected: + sources: + - serviceAccountToken: + path: token + expirationSeconds: 7200 + audience: {{ .audience }} + - configMap: + name: {{ .caConfigMapName }} + items: + - key: ca.crt + path: ca.crt + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/control-plane-operator/templates/rbac.yaml b/charts/control-plane-operator/templates/rbac.yaml new file mode 100644 index 0000000..b928574 --- /dev/null +++ b/charts/control-plane-operator/templates/rbac.yaml @@ -0,0 +1,66 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +rules: + - apiGroups: ["admissionregistration.k8s.io"] + resources: + - validatingwebhookconfigurations + - mutatingwebhookconfigurations + verbs: ["*"] + - apiGroups: ["apiextensions.k8s.io"] + resources: + - customresourcedefinitions + verbs: ["*"] + {{- with .Values.rbac.clusterRole.rules }} + {{- toYaml . | nindent 2 }} + {{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "operator.fullname" . }} +subjects: +- kind: ServiceAccount + name: {{ include "operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["*"] + resourceNames: + - {{ include "operator.fullname" . }}-webhooks-tls + {{- with .Values.rbac.role.rules }} + {{- toYaml . | nindent 2 }} + {{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "operator.fullname" . }} +subjects: +- kind: ServiceAccount + name: {{ include "operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +--- diff --git a/charts/control-plane-operator/templates/secret-webhooks.yaml b/charts/control-plane-operator/templates/secret-webhooks.yaml new file mode 100644 index 0000000..7148049 --- /dev/null +++ b/charts/control-plane-operator/templates/secret-webhooks.yaml @@ -0,0 +1,9 @@ +{{- if .Values.webhooks.listen }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "operator.fullname" . }}-webhooks-tls + labels: + {{- include "operator.labels" . | nindent 4 }} +type: Opaque +{{- end }} diff --git a/charts/control-plane-operator/templates/service-metrics.yaml b/charts/control-plane-operator/templates/service-metrics.yaml new file mode 100644 index 0000000..6d7360a --- /dev/null +++ b/charts/control-plane-operator/templates/service-metrics.yaml @@ -0,0 +1,17 @@ +{{- if .Values.metrics.service.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "operator.fullname" . }}-metrics + labels: + {{- include "operator.labels" . | nindent 4 }} +spec: + type: {{ .Values.metrics.service.type }} + ports: + - port: {{ .Values.metrics.service.port }} + targetPort: metrics-http + protocol: TCP + name: http + selector: + {{- include "operator.selectorLabels" . | nindent 4 }} +{{- end -}} diff --git a/charts/control-plane-operator/templates/service-webhooks.yaml b/charts/control-plane-operator/templates/service-webhooks.yaml new file mode 100644 index 0000000..4a79d50 --- /dev/null +++ b/charts/control-plane-operator/templates/service-webhooks.yaml @@ -0,0 +1,17 @@ +{{- if .Values.webhooks.service.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "operator.fullname" . }}-webhooks + labels: + {{- include "operator.labels" . | nindent 4 }} +spec: + type: {{ .Values.webhooks.service.type }} + ports: + - port: {{ .Values.webhooks.service.port }} + targetPort: webhooks-https + protocol: TCP + name: https + selector: + {{- include "operator.selectorLabels" . | nindent 4 }} +{{- end -}} diff --git a/charts/control-plane-operator/templates/serviceaccount.yaml b/charts/control-plane-operator/templates/serviceaccount.yaml new file mode 100644 index 0000000..a0d3118 --- /dev/null +++ b/charts/control-plane-operator/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "operator.serviceAccountName" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/charts/control-plane-operator/values.yaml b/charts/control-plane-operator/values.yaml new file mode 100644 index 0000000..0112975 --- /dev/null +++ b/charts/control-plane-operator/values.yaml @@ -0,0 +1,155 @@ +# Default values for control-plane-operator. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: deploy-releases-hyperspace-docker.common.repositories.cloud.sap/cloud-orchestration/control-plane-operator + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: 0.1.3 + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" +syncPeriod: 1m + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +init: + enabled: true + + args: [] + extraArgs: [] + + env: [] + # Extra environment variables to add to the init container. + extraEnv: [] + + # Volumes to mount to the init container. + volumeMounts: [] + # Extra volumes to mount to the init container. + extraVolumeMounts: [] + +manager: + args: [] + extraArgs: [] + + env: [] + # Extra environment variables to add to the manager container. + extraEnv: [] + + # Volumes to mount to the manager container. + volumeMounts: [] + # Extra volumes to mount to the manager container. + extraVolumeMounts: [] + +# Volumes to pass to pod. +volumes: [] + +# Extra volumes to pass to pod. +extraVolumes: [] + +podAnnotations: {} +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: + runAsNonRoot: true + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsUser: 1000 + +resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +crds: + manage: true + +metrics: + listen: + port: 8080 + service: + enabled: false + port: 8080 + type: ClusterIP + annotations: {} + +webhooks: + manage: true + url: "" + listen: + port: 9443 + service: + enabled: true + port: 443 + type: ClusterIP + annotations: {} + +rbac: + clusterRole: + rules: + - apiGroups: + - core.orchestrate.cloud.sap + resources: + - controlplanes + - controlplanes/status + - controlplanes/finalizers + - releasechannels + - releasechannels/status + - releasechannels/finalizers + - crossplanepackagerestrictions + - crossplanepackagerestrictions/status + - crossplanepackagerestrictions/finalizers + verbs: + - "*" + - apiGroups: + - "" + resources: + - namespaces + - secrets + - serviceaccounts # local deployment + - events + verbs: + - "*" + - apiGroups: + - rbac.authorization.k8s.io + resources: + - clusterrolebindings # local deployment + verbs: + - "*" + - apiGroups: + - helm.toolkit.fluxcd.io + - kustomize.toolkit.fluxcd.io + - source.toolkit.fluxcd.io + resources: + - "*" + verbs: + - "*" + role: + rules: [] + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/charts/control-plane-operator/values.yaml.tpl b/charts/control-plane-operator/values.yaml.tpl new file mode 100644 index 0000000..2f97a79 --- /dev/null +++ b/charts/control-plane-operator/values.yaml.tpl @@ -0,0 +1,155 @@ +# Default values for control-plane-operator. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: deploy-releases-hyperspace-docker.common.repositories.cloud.sap/cloud-orchestration/control-plane-operator + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: $OPERATOR_VERSION + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" +syncPeriod: 1m + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +init: + enabled: true + + args: [] + extraArgs: [] + + env: [] + # Extra environment variables to add to the init container. + extraEnv: [] + + # Volumes to mount to the init container. + volumeMounts: [] + # Extra volumes to mount to the init container. + extraVolumeMounts: [] + +manager: + args: [] + extraArgs: [] + + env: [] + # Extra environment variables to add to the manager container. + extraEnv: [] + + # Volumes to mount to the manager container. + volumeMounts: [] + # Extra volumes to mount to the manager container. + extraVolumeMounts: [] + +# Volumes to pass to pod. +volumes: [] + +# Extra volumes to pass to pod. +extraVolumes: [] + +podAnnotations: {} +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: + runAsNonRoot: true + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsUser: 1000 + +resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +crds: + manage: true + +metrics: + listen: + port: 8080 + service: + enabled: false + port: 8080 + type: ClusterIP + annotations: {} + +webhooks: + manage: true + url: "" + listen: + port: 9443 + service: + enabled: true + port: 443 + type: ClusterIP + annotations: {} + +rbac: + clusterRole: + rules: + - apiGroups: + - core.orchestrate.cloud.sap + resources: + - controlplanes + - controlplanes/status + - controlplanes/finalizers + - releasechannels + - releasechannels/status + - releasechannels/finalizers + - crossplanepackagerestrictions + - crossplanepackagerestrictions/status + - crossplanepackagerestrictions/finalizers + verbs: + - "*" + - apiGroups: + - "" + resources: + - namespaces + - secrets + - serviceaccounts # local deployment + - events + verbs: + - "*" + - apiGroups: + - rbac.authorization.k8s.io + resources: + - clusterrolebindings # local deployment + verbs: + - "*" + - apiGroups: + - helm.toolkit.fluxcd.io + - kustomize.toolkit.fluxcd.io + - source.toolkit.fluxcd.io + resources: + - "*" + verbs: + - "*" + role: + rules: [] + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/cmd/embedded/crds/core.orchestrate.cloud.sap_controlplanes.yaml b/cmd/embedded/crds/core.orchestrate.cloud.sap_controlplanes.yaml new file mode 100644 index 0000000..66e8d58 --- /dev/null +++ b/cmd/embedded/crds/core.orchestrate.cloud.sap_controlplanes.yaml @@ -0,0 +1,539 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: controlplanes.core.orchestrate.cloud.sap +spec: + group: core.orchestrate.cloud.sap + names: + kind: ControlPlane + listKind: ControlPlaneList + plural: controlplanes + shortNames: + - cp + singular: controlplane + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.componentsHealthy + name: Components Healthy + type: integer + - jsonPath: .status.componentsEnabled + name: Components Enabled + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: ControlPlane is the Schema for the ControlPlane API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ControlPlaneSpec defines the desired state of ControlPlane + properties: + btpServiceOperator: + description: |- + Configuration for the BTP Service Operator. More info: + https://github.com/SAP/sap-btp-service-operator + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the BTP Service Operator Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of BTP Service Operator to install. + type: string + required: + - version + type: object + certManager: + description: |- + CertManager configures the cert-manager component. More info: + https://cert-manager.io/ + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the cert-manager Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of the cert-manager to install. + type: string + required: + - version + type: object + coreRef: + default: + name: default + description: Reference to a core configuration + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + crossplane: + description: Configuration for the Crossplane installation of this + ControlPlane. + properties: + chart: + description: Optional custom Helm chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + providers: + description: List of Crossplane providers to be installed. + items: + description: |- + CrossplaneProviderConfig represents configuration for Crossplane providers in a ControlPlane. + Primarily based on the Crossplane open source API. + properties: + controllerConfigRef: + description: |- + ControllerConfigRef references a ControllerConfig resource that will be + used to configure the packaged controller Deployment. + Deprecated: Use RuntimeConfigReference instead. + properties: + name: + description: Name of the ControllerConfig. + type: string + required: + - name + type: object + name: + description: |- + Name of the provider. + Using a well-known name will automatically configure the "package" field. + type: string + package: + description: |- + Provider package to be installed. + If "name" is set to a well-known value, this field will be configured automatically. + type: string + packagePullPolicy: + default: IfNotPresent + description: |- + Pull policy for the provider. + One of Always, Never, IfNotPresent. + enum: + - Always + - Never + - IfNotPresent + type: string + packagePullSecrets: + description: PackagePullSecrets are named secrets in the + same namespace that can be used to fetch packages from + private registries. + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + runtimeConfigRef: + default: + name: default + description: |- + RuntimeConfigRef references a RuntimeConfig resource that will be used + to configure the package runtime. + properties: + apiVersion: + default: pkg.crossplane.io/v1beta1 + description: API version of the referent. + type: string + kind: + default: DeploymentRuntimeConfig + description: Kind of the referent. + type: string + name: + description: Name of the RuntimeConfig. + type: string + required: + - name + type: object + version: + description: Version of the provider to install. + type: string + required: + - name + - version + type: object + type: array + values: + description: Optional additional values that should be passed + to the Crossplane Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of Crossplane to install. + type: string + required: + - version + type: object + externalSecretsOperator: + description: |- + Configuration for the External Secrets Operator. More info: + https://external-secrets.io + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the External Secrets Operator Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of External Secrets Operator to install. + type: string + required: + - version + type: object + flux: + description: |- + Configuration for Flux. More info: + https://fluxcd.io/ + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the Flux Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of Flux to install. + type: string + required: + - version + type: object + kyverno: + description: |- + Configuration for Kyverno. More info: + https://kyverno.io/ + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the Kyverno Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of Kyverno to install. + type: string + required: + - version + type: object + pullSecrets: + description: Pull secrets which will be used when pulling charts, + providers, etc. + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + target: + description: Configuration of the ControlPlane target (local or remote + cluster) + properties: + fluxServiceAccount: + description: FluxServiceAccount is a reference to a service account + that should be used by Flux. + properties: + name: + description: Name is the name of the service account. + minLength: 1 + type: string + namespace: + description: Namespace is the namespace of the service account. + minLength: 1 + type: string + overrides: + description: Overrides specifies fields that should be overwritten + when a kubeconfig is generated from this ServiceAccountReference. + properties: + host: + description: Host must be a host string, a host:port pair, + or a URL to the base of the apiserver. + type: string + type: object + required: + - name + - namespace + type: object + kubeconfig: + description: Kubeconfig is an inline kubeconfig. + x-kubernetes-preserve-unknown-fields: true + kubeconfigFile: + description: KubeconfigFile is a path to a file containing a kubeconfig. + type: string + kubeconfigRef: + description: KubeconfigRef is a reference to a Kubernetes secret + that contains a kubeconfig. + properties: + key: + default: kubeconfig + description: The key of the secret to select from. Must be + a valid secret key. + type: string + name: + description: name is unique within a namespace to reference + a secret resource. + type: string + namespace: + description: namespace defines the space within which the + secret name must be unique. + type: string + required: + - key + type: object + x-kubernetes-map-type: atomic + serviceAccount: + description: ServiceAccount references a local service account. + properties: + caData: + description: |- + CAData holds (Base64-)PEM-encoded bytes. + CAData takes precedence over CAFile. + This value is optional. If not provided, the CAData of the in-cluster config will be used. + Providing an empty string means that the operating system's defaults root certificates will be used. + type: string + caFile: + description: |- + CAFile points to a file containing the root certificates for the API server. + This value is optional. If not provided, the value of CAData will be used. + type: string + host: + description: |- + Host must be a host string, a host:port pair, or a URL to the base of the apiserver. + This value is optional. If not provided, the local API server will be used. + type: string + name: + description: |- + Name is the name of the service account. + This value is optional. If not provided, the pod's service account will be used. + type: string + namespace: + description: |- + Namespace is the name of the service account. + This value is optional. If not provided, the pod's service account will be used. + type: string + tokenFile: + description: |- + TokenFile points to a file containing a bearer token (e.g. projected service account token (PSAT) with custom audience) to be used for authentication against the API server. + If provided, all other authentication methods (Basic, client-side TLS, etc.) will be disabled. + type: string + type: object + required: + - fluxServiceAccount + type: object + telemetry: + description: Configuration for the telemetry. + properties: + enabled: + description: Enables or disables telemetry. + type: boolean + type: object + required: + - target + type: object + status: + description: ControlPlaneStatus defines the observed state of ControlPlane + properties: + componentsEnabled: + description: Number of enabled components. + type: integer + componentsHealthy: + description: Number of healthy components. + type: integer + conditions: + description: Current service state of the ControlPlane. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + namespace: + description: Namespace that contains resources related to the ControlPlane. + type: string + required: + - componentsEnabled + - componentsHealthy + - conditions + - namespace + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/cmd/embedded/crds/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml b/cmd/embedded/crds/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml new file mode 100644 index 0000000..516b788 --- /dev/null +++ b/cmd/embedded/crds/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml @@ -0,0 +1,108 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: crossplanepackagerestrictions.core.orchestrate.cloud.sap +spec: + group: core.orchestrate.cloud.sap + names: + kind: CrossplanePackageRestriction + listKind: CrossplanePackageRestrictionList + plural: crossplanepackagerestrictions + singular: crossplanepackagerestriction + scope: Cluster + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: CrossplanePackageRestriction is the Schema for the crossplanepackagerestrictions + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: CrossplanePackageRestrictionSpec defines the desired state + of CrossplanePackageRestriction + properties: + configurations: + description: |- + PackageRestriction restricts a package type (e.g. providers) to certain registries or literal packages. + If both Registries and Packages are empty, no packages of this type will be allowed. + properties: + packages: + items: + type: string + type: array + registries: + items: + type: string + type: array + required: + - packages + - registries + type: object + functions: + description: |- + PackageRestriction restricts a package type (e.g. providers) to certain registries or literal packages. + If both Registries and Packages are empty, no packages of this type will be allowed. + properties: + packages: + items: + type: string + type: array + registries: + items: + type: string + type: array + required: + - packages + - registries + type: object + providers: + description: |- + PackageRestriction restricts a package type (e.g. providers) to certain registries or literal packages. + If both Registries and Packages are empty, no packages of this type will be allowed. + properties: + packages: + items: + type: string + type: array + registries: + items: + type: string + type: array + required: + - packages + - registries + type: object + required: + - configurations + - functions + - providers + type: object + status: + description: CrossplanePackageRestrictionStatus defines the observed state + of CrossplanePackageRestriction + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/cmd/embedded/crds/core.orchestrate.cloud.sap_releasechannels.yaml b/cmd/embedded/crds/core.orchestrate.cloud.sap_releasechannels.yaml new file mode 100644 index 0000000..4aa668a --- /dev/null +++ b/cmd/embedded/crds/core.orchestrate.cloud.sap_releasechannels.yaml @@ -0,0 +1,155 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: releasechannels.core.orchestrate.cloud.sap +spec: + group: core.orchestrate.cloud.sap + names: + kind: ReleaseChannel + listKind: ReleaseChannelList + plural: releasechannels + shortNames: + - rc + singular: releasechannel + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: ReleaseChannel is the Schema for the ReleaseChannel API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ReleaseChannelSpec defines the desired state of ReleaseChannel + properties: + interval: + default: 15m + description: Interval specifies the timespan when the registry is + checked again + type: string + ocmRegistrySecretKey: + description: |- + Here you must specify the key which contains the tar based ocm registry in the referenced secret. + Required, if ocmRegistrySecretRef is specified. + type: string + ocmRegistrySecretRef: + description: |- + This parameter can be used for a tar based ocm registry in a secret. + The secret referenced here must contain a key where a tar based ocm registry is stored in. + properties: + name: + description: name is unique within a namespace to reference a + secret resource. + type: string + namespace: + description: namespace defines the space within which the secret + name must be unique. + type: string + type: object + x-kubernetes-map-type: atomic + ocmRegistryUrl: + description: Specify a ocm registry url where the releasechannel components + are uploaded + minLength: 1 + type: string + prefixFilter: + description: |- + When specified only components starting with this prefix will be fetched. + Also this prefix will be cut from the componentNames in the status field. + type: string + pullSecretRef: + description: |- + This should be a reference to a secret, which has the `username` and `password` keys. + If specified, will be used when accessing the ocmRegistry specified in ocmRegistryUrl. + properties: + name: + description: name is unique within a namespace to reference a + secret resource. + type: string + namespace: + description: namespace defines the space within which the secret + name must be unique. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - interval + type: object + status: + description: ReleaseChannelStatus defines the observed state of ReleaseChannel + properties: + components: + description: The components which are inside the ocm registry + items: + properties: + name: + description: Name of the component which can be used to install + it via the controlplane CR. + type: string + versions: + description: All available versions for that component. + items: + properties: + dockerRef: + description: if it's a Docker Image, this specifies the + Docker reference for pulling the image + type: string + helmChart: + description: if it's a helm chart, this specifies the + chart name + type: string + helmRepo: + description: if it's a helm chart, this specifies the + helm repo + type: string + version: + description: The version number for that ComponentVersion + type: string + required: + - version + type: object + type: array + required: + - name + - versions + type: object + type: array + type: object + type: object + x-kubernetes-validations: + - message: You can't specify 'ocmRegistryUrl' and 'ocmRegistrySecretRef' at + the same time, either use a remote ocm registry or a secret + rule: (!(has(self.spec.ocmRegistryUrl) && has(self.spec.ocmRegistrySecretRef))) + - message: You need to specify an 'ocmRegistrySecretKey' if you want to use + the 'ocmRegistrySecretRef'. + rule: (!(has(self.spec.ocmRegistrySecretRef) && !has(self.spec.ocmRegistrySecretKey))) + - message: If you specify a 'pullSecretRef' you must specify an 'ocmRegistryUrl' + otherwise the 'pullSecretRef' will not be used. + rule: (!(has(self.spec.pullSecretRef) && !has(self.spec.ocmRegistryUrl))) + served: true + storage: true + subresources: + status: {} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..b88c0b0 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,229 @@ +/* +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 main + +import ( + "embed" + "flag" + "os" + "time" + + "k8s.io/apimachinery/pkg/types" + + "golang.org/x/net/context" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openmcp-project/control-plane-operator/cmd/options" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/secretresolver" + + // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) + // to ensure that exec-entrypoint and run can make use of them. + _ "k8s.io/client-go/plugin/pkg/client/auth" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "github.com/openmcp-project/controller-utils/pkg/init/crds" + "github.com/openmcp-project/controller-utils/pkg/init/webhooks" + + corev1beta1 "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/internal/controller" + "github.com/openmcp-project/control-plane-operator/internal/schemes" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/kubeconfiggen" + //+kubebuilder:scaffold:imports +) + +var ( + setupLog = ctrl.Log.WithName("setup") + + //go:embed embedded/crds + crdFiles embed.FS + + crdFlags = crds.BindFlags(flag.CommandLine) + webhooksFlags = webhooks.BindFlags(flag.CommandLine) +) + +func runInit(setupClient client.Client) { + initContext := context.Background() + + if webhooksFlags.Install { + // Generate webhook certificate + if err := webhooks.GenerateCertificate(initContext, setupClient, webhooksFlags.CertOptions...); err != nil { + setupLog.Error(err, "unable to generate webhook certificates") + os.Exit(1) + } + + // Install webhooks + err := webhooks.Install( + initContext, + setupClient, + schemes.Local, + []client.Object{ + &corev1beta1.ControlPlane{}, + }, + ) + if err != nil { + setupLog.Error(err, "unable to configure webhooks") + os.Exit(1) + } + } + + if crdFlags.Install { + // Install CRDs + if err := crds.Install(initContext, setupClient, crdFiles); err != nil { + setupLog.Error(err, "unable to install Custom Resource Definitions") + os.Exit(1) + } + } +} + +func main() { + var metricsAddr string + var enableLeaderElection bool + var probeAddr string + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, + "Enable leader election for controller manager. "+ + "Enabling this will ensure there is only one active controller manager.") + opts := zap.Options{ + Development: true, + } + opts.BindFlags(flag.CommandLine) + + var syncPeriod string + flag.StringVar(&syncPeriod, "sync-period", "1m", "The period at which the controller will sync the resources.") + + // component flags + var webhookMiddlewareName string + flag.StringVar(&webhookMiddlewareName, "webhook-middleware-name", "", + "Name of the middleware that should be used for the webhooks.") + + var webhookMiddlewareNamespace string + flag.StringVar(&webhookMiddlewareNamespace, "webhook-middleware-namespace", "", + "Namespace of the middleware that should be used for the webhooks.") + + options.AddOptions() + + // skip os.Args[1] which is the command (start or init) + if err := flag.CommandLine.Parse(os.Args[2:]); err != nil { + setupLog.Error(err, "failed to parse flags") + os.Exit(1) + } + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + setupContext := context.Background() + + setupClient, err := client.New(ctrl.GetConfigOrDie(), client.Options{Scheme: schemes.Local}) + if err != nil { + setupLog.Error(err, "unable to create client") + os.Exit(1) + } + + if os.Args[1] == "init" { + runInit(setupClient) + return + } + + fluxSecretResolver := secretresolver.NewFluxSecretResolver(setupClient) + err = fluxSecretResolver.Start(setupContext) + if err != nil { + setupLog.Error(err, "failed to start FluxSecretResolver") + os.Exit(1) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: schemes.Local, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "c627d721.core.orchestrate.cloud.sap", + // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily + // when the Manager ends. This requires the binary to immediately end when the + // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly + // speeds up voluntary leader transitions as the new leader don't have to wait + // LeaseDuration time first. + // + // In the default scaffold provided, the program ends immediately after + // the manager stops, so would be fine to enable this option. However, + // if you are doing or is intended to do any operation such as perform cleanups + // after the manager stops then its usage might be unsafe. + // LeaderElectionReleaseOnCancel: true, + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + reconcilePeriod, errPeriod := time.ParseDuration(syncPeriod) + if errPeriod != nil { + reconcilePeriod = 1 * time.Minute + } + setupLog.Info("sync period set to", "syncPeriod", reconcilePeriod) + + if err = (&controller.ControlPlaneReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Kubeconfiggen: &kubeconfiggen.Default{}, + FluxSecretResolver: fluxSecretResolver, + WebhookMiddleware: types.NamespacedName{ + Namespace: webhookMiddlewareNamespace, + Name: webhookMiddlewareName, + }, + ReconcilePeriod: reconcilePeriod, + RemoteConfigBuilder: controller.NewRemoteConfigBuilder(), + Recorder: mgr.GetEventRecorderFor("controlplane-controller"), + EmbeddedCRDs: crdFiles, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ControlPlane") + os.Exit(1) + } + if err = (&controller.SecretReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Secret") + os.Exit(1) + } + //+kubebuilder:scaffold:builder + + if err = (&controller.ReleaseChannelReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Releasechannel") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} diff --git a/cmd/options/options.go b/cmd/options/options.go new file mode 100644 index 0000000..5c401ab --- /dev/null +++ b/cmd/options/options.go @@ -0,0 +1,29 @@ +package options + +import "flag" + +var ( + // enableDeploymentRuntimeConfigProtection is a flag to enable the + // Crossplane DeploymentRuntimeConfig protection feature for all control planes. + // When enabled, certain fields in the Crossplane DeploymentRuntimeConfig will be protected + // from being modified by the user. + // When disabled, users won't gain permissions to modify DeploymentRuntimeConfigs. + // Default is disabled. + enableDeploymentRuntimeConfigProtection = false +) + +// SetEnableDeploymentRuntimeConfigProtection sets the enableDeploymentRuntimeConfigProtection flag. +func SetEnableDeploymentRuntimeConfigProtection(enable bool) { + enableDeploymentRuntimeConfigProtection = enable +} + +// IsDeploymentRuntimeConfigProtectionEnabled returns the value of the enableDeploymentRuntimeConfigProtection flag. +func IsDeploymentRuntimeConfigProtectionEnabled() bool { + return enableDeploymentRuntimeConfigProtection +} + +// AddOptions adds the options to the flag set. +func AddOptions() { + flag.BoolVar(&enableDeploymentRuntimeConfigProtection, "enable-deploymentruntimeconfig-protection", false, + "Enable DeploymentRuntimeConfig protection feature for all control planes.") +} diff --git a/config/crd/bases/core.orchestrate.cloud.sap_controlplanes.yaml b/config/crd/bases/core.orchestrate.cloud.sap_controlplanes.yaml new file mode 100644 index 0000000..66e8d58 --- /dev/null +++ b/config/crd/bases/core.orchestrate.cloud.sap_controlplanes.yaml @@ -0,0 +1,539 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: controlplanes.core.orchestrate.cloud.sap +spec: + group: core.orchestrate.cloud.sap + names: + kind: ControlPlane + listKind: ControlPlaneList + plural: controlplanes + shortNames: + - cp + singular: controlplane + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.componentsHealthy + name: Components Healthy + type: integer + - jsonPath: .status.componentsEnabled + name: Components Enabled + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: ControlPlane is the Schema for the ControlPlane API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ControlPlaneSpec defines the desired state of ControlPlane + properties: + btpServiceOperator: + description: |- + Configuration for the BTP Service Operator. More info: + https://github.com/SAP/sap-btp-service-operator + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the BTP Service Operator Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of BTP Service Operator to install. + type: string + required: + - version + type: object + certManager: + description: |- + CertManager configures the cert-manager component. More info: + https://cert-manager.io/ + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the cert-manager Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of the cert-manager to install. + type: string + required: + - version + type: object + coreRef: + default: + name: default + description: Reference to a core configuration + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + crossplane: + description: Configuration for the Crossplane installation of this + ControlPlane. + properties: + chart: + description: Optional custom Helm chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + providers: + description: List of Crossplane providers to be installed. + items: + description: |- + CrossplaneProviderConfig represents configuration for Crossplane providers in a ControlPlane. + Primarily based on the Crossplane open source API. + properties: + controllerConfigRef: + description: |- + ControllerConfigRef references a ControllerConfig resource that will be + used to configure the packaged controller Deployment. + Deprecated: Use RuntimeConfigReference instead. + properties: + name: + description: Name of the ControllerConfig. + type: string + required: + - name + type: object + name: + description: |- + Name of the provider. + Using a well-known name will automatically configure the "package" field. + type: string + package: + description: |- + Provider package to be installed. + If "name" is set to a well-known value, this field will be configured automatically. + type: string + packagePullPolicy: + default: IfNotPresent + description: |- + Pull policy for the provider. + One of Always, Never, IfNotPresent. + enum: + - Always + - Never + - IfNotPresent + type: string + packagePullSecrets: + description: PackagePullSecrets are named secrets in the + same namespace that can be used to fetch packages from + private registries. + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + runtimeConfigRef: + default: + name: default + description: |- + RuntimeConfigRef references a RuntimeConfig resource that will be used + to configure the package runtime. + properties: + apiVersion: + default: pkg.crossplane.io/v1beta1 + description: API version of the referent. + type: string + kind: + default: DeploymentRuntimeConfig + description: Kind of the referent. + type: string + name: + description: Name of the RuntimeConfig. + type: string + required: + - name + type: object + version: + description: Version of the provider to install. + type: string + required: + - name + - version + type: object + type: array + values: + description: Optional additional values that should be passed + to the Crossplane Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of Crossplane to install. + type: string + required: + - version + type: object + externalSecretsOperator: + description: |- + Configuration for the External Secrets Operator. More info: + https://external-secrets.io + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the External Secrets Operator Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of External Secrets Operator to install. + type: string + required: + - version + type: object + flux: + description: |- + Configuration for Flux. More info: + https://fluxcd.io/ + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the Flux Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of Flux to install. + type: string + required: + - version + type: object + kyverno: + description: |- + Configuration for Kyverno. More info: + https://kyverno.io/ + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the Kyverno Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of Kyverno to install. + type: string + required: + - version + type: object + pullSecrets: + description: Pull secrets which will be used when pulling charts, + providers, etc. + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + target: + description: Configuration of the ControlPlane target (local or remote + cluster) + properties: + fluxServiceAccount: + description: FluxServiceAccount is a reference to a service account + that should be used by Flux. + properties: + name: + description: Name is the name of the service account. + minLength: 1 + type: string + namespace: + description: Namespace is the namespace of the service account. + minLength: 1 + type: string + overrides: + description: Overrides specifies fields that should be overwritten + when a kubeconfig is generated from this ServiceAccountReference. + properties: + host: + description: Host must be a host string, a host:port pair, + or a URL to the base of the apiserver. + type: string + type: object + required: + - name + - namespace + type: object + kubeconfig: + description: Kubeconfig is an inline kubeconfig. + x-kubernetes-preserve-unknown-fields: true + kubeconfigFile: + description: KubeconfigFile is a path to a file containing a kubeconfig. + type: string + kubeconfigRef: + description: KubeconfigRef is a reference to a Kubernetes secret + that contains a kubeconfig. + properties: + key: + default: kubeconfig + description: The key of the secret to select from. Must be + a valid secret key. + type: string + name: + description: name is unique within a namespace to reference + a secret resource. + type: string + namespace: + description: namespace defines the space within which the + secret name must be unique. + type: string + required: + - key + type: object + x-kubernetes-map-type: atomic + serviceAccount: + description: ServiceAccount references a local service account. + properties: + caData: + description: |- + CAData holds (Base64-)PEM-encoded bytes. + CAData takes precedence over CAFile. + This value is optional. If not provided, the CAData of the in-cluster config will be used. + Providing an empty string means that the operating system's defaults root certificates will be used. + type: string + caFile: + description: |- + CAFile points to a file containing the root certificates for the API server. + This value is optional. If not provided, the value of CAData will be used. + type: string + host: + description: |- + Host must be a host string, a host:port pair, or a URL to the base of the apiserver. + This value is optional. If not provided, the local API server will be used. + type: string + name: + description: |- + Name is the name of the service account. + This value is optional. If not provided, the pod's service account will be used. + type: string + namespace: + description: |- + Namespace is the name of the service account. + This value is optional. If not provided, the pod's service account will be used. + type: string + tokenFile: + description: |- + TokenFile points to a file containing a bearer token (e.g. projected service account token (PSAT) with custom audience) to be used for authentication against the API server. + If provided, all other authentication methods (Basic, client-side TLS, etc.) will be disabled. + type: string + type: object + required: + - fluxServiceAccount + type: object + telemetry: + description: Configuration for the telemetry. + properties: + enabled: + description: Enables or disables telemetry. + type: boolean + type: object + required: + - target + type: object + status: + description: ControlPlaneStatus defines the observed state of ControlPlane + properties: + componentsEnabled: + description: Number of enabled components. + type: integer + componentsHealthy: + description: Number of healthy components. + type: integer + conditions: + description: Current service state of the ControlPlane. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + namespace: + description: Namespace that contains resources related to the ControlPlane. + type: string + required: + - componentsEnabled + - componentsHealthy + - conditions + - namespace + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml b/config/crd/bases/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml new file mode 100644 index 0000000..516b788 --- /dev/null +++ b/config/crd/bases/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml @@ -0,0 +1,108 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: crossplanepackagerestrictions.core.orchestrate.cloud.sap +spec: + group: core.orchestrate.cloud.sap + names: + kind: CrossplanePackageRestriction + listKind: CrossplanePackageRestrictionList + plural: crossplanepackagerestrictions + singular: crossplanepackagerestriction + scope: Cluster + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: CrossplanePackageRestriction is the Schema for the crossplanepackagerestrictions + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: CrossplanePackageRestrictionSpec defines the desired state + of CrossplanePackageRestriction + properties: + configurations: + description: |- + PackageRestriction restricts a package type (e.g. providers) to certain registries or literal packages. + If both Registries and Packages are empty, no packages of this type will be allowed. + properties: + packages: + items: + type: string + type: array + registries: + items: + type: string + type: array + required: + - packages + - registries + type: object + functions: + description: |- + PackageRestriction restricts a package type (e.g. providers) to certain registries or literal packages. + If both Registries and Packages are empty, no packages of this type will be allowed. + properties: + packages: + items: + type: string + type: array + registries: + items: + type: string + type: array + required: + - packages + - registries + type: object + providers: + description: |- + PackageRestriction restricts a package type (e.g. providers) to certain registries or literal packages. + If both Registries and Packages are empty, no packages of this type will be allowed. + properties: + packages: + items: + type: string + type: array + registries: + items: + type: string + type: array + required: + - packages + - registries + type: object + required: + - configurations + - functions + - providers + type: object + status: + description: CrossplanePackageRestrictionStatus defines the observed state + of CrossplanePackageRestriction + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/core.orchestrate.cloud.sap_releasechannels.yaml b/config/crd/bases/core.orchestrate.cloud.sap_releasechannels.yaml new file mode 100644 index 0000000..4aa668a --- /dev/null +++ b/config/crd/bases/core.orchestrate.cloud.sap_releasechannels.yaml @@ -0,0 +1,155 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: releasechannels.core.orchestrate.cloud.sap +spec: + group: core.orchestrate.cloud.sap + names: + kind: ReleaseChannel + listKind: ReleaseChannelList + plural: releasechannels + shortNames: + - rc + singular: releasechannel + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: ReleaseChannel is the Schema for the ReleaseChannel API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ReleaseChannelSpec defines the desired state of ReleaseChannel + properties: + interval: + default: 15m + description: Interval specifies the timespan when the registry is + checked again + type: string + ocmRegistrySecretKey: + description: |- + Here you must specify the key which contains the tar based ocm registry in the referenced secret. + Required, if ocmRegistrySecretRef is specified. + type: string + ocmRegistrySecretRef: + description: |- + This parameter can be used for a tar based ocm registry in a secret. + The secret referenced here must contain a key where a tar based ocm registry is stored in. + properties: + name: + description: name is unique within a namespace to reference a + secret resource. + type: string + namespace: + description: namespace defines the space within which the secret + name must be unique. + type: string + type: object + x-kubernetes-map-type: atomic + ocmRegistryUrl: + description: Specify a ocm registry url where the releasechannel components + are uploaded + minLength: 1 + type: string + prefixFilter: + description: |- + When specified only components starting with this prefix will be fetched. + Also this prefix will be cut from the componentNames in the status field. + type: string + pullSecretRef: + description: |- + This should be a reference to a secret, which has the `username` and `password` keys. + If specified, will be used when accessing the ocmRegistry specified in ocmRegistryUrl. + properties: + name: + description: name is unique within a namespace to reference a + secret resource. + type: string + namespace: + description: namespace defines the space within which the secret + name must be unique. + type: string + type: object + x-kubernetes-map-type: atomic + required: + - interval + type: object + status: + description: ReleaseChannelStatus defines the observed state of ReleaseChannel + properties: + components: + description: The components which are inside the ocm registry + items: + properties: + name: + description: Name of the component which can be used to install + it via the controlplane CR. + type: string + versions: + description: All available versions for that component. + items: + properties: + dockerRef: + description: if it's a Docker Image, this specifies the + Docker reference for pulling the image + type: string + helmChart: + description: if it's a helm chart, this specifies the + chart name + type: string + helmRepo: + description: if it's a helm chart, this specifies the + helm repo + type: string + version: + description: The version number for that ComponentVersion + type: string + required: + - version + type: object + type: array + required: + - name + - versions + type: object + type: array + type: object + type: object + x-kubernetes-validations: + - message: You can't specify 'ocmRegistryUrl' and 'ocmRegistrySecretRef' at + the same time, either use a remote ocm registry or a secret + rule: (!(has(self.spec.ocmRegistryUrl) && has(self.spec.ocmRegistrySecretRef))) + - message: You need to specify an 'ocmRegistrySecretKey' if you want to use + the 'ocmRegistrySecretRef'. + rule: (!(has(self.spec.ocmRegistrySecretRef) && !has(self.spec.ocmRegistrySecretKey))) + - message: If you specify a 'pullSecretRef' you must specify an 'ocmRegistryUrl' + otherwise the 'pullSecretRef' will not be used. + rule: (!(has(self.spec.pullSecretRef) && !has(self.spec.ocmRegistryUrl))) + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml new file mode 100644 index 0000000..930a1a3 --- /dev/null +++ b/config/crd/kustomization.yaml @@ -0,0 +1,28 @@ +# This kustomization.yaml is not intended to be run by itself, +# since it depends on service name and namespace that are out of this kustomize package. +# It should be run by config/default +resources: +- bases/core.orchestrate.cloud.sap_controlplanes.yaml +- bases/core.orchestrate.cloud.sap_releasechannels.yaml +- bases/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml +#+kubebuilder:scaffold:crdkustomizeresource + +patches: +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. +# patches here are for enabling the conversion webhook for each CRD +#- path: patches/webhook_in_controlplanes.yaml +#- path: patches/webhook_in_releasechannels.yaml +#+kubebuilder:scaffold:crdkustomizewebhookpatch + +# [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. +# patches here are for enabling the CA injection for each CRD +#- path: patches/cainjection_in_controlplanes.yaml +#- path: patches/cainjection_in_releasechannels.yaml +#- path: patches/cainjection_in_crossplanepackagerestrictions.yaml +#+kubebuilder:scaffold:crdkustomizecainjectionpatch + +# [WEBHOOK] To enable webhook, uncomment the following section +# the following config is for teaching kustomize how to do kustomization for CRDs. + +#configurations: +#- kustomizeconfig.yaml diff --git a/config/crd/kustomizeconfig.yaml b/config/crd/kustomizeconfig.yaml new file mode 100644 index 0000000..ec5c150 --- /dev/null +++ b/config/crd/kustomizeconfig.yaml @@ -0,0 +1,19 @@ +# This file is for teaching kustomize how to substitute name and namespace reference in CRD +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: CustomResourceDefinition + version: v1 + group: apiextensions.k8s.io + path: spec/conversion/webhook/clientConfig/service/name + +namespace: +- kind: CustomResourceDefinition + version: v1 + group: apiextensions.k8s.io + path: spec/conversion/webhook/clientConfig/service/namespace + create: false + +varReference: +- path: metadata/annotations diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml new file mode 100644 index 0000000..9f04c95 --- /dev/null +++ b/config/default/kustomization.yaml @@ -0,0 +1,142 @@ +# Adds namespace to all resources. +namespace: control-plane-operator-system + +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. +namePrefix: control-plane-operator- + +# Labels to add to all resources and selectors. +#labels: +#- includeSelectors: true +# pairs: +# someName: someValue + +resources: +- ../crd +- ../rbac +- ../manager +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in +# crd/kustomization.yaml +#- ../webhook +# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. +#- ../certmanager +# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. +#- ../prometheus + +patches: +# Protect the /metrics endpoint by putting it behind auth. +# If you want your controller-manager to expose the /metrics +# endpoint w/o any authn/z, please comment the following line. +- path: manager_auth_proxy_patch.yaml + +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in +# crd/kustomization.yaml +#- path: manager_webhook_patch.yaml + +# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. +# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. +# 'CERTMANAGER' needs to be enabled to use ca injection +#- path: webhookcainjection_patch.yaml + +# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. +# Uncomment the following replacements to add the cert-manager CA injection annotations +#replacements: +# - source: # Add cert-manager annotation to ValidatingWebhookConfiguration, MutatingWebhookConfiguration and CRDs +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert # this name should match the one in certificate.yaml +# fieldPath: .metadata.namespace # namespace of the certificate CR +# targets: +# - select: +# kind: ValidatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 0 +# create: true +# - select: +# kind: MutatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 0 +# create: true +# - select: +# kind: CustomResourceDefinition +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 0 +# create: true +# - source: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert # this name should match the one in certificate.yaml +# fieldPath: .metadata.name +# targets: +# - select: +# kind: ValidatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 1 +# create: true +# - select: +# kind: MutatingWebhookConfiguration +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 1 +# create: true +# - select: +# kind: CustomResourceDefinition +# fieldPaths: +# - .metadata.annotations.[cert-manager.io/inject-ca-from] +# options: +# delimiter: '/' +# index: 1 +# create: true +# - source: # Add cert-manager annotation to the webhook Service +# kind: Service +# version: v1 +# name: webhook-service +# fieldPath: .metadata.name # namespace of the service +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# fieldPaths: +# - .spec.dnsNames.0 +# - .spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 0 +# create: true +# - source: +# kind: Service +# version: v1 +# name: webhook-service +# fieldPath: .metadata.namespace # namespace of the service +# targets: +# - select: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# fieldPaths: +# - .spec.dnsNames.0 +# - .spec.dnsNames.1 +# options: +# delimiter: '.' +# index: 1 +# create: true diff --git a/config/default/manager_auth_proxy_patch.yaml b/config/default/manager_auth_proxy_patch.yaml new file mode 100644 index 0000000..70c3437 --- /dev/null +++ b/config/default/manager_auth_proxy_patch.yaml @@ -0,0 +1,39 @@ +# This patch inject a sidecar container which is a HTTP proxy for the +# controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: kube-rbac-proxy + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + image: gcr.io/kubebuilder/kube-rbac-proxy:v0.15.0 + args: + - "--secure-listen-address=0.0.0.0:8443" + - "--upstream=http://127.0.0.1:8080/" + - "--logtostderr=true" + - "--v=0" + ports: + - containerPort: 8443 + protocol: TCP + name: https + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 5m + memory: 64Mi + - name: manager + args: + - "--health-probe-bind-address=:8081" + - "--metrics-bind-address=127.0.0.1:8080" + - "--leader-elect" diff --git a/config/default/manager_config_patch.yaml b/config/default/manager_config_patch.yaml new file mode 100644 index 0000000..f6f5891 --- /dev/null +++ b/config/default/manager_config_patch.yaml @@ -0,0 +1,10 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: manager diff --git a/config/helm-default/kustomization.yaml b/config/helm-default/kustomization.yaml new file mode 100644 index 0000000..e751e49 --- /dev/null +++ b/config/helm-default/kustomization.yaml @@ -0,0 +1,9 @@ +# Adds namespace to all resources. +namespace: "{{ .Release.Namespace }}" + +# Labels to add to all resources and selectors. +commonLabels: + app.kubernetes.io/managed-by: helm + +bases: + - ../default \ No newline at end of file diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml new file mode 100644 index 0000000..2fa6dba --- /dev/null +++ b/config/manager/kustomization.yaml @@ -0,0 +1,8 @@ +resources: +- manager.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +images: +- name: controller + newName: control-plane-operator + newTag: dev diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml new file mode 100644 index 0000000..d4c2b96 --- /dev/null +++ b/config/manager/manager.yaml @@ -0,0 +1,102 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: namespace + app.kubernetes.io/instance: system + app.kubernetes.io/component: manager + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system + labels: + control-plane: controller-manager + app.kubernetes.io/name: deployment + app.kubernetes.io/instance: controller-manager + app.kubernetes.io/component: manager + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize +spec: + selector: + matchLabels: + control-plane: controller-manager + replicas: 1 + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + control-plane: controller-manager + spec: + # TODO(user): Uncomment the following code to configure the nodeAffinity expression + # according to the platforms which are supported by your solution. + # It is considered best practice to support multiple architectures. You can + # build your manager image using the makefile target docker-buildx. + # affinity: + # nodeAffinity: + # requiredDuringSchedulingIgnoredDuringExecution: + # nodeSelectorTerms: + # - matchExpressions: + # - key: kubernetes.io/arch + # operator: In + # values: + # - amd64 + # - arm64 + # - ppc64le + # - s390x + # - key: kubernetes.io/os + # operator: In + # values: + # - linux + securityContext: + runAsNonRoot: true + # TODO(user): For common cases that do not require escalating privileges + # it is recommended to ensure that all your Pods/Containers are restrictive. + # More info: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted + # Please uncomment the following code if your project does NOT have to work on old Kubernetes + # versions < 1.19 or on vendors versions which do NOT support this field by default (i.e. Openshift < 4.11 ). + # seccompProfile: + # type: RuntimeDefault + containers: + - command: + - /manager + args: + - --leader-elect + image: controller:latest + name: manager + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + # TODO(user): Configure the resources accordingly based on the project requirements. + # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + serviceAccountName: controller-manager + terminationGracePeriodSeconds: 10 diff --git a/config/prometheus/kustomization.yaml b/config/prometheus/kustomization.yaml new file mode 100644 index 0000000..ed13716 --- /dev/null +++ b/config/prometheus/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- monitor.yaml diff --git a/config/prometheus/monitor.yaml b/config/prometheus/monitor.yaml new file mode 100644 index 0000000..2860992 --- /dev/null +++ b/config/prometheus/monitor.yaml @@ -0,0 +1,25 @@ +# Prometheus Monitor Service (Metrics) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: servicemonitor + app.kubernetes.io/instance: controller-manager-metrics-monitor + app.kubernetes.io/component: metrics + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-monitor + namespace: system +spec: + endpoints: + - path: /metrics + port: https + scheme: https + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + tlsConfig: + insecureSkipVerify: true + selector: + matchLabels: + control-plane: controller-manager diff --git a/config/rbac/auth_proxy_client_clusterrole.yaml b/config/rbac/auth_proxy_client_clusterrole.yaml new file mode 100644 index 0000000..4ac4382 --- /dev/null +++ b/config/rbac/auth_proxy_client_clusterrole.yaml @@ -0,0 +1,16 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: metrics-reader + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: metrics-reader +rules: +- nonResourceURLs: + - "/metrics" + verbs: + - get diff --git a/config/rbac/auth_proxy_role.yaml b/config/rbac/auth_proxy_role.yaml new file mode 100644 index 0000000..322a895 --- /dev/null +++ b/config/rbac/auth_proxy_role.yaml @@ -0,0 +1,24 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: proxy-role + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: proxy-role +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create diff --git a/config/rbac/auth_proxy_role_binding.yaml b/config/rbac/auth_proxy_role_binding.yaml new file mode 100644 index 0000000..66dde6c --- /dev/null +++ b/config/rbac/auth_proxy_role_binding.yaml @@ -0,0 +1,19 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: clusterrolebinding + app.kubernetes.io/instance: proxy-rolebinding + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: proxy-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: proxy-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/rbac/auth_proxy_service.yaml b/config/rbac/auth_proxy_service.yaml new file mode 100644 index 0000000..0e235c5 --- /dev/null +++ b/config/rbac/auth_proxy_service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: service + app.kubernetes.io/instance: controller-manager-metrics-service + app.kubernetes.io/component: kube-rbac-proxy + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-service + namespace: system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: https + selector: + control-plane: controller-manager diff --git a/config/rbac/controlplane_editor_role.yaml b/config/rbac/controlplane_editor_role.yaml new file mode 100644 index 0000000..08ad161 --- /dev/null +++ b/config/rbac/controlplane_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit controlplanes. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: controlplane-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: controlplane-editor-role +rules: +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - controlplanes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - controlplanes/status + verbs: + - get diff --git a/config/rbac/controlplane_viewer_role.yaml b/config/rbac/controlplane_viewer_role.yaml new file mode 100644 index 0000000..3c42aba --- /dev/null +++ b/config/rbac/controlplane_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view controlplanes. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: controlplane-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: controlplane-viewer-role +rules: +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - controlplanes + verbs: + - get + - list + - watch +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - controlplanes/status + verbs: + - get diff --git a/config/rbac/crossplanepackagerestriction_editor_role.yaml b/config/rbac/crossplanepackagerestriction_editor_role.yaml new file mode 100644 index 0000000..0b1d042 --- /dev/null +++ b/config/rbac/crossplanepackagerestriction_editor_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to edit crossplanepackagerestrictions. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: crossplanepackagerestriction-editor-role +rules: +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - crossplanepackagerestrictions + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - crossplanepackagerestrictions/status + verbs: + - get diff --git a/config/rbac/crossplanepackagerestriction_viewer_role.yaml b/config/rbac/crossplanepackagerestriction_viewer_role.yaml new file mode 100644 index 0000000..b07ad2c --- /dev/null +++ b/config/rbac/crossplanepackagerestriction_viewer_role.yaml @@ -0,0 +1,23 @@ +# permissions for end users to view crossplanepackagerestrictions. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: crossplanepackagerestriction-viewer-role +rules: +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - crossplanepackagerestrictions + verbs: + - get + - list + - watch +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - crossplanepackagerestrictions/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml new file mode 100644 index 0000000..a67ee51 --- /dev/null +++ b/config/rbac/kustomization.yaml @@ -0,0 +1,25 @@ +resources: +# All RBAC will be applied under this service account in +# the deployment namespace. You may comment out this resource +# if your manager will use a service account that exists at +# runtime. Be sure to update RoleBinding and ClusterRoleBinding +# subjects if changing service account names. +- service_account.yaml +- role.yaml +- role_binding.yaml +- leader_election_role.yaml +- leader_election_role_binding.yaml +# Comment the following 4 lines if you want to disable +# the auth proxy (https://github.com/brancz/kube-rbac-proxy) +# which protects your /metrics endpoint. +- auth_proxy_service.yaml +- auth_proxy_role.yaml +- auth_proxy_role_binding.yaml +- auth_proxy_client_clusterrole.yaml +# For each CRD, "Editor" and "Viewer" roles are scaffolded by +# default, aiding admins in cluster management. Those roles are +# not used by the Project itself. You can comment the following lines +# if you do not want those helpers be installed with your Project. +- crossplanepackagerestriction_editor_role.yaml +- crossplanepackagerestriction_viewer_role.yaml + diff --git a/config/rbac/leader_election_role.yaml b/config/rbac/leader_election_role.yaml new file mode 100644 index 0000000..04612c9 --- /dev/null +++ b/config/rbac/leader_election_role.yaml @@ -0,0 +1,44 @@ +# permissions to do leader election. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/name: role + app.kubernetes.io/instance: leader-election-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: leader-election-role +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch diff --git a/config/rbac/leader_election_role_binding.yaml b/config/rbac/leader_election_role_binding.yaml new file mode 100644 index 0000000..32d9605 --- /dev/null +++ b/config/rbac/leader_election_role_binding.yaml @@ -0,0 +1,19 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/name: rolebinding + app.kubernetes.io/instance: leader-election-rolebinding + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: leader-election-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: leader-election-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/rbac/releasechannel_editor_role.yaml b/config/rbac/releasechannel_editor_role.yaml new file mode 100644 index 0000000..8f8b847 --- /dev/null +++ b/config/rbac/releasechannel_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit releasechannels. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: releasechannel-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: releasechannel-editor-role +rules: +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - releasechannels + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - releasechannels/status + verbs: + - get diff --git a/config/rbac/releasechannel_viewer_role.yaml b/config/rbac/releasechannel_viewer_role.yaml new file mode 100644 index 0000000..2ebcc67 --- /dev/null +++ b/config/rbac/releasechannel_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view releasechannels. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: releasechannel-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: releasechannel-viewer-role +rules: +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - releasechannels + verbs: + - get + - list + - watch +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - releasechannels/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml new file mode 100644 index 0000000..c91176b --- /dev/null +++ b/config/rbac/role.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: manager-role +rules: +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - releasechannels + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - releasechannels/finalizers + verbs: + - update +- apiGroups: + - core.orchestrate.cloud.sap + resources: + - releasechannels/status + verbs: + - get + - patch + - update diff --git a/config/rbac/role_binding.yaml b/config/rbac/role_binding.yaml new file mode 100644 index 0000000..2ba15a6 --- /dev/null +++ b/config/rbac/role_binding.yaml @@ -0,0 +1,19 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: clusterrolebinding + app.kubernetes.io/instance: manager-rolebinding + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: manager-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/rbac/service_account.yaml b/config/rbac/service_account.yaml new file mode 100644 index 0000000..ff9f631 --- /dev/null +++ b/config/rbac/service_account.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: serviceaccount + app.kubernetes.io/instance: controller-manager-sa + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: control-plane-operator + app.kubernetes.io/part-of: control-plane-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager + namespace: system diff --git a/config/samples/controlplane_kubeconfig.yaml b/config/samples/controlplane_kubeconfig.yaml new file mode 100644 index 0000000..99f7d1c --- /dev/null +++ b/config/samples/controlplane_kubeconfig.yaml @@ -0,0 +1,41 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: controlplane-sample +spec: + target: + # use inline kubeconfig + kubeconfig: + apiVersion: v1 + kind: Config + clusters: + - name: development + cluster: + certificate-authority: fake-ca-file + server: https://1.2.3.4 + contexts: + - name: development + context: + cluster: development + user: development + current-context: development + preferences: {} + users: + - name: development + user: + client-certificate: fake-cert-file + client-key: fake-key-file + fluxServiceAccount: + name: flux-deployer + namespace: default + crossplane: + version: 1.17.0 + providers: + - name: kubernetes + version: 0.14.1 + btpServiceOperator: + version: 0.6.0 + certManager: + version: 1.16.1 + externalSecretsOperator: + version: 0.10.0 diff --git a/config/samples/controlplane_kubeconfigRef.yaml b/config/samples/controlplane_kubeconfigRef.yaml new file mode 100644 index 0000000..f7795aa --- /dev/null +++ b/config/samples/controlplane_kubeconfigRef.yaml @@ -0,0 +1,69 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: controlplane-sample +spec: + target: + # read kubeconfig from secret + kubeconfigRef: + name: mykubeconfig + namespace: default + key: customMapKey + fluxServiceAccount: + name: flux-deployer + namespace: default + crossplane: + version: 1.17.0 + providers: + - name: kubernetes + version: 0.14.1 + btpServiceOperator: + version: 0.6.0 + certManager: + version: 1.16.1 + externalSecretsOperator: + version: 0.10.0 +--- +apiVersion: v1 +kind: Secret +metadata: + name: mykubeconfig + namespace: default +type: Opaque +stringData: + customMapKey: | + apiVersion: v1 + kind: Config + clusters: + - name: development + cluster: + certificate-authority: fake-ca-file + server: https://1.2.3.4 + contexts: + - name: development + context: + cluster: development + user: development + current-context: development + preferences: {} + users: + - name: development + user: + client-certificate: fake-cert-file + client-key: fake-key-file +--- +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: CrossplanePackageRestriction +metadata: + name: default +spec: + providers: + registries: + - xpkg.upbound.io + packages: [] + configurations: + registries: [] + packages: [] + functions: + registries: [] + packages: [] \ No newline at end of file diff --git a/config/samples/controlplane_local.yaml b/config/samples/controlplane_local.yaml new file mode 100644 index 0000000..b42dcc6 --- /dev/null +++ b/config/samples/controlplane_local.yaml @@ -0,0 +1,38 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: controlplane-sample +spec: + target: + # use local cluster + serviceAccount: {} + fluxServiceAccount: + name: flux-deployer + namespace: default + overrides: + host: https://kubernetes.default.svc + # optional impersonation + # serviceAccount: + # name: serviceaccount-to-impersonate + # namespace: namespace-of-that-serviceaccount + crossplane: + version: 1.17.0 + providers: + - name: kubernetes + version: 0.14.0 +--- +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: CrossplanePackageRestriction +metadata: + name: default +spec: + providers: + registries: + - xpkg.upbound.io + packages: [] + configurations: + registries: [] + packages: [] + functions: + registries: [] + packages: [] \ No newline at end of file diff --git a/config/samples/controlplane_psat.yaml b/config/samples/controlplane_psat.yaml new file mode 100644 index 0000000..3687bdd --- /dev/null +++ b/config/samples/controlplane_psat.yaml @@ -0,0 +1,45 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: controlplane-sample +spec: + target: + # use projected service account token + serviceAccount: + tokenFile: /service-account/token + host: https://some.other.cluster.example.com + caData: LS0tLS1CRUdJTiBDRVJUSUZJQ0FUR... + # optional + # name: remote-serviceaccount-to-impersonate + # namespace: namespace-of-that-serviceaccount + # caFile: /etc/certs/some.other.cluster.crt + fluxServiceAccount: + name: flux-deployer + namespace: default + crossplane: + version: 1.17.0 + providers: + - name: kubernetes + version: 0.14.1 + btpServiceOperator: + version: 0.6.0 + certManager: + version: 1.16.1 + externalSecretsOperator: + version: 0.10.0 +--- +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: CrossplanePackageRestriction +metadata: + name: default +spec: + providers: + registries: + - xpkg.upbound.io + packages: [] + configurations: + registries: [] + packages: [] + functions: + registries: [] + packages: [] diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml new file mode 100644 index 0000000..81aff8e --- /dev/null +++ b/config/samples/kustomization.yaml @@ -0,0 +1,6 @@ +## Append samples of your project ## +resources: +#- controlplane_local.yaml +#- _v1beta1_releasechannel.yaml +- v1beta1_crossplanepackagerestriction.yaml +#+kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/releasechannel/local.yaml b/config/samples/releasechannel/local.yaml new file mode 100644 index 0000000..c8ecffe --- /dev/null +++ b/config/samples/releasechannel/local.yaml @@ -0,0 +1,11 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ReleaseChannel +metadata: + name: cloudorchestration-local +spec: + ocmRegistrySecretRef: + name: ocm-registry + namespace: co-system + ocmRegistrySecretKey: ocm_registry.tgz + prefixFilter: "core.orchestrate.cloud.sap" + diff --git a/config/samples/v1beta1_crossplanepackagerestriction.yaml b/config/samples/v1beta1_crossplanepackagerestriction.yaml new file mode 100644 index 0000000..45792bc --- /dev/null +++ b/config/samples/v1beta1_crossplanepackagerestriction.yaml @@ -0,0 +1,20 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: CrossplanePackageRestriction +metadata: + name: default +spec: + providers: + registries: + - xpkg.upbound.io # any provider from this registry can be pulled + - registry.example.com # same here + packages: + - ghcr.io/some-owner/some-provider:v0.14.0 # allows only specific version + - ghcr.io/some-owner/some-other-provider # allows any version + configurations: + registries: + - "*" # allows any registry + packages: [] + functions: + registries: [] + packages: + - "*" # allows any package diff --git a/e2e.env b/e2e.env new file mode 100644 index 0000000..94a56b6 --- /dev/null +++ b/e2e.env @@ -0,0 +1 @@ +E2E_IMAGES={"cloud-orchestration/control-plane-operator":"control-plane-operator:dev"} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..462184f --- /dev/null +++ b/go.mod @@ -0,0 +1,350 @@ +module github.com/openmcp-project/control-plane-operator + +go 1.23.2 + +toolchain go1.23.7 + +require ( + github.com/crossplane-contrib/xp-testing v1.3.4 + github.com/crossplane/crossplane v1.17.2 + github.com/fluxcd/helm-controller/api v1.1.0 + github.com/fluxcd/kustomize-controller/api v1.4.0 + github.com/fluxcd/pkg/apis/meta v1.6.1 + github.com/fluxcd/source-controller/api v1.4.1 + github.com/go-logr/logr v1.4.2 + github.com/openmcp-project/controller-utils v0.4.2 + github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.10.0 + gotest.tools/v3 v3.5.1 + k8s.io/api v0.32.2 + k8s.io/apiextensions-apiserver v0.32.2 + k8s.io/apimachinery v0.32.2 + k8s.io/client-go v0.32.2 + k8s.io/klog/v2 v2.130.1 + ocm.software/ocm v0.17.0 + sigs.k8s.io/controller-runtime v0.20.2 + sigs.k8s.io/e2e-framework v0.5.0 +) + +require ( + cloud.google.com/go/auth v0.9.8 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect + cloud.google.com/go/compute/metadata v0.5.2 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect + github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/provider v0.15.2 // indirect + github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Azure/go-autorest v14.2.0+incompatible // indirect + github.com/Azure/go-autorest/autorest v0.11.29 // indirect + github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect + github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect + github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect + github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect + github.com/Azure/go-autorest/logger v0.2.1 // indirect + github.com/Azure/go-autorest/tracing v0.6.0 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.0.0 // indirect + github.com/ThalesIgnite/crypto11 v1.2.5 // indirect + github.com/a8m/envsubst v1.4.2 // indirect + github.com/alecthomas/participle/v2 v2.1.1 // indirect + github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect + github.com/alibabacloud-go/cr-20160607 v1.0.1 // indirect + github.com/alibabacloud-go/cr-20181201 v1.0.10 // indirect + github.com/alibabacloud-go/darabonba-openapi v0.2.1 // indirect + github.com/alibabacloud-go/debug v1.0.1 // indirect + github.com/alibabacloud-go/endpoint-util v1.1.1 // indirect + github.com/alibabacloud-go/openapi-util v0.1.1 // indirect + github.com/alibabacloud-go/tea v1.2.2 // indirect + github.com/alibabacloud-go/tea-utils v1.4.5 // indirect + github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect + github.com/alibabacloud-go/tea-xml v1.1.3 // indirect + github.com/aliyun/credentials-go v1.3.10 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.32.2 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect + github.com/aws/aws-sdk-go-v2/config v1.28.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.41 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21 // indirect + github.com/aws/aws-sdk-go-v2/service/ecr v1.36.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.27.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 // indirect + github.com/aws/smithy-go v1.22.0 // indirect + github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20241009180534-e718692eec62 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/buildkite/agent/v3 v3.83.1 // indirect + github.com/buildkite/go-pipeline v0.13.2 // indirect + github.com/buildkite/interpolate v0.1.4 // indirect + github.com/buildkite/roko v1.2.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/cloudflare/cfssl v1.6.5 // indirect + github.com/cloudflare/circl v1.5.0 // indirect + github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect + github.com/containerd/containerd v1.7.23 // indirect + github.com/containerd/errdefs v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/containers/image/v5 v5.32.2 // indirect + github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect + github.com/containers/ocicrypt v1.2.0 // indirect + github.com/containers/storage v1.55.0 // indirect + github.com/coreos/go-oidc/v3 v3.11.0 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f // indirect + github.com/cyphar/filepath-securejoin v0.3.4 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect + github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/cli v27.3.1+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker v27.3.1+incompatible // indirect + github.com/docker/docker-credential-helpers v0.8.2 // indirect + github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-metrics v0.0.1 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elliotchance/orderedmap v1.6.0 // indirect + github.com/fatih/color v1.17.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fvbommel/sortorder v1.1.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-chi/chi v4.1.2+incompatible // indirect + github.com/go-errors/errors v1.5.1 // indirect + github.com/go-jose/go-jose/v3 v3.0.3 // indirect + github.com/go-jose/go-jose/v4 v4.0.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.23.0 // indirect + github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/loads v0.22.0 // indirect + github.com/go-openapi/runtime v0.28.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/strfmt v0.23.0 // indirect + github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-test/deep v1.1.1 // indirect + github.com/gobuffalo/flect v1.0.2 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/goccy/go-yaml v1.12.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/certificate-transparency-go v1.2.1 // indirect + github.com/google/go-github/v45 v45.2.0 // indirect + github.com/google/go-github/v55 v55.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/s2a-go v0.1.8 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/gowebpki/jcs v1.0.1 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/hcl v1.0.1-vault-5 // indirect + github.com/hashicorp/vault-client-go v0.4.3 // indirect + github.com/in-toto/in-toto-golang v0.9.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect + github.com/jinzhu/copier v0.4.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect + github.com/letsencrypt/boulder v0.0.0-20241010192615-6692160cedfa // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mandelsoft/filepath v0.0.0-20240223090642-3e2777258aa3 // indirect + github.com/mandelsoft/goutils v0.0.0-20241005173814-114fa825bbdc // indirect + github.com/mandelsoft/logging v0.0.0-20240618075559-fdca28a87b0a // indirect + github.com/mandelsoft/spiff v1.7.0-beta-6 // indirect + github.com/mandelsoft/vfs v0.4.4 // indirect + github.com/marstr/guid v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/miekg/pkcs11 v1.1.1 // indirect + github.com/mikefarah/yq/v4 v4.44.3 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/locker v1.0.1 // indirect + github.com/moby/sys/mountinfo v0.7.2 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/mozillazg/docker-credential-acr-helper v0.4.0 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/oleiade/reflections v1.1.0 // indirect + github.com/opencontainers/runtime-spec v1.2.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pborman/uuid v1.2.1 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/redis/go-redis/v9 v9.7.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sagikazarmark/locafero v0.6.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sassoftware/relic v7.2.1+incompatible // indirect + github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect + github.com/segmentio/ksuid v1.0.4 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/sigstore/cosign/v2 v2.4.1 // indirect + github.com/sigstore/fulcio v1.6.5 // indirect + github.com/sigstore/protobuf-specs v0.3.2 // indirect + github.com/sigstore/rekor v1.3.6 // indirect + github.com/sigstore/sigstore v1.8.10 // indirect + github.com/sigstore/timestamp-authority v1.2.3 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/cobra v1.8.1 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.4.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect + github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect + github.com/texttheater/golang-levenshtein/levenshtein v0.0.0-20200805054039-cae8b0eaed6c // indirect + github.com/thales-e-security/pool v0.0.2 // indirect + github.com/theupdateframework/go-tuf v0.7.0 // indirect + github.com/theupdateframework/notary v0.7.0 // indirect + github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect + github.com/tonglil/buflogr v1.1.1 // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/ulikunitz/xz v0.5.12 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xanzy/go-gitlab v0.112.0 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/zeebo/errs v1.4.0 // indirect + go.mongodb.org/mongo-driver v1.17.1 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect + go.opentelemetry.io/otel v1.31.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 // indirect + go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.opentelemetry.io/otel/sdk v1.31.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.31.0 // indirect + go.opentelemetry.io/otel/trace v1.31.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.step.sm/crypto v0.54.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/tools v0.26.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/api v0.200.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect + google.golang.org/grpc v1.67.1 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 // indirect + helm.sh/helm/v3 v3.16.2 // indirect + k8s.io/cli-runtime v0.31.1 // indirect + k8s.io/component-base v0.32.2 // indirect + oras.land/oras-go v1.2.6 // indirect + sigs.k8s.io/controller-tools v0.14.0 // indirect + sigs.k8s.io/kustomize/api v0.18.0 // indirect + sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect + sigs.k8s.io/release-utils v0.8.5 // indirect +) + +require ( + dario.cat/mergo v1.0.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect + github.com/crossplane/crossplane-runtime v1.17.0 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.1 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fluxcd/pkg/apis/acl v0.3.0 // indirect + github.com/fluxcd/pkg/apis/kustomize v1.6.1 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/go-cmp v0.6.0 + github.com/google/go-containerregistry v0.20.2 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/samber/lo v1.39.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/vbatts/tar-split v0.11.6 // indirect + github.com/vladimirvivien/gexe v0.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect + golang.org/x/net v0.35.0 + golang.org/x/oauth2 v0.26.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/term v0.29.0 // indirect + golang.org/x/text v0.22.0 + golang.org/x/time v0.10.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect + k8s.io/utils v0.0.0-20241210054802-24370beab758 + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.5.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..40970f2 --- /dev/null +++ b/go.sum @@ -0,0 +1,1338 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ= +cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc= +cloud.google.com/go/auth v0.9.8 h1:+CSJ0Gw9iVeSENVCKJoLHhdUykDgXSc4Qn+gu2BRtR8= +cloud.google.com/go/auth v0.9.8/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI= +cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= +cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= +cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= +cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= +cloud.google.com/go/iam v1.2.1 h1:QFct02HRb7H12J/3utj0qf5tobFh9V4vR6h9eX5EBRU= +cloud.google.com/go/iam v1.2.1/go.mod h1:3VUIJDPpwT6p/amXRC5GY8fCCh70lxPygguVtI0Z4/g= +cloud.google.com/go/kms v1.20.0 h1:uKUvjGqbBlI96xGE669hcVnEMw1Px/Mvfa62dhM5UrY= +cloud.google.com/go/kms v1.20.0/go.mod h1:/dMbFF1tLLFnQV44AoI2GlotbjowyUfgVwezxW291fM= +cloud.google.com/go/longrunning v0.6.1 h1:lOLTFxYpr8hcRtcwWir5ITh1PAKUD/sG2lKrTSYjyMc= +cloud.google.com/go/longrunning v0.6.1/go.mod h1:nHISoOZpBcmlwbJmiVk5oDRz0qG/ZxPynEGs1iZ79s0= +cuelabs.dev/go/oci/ociregistry v0.0.0-20240404174027-a39bec0462d2 h1:BnG6pr9TTr6CYlrJznYUDj6V7xldD1W+1iXPum0wT/w= +cuelabs.dev/go/oci/ociregistry v0.0.0-20240404174027-a39bec0462d2/go.mod h1:pK23AUVXuNzzTpfMCA06sxZGeVQ/75FdVtW249de9Uo= +cuelang.org/go v0.9.2 h1:pfNiry2PdRBr02G/aKm5k2vhzmqbAOoaB4WurmEbWvs= +cuelang.org/go v0.9.2/go.mod h1:qpAYsLOf7gTM1YdEg6cxh553uZ4q9ZDWlPbtZr9q1Wk= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= +github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/provider v0.15.2 h1:wkOtHL8dnMm2HA6IJg72rgNUDctcrr7bmX5NExZ72oc= +github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/provider v0.15.2/go.mod h1:tlqp9mUGbsP+0z3Q+c0Q5MgSdq/OMwQhm5bffR3Q3ss= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 h1:nyQWyZvwGTvunIMxi1Y9uXkcyr+I7TeNrr/foo4Kpk8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0 h1:DRiANoJTiW6obBQe3SqZizkuV1PEgfiiGivmVocDy64= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0/go.mod h1:qLIye2hwb/ZouqhpSD9Zn3SJipvpEnz1Ywl3VUk9Y0s= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= +github.com/Azure/go-autorest/autorest v0.11.29 h1:I4+HL/JDvErx2LjyzaVxllw2lRDB5/BT2Bm4g20iqYw= +github.com/Azure/go-autorest/autorest v0.11.29/go.mod h1:ZtEzC4Jy2JDrZLxvWs8LrBWEBycl1hbT1eknI8MtfAs= +github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= +github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= +github.com/Azure/go-autorest/autorest/adal v0.9.24 h1:BHZfgGsGwdkHDyZdtQRQk1WeUdW0m2WPAwuHZwUi5i4= +github.com/Azure/go-autorest/autorest/adal v0.9.24/go.mod h1:7T1+g0PYFmACYW5LlG2fcoPiPlFHjClyRGL7dRlP5c8= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 h1:w77/uPk80ZET2F+AfQExZyEWtn+0Rk/uw17m9fv5Ajc= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= +github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.2 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= +github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= +github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4= +github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Microsoft/hcsshim v0.12.5 h1:bpTInLlDy/nDRWFVcefDZZ1+U8tS+rz3MxjKgu9boo0= +github.com/Microsoft/hcsshim v0.12.5/go.mod h1:tIUGego4G1EN5Hb6KC90aDYiUI2dqLSTTOCjVNpOgZ8= +github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= +github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= +github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/ThalesIgnite/crypto11 v1.2.5 h1:1IiIIEqYmBvUYFeMnHqRft4bwf/O36jryEUpY+9ef8E= +github.com/ThalesIgnite/crypto11 v1.2.5/go.mod h1:ILDKtnCKiQ7zRoNxcp36Y1ZR8LBPmR2E23+wTQe/MlE= +github.com/a8m/envsubst v1.4.2 h1:4yWIHXOLEJHQEFd4UjrWDrYeYlV7ncFWJOCBRLOZHQg= +github.com/a8m/envsubst v1.4.2/go.mod h1:MVUTQNGQ3tsjOOtKCNd+fl8RzhsXcDvvAEzkhGtlsbY= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0= +github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= +github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8= +github.com/alecthomas/participle/v2 v2.1.1/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.2/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= +github.com/alibabacloud-go/cr-20160607 v1.0.1 h1:WEnP1iPFKJU74ryUKh/YDPHoxMZawqlPajOymyNAkts= +github.com/alibabacloud-go/cr-20160607 v1.0.1/go.mod h1:QHeKZtZ3F3FOE+/uIXCBAp8POwnUYekpLwr1dtQa5r0= +github.com/alibabacloud-go/cr-20181201 v1.0.10 h1:B60f6S1imsgn2fgC6X6FrVNrONDrbCT0NwYhsJ0C9/c= +github.com/alibabacloud-go/cr-20181201 v1.0.10/go.mod h1:VN9orB/w5G20FjytoSpZROqu9ZqxwycASmGqYUJSoDc= +github.com/alibabacloud-go/darabonba-openapi v0.1.12/go.mod h1:sTAjsFJmVsmcVeklL9d9uDBlFsgl43wZ6jhI6BHqHqU= +github.com/alibabacloud-go/darabonba-openapi v0.1.14/go.mod h1:w4CosR7O/kapCtEEMBm3JsQqWBU/CnZ2o0pHorsTWDI= +github.com/alibabacloud-go/darabonba-openapi v0.2.1 h1:WyzxxKvhdVDlwpAMOHgAiCJ+NXa6g5ZWPFEzaK/ewwY= +github.com/alibabacloud-go/darabonba-openapi v0.2.1/go.mod h1:zXOqLbpIqq543oioL9IuuZYOQgHQ5B8/n5OPrnko8aY= +github.com/alibabacloud-go/darabonba-string v1.0.0/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= +github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/endpoint-util v1.1.1 h1:ZkBv2/jnghxtU0p+upSU0GGzW1VL9GQdZO3mcSUTUy8= +github.com/alibabacloud-go/endpoint-util v1.1.1/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/openapi-util v0.0.9/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/openapi-util v0.0.10/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/openapi-util v0.0.11/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/openapi-util v0.1.1 h1:ujGErJjG8ncRW6XtBBMphzHTvCxn4DjrVw4m04HsS28= +github.com/alibabacloud-go/openapi-util v0.1.1/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw= +github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= +github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.1.19/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.2.2 h1:aTsR6Rl3ANWPfqeQugPglfurloyBJY85eFy7Gc1+8oU= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= +github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils v1.3.9/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils v1.4.3/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw= +github.com/alibabacloud-go/tea-utils v1.4.5 h1:h0/6Xd2f3bPE4XHTvkpjwxowIwRCJAJOqY6Eq8f3zfA= +github.com/alibabacloud-go/tea-utils v1.4.5/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw= +github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0= +github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0= +github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= +github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= +github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA= +github.com/aliyun/credentials-go v1.3.10/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= +github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go-v2 v1.32.2 h1:AkNLZEyYMLnx/Q/mSKkcMqwNFXMAvFto9bNsHqcTduI= +github.com/aws/aws-sdk-go-v2 v1.32.2/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA= +github.com/aws/aws-sdk-go-v2/config v1.28.0 h1:FosVYWcqEtWNxHn8gB/Vs6jOlNwSoyOCA/g/sxyySOQ= +github.com/aws/aws-sdk-go-v2/config v1.28.0/go.mod h1:pYhbtvg1siOOg8h5an77rXle9tVG8T+BWLWAo7cOukc= +github.com/aws/aws-sdk-go-v2/credentials v1.17.41 h1:7gXo+Axmp+R4Z+AK8YFQO0ZV3L0gizGINCOWxSLY9W8= +github.com/aws/aws-sdk-go-v2/credentials v1.17.41/go.mod h1:u4Eb8d3394YLubphT4jLEwN1rLNq2wFOlT6OuxFwPzU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 h1:TMH3f/SCAWdNtXXVPPu5D6wrr4G5hI1rAxbcocKfC7Q= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17/go.mod h1:1ZRXLdTpzdJb9fwTMXiLipENRxkGMTn1sfKexGllQCw= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33 h1:X+4YY5kZRI/cOoSMVMGTqFXHAMg1bvvay7IBcqHpybQ= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33/go.mod h1:DPynzu+cn92k5UQ6tZhX+wfTB4ah6QDU/NgdHqatmvk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 h1:UAsR3xA31QGf79WzpG/ixT9FZvQlh5HY1NRqSHBNOCk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21/go.mod h1:JNr43NFf5L9YaG3eKTm7HQzls9J+A9YYcGI5Quh1r2Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 h1:6jZVETqmYCadGFvrYEQfC5fAQmlo80CeL5psbno6r0s= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21/go.mod h1:1SR0GbLlnN3QUmYaflZNiH1ql+1qrSiB2vwcJ+4UM60= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21 h1:7edmS3VOBDhK00b/MwGtGglCm7hhwNYnjJs/PgFdMQE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21/go.mod h1:Q9o5h4HoIWG8XfzxqiuK/CGUbepCJ8uTlaE3bAbxytQ= +github.com/aws/aws-sdk-go-v2/service/ecr v1.36.2 h1:VDQaVwGOokbd3VUbHF+wupiffdrbAZPdQnr5XZMJqrs= +github.com/aws/aws-sdk-go-v2/service/ecr v1.36.2/go.mod h1:lvUlMghKYmSxSfv0vU7pdU/8jSY+s0zpG8xXhaGKCw0= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.27.2 h1:Zru9Iy2JPM5+uRnFnoqeOZzi8JIVIHJ0ua6JdeDHcyg= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.27.2/go.mod h1:PtQC3XjutCYFCn1+i8+wtpDaXvEK+vXF2gyLIKAmh4A= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 h1:4FMHqLfk0efmTqhXVRL5xYRqlEBNBiRI7N6w4jsEdd4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2/go.mod h1:LWoqeWlK9OZeJxsROW2RqrSPvQHKTpp69r/iDjwsSaw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 h1:s7NA1SOw8q/5c0wr8477yOPp0z+uBaXBnLE0XYb0POA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2/go.mod h1:fnjjWyAW/Pj5HYOxl9LJqWtEwS7W2qgcRLWP+uWbss0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 h1:t7iUP9+4wdc5lt3E41huP+GvQZJD38WLsgVp4iOtAjg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2/go.mod h1:/niFCtmuQNxqx9v8WAPq5qh7EH25U4BF6tjoyq9bObM= +github.com/aws/aws-sdk-go-v2/service/kms v1.37.0 h1:ovrHGOiNu4S0GSMeexZlsMhBkUb3bCE3iOktFZ7rmBU= +github.com/aws/aws-sdk-go-v2/service/kms v1.37.0/go.mod h1:YLqfMkq9GWbICgqT5XMIzT8I2+MxVKodTnNBo3BONgE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0 h1:xA6XhTF7PE89BCNHJbQi8VvPzcgMtmGC5dr8S8N7lHk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0/go.mod h1:cB6oAuus7YXRZhWCc1wIwPywwZ1XwweNp2TVAEGYeB8= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 h1:bSYXVyUzoTHoKalBmwaZxs97HU9DWWI3ehHSAMa7xOk= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.2/go.mod h1:skMqY7JElusiOUjMJMOv1jJsP7YUg7DrhgqZZWuzu1U= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 h1:AhmO1fHINP9vFYUE0LHzCWg/LfUWUF+zFPEcY9QXb7o= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2/go.mod h1:o8aQygT2+MVP0NaV6kbdE1YnnIM8RRVQzoeUH45GOdI= +github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 h1:CiS7i0+FUe+/YY1GvIBLLrR/XNGZ4CtM1Ll0XavNuVo= +github.com/aws/aws-sdk-go-v2/service/sts v1.32.2/go.mod h1:HtaiBI8CjYoNVde8arShXb94UbQQi9L4EMr6D+xGBwo= +github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM= +github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20241009180534-e718692eec62 h1:T5b8GwBFIlqQzAbqTNcyLvzcAvJ09MXrF6zyUlIic8A= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20241009180534-e718692eec62/go.mod h1:OnsZ5l5pGZhtamSysVzGItV+jqDiWz2MJ03H0n8Vxq4= +github.com/beorn7/perks v0.0.0-20150223135152-b965b613227f/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bugsnag/bugsnag-go v1.0.5-0.20150529004307-13fd6b8acda0 h1:s7+5BfS4WFJoVF9pnB8kBk03S7pZXRdKamnV0FOl5Sc= +github.com/bugsnag/bugsnag-go v1.0.5-0.20150529004307-13fd6b8acda0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembjv71DPz3uX/V/6MMlSyD9JBQ6kQ= +github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= +github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o= +github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/buildkite/agent/v3 v3.83.1 h1:a2ne2ndJqEsgiTvGJH6SxDwvnoe+Bd7lA3VTLcjPro4= +github.com/buildkite/agent/v3 v3.83.1/go.mod h1:XP77C8LeknCtOCYtyjdSv0aSKPmyOJxp6Rig14G0MCM= +github.com/buildkite/go-pipeline v0.13.2 h1:M378/FoxOVvu7F5J+iG4/p3jQDMl3VNP1i/Nc4eWF0o= +github.com/buildkite/go-pipeline v0.13.2/go.mod h1:Lbtal6Q/xxLC1PZFygMqtXzM7ppSkiclll0kVqY8yNQ= +github.com/buildkite/interpolate v0.1.4 h1:qacB5WR+7+Ol1xiJ4xZajHh/CGDdhcMesXR+V3thZPA= +github.com/buildkite/interpolate v0.1.4/go.mod h1:dHnrwHew5O8VNOAgMDpwRlFnhL5VSN6M1bHVmRZ9Ccc= +github.com/buildkite/roko v1.2.0 h1:hbNURz//dQqNl6Eo9awjQOVOZwSDJ8VEbBDxSfT9rGQ= +github.com/buildkite/roko v1.2.0/go.mod h1:23R9e6nHxgedznkwwfmqZ6+0VJZJZ2Sg/uVcp2cP46I= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= +github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 h1:krfRl01rzPzxSxyLyrChD+U+MzsBXbm0OwYYB67uF+4= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= +github.com/cloudflare/cfssl v1.6.5 h1:46zpNkm6dlNkMZH/wMW22ejih6gIaJbzL2du6vD7ZeI= +github.com/cloudflare/cfssl v1.6.5/go.mod h1:Bk1si7sq8h2+yVEDrFJiz3d7Aw+pfjjJSZVaD+Taky4= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys= +github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudfoundry-incubator/candiedyaml v0.0.0-20170901234223-a41693b7b7af h1:6Cpkahw28+gcBdnXQL7LcMTX488+6jl6hfoTMRT6Hm4= +github.com/cloudfoundry-incubator/candiedyaml v0.0.0-20170901234223-a41693b7b7af/go.mod h1:dOLSIXcRQJiDS1vlrYFNJicoHNZLsBKideE+70hGdV4= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= +github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= +github.com/containerd/cgroups/v3 v3.0.3 h1:S5ByHZ/h9PMe5IOQoN7E+nMc2UcLEM/V48DGDJ9kip0= +github.com/containerd/cgroups/v3 v3.0.3/go.mod h1:8HBe7V3aWGLFPd/k03swSIsGjZhHI2WzJmticMgVuz0= +github.com/containerd/containerd v1.7.23 h1:H2CClyUkmpKAGlhQp95g2WXHfLYc7whAuvZGBNYOOwQ= +github.com/containerd/containerd v1.7.23/go.mod h1:7QUzfURqZWCZV7RLNEn1XjUCQLEf0bkaK4GjUaZehxw= +github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG023MDM= +github.com/containerd/continuity v0.4.2/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= +github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= +github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/containerd/stargz-snapshotter/estargz v0.15.1 h1:eXJjw9RbkLFgioVaTG+G/ZW/0kEe2oEKCdS/ZxIyoCU= +github.com/containerd/stargz-snapshotter/estargz v0.15.1/go.mod h1:gr2RNwukQ/S9Nv33Lt6UC7xEx58C+LHRdoqbEKjz1Kk= +github.com/containers/image/v5 v5.32.2 h1:SzNE2Y6sf9b1GJoC8qjCuMBXwQrACFp4p0RK15+4gmQ= +github.com/containers/image/v5 v5.32.2/go.mod h1:v1l73VeMugfj/QtKI+jhYbwnwFCFnNGckvbST3rQ5Hk= +github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 h1:Qzk5C6cYglewc+UyGf6lc8Mj2UaPTHy/iF2De0/77CA= +github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01/go.mod h1:9rfv8iPl1ZP7aqh9YA68wnZv2NUDbXdcdPHVz0pFbPY= +github.com/containers/ocicrypt v1.2.0 h1:X14EgRK3xNFvJEfI5O4Qn4T3E25ANudSOZz/sirVuPM= +github.com/containers/ocicrypt v1.2.0/go.mod h1:ZNviigQajtdlxIZGibvblVuIFBKIuUI2M0QM12SD31U= +github.com/containers/storage v1.55.0 h1:wTWZ3YpcQf1F+dSP4KxG9iqDfpQY1otaUXjPpffuhgg= +github.com/containers/storage v1.55.0/go.mod h1:28cB81IDk+y7ok60Of6u52RbCeBRucbFOeLunhER1RQ= +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.19 h1:tUN6H7LWqNx4hQVxomd0CVsDwaDr9gaRQaI4GpSmrsA= +github.com/creack/pty v1.1.19/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/crossplane-contrib/xp-testing v1.3.4 h1:3v2jaLWmiN+yfUfwCP92uJTogS9sLt0E8Rc/qZYXgwI= +github.com/crossplane-contrib/xp-testing v1.3.4/go.mod h1:L5/0u+QObxlQO7xm69FsWoOEJ/lpfV8B/TTtAIvy0+M= +github.com/crossplane/crossplane v1.17.2 h1:iJyqNCI0ub5W+L/2eUvWnKXHBHhqmRynVDhFuvZanvw= +github.com/crossplane/crossplane v1.17.2/go.mod h1:KAoOkSa6i9EW+Q9yseuf6GLKSko2HWztkNrFTMeh1bc= +github.com/crossplane/crossplane-runtime v1.17.0 h1:y+GvxPT1M9s8BKt2AeZJdd2d6pg2xZeCO6LiR+VxEF8= +github.com/crossplane/crossplane-runtime v1.17.0/go.mod h1:vtglCrnnbq2HurAk9yLHa4qS0bbnCxaKL7C21cQcB/0= +github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f h1:eHnXnuK47UlSTOQexbzxAZfekVz6i+LKRdj1CU5DPaM= +github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/cyphar/filepath-securejoin v0.3.4 h1:VBWugsJh2ZxJmLFSM06/0qzQyiQX2Qs0ViKrUAcqdZ8= +github.com/cyphar/filepath-securejoin v0.3.4/go.mod h1:8s/MCNJREmFK0H02MF6Ihv1nakJe4L/w3WZLHNkvlYM= +github.com/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs= +github.com/danieljoos/wincred v1.2.1/go.mod h1:uGaFL9fDn3OLTvzCGulzE+SzjEe5NGlh5FdCcyfPwps= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/depcheck-test/depcheck-test v0.0.0-20220607135614-199033aaa936 h1:foGzavPWwtoyBvjWyKJYDYsyzy+23iBV7NKTwdk+LRY= +github.com/depcheck-test/depcheck-test v0.0.0-20220607135614-199033aaa936/go.mod h1:ttKPnOepYt4LLzD+loXQ1rT6EmpyIYHro7TAJuIIlHo= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= +github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/distribution/distribution/v3 v3.0.0-beta.1 h1:X+ELTxPuZ1Xe5MsD3kp2wfGUhc8I+MPfRis8dZ818Ic= +github.com/distribution/distribution/v3 v3.0.0-beta.1/go.mod h1:O9O8uamhHzWWQVTjuQpyYUVm/ShPHPUDgvQMpHGVBDs= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ= +github.com/docker/cli v27.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= +github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0= +github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= +github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM= +github.com/elliotchance/orderedmap v1.6.0 h1:xjn+kbbKXeDq6v9RVE+WYwRbYfAZKvlWfcJNxM8pvEw= +github.com/elliotchance/orderedmap v1.6.0/go.mod h1:wsDwEaX5jEoyhbs7x93zk2H/qv0zwuhg4inXhDkYqys= +github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= +github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/proto v1.12.1 h1:6n/Z2pZAnBwuhU66Gs8160B8rrrYKo7h2F2sCOnNceE= +github.com/emicklei/proto v1.12.1/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluxcd/helm-controller/api v1.1.0 h1:NS5Wm3U6Kv4w7Cw2sDOV++vf2ecGfFV00x1+2Y3QcOY= +github.com/fluxcd/helm-controller/api v1.1.0/go.mod h1:BgHMgMY6CWynzl4KIbHpd6Wpn3FN9BqgkwmvoKCp6iE= +github.com/fluxcd/kustomize-controller/api v1.4.0 h1:QReBAuNAlrAez4aLkbYjVlWDCIZABFsKPgRa9W05VCQ= +github.com/fluxcd/kustomize-controller/api v1.4.0/go.mod h1:z1SD5EJS+vbGmF6B17x8aJfT5nnzGVEQ+3WGquOmhwA= +github.com/fluxcd/pkg/apis/acl v0.3.0 h1:UOrKkBTOJK+OlZX7n8rWt2rdBmDCoTK+f5TY2LcZi8A= +github.com/fluxcd/pkg/apis/acl v0.3.0/go.mod h1:WVF9XjSMVBZuU+HTTiSebGAWMgM7IYexFLyVWbK9bNY= +github.com/fluxcd/pkg/apis/kustomize v1.6.1 h1:22FJc69Mq4i8aCxnKPlddHhSMyI4UPkQkqiAdWFcqe0= +github.com/fluxcd/pkg/apis/kustomize v1.6.1/go.mod h1:5dvQ4IZwz0hMGmuj8tTWGtarsuxW0rWsxJOwC6i+0V8= +github.com/fluxcd/pkg/apis/meta v1.6.1 h1:maLhcRJ3P/70ArLCY/LF/YovkxXbX+6sTWZwZQBeNq0= +github.com/fluxcd/pkg/apis/meta v1.6.1/go.mod h1:YndB/gxgGZmKfqpAfFxyCDNFJFP0ikpeJzs66jwq280= +github.com/fluxcd/source-controller/api v1.4.1 h1:zV01D7xzHOXWbYXr36lXHWWYS7POARsjLt61Nbh3kVY= +github.com/fluxcd/source-controller/api v1.4.1/go.mod h1:gSjg57T+IG66SsBR0aquv+DFrm4YyBNpKIJVDnu3Ya8= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= +github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= +github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= +github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= +github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= +github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= +github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= +github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= +github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= +github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= +github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/go-piv/piv-go v1.11.0 h1:5vAaCdRTFSIW4PeqMbnsDlUZ7odMYWnHBDGdmtU/Zhg= +github.com/go-piv/piv-go v1.11.0/go.mod h1:NZ2zmjVkfFaL/CF8cVQ/pXdXtuj110zEKGdJM6fJZZM= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= +github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= +github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA= +github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.12.0 h1:/1WHjnMsI1dlIBQutrvSMGZRQufVO3asrHfTwfACoPM= +github.com/goccy/go-yaml v1.12.0/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/certificate-transparency-go v1.0.10-0.20180222191210-5ab67e519c93/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= +github.com/google/certificate-transparency-go v1.2.1 h1:4iW/NwzqOqYEEoCBEFP+jPbBXbLqMpq3CifMyOnDUME= +github.com/google/certificate-transparency-go v1.2.1/go.mod h1:bvn/ytAccv+I6+DGkqpvSsEdiVGramgaSC6RD3tEmeE= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-containerregistry v0.20.2 h1:B1wPJ1SN/S7pB+ZAimcciVD+r+yV/l/DSArMxlbwseo= +github.com/google/go-containerregistry v0.20.2/go.mod h1:z38EKdKh4h7IP2gSfUUqEvalZBqs6AoLeWfUy34nQC8= +github.com/google/go-github/v45 v45.2.0 h1:5oRLszbrkvxDDqBCNj2hjDZMKmvexaZ1xw/FCD+K3FI= +github.com/google/go-github/v45 v45.2.0/go.mod h1:FObaZJEDSTa/WGCzZ2Z3eoCDXWJKMenWWTrd8jrta28= +github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg= +github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w= +github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM= +github.com/google/trillian v1.6.0 h1:jMBeDBIkINFvS2n6oV5maDqfRlxREAc6CW9QYWQ0qT4= +github.com/google/trillian v1.6.0/go.mod h1:Yu3nIMITzNhhMJEHjAtp6xKiu+H/iHu2Oq5FjV2mCWI= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= +github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= +github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 h1:UpiO20jno/eV1eVZcxqWnUohyKRe1g8FPV/xH1s/2qs= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.5 h1:dvk7TIXCZpmfOlM+9mlcrWmWjw/wlKT+VDq2wMvfPJU= +github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM= +github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc= +github.com/hashicorp/vault-client-go v0.4.3/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY= +github.com/hashicorp/vault/api v1.14.0 h1:Ah3CFLixD5jmjusOgm8grfN9M0d+Y8fVR2SW0K6pJLU= +github.com/hashicorp/vault/api v1.14.0/go.mod h1:pV9YLxBGSz+cItFDd8Ii4G17waWOQ32zVjMWHe/cOqk= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/in-toto/attestation v1.1.0 h1:oRWzfmZPDSctChD0VaQV7MJrywKOzyNrtpENQFq//2Q= +github.com/in-toto/attestation v1.1.0/go.mod h1:DB59ytd3z7cIHgXxwpSX2SABrU6WJUKg/grpdgHVgVs= +github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU= +github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 h1:TMtDYDHKYY15rFihtRfck/bfFqNfvcabqvXAFQfAUpY= +github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267/go.mod h1:h1nSAbGFqGVzn6Jyl1R/iCcBUHN4g+gW1u9CoBTrb9E= +github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= +github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw= +github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= +github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= +github.com/jinzhu/gorm v0.0.0-20170222002820-5409931a1bb8 h1:CZkYfurY6KGhVtlalI4QwQ6T0Cu6iuY3e0x5RLu96WE= +github.com/jinzhu/gorm v0.0.0-20170222002820-5409931a1bb8/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= +github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d h1:jRQLvyVGL+iVtDElaEIDdKwpPqUIZJfzkNLV34htpEc= +github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= +github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/letsencrypt/boulder v0.0.0-20241010192615-6692160cedfa h1:/kPrcWfMENmWJh9AceGWaTJ0QBS3OyCDENx2vI71T8k= +github.com/letsencrypt/boulder v0.0.0-20241010192615-6692160cedfa/go.mod h1:D28pusRAxf40EQHz8scPN238sxgzUViT7pklzw+NGWY= +github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mandelsoft/filepath v0.0.0-20240223090642-3e2777258aa3 h1:oo9nIgnyiBgYPbcZslRT4y29siuL5EoNJ/t1tr0xEVQ= +github.com/mandelsoft/filepath v0.0.0-20240223090642-3e2777258aa3/go.mod h1:LxhqC7khDoRENwooP6f/vWvia9ivj6TqLYrR39zqkN0= +github.com/mandelsoft/goutils v0.0.0-20241005173814-114fa825bbdc h1:706IHCvAg6DjmG4cQ24CRNpvNVJ8+KQkcGEdQiLyJbU= +github.com/mandelsoft/goutils v0.0.0-20241005173814-114fa825bbdc/go.mod h1:9TJgkwSY43RWHiIAAz7fL8SEIHf0L13Pk4w8fDIt+i4= +github.com/mandelsoft/logging v0.0.0-20240618075559-fdca28a87b0a h1:MAvh0gbP2uwKmf7wWCkYCzrYa6vPjBvYeGhoUlVHwtI= +github.com/mandelsoft/logging v0.0.0-20240618075559-fdca28a87b0a/go.mod h1:uO460C1lIB3IOOgrbXhAlz3AKsOv4T2K6ALBn3PwuSg= +github.com/mandelsoft/spiff v1.7.0-beta-6 h1:0h2Si4u73Ys1TDK9MFzwzcsVZTviN3xHWvLk2HGrPLk= +github.com/mandelsoft/spiff v1.7.0-beta-6/go.mod h1:HXurS33cKPLXXAI3SYll+Z6QotB1yzFFDyOFZ4QODcA= +github.com/mandelsoft/vfs v0.4.4 h1:hq+nI7NWzLLWR3Ii/w4agup4KpWjLpw6dAGtmvWr1Vw= +github.com/mandelsoft/vfs v0.4.4/go.mod h1:3ODt1ze/dCdOJCbhHX8ARAw7l422fDZUhbt0wqplBRs= +github.com/marstr/guid v1.1.0 h1:/M4H/1G4avsieL6BbUwCOBzulmoeKVP5ux/3mQNnbyI= +github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= +github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= +github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/miekg/pkcs11 v1.0.3-0.20190429190417-a667d056470f/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= +github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/mikefarah/yq/v4 v4.44.3 h1:3zxHntH67maSHr6ynCjM44htw7LZNINmTzYn3tM2t+I= +github.com/mikefarah/yq/v4 v4.44.3/go.mod h1:1pm9sJoyZLDql3OqgklvRCkD0XIIHMZV38jKZgAuxwY= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v0.0.0-20150613213606-2caf8efc9366/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= +github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mozillazg/docker-credential-acr-helper v0.4.0 h1:Uoh3Z9CcpEDnLiozDx+D7oDgRq7X+R296vAqAumnOcw= +github.com/mozillazg/docker-credential-acr-helper v0.4.0/go.mod h1:2kiicb3OlPytmlNC9XGkLvVC+f0qTiJw3f/mhmeeQBg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 h1:Up6+btDp321ZG5/zdSLo48H9Iaq0UQGthrhWC6pCxzE= +github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481/go.mod h1:yKZQO8QE2bHlgozqWDiRVqTFlLQSj30K/6SAK8EeYFw= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oleiade/reflections v1.1.0 h1:D+I/UsXQB4esMathlt0kkZRJZdUDmhv5zGi/HOwYTWo= +github.com/oleiade/reflections v1.1.0/go.mod h1:mCxx0QseeVCHs5Um5HhJeCKVC7AwS8kO67tky4rdisA= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= +github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= +github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/open-policy-agent/opa v0.68.0 h1:Jl3U2vXRjwk7JrHmS19U3HZO5qxQRinQbJ2eCJYSqJQ= +github.com/open-policy-agent/opa v0.68.0/go.mod h1:5E5SvaPwTpwt2WM177I9Z3eT7qUpmOGjk1ZdHs+TZ4w= +github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk= +github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/openmcp-project/controller-utils v0.4.2 h1:8viXmZZdBLanN1ummLr05ceCd7XaEBToflm1hU17ecQ= +github.com/openmcp-project/controller-utils v0.4.2/go.mod h1:6BeRpSZK/FkJbelqGA4pv10FEtNerT2RvWb3Eg1Z9j0= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/protocolbuffers/txtpbfmt v0.0.0-20231025115547-084445ff1adf h1:014O62zIzQwvoD7Ekj3ePDF5bv9Xxy0w6AZk0qYbjUk= +github.com/protocolbuffers/txtpbfmt v0.0.0-20231025115547-084445ff1adf/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/redis/go-redis/extra/rediscmd/v9 v9.5.3 h1:1/BDligzCa40GTllkDnY3Y5DTHuKCONbB2JcRyIfl20= +github.com/redis/go-redis/extra/rediscmd/v9 v9.5.3/go.mod h1:3dZmcLn3Qw6FLlWASn1g4y+YO9ycEFUOM+bhBmzLVKQ= +github.com/redis/go-redis/extra/redisotel/v9 v9.5.3 h1:kuvuJL/+MZIEdvtb/kTBRiRgYaOmx1l+lYJyVdrRUOs= +github.com/redis/go-redis/extra/redisotel/v9 v9.5.3/go.mod h1:7f/FMrf5RRRVHXgfk7CzSVzXHiWeuOQUu2bsVqWoa+g= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= +github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA= +github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU= +github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/sigstore/cosign/v2 v2.4.1 h1:b8UXEfJFks3hmTwyxrRNrn6racpmccUycBHxDMkEPvU= +github.com/sigstore/cosign/v2 v2.4.1/go.mod h1:GvzjBeUKigI+XYnsoVQDmMAsMMc6engxztRSuxE+x9I= +github.com/sigstore/fulcio v1.6.5 h1:A9DtV2hmeJ835mtqqxgvV0Sie0HOPKaLPgvFVF4FU7c= +github.com/sigstore/fulcio v1.6.5/go.mod h1:wUei0BMnlz5iY10keKAx5FXFGnMX0N25ZSe4jGRA65M= +github.com/sigstore/protobuf-specs v0.3.2 h1:nCVARCN+fHjlNCk3ThNXwrZRqIommIeNKWwQvORuRQo= +github.com/sigstore/protobuf-specs v0.3.2/go.mod h1:RZ0uOdJR4OB3tLQeAyWoJFbNCBFrPQdcokntde4zRBA= +github.com/sigstore/rekor v1.3.6 h1:QvpMMJVWAp69a3CHzdrLelqEqpTM3ByQRt5B5Kspbi8= +github.com/sigstore/rekor v1.3.6/go.mod h1:JDTSNNMdQ/PxdsS49DJkJ+pRJCO/83nbR5p3aZQteXc= +github.com/sigstore/sigstore v1.8.10 h1:r4t+TYzJlG9JdFxMy+um9GZhZ2N1hBTyTex0AHEZxFs= +github.com/sigstore/sigstore v1.8.10/go.mod h1:BekjqxS5ZtHNJC4u3Q3Stvfx2eyisbW/lUZzmPU2u4A= +github.com/sigstore/sigstore-go v0.6.1 h1:tGkkv1oDIER+QYU5MrjqlttQOVDWfSkmYwMqkJhB/cg= +github.com/sigstore/sigstore-go v0.6.1/go.mod h1:Xe5GHmUeACRFbomUWzVkf/xYCn8xVifb9DgqJrV2dIw= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.9 h1:tgpdvjyoEgYFeTBFe4MHvBKsG+J4E7NVtstChIExVT8= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.9/go.mod h1:wCz6cAZKL/wFumDHX9l8VkVITS2GntrOfs2j/kwH4wo= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.9 h1:eXFm3cte0hvxxYsvGpCMd7aBusEgKJdlUw1Fb5AZQpw= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.9/go.mod h1:RYy9GKnFKKwqbg3Uc6rUyhQdichSVkFlfxnY6f7cAWc= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.9 h1:liWcl12dfFeQXU0JemQVgdVQx02Fls9UPdrFzVrCWhs= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.9/go.mod h1:Ckx62auqPQvNJWRBAboY+/kHs77gy6L33b6UtB/FB5U= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.9 h1:E+bvFTS6uM//iSAeneNj5pubzntQmio/yAKFzmRzzD0= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.9/go.mod h1:0RKVuZXIZAFhT0frfx+GzyrtlesiRK3ceF55nIgkZI4= +github.com/sigstore/timestamp-authority v1.2.3 h1:/4YXCKF/+ZQsad89D0Lj2QIz78s5/aRgLa8Nwq2/Kd4= +github.com/sigstore/timestamp-authority v1.2.3/go.mod h1:q2tJKJzP34hLIbVu3Y1A9bBZTBuZ/gEmMN7MtAoGQKI= +github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY= +github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spiffe/go-spiffe/v2 v2.4.0 h1:j/FynG7hi2azrBG5cvjRcnQ4sux/VNj8FAVc99Fl66c= +github.com/spiffe/go-spiffe/v2 v2.4.0/go.mod h1:m5qJ1hGzjxjtrkGHZupoXHo/FDWwCB1MdSyBzfHugx0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI= +github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDdvS342BElfbETmL1Aiz3i2t0zfRj16Hs= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48= +github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes= +github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/texttheater/golang-levenshtein/levenshtein v0.0.0-20200805054039-cae8b0eaed6c h1:HelZ2kAFadG0La9d+4htN4HzQ68Bm2iM9qKMSMES6xg= +github.com/texttheater/golang-levenshtein/levenshtein v0.0.0-20200805054039-cae8b0eaed6c/go.mod h1:JlzghshsemAMDGZLytTFY8C1JQxQPhnatWqNwUXjggo= +github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg= +github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.0.1 h1:11p9tXpq10KQEujxjcIjDSivMKCMLguls7erXHZnxJQ= +github.com/theupdateframework/go-tuf/v2 v2.0.1/go.mod h1:baB22nBHeHBCeuGZcIlctNq4P61PcOdyARlplg5xmLA= +github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c= +github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/tonglil/buflogr v1.1.1 h1:CKAjOHBSMmgbRFxpn/RhQHPj5oANc7ekhlsoUDvcZIg= +github.com/tonglil/buflogr v1.1.1/go.mod h1:WLLtPRLqcFYWQLbA+ytXy5WrFTYnfA+beg1MpvJCxm4= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vbatts/tar-split v0.11.6 h1:4SjTW5+PU11n6fZenf2IPoV8/tz3AaYHMWjf23envGs= +github.com/vbatts/tar-split v0.11.6/go.mod h1:dqKNtesIOr2j2Qv3W/cHjnvk9I8+G7oAkFDFN6TCBEI= +github.com/vladimirvivien/gexe v0.3.0 h1:4xwiOwGrDob5OMR6E92B9olDXYDglXdHhzR1ggYtWJM= +github.com/vladimirvivien/gexe v0.3.0/go.mod h1:fp7cy60ON1xjhtEI/+bfSEIXX35qgmI+iRYlGOqbBFM= +github.com/weppos/publicsuffix-go v0.40.3-0.20240815124645-a8ed110559c9 h1:4pH9wXOWQdW8kVMJ8P/kxbuxJKR+iNvDeC8zEVLy7eM= +github.com/weppos/publicsuffix-go v0.40.3-0.20240815124645-a8ed110559c9/go.mod h1:o4XOb/pL91sSlesP+I2Xcp38P4/emRvDF6N6xUWvwzg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xanzy/go-gitlab v0.112.0 h1:6Z0cqEooCvBMfBIHw+CgO4AKGRV8na/9781xOb0+DKw= +github.com/xanzy/go-gitlab v0.112.0/go.mod h1:wKNKh3GkYDMOsGmnfuX+ITCmDuSDWFO0G+C4AygL9RY= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= +github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c h1:U1b4THKcgOpJ+kILupuznNwPiURtwVW3e9alJvji9+s= +github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c/go.mod h1:GSDpFDD4TASObxvfZfvpZZ3OWHIUHMlhVWlkOe4ewVk= +github.com/zmap/zlint/v3 v3.6.0 h1:vTEaDRtYN0d/1Ax60T+ypvbLQUHwHxbvYRnUMVr35ug= +github.com/zmap/zlint/v3 v3.6.0/go.mod h1:NVgiIWssgzp0bNl8P4Gz94NHV2ep/4Jyj9V69uTmZyg= +go.mongodb.org/mongo-driver v1.17.1 h1:Wic5cJIwJgSpBhe3lx3+/RybR5PiYRMpVFgO7cOHyIM= +go.mongodb.org/mongo-driver v1.17.1/go.mod h1:wwWm/+BuOddhcq3n68LKRmgk2wXzmF6s0SFOa0GINL4= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/exporters/autoexport v0.46.1 h1:ysCfPZB9AjUlMa1UHYup3c9dAOCMQX/6sxSfPBUoxHw= +go.opentelemetry.io/contrib/exporters/autoexport v0.46.1/go.mod h1:ha0aiYm+DOPsLHjh0zoQ8W8sLT+LJ58J3j47lGpSLrU= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.55.0 h1:hCq2hNMwsegUvPzI7sPOvtO9cqyy5GbWt/Ybp2xrx8Q= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.55.0/go.mod h1:LqaApwGx/oUmzsbqxkzuBvyoPpkxk3JQWnqfVrJ3wCA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0 h1:FZ6ei8GFW7kyPYdxJaV2rgI6M+4tvZzhYsQ2wgyVC08= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.31.0/go.mod h1:MdEu/mC6j3D+tTEfvI15b5Ci2Fn7NneJ71YMoiS3tpI= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 h1:bflGWrfYyuulcdxf14V6n9+CoQcu5SAAdHmDPAJnlps= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0/go.mod h1:qcTO4xHAxZLaLxPd60TdE88rxtItPHgHWqOhOGRr0as= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 h1:FFeLy03iVTXP6ffeN2iXrxfGsZGCjVx0/4KlizjyBwU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0/go.mod h1:TMu73/k1CP8nBUpDLc71Wj/Kf7ZS9FK5b53VapRsP9o= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.26.0 h1:1wp/gyxsuYtuE/JFxsQRtcCDtMrO2qMvlfXALU5wkzI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.26.0/go.mod h1:gbTHmghkGgqxMomVQQMur1Nba4M0MQ8AYThXDUjsJ38= +go.opentelemetry.io/otel/exporters/prometheus v0.44.0 h1:08qeJgaPC0YEBu2PQMbqU3rogTlyzpjhCI2b58Yn00w= +go.opentelemetry.io/otel/exporters/prometheus v0.44.0/go.mod h1:ERL2uIeBtg4TxZdojHUwzZfIFlUIjZtxubT5p4h1Gjg= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.44.0 h1:dEZWPjVN22urgYCza3PXRUGEyCB++y1sAqm6guWFesk= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.44.0/go.mod h1:sTt30Evb7hJB/gEk27qLb1+l9n4Tb8HvHkR0Wx3S6CU= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.21.0 h1:VhlEQAPp9R1ktYfrPk5SOryw1e9LDDTZCbIPFrho0ec= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.21.0/go.mod h1:kB3ufRbfU+CQ4MlUcqtW8Z7YEOBeK2DJ6CmR5rYYF3E= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.step.sm/crypto v0.54.0 h1:V8p+12Ld0NRA/RBMYoKXA0dWmVKZSdCwP56IwzweT9g= +go.step.sm/crypto v0.54.0/go.mod h1:vQJyTngfZDW+UyZdFzOMCY/txWDAmcwViEUC7Gn4YfU= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= +golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/api v0.200.0 h1:0ytfNWn101is6e9VBoct2wrGDjOi5vn7jw5KtaQgDrU= +google.golang.org/api v0.200.0/go.mod h1:Tc5u9kcbjO7A8SwGlYj4IiVifJU01UqXtEgDMYmBmV8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9 h1:nFS3IivktIU5Mk6KQa+v6RKkHUpdQpphqGNLxqNnbEk= +google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:tEzYTYZxbmVNOu0OAFH9HzdJtLn6h4Aj89zzlBCdHms= +google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg= +google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/cenkalti/backoff.v2 v2.2.1 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII= +gopkg.in/cenkalti/backoff.v2 v2.2.1/go.mod h1:S0QdOvT2AlerfSBkp0O+dk+bbIMaNbEmVk876gPCthU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 h1:6D+BvnJ/j6e222UW8s2qTSe3wGBtvo0MbVQG/c5k8RE= +gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473/go.mod h1:N1eN2tsCx0Ydtgjl4cqmbRCsY4/+z4cYDeqwZTk6zog= +gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.1 h1:d4KQkxAaAiRY2h5Zqis161Pv91A37uZyJOx73duwUwM= +gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.1/go.mod h1:WbjuEoo1oadwzQ4apSDU+JTvmllEHtsNHS6y7vFc7iw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +helm.sh/helm/v3 v3.16.2 h1:Y9v7ry+ubQmi+cb5zw1Llx8OKHU9Hk9NQ/+P+LGBe2o= +helm.sh/helm/v3 v3.16.2/go.mod h1:SyTXgKBjNqi2NPsHCW5dDAsHqvGIu0kdNYNH9gQaw70= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.32.2 h1:bZrMLEkgizC24G9eViHGOPbW+aRo9duEISRIJKfdJuw= +k8s.io/api v0.32.2/go.mod h1:hKlhk4x1sJyYnHENsrdCWw31FEmCijNGPJO5WzHiJ6Y= +k8s.io/apiextensions-apiserver v0.32.2 h1:2YMk285jWMk2188V2AERy5yDwBYrjgWYggscghPCvV4= +k8s.io/apiextensions-apiserver v0.32.2/go.mod h1:GPwf8sph7YlJT3H6aKUWtd0E+oyShk/YHWQHf/OOgCA= +k8s.io/apimachinery v0.32.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ= +k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/cli-runtime v0.31.1 h1:/ZmKhmZ6hNqDM+yf9s3Y4KEYakNXUn5sod2LWGGwCuk= +k8s.io/cli-runtime v0.31.1/go.mod h1:pKv1cDIaq7ehWGuXQ+A//1OIF+7DI+xudXtExMCbe9U= +k8s.io/client-go v0.32.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA= +k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94= +k8s.io/component-base v0.32.2 h1:1aUL5Vdmu7qNo4ZsE+569PV5zFatM9hl+lb3dEea2zU= +k8s.io/component-base v0.32.2/go.mod h1:PXJ61Vx9Lg+P5mS8TLd7bCIr+eMJRQTyXe8KvkrvJq0= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= +k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= +k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +ocm.software/ocm v0.17.0 h1:ezXIoZjv4ZWf/+N91QifyZj/IwgZlNio2wkO1O5v0ic= +ocm.software/ocm v0.17.0/go.mod h1:4oVMsLbGzNRPkNY5J4E2N2PTdpL+PHpgBzQ7sF9JKg0= +oras.land/oras-go v1.2.6 h1:z8cmxQXBU8yZ4mkytWqXfo6tZcamPwjsuxYU81xJ8Lk= +oras.land/oras-go v1.2.6/go.mod h1:OVPc1PegSEe/K8YiLfosrlqlqTN9PUyFvOw5Y9gwrT8= +sigs.k8s.io/controller-runtime v0.20.2 h1:/439OZVxoEc02psi1h4QO3bHzTgu49bb347Xp4gW1pc= +sigs.k8s.io/controller-runtime v0.20.2/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= +sigs.k8s.io/controller-tools v0.14.0 h1:rnNoCC5wSXlrNoBKKzL70LNJKIQKEzT6lloG6/LF73A= +sigs.k8s.io/controller-tools v0.14.0/go.mod h1:TV7uOtNNnnR72SpzhStvPkoS/U5ir0nMudrkrC4M9Sc= +sigs.k8s.io/e2e-framework v0.5.0 h1:YLhk8R7EHuTFQAe6Fxy5eBzn5Vb+yamR5u8MH1Rq3cE= +sigs.k8s.io/e2e-framework v0.5.0/go.mod h1:jJSH8u2RNmruekUZgHAtmRjb5Wj67GErli9UjLSY7Zc= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo= +sigs.k8s.io/kustomize/api v0.18.0/go.mod h1:f8isXnX+8b+SGLHQ6yO4JG1rdkZlvhaCf/uZbLVMb0U= +sigs.k8s.io/kustomize/kyaml v0.18.1 h1:WvBo56Wzw3fjS+7vBjN6TeivvpbW9GmRaWZ9CIVmt4E= +sigs.k8s.io/kustomize/kyaml v0.18.1/go.mod h1:C3L2BFVU1jgcddNBE1TxuVLgS46TjObMwW5FT9FcjYo= +sigs.k8s.io/release-utils v0.8.5 h1:FUtFqEAN621gSXv0L7kHyWruBeS7TUU9aWf76olX7uQ= +sigs.k8s.io/release-utils v0.8.5/go.mod h1:qsm5bdxdgoHkD8HsXpgme2/c3mdsNaiV53Sz2HmKeJA= +sigs.k8s.io/structured-merge-diff/v4 v4.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk= +sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt new file mode 100644 index 0000000..65b8622 --- /dev/null +++ b/hack/boilerplate.go.txt @@ -0,0 +1,15 @@ +/* +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. +*/ \ No newline at end of file diff --git a/hack/create-token-opt.sh b/hack/create-token-opt.sh new file mode 100755 index 0000000..fa45eaf --- /dev/null +++ b/hack/create-token-opt.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Better safe then sorry +set -e -o pipefail +if [[ $MAKEFLAGS == *"--debug"* ]]; then + set -x +fi + +BASE_DIR=$(git rev-parse --show-toplevel) +GENERATED_DIR="${BASE_DIR}"/hack/.generated +mkdir -p $GENERATED_DIR +if ! "${BASE_DIR}"/hack/jwt-expired.sh; then + JWT_EXPIRED=true +else + JWT_EXPIRED=false +fi + + + +if [[ "$JWT_EXPIRED" = true ]]; then + jf rt access-token-create --expiry 7776000 --refreshable > "${GENERATED_DIR}"/artifactory-access-token.json + cat < "${GENERATED_DIR}"/artifactory-access-token.json | jq -r .access_token > "${GENERATED_DIR}"/artifactory-bearer-token.json; + echo $("${BASE_DIR}"/hack/userid.sh) > "${GENERATED_DIR}"/artifactory-user; +fi diff --git a/hack/goimports_version b/hack/goimports_version new file mode 100644 index 0000000..f7689f3 --- /dev/null +++ b/hack/goimports_version @@ -0,0 +1 @@ +v0.26.0 \ No newline at end of file diff --git a/hack/jwt-expired.sh b/hack/jwt-expired.sh new file mode 100755 index 0000000..bb66757 --- /dev/null +++ b/hack/jwt-expired.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Better safe then sorry +set -e -o pipefail +if [[ $MAKEFLAGS == *"--debug"* ]]; then + set -x +fi +BASE_DIR=$(git rev-parse --show-toplevel) +TOKEN_FILE="$BASE_DIR"/hack/.generated/artifactory-access-token.json +if [[ ! -f $TOKEN_FILE ]]; then + exit 1 +fi + +expiry=$(cat < "$TOKEN_FILE" | jq -r .access_token | jq -r -R 'split(".") | .[1]' | jq -r -R '@base64d' | jq -r .exp) + +now=$(date '+%s') + +# It's math time. + +if [ $((expiry - now)) -gt 3600 ]; then + exit 0 +fi + +exit 1 diff --git a/hack/userid.sh b/hack/userid.sh new file mode 100755 index 0000000..33fe878 --- /dev/null +++ b/hack/userid.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Better safe then sorry +set -e -o pipefail +if [[ $MAKEFLAGS == *"--debug"* ]]; then + set -x +fi +BASE_DIR=$(git rev-parse --show-toplevel) +TOKEN_FILE="$BASE_DIR"/hack/.generated/artifactory-access-token.json +cat < "$TOKEN_FILE" | jq -r .access_token | jq -r -R 'split(".") | .[1]' | jq -r -R '@base64d' | jq -r .sub | awk -F/ '{print $(NF-0)}' diff --git a/internal/controller/controlplane_controller.go b/internal/controller/controlplane_controller.go new file mode 100644 index 0000000..659572c --- /dev/null +++ b/internal/controller/controlplane_controller.go @@ -0,0 +1,463 @@ +/* +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" + "embed" + "errors" + "time" + + "github.com/go-logr/logr" + "github.com/openmcp-project/control-plane-operator/internal/ocm" + "k8s.io/apimachinery/pkg/types" + + "github.com/openmcp-project/control-plane-operator/cmd/options" + "github.com/openmcp-project/control-plane-operator/internal/schemes" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/components/clusterroles" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/components/crds" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/components/policies" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/crossplane" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/secretresolver" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/secrets" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/fluxcd" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/object" + "github.com/openmcp-project/control-plane-operator/pkg/utils/rcontext" + + corev1beta1 "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/components" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/kubeconfiggen" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/targetrbac" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + condApi "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + requeueAfter = 1 * time.Minute + requeueAfterError = 5 * time.Second + + cpNamespacePrefix = "cp-" + cpNamespaceMaxLen = 63 +) + +var ( + errComponentRemaining = errors.New("at least one component is still installed") + errFailedToCreateCPNamespace = errors.New("failed to create namespace for ControlPlane") + errFailedToBuildRESTConfig = errors.New("failed to build REST config from ControlPlane target") + errFailedToRemoteClient = errors.New("failed to build client for ControlPlane target") + errFailedToEnsureFluxKubeconfig = errors.New("failed to generate or save Flux kubeconfig") + errFailedToApplyFluxRBAC = errors.New("failed to apply Flux RBAC") + + secretTargetNamespaces = []string{ + components.CrossplaneNamespace, + } + + embeddedCRDsToInstall = []string{ + "crossplanepackagerestrictions.core.orchestrate.cloud.sap", + } +) + +// ControlPlaneReconciler reconciles a ControlPlane object +type ControlPlaneReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Kubeconfiggen kubeconfiggen.Generator + FluxSecretResolver secretresolver.SecretResolver + WebhookMiddleware types.NamespacedName + ReconcilePeriod time.Duration + RemoteConfigBuilder RemoteConfigBuilder + EmbeddedCRDs embed.FS +} + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *ControlPlaneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + newConditions := []metav1.Condition{} + + cp := &corev1beta1.ControlPlane{} + if err := r.Get(ctx, req.NamespacedName, cp); err != nil { + if apierrors.IsNotFound(err) { + log.Info("ControlPlane not found") + return ctrl.Result{}, nil + } + log.Error(err, "unable to fetch ControlPlane") + return ctrl.Result{}, err + } + + namespace, err := r.ensureNamespace(ctx, cp) + if err != nil { + return ctrl.Result{}, errors.Join(errFailedToCreateCPNamespace, err) + } + ctx = rcontext.WithTenantNamespace(ctx, namespace) + + resolverFn := r.getReleaseChannels(ctx) + ctx = rcontext.WithVersionResolver(ctx, resolverFn) + ctx = rcontext.WithSecretRefResolver(ctx, r.FluxSecretResolver.Resolve) + + // get a remote config for the target cluster + remoteCfg, _, err := r.RemoteConfigBuilder(cp.Spec.Target) + if err != nil { + return ctrl.Result{}, errors.Join(errFailedToBuildRESTConfig, err) + } + + // create a remote client + remoteClient, err := client.New(remoteCfg, client.Options{Scheme: schemes.Remote}) + if err != nil { + return ctrl.Result{}, errors.Join(errFailedToRemoteClient, err) + } + + // Flux kubeconfig and RBAC + if err := targetrbac.Apply(ctx, remoteClient, cp.Spec.Target.FluxServiceAccount); err != nil { + return ctrl.Result{}, errors.Join(errFailedToApplyFluxRBAC, err) + } + + fluxKubeconfig, err := r.ensureKubeconfig(ctx, remoteCfg, namespace, "flux-kubeconfig", cp.Spec.Target.FluxServiceAccount) + if err != nil { + return ctrl.Result{}, errors.Join(errFailedToEnsureFluxKubeconfig, err) + } + ctx = rcontext.WithFluxKubeconfigRef(ctx, fluxKubeconfig) + + // Always update status + defer func() { + utils.UpdateConditions(&cp.Status.Conditions, newConditions) + if err := r.Status().Update(ctx, cp); err != nil { + log.Error(err, "failed to update status") + } + }() + + if !cp.DeletionTimestamp.IsZero() { + return r.deleteControlPlane(ctx, cp, remoteClient, &newConditions) + } + + if err := r.ensureFinalizer(ctx, cp); err != nil { + return ctrl.Result{}, err + } + + // update ControlPlane v1beta1.ComponentConfig + conditions, err := r.updateControlPlaneComponents(ctx, cp, remoteClient) + if err != nil { + return ctrl.Result{}, err + } + + // append collected conditions to Status + for _, c := range conditions { + condApi.SetStatusCondition(&newConditions, c) + } + + // set status conditions Available + condApi.SetStatusCondition(&newConditions, corev1beta1.Available()) + + cp.Status.Namespace = namespace + + return ctrl.Result{RequeueAfter: r.ReconcilePeriod}, nil +} + +// getReleaseChannels returns a function that can be used to resolve the version of a component +func (r *ControlPlaneReconciler) getReleaseChannels(ctx context.Context) corev1beta1.VersionResolverFn { + return func(componentName string, version string) (corev1beta1.ComponentVersion, error) { + return ocm.GetOCMComponent(ctx, r.Client, componentName, version) + } +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ControlPlaneReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&corev1beta1.ControlPlane{}). + Complete(r) +} + +// updateControlPlaneComponents is the reconcile method where the v1beta1.ControlPlane components get reconciled +// by the components.Juggler. This function will return a list of Kubernetes conditions for the particular components. +func (r *ControlPlaneReconciler) updateControlPlaneComponents(ctx context.Context, cp *corev1beta1.ControlPlane, remoteClient client.Client) ([]metav1.Condition, error) { + j, err := r.newJuggler(ctx, cp, remoteClient) + if err != nil { + return nil, err + } + result := j.Reconcile(ctx) + + enabledComponents := 0 + healthyComponents := 0 + conditions := []metav1.Condition{} + for _, componentResult := range result { + if componentResult.Component.IsEnabled() { + enabledComponents++ + } + if componentResult.Result == juggler.StatusHealthy { + healthyComponents++ + } + + if !componentResult.Component.IsEnabled() && componentResult.Result == juggler.StatusDisabled { + // Component is not enabled and has been successfully uninstalled (or has never been installed). + // Don't output a condition in this case. + continue + } + conditions = append(conditions, componentResult.ToCondition()) + } + + cp.Status.ComponentsEnabled = enabledComponents + cp.Status.ComponentsHealthy = healthyComponents + + return conditions, nil +} + +func (r *ControlPlaneReconciler) deleteControlPlane(ctx context.Context, cp *corev1beta1.ControlPlane, remoteClient client.Client, newConditions *[]metav1.Condition) (ctrl.Result, error) { + if !r.hasFinalizer(cp) { + return ctrl.Result{}, nil + } + + log := log.FromContext(ctx) + + conditions, err := r.deleteControlPlaneComponents(ctx, cp, remoteClient) + // append collected conditions to Status + for _, c := range conditions { + condApi.SetStatusCondition(newConditions, c) + } + if errors.Is(err, errComponentRemaining) { + log.Info(err.Error()) + return ctrl.Result{RequeueAfter: requeueAfterError}, nil + } + if err != nil { + return ctrl.Result{}, err + } + + if err := targetrbac.Delete(ctx, remoteClient); err != nil { + return ctrl.Result{}, err + } + + if err := r.removeFinalizer(ctx, cp); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *ControlPlaneReconciler) deleteControlPlaneComponents(ctx context.Context, cp *corev1beta1.ControlPlane, remoteClient client.Client) ([]metav1.Condition, error) { + // disable all components + cpCopy := cp.DeepCopy() + cpCopy.Spec.ComponentsConfig = corev1beta1.ComponentsConfig{} + + j, err := r.newJuggler(ctx, cpCopy, remoteClient) + if err != nil { + return nil, err + } + result := j.Reconcile(ctx) + + anyComponentRemaining := false + for _, cr := range result { + // do not count components that are marked as "keep on uninstall". + if kou, ok := cr.Component.(juggler.KeepOnUninstall); ok && kou.KeepOnUninstall() { + continue + } + // status must be "Disabled", otherwise the component is counted as "Remaining". + if cr.Result != juggler.StatusDisabled { + anyComponentRemaining = true + } + } + + conditions := []metav1.Condition{} + for _, componentResult := range result { + conditions = append(conditions, componentResult.ToCondition()) + } + + if anyComponentRemaining { + return conditions, errComponentRemaining + } + + return conditions, nil +} + +func (r *ControlPlaneReconciler) newJuggler(ctx context.Context, cp *corev1beta1.ControlPlane, remoteClient client.Client) (*juggler.Juggler, error) { + logger := log.FromContext(ctx) + juggler := juggler.NewJuggler(logger, juggler.NewEventRecorder(r.Recorder, cp)) + + secretsToCopy, err := r.addPullSecrets(ctx, cp) + if err != nil { + return nil, err + } + juggler.RegisterComponent(secretsToCopy...) + + // register Components that get installed on the target cluster + cpComponents := r.controlPlaneComponents(cp) + juggler.RegisterComponent(cpComponents...) + + // register ClusterRoles + clusterroles.RegisterAsComponents(juggler, cpComponents, !cp.WasDeleted()) + + // register CRDs + if err := crds.RegisterAsComponents(juggler, r.EmbeddedCRDs, !cp.WasDeleted(), embeddedCRDsToInstall...); err != nil { + return nil, err + } + + // register policies + if err := policies.RegisterAsComponents(juggler, r.Client, !cp.WasDeleted()); err != nil { + return nil, err + } + + if err := policies.RegisterDeploymentRuntimeConfigProtection(juggler, r.Client, options.IsDeploymentRuntimeConfigProtectionEnabled() && !cp.WasDeleted()); err != nil { + return nil, err + } + + r.registerReconcilers(juggler, logger, remoteClient) + + if err := juggler.RegisterOrphanedComponents(ctx); err != nil { + return nil, err + } + + return juggler, nil +} + +func (r *ControlPlaneReconciler) registerReconcilers(juggler *juggler.Juggler, logger logr.Logger, remoteClient client.Client) { + fr := fluxcd.NewFluxReconciler(logger, r.Client, remoteClient) + fr.RegisterType( + &components.BTPServiceOperator{}, + &components.CertManager{}, + &components.Crossplane{}, + &components.ExternalSecretsOperator{}, + &components.Flux{}, + &components.Kyverno{}, + ) + juggler.RegisterReconciler(fr) + + or := object.NewReconciler(logger, remoteClient) + or.RegisterType( + &components.ClusterRole{}, + &components.CrossplaneProvider{}, + &components.CrossplaneDeploymentRuntimeConfig{}, + &components.Secret{}, + &components.GenericObjectComponent{}, + ) + juggler.RegisterReconciler(or) +} + +func (r *ControlPlaneReconciler) addPullSecrets(ctx context.Context, cp *corev1beta1.ControlPlane) ([]juggler.Component, error) { + pullSecrets, err := secrets.AvailablePullSecrets(ctx, r.Client) + if err != nil { + return nil, err + } + + comps := []juggler.Component{} + for _, ps := range pullSecrets { + for _, ns := range secretTargetNamespaces { + // Add Secret component for each pull secret in every target namespace. + comps = append(comps, &components.Secret{ + Enabled: !cp.WasDeleted(), + SourceClient: r.Client, + Source: ps, + Target: types.NamespacedName{ + Name: ps.Name, + Namespace: ns, + }, + }) + } + + // If Crossplane is enabled, add secret ref to the list of pull secrets. + if cp.Spec.Crossplane != nil { + for _, provider := range cp.Spec.Crossplane.Providers { + provider.PackagePullSecrets = append(provider.PackagePullSecrets, corev1.LocalObjectReference{ + Name: ps.Name, + }) + } + } + } + + return comps, nil +} + +// controlPlaneComponents will extract the components from the v1beta1.ControlPlane spec that will be installed in the target cluster, +// so that the Juggler can reconcile them. +func (r *ControlPlaneReconciler) controlPlaneComponents(cp *corev1beta1.ControlPlane) []juggler.Component { + comps := []juggler.Component{} + xp := &components.Crossplane{ + Config: cp.Spec.Crossplane, + } + comps = append(comps, xp) + if cp.Spec.Crossplane != nil { + for _, provider := range cp.Spec.Crossplane.Providers { + comps = append(comps, &components.CrossplaneProvider{ + Config: provider, + Enabled: xp.IsEnabled(), + }) + comps = append(comps, &components.CrossplaneDeploymentRuntimeConfig{ + Name: crossplane.DeploymentRuntimeNameForProviderConfig(provider), + Enabled: xp.IsEnabled(), + }) + } + } + comps = append(comps, &components.CertManager{ + Config: cp.Spec.CertManager, + }) + comps = append(comps, &components.BTPServiceOperator{ + Config: cp.Spec.BTPServiceOperator, + }) + comps = append(comps, &components.ExternalSecretsOperator{ + Config: cp.Spec.ExternalSecretsOperator, + }) + comps = append(comps, &components.Kyverno{ + Config: cp.Spec.Kyverno, + }) + comps = append(comps, &components.Flux{ + Config: cp.Spec.Flux, + }) + return comps +} + +func (r *ControlPlaneReconciler) ensureNamespace(ctx context.Context, cp *corev1beta1.ControlPlane) (string, error) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: shortenToXCharacters(cpNamespacePrefix+cp.Name, cpNamespaceMaxLen), + }, + } + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, ns, func() error { + utils.SetLabel(ns, corev1beta1.LabelControlPlane, cp.Name) + utils.SetManagedBy(ns) + return controllerutil.SetOwnerReference(cp, ns, r.Scheme) + }) + return ns.Name, err +} + +func (r *ControlPlaneReconciler) ensureFinalizer(ctx context.Context, cp *corev1beta1.ControlPlane) error { + updated := controllerutil.AddFinalizer(cp, corev1beta1.Finalizer) + if updated { + return r.Update(ctx, cp) + } + return nil +} + +func (r *ControlPlaneReconciler) removeFinalizer(ctx context.Context, cp *corev1beta1.ControlPlane) error { + updated := controllerutil.RemoveFinalizer(cp, corev1beta1.Finalizer) + if updated { + return r.Update(ctx, cp) + } + return nil +} + +func (r *ControlPlaneReconciler) hasFinalizer(cp *corev1beta1.ControlPlane) bool { + return controllerutil.ContainsFinalizer(cp, corev1beta1.Finalizer) +} diff --git a/internal/controller/controlplane_controller_test.go b/internal/controller/controlplane_controller_test.go new file mode 100644 index 0000000..fc85114 --- /dev/null +++ b/internal/controller/controlplane_controller_test.go @@ -0,0 +1,378 @@ +package controller + +import ( + "context" + "errors" + "log" + "os" + "testing" + "time" + + testutils "github.com/openmcp-project/control-plane-operator/test/utils" + + "github.com/openmcp-project/controller-utils/pkg/clientconfig" + + corev1beta1 "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/cmd/options" + "github.com/openmcp-project/control-plane-operator/internal/schemes" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/kubeconfiggen" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/secretresolver" + envtestutil "github.com/openmcp-project/control-plane-operator/pkg/utils/envtest" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/record" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +func TestMain(m *testing.M) { + if err := envtestutil.Install(); err != nil { + log.Fatalln(err) + } + os.Exit(m.Run()) +} + +var ( + ocmSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "artifactory-readonly-docker-openmcp", + Namespace: "co-system", + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{ + corev1.DockerConfigJsonKey: []byte(`{}`), + }, + } + coSystemNamespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "co-system", + }, + } +) + +func TestControlPlaneReconciler_Reconcile(t *testing.T) { + testCases := []struct { + desc string + initObjs []client.Object + interceptorFuncs interceptor.Funcs + expectedResult ctrl.Result + expectedErr error + validate func(t *testing.T, ctx context.Context, c client.Client) error + }{ + { + desc: "error - resource not found", + initObjs: []client.Object{ + &corev1beta1.ControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-controlplane", + }, + }, + coSystemNamespace, + ocmSecret, + }, + interceptorFuncs: interceptor.Funcs{Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return apierrors.NewNotFound(schema.GroupResource{Group: corev1beta1.GroupVersion.Group, Resource: "controlplanes"}, key.Name) + }}, + expectedResult: ctrl.Result{}, + expectedErr: nil, + }, + { + desc: "error - unable to fetch ControlPlane", + initObjs: []client.Object{ + &corev1beta1.ControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-controlplane", + }, + }, + coSystemNamespace, + ocmSecret, + }, + interceptorFuncs: interceptor.Funcs{Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return errTest + }}, + expectedResult: ctrl.Result{}, + expectedErr: errTest, + }, + { + desc: "ensure Namespace creation failed", + initObjs: []client.Object{ + &corev1beta1.ControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-controlplane", + }, + }, + coSystemNamespace, + ocmSecret, + }, + interceptorFuncs: interceptor.Funcs{ + Create: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + if _, ok := obj.(*corev1.Namespace); ok { + return errTest + } + return nil + }, + }, + expectedResult: ctrl.Result{}, + expectedErr: errors.Join(errFailedToCreateCPNamespace, errTest), + }, + { + desc: "failed to apply Flux RBAC for target cluster", + initObjs: []client.Object{ + &corev1beta1.ControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-controlplane", + }, + Spec: corev1beta1.ControlPlaneSpec{ + Target: corev1beta1.Target{}, + }, + }, + coSystemNamespace, + ocmSecret, + }, + expectedResult: ctrl.Result{}, + expectedErr: errors.Join(errFailedToApplyFluxRBAC, errors.New("name or namespace in service account reference must not be empty")), + }, + { + desc: "failed to ensure Flux Kubeconfig for target cluster - Get Secret error (unexpected)", + initObjs: []client.Object{ + &corev1beta1.ControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-controlplane", + }, + Spec: corev1beta1.ControlPlaneSpec{ + Target: corev1beta1.Target{ + FluxServiceAccount: corev1beta1.ServiceAccountReference{ + Name: "flux-deployer", + Namespace: "default", + }, + }, + }, + }, + coSystemNamespace, + ocmSecret, + }, + interceptorFuncs: interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if s, ok := obj.(*corev1.Secret); ok && s.Name == "flux-kubeconfig" { + return errTest + } + return client.Get(ctx, key, obj, opts...) + }, + }, + expectedResult: ctrl.Result{}, + expectedErr: errors.Join(errFailedToEnsureFluxKubeconfig, errTest), + }, + { + desc: "failed to ensure Finalizer for ControlPlane resource - Update error (unexpected)", + initObjs: []client.Object{ + &corev1beta1.ControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-controlplane", + }, + Spec: corev1beta1.ControlPlaneSpec{ + Target: corev1beta1.Target{ + FluxServiceAccount: corev1beta1.ServiceAccountReference{ + Name: "flux-deployer", + Namespace: "default", + }, + }, + }, + }, + coSystemNamespace, + ocmSecret, + }, + interceptorFuncs: interceptor.Funcs{ + Update: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.UpdateOption) error { + if _, ok := obj.(*corev1beta1.ControlPlane); ok { // can not update ControlPlane to add finalizer + return errTest + } + return nil + }, + }, + expectedResult: ctrl.Result{}, + expectedErr: errTest, + }, + { + desc: "successful ControlPlane reconciliation - Crossplane installed", + initObjs: []client.Object{ + &corev1beta1.ControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-controlplane", + }, + Spec: corev1beta1.ControlPlaneSpec{ + Target: corev1beta1.Target{ + FluxServiceAccount: corev1beta1.ServiceAccountReference{ + Name: "flux-deployer", + Namespace: "default", + }, + }, + ComponentsConfig: corev1beta1.ComponentsConfig{ + Crossplane: &corev1beta1.CrossplaneConfig{ + Version: "1.15.0", + }, + }, + }, + }, + &corev1beta1.ReleaseChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-releasechannel", + }, + Status: corev1beta1.ReleaseChannelStatus{Components: []corev1beta1.Component{ + {Name: "crossplane", Versions: []corev1beta1.ComponentVersion{{Version: "1.15.0", HelmRepo: "https://charts.crossplane.io/stable", HelmChart: "crossplane"}}}, + {Name: "provider-helm", Versions: []corev1beta1.ComponentVersion{{Version: "0.19.0", DockerRef: "xpkg.upbound.io/crossplane-contrib/provider-helm:v0.19.0"}}}, + }}, + }, + coSystemNamespace, + ocmSecret, + }, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + cp := &corev1beta1.ControlPlane{} + if err := c.Get(ctx, client.ObjectKey{Name: "some-controlplane"}, cp); err != nil { + return err + } + expectedComponentsEnabled := 10 + if options.IsDeploymentRuntimeConfigProtectionEnabled() { + expectedComponentsEnabled += 2 + } + assert.Equal(t, expectedComponentsEnabled, cp.Status.ComponentsEnabled) + cond := meta.FindStatusCondition(cp.Status.Conditions, "CrossplaneReady") + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, "Installed", cond.Reason) + return nil + }, + expectedResult: ctrl.Result{RequeueAfter: time.Second * 30}, + expectedErr: nil, + }, + { + desc: "successful ControlPlane deletion - Step 1", + initObjs: []client.Object{ + &corev1beta1.ControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-controlplane", + DeletionTimestamp: ptr.To(metav1.Now()), + Finalizers: []string{ + corev1beta1.Finalizer, + }, + }, + Spec: corev1beta1.ControlPlaneSpec{ + Target: corev1beta1.Target{ + FluxServiceAccount: corev1beta1.ServiceAccountReference{ + Name: "flux-deployer", + Namespace: "default", + }, + }, + ComponentsConfig: corev1beta1.ComponentsConfig{ + Crossplane: &corev1beta1.CrossplaneConfig{ + Version: "1.15.0", + }, + }, + }, + }, + coSystemNamespace, + ocmSecret, + }, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + cp := &corev1beta1.ControlPlane{} + if err := c.Get(ctx, client.ObjectKey{Name: "some-controlplane"}, cp); err != nil { + return err + } + assert.Equal(t, 0, cp.Status.ComponentsEnabled) + cond := meta.FindStatusCondition(cp.Status.Conditions, "clusterRoleAdminReady") + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, "Uninstalled", cond.Reason) + return nil + }, + expectedResult: ctrl.Result{RequeueAfter: 5 * time.Second}, + expectedErr: nil, + }, + { + desc: "successful ControlPlane deletion - Step 2", + initObjs: []client.Object{ + &corev1beta1.ControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-controlplane", + DeletionTimestamp: ptr.To(metav1.Now()), + Finalizers: []string{ + corev1beta1.Finalizer, + }, + }, + Spec: corev1beta1.ControlPlaneSpec{ + Target: corev1beta1.Target{ + FluxServiceAccount: corev1beta1.ServiceAccountReference{ + Name: "flux-deployer", + Namespace: "default", + }, + }, + ComponentsConfig: corev1beta1.ComponentsConfig{ + Crossplane: &corev1beta1.CrossplaneConfig{ + Version: "1.15.0", + }, + }, + }, + }, + coSystemNamespace, + ocmSecret, + }, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + cp := &corev1beta1.ControlPlane{} + err := c.Get(ctx, client.ObjectKey{Name: "some-controlplane"}, cp) + assert.True(t, apierrors.IsNotFound(err)) + return nil + }, + expectedResult: ctrl.Result{}, + expectedErr: nil, + }, + } + + testEnv := &envtest.Environment{} + testCfg, err := testEnv.Start() + if err != nil { + t.Fatal(err) + } + defer func() { + assert.NoError(t, testEnv.Stop()) + }() + + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + c := fake.NewClientBuilder().WithObjects(tC.initObjs...).WithInterceptorFuncs(tC.interceptorFuncs).WithStatusSubresource(tC.initObjs[0]).WithScheme(schemes.Local).Build() + ctx := newContext() + + testSecretResolver := secretresolver.NewFluxSecretResolver(c) + _ = testSecretResolver.Start(ctx) + + cpr := &ControlPlaneReconciler{ + Client: c, + Scheme: c.Scheme(), + Kubeconfiggen: &kubeconfiggen.Default{}, + FluxSecretResolver: testSecretResolver, + WebhookMiddleware: types.NamespacedName{}, + ReconcilePeriod: time.Second * 30, + Recorder: record.NewFakeRecorder(100), + RemoteConfigBuilder: func(target corev1beta1.Target) (*rest.Config, clientconfig.ReloadFunc, error) { + return testCfg, nil, nil + }, + } + assert.NoError(t, testutils.SetEnvironmentVariableForLocalOCMTar(testutils.LocalOCMRepositoryPathValid)) + req := newRequest(tC.initObjs[0]) + result, err := cpr.Reconcile(ctx, req) + + assert.Equal(t, tC.expectedResult, result) + assert.Equal(t, tC.expectedErr, err) + + if tC.validate != nil { + assert.NoError(t, tC.validate(t, ctx, c)) + } + }) + } +} diff --git a/internal/controller/kubeconfigs.go b/internal/controller/kubeconfigs.go new file mode 100644 index 0000000..fb4e151 --- /dev/null +++ b/internal/controller/kubeconfigs.go @@ -0,0 +1,84 @@ +package controller + +import ( + "context" + "errors" + "time" + + corev1beta1 "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const ( + keyKubeconfig = "kubeconfig" + keyExpiration = "expiresAt" + + kubeconfigExpiration = 10 * time.Minute + kubeconfigBuffer = 3 * requeueAfter +) + +var ( + errInvalidExpirationOrBuffer = errors.New("desired expiration and buffer are incompatible. make sure that desired expiration is greater than the buffer") +) + +func (r *ControlPlaneReconciler) ensureKubeconfig(ctx context.Context, remoteCfg *rest.Config, namespace string, secretName string, svcaccountRef corev1beta1.ServiceAccountReference) (*corev1.SecretReference, error) { + if kubeconfigBuffer >= kubeconfigExpiration { + return nil, errInvalidExpirationOrBuffer + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespace, + }, + } + if err := r.Get(ctx, client.ObjectKeyFromObject(secret), secret); client.IgnoreNotFound(err) != nil { + return nil, err + } + + if secret.Data == nil { + secret.Data = map[string][]byte{} + } + + if expirationStr, ok := secret.Data[keyExpiration]; ok { + expiration, err := time.Parse(time.RFC3339, string(expirationStr)) + if err != nil { + return nil, err + } + + if time.Now().Before(expiration.Add(-kubeconfigBuffer)) { + // kubeconfig is still valid + return &corev1.SecretReference{Name: secret.Name, Namespace: secret.Namespace}, nil + } + } + + kubeconfig, expiration, err := r.Kubeconfiggen.ForServiceAccount(ctx, remoteCfg, svcaccountRef, kubeconfigExpiration) + if err != nil { + return nil, err + } + + kubeconfigBytes, err := clientcmd.Write(*kubeconfig) + if err != nil { + return nil, err + } + + _, err = controllerutil.CreateOrUpdate(ctx, r.Client, secret, func() error { + utils.SetManagedBy(secret) + + if secret.Data == nil { + secret.Data = map[string][]byte{} + } + + secret.Data[keyKubeconfig] = kubeconfigBytes + secret.Data[keyExpiration] = []byte(expiration.Format(time.RFC3339)) + return nil + }) + + return &corev1.SecretReference{Name: secret.Name, Namespace: secret.Namespace}, err +} diff --git a/internal/controller/releasechannel_controller.go b/internal/controller/releasechannel_controller.go new file mode 100644 index 0000000..620b203 --- /dev/null +++ b/internal/controller/releasechannel_controller.go @@ -0,0 +1,149 @@ +package controller + +import ( + "context" + "fmt" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/internal/ocm" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + ocmlib "ocm.software/ocm/api/ocm" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +type ReleaseChannelReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +func (r *ReleaseChannelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + + var releasechannel v1beta1.ReleaseChannel + if err := r.Get(ctx, req.NamespacedName, &releasechannel); err != nil { + log.Error(err, "unable to fetch ReleaseChannel") + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + log.Info("Reconciling ReleaseChannel") + + var repo ocmlib.Repository + var componentNames []string + if releasechannel.Spec.OcmRegistryUrl != "" { + // Get the secret using the PullSecretRef in the ReleaseChannel + var secret corev1.Secret + if err := r.Get(ctx, client.ObjectKey{ + Namespace: releasechannel.Spec.PullSecretRef.Namespace, + Name: releasechannel.Spec.PullSecretRef.Name, + }, &secret); err != nil { + log.Error(err, "unable to fetch Secret") + return ctrl.Result{ + RequeueAfter: requeueAfterError, + }, fmt.Errorf("unable to fetch Secret: %w", err) + } + + var err error + repo, componentNames, err = ocm.GetOCMRemoteRepo(releasechannel.Spec.OcmRegistryUrl, secret, releasechannel.Spec.PrefixFilter) + if err != nil { + log.Error(err, "unable to get components from remote OCM") + return ctrl.Result{ + RequeueAfter: requeueAfterError, + }, fmt.Errorf("unable to get components from OCM: %w", err) + } + } else if releasechannel.Spec.OcmRegistrySecretRef.Name != "" && releasechannel.Spec.OcmRegistrySecretKey != "" { + var err error + // Get data from secret + var secret corev1.Secret + if err := r.Get(ctx, client.ObjectKey{ + Namespace: releasechannel.Spec.OcmRegistrySecretRef.Namespace, + Name: releasechannel.Spec.OcmRegistrySecretRef.Name, + }, &secret); err != nil { + log.Error(err, "unable to fetch Secret") + return ctrl.Result{ + RequeueAfter: requeueAfterError, + }, fmt.Errorf("unable to fetch Secret: %w", err) + } + + data, ok := secret.Data[releasechannel.Spec.OcmRegistrySecretKey] + if !ok { + err := fmt.Errorf("key %s not found in secret %s", releasechannel.Spec.OcmRegistrySecretKey, releasechannel.Spec.OcmRegistrySecretRef.Name) + log.Error(err, "unable to get data from secret") + return ctrl.Result{ + RequeueAfter: requeueAfterError, + }, err + } + + repo, componentNames, err = ocm.GetOCMLocalRepo(data, releasechannel.Spec.PrefixFilter) + if err != nil { + log.Error(err, "unable to get components from local OCM") + return ctrl.Result{ + RequeueAfter: requeueAfterError, + }, fmt.Errorf("unable to get components from local OCM: %w", err) + } + } else { + return ctrl.Result{ + RequeueAfter: requeueAfterError, + }, fmt.Errorf("either 'OcmRegistryUrl' or 'OcmRegistrySecretRef & OcmRegistrySecretKey' must be set") + } + + components, err := ocm.GetOCMComponentsWithVersions(repo, componentNames, releasechannel.Spec.PrefixFilter) + if err != nil { + log.Error(err, "unable to get components from OCM") + return ctrl.Result{ + RequeueAfter: requeueAfterError, + }, fmt.Errorf("unable to get components from OCM: %w", err) + } + + releasechannel.Status.Components = components + + err = r.Status().Update(ctx, &releasechannel) + if err != nil { + log.Error(err, "unable to update ReleaseChannel status") + return ctrl.Result{ + RequeueAfter: requeueAfterError, + }, fmt.Errorf("unable to update ReleaseChannel status: %w", err) + } + + log.Info("Finish Reconciling ReleaseChannel") + + return ctrl.Result{ + RequeueAfter: releasechannel.Spec.Interval.Duration, + }, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ReleaseChannelReconciler) SetupWithManager(mgr ctrl.Manager) error { + updatePred := predicate.Funcs{ + // Only allow updates when the spec.size of the Busybox resource changes + UpdateFunc: func(e event.UpdateEvent) bool { + oldObj := e.ObjectOld.(*v1beta1.ReleaseChannel) + newObj := e.ObjectNew.(*v1beta1.ReleaseChannel) + + return oldObj.Spec != newObj.Spec + }, + // Allow create events + CreateFunc: func(e event.CreateEvent) bool { + return true + }, + + // Allow delete events + DeleteFunc: func(e event.DeleteEvent) bool { + return true + }, + + // Allow generic events (e.g., external triggers) + GenericFunc: func(e event.GenericEvent) bool { + return true + }, + } + + return ctrl.NewControllerManagedBy(mgr). + For(&v1beta1.ReleaseChannel{}, builder.WithPredicates(updatePred)). + Complete(r) +} diff --git a/internal/controller/releasechannel_controller_test.go b/internal/controller/releasechannel_controller_test.go new file mode 100644 index 0000000..8cd989b --- /dev/null +++ b/internal/controller/releasechannel_controller_test.go @@ -0,0 +1,272 @@ +package controller + +import ( + "context" + "errors" + "os" + "testing" + "time" + + corev1beta1 "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/internal/schemes" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const localOCMRegistryTestDataPath = "../../test/testdata/ocm_registry.tgz" + +func Test_ReleaseChannelReconciler_Reconcile(t *testing.T) { + ocmTestRegistry, err := os.ReadFile(localOCMRegistryTestDataPath) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + testCases := []struct { + desc string + initObjs []client.Object + expectedResult ctrl.Result + expectedErr error + validate func(t *testing.T, ctx context.Context, c client.Client) error + }{ + { + desc: "should return error when OcmRegistryUrl and OcmRegistrySecretRef are not set", + initObjs: []client.Object{ + &corev1beta1.ReleaseChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-releasechannel", + }, + }, + }, + expectedResult: ctrl.Result{ + RequeueAfter: requeueAfterError, + }, + expectedErr: errors.New("either 'OcmRegistryUrl' or 'OcmRegistrySecretRef & OcmRegistrySecretKey' must be set"), + }, { + desc: "should return error, when secret is not found", + initObjs: []client.Object{ + &corev1beta1.ReleaseChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-releasechannel", + }, + Spec: corev1beta1.ReleaseChannelSpec{ + OcmRegistryUrl: "https://some.url", + PullSecretRef: corev1.SecretReference{ + Name: "some-secret", + }, + }, + }, + }, + expectedResult: ctrl.Result{ + RequeueAfter: requeueAfterError, + }, + expectedErr: errors.New("unable to fetch Secret"), + }, { + desc: "Cant get username from secret", + initObjs: []client.Object{ + &corev1beta1.ReleaseChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-releasechannel", + }, + Spec: corev1beta1.ReleaseChannelSpec{ + OcmRegistryUrl: "https://some.url", + PullSecretRef: corev1.SecretReference{ + Name: "some-secret", + }, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + }, + }, + }, + expectedResult: ctrl.Result{ + RequeueAfter: requeueAfterError, + }, + expectedErr: errors.New("Failed to get username from secret"), + }, { + desc: "Cant get password from secret", + initObjs: []client.Object{ + &corev1beta1.ReleaseChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-releasechannel", + }, + Spec: corev1beta1.ReleaseChannelSpec{ + OcmRegistryUrl: "https://some.url", + PullSecretRef: corev1.SecretReference{ + Name: "some-secret", + }, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + }, + Data: map[string][]byte{ + "username": []byte("some-username"), + }, + }, + }, + expectedResult: ctrl.Result{ + RequeueAfter: requeueAfterError, + }, + expectedErr: errors.New("Failed to get password from secret"), + }, { + desc: "should return error when unable to get components from remote OCM", + initObjs: []client.Object{ + &corev1beta1.ReleaseChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-releasechannel", + }, + Spec: corev1beta1.ReleaseChannelSpec{ + OcmRegistryUrl: "https://some.url", + PullSecretRef: corev1.SecretReference{ + Name: "some-secret", + }, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + }, + Data: map[string][]byte{ + "username": []byte("some-username"), + "password": []byte("some-password"), + }, + }, + }, + expectedResult: ctrl.Result{ + RequeueAfter: requeueAfterError, + }, + expectedErr: errors.New("unable to get components from remote OCM"), + }, + { + desc: "should return an error, if OcmRegistrySecretRef is set but secret is not found", + initObjs: []client.Object{ + &corev1beta1.ReleaseChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-releasechannel", + }, + Spec: corev1beta1.ReleaseChannelSpec{ + OcmRegistrySecretRef: corev1.SecretReference{ + Name: "some-secret", + }, + OcmRegistrySecretKey: "registry.tar.gz", + }, + }, + }, + expectedResult: ctrl.Result{ + RequeueAfter: requeueAfterError, + }, + expectedErr: errors.New("unable to fetch Secret"), + }, + { + desc: "should return an error, if OcmRegistrySecretRef is set but key is not found in secret", + initObjs: []client.Object{ + &corev1beta1.ReleaseChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-releasechannel", + }, + Spec: corev1beta1.ReleaseChannelSpec{ + OcmRegistrySecretRef: corev1.SecretReference{ + Name: "some-secret", + }, + OcmRegistrySecretKey: "registry.tar.gz", + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + }, + }, + }, + expectedResult: ctrl.Result{ + RequeueAfter: requeueAfterError, + }, + expectedErr: errors.New("key registry.tar.gz not found in secret some-secret"), + }, { + desc: "should return an error, if unable to get components from local OCM", + initObjs: []client.Object{ + &corev1beta1.ReleaseChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-releasechannel", + }, + Spec: corev1beta1.ReleaseChannelSpec{ + OcmRegistrySecretRef: corev1.SecretReference{ + Name: "some-secret", + }, + OcmRegistrySecretKey: "registry.tar.gz", + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + }, + Data: map[string][]byte{ + "registry.tar.gz": []byte("some-data"), + }, + }, + }, + expectedResult: ctrl.Result{ + RequeueAfter: requeueAfterError, + }, + expectedErr: errors.New("unable to get components from local OCM"), + }, { + desc: "success case", + initObjs: []client.Object{ + &corev1beta1.ReleaseChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-releasechannel", + }, + Spec: corev1beta1.ReleaseChannelSpec{ + OcmRegistrySecretRef: corev1.SecretReference{ + Name: "some-secret", + }, + OcmRegistrySecretKey: "registry.tar.gz", + Interval: metav1.Duration{Duration: 15 * time.Minute}, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + }, + Data: map[string][]byte{ + "registry.tar.gz": ocmTestRegistry, + }, + }, + }, + expectedResult: ctrl.Result{ + RequeueAfter: time.Minute * 15, + }, + }, + } + + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + c := fake.NewClientBuilder().WithObjects(tC.initObjs...).WithStatusSubresource(tC.initObjs[0]).WithScheme(schemes.Local).Build() + ctx := newContext() + req := newRequest(tC.initObjs[0]) + + sr := &ReleaseChannelReconciler{ + Client: c, + Scheme: c.Scheme(), + } + result, err := sr.Reconcile(ctx, req) + + assert.Equal(t, tC.expectedResult, result) + if tC.expectedErr != nil { + assert.Errorf(t, err, tC.expectedErr.Error()) + } else { + assert.NoError(t, err) + } + + if tC.validate != nil { + assert.NoError(t, tC.validate(t, ctx, c)) + } + }) + } +} diff --git a/internal/controller/remote_config_builder.go b/internal/controller/remote_config_builder.go new file mode 100644 index 0000000..37cdf91 --- /dev/null +++ b/internal/controller/remote_config_builder.go @@ -0,0 +1,15 @@ +package controller + +import ( + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/controller-utils/pkg/clientconfig" + "k8s.io/client-go/rest" +) + +type RemoteConfigBuilder func(target v1beta1.Target) (*rest.Config, clientconfig.ReloadFunc, error) + +func NewRemoteConfigBuilder() RemoteConfigBuilder { + return func(target v1beta1.Target) (*rest.Config, clientconfig.ReloadFunc, error) { + return clientconfig.New(target.Target).GetRESTConfig() + } +} diff --git a/internal/controller/remote_config_builder_test.go b/internal/controller/remote_config_builder_test.go new file mode 100644 index 0000000..c72e0f0 --- /dev/null +++ b/internal/controller/remote_config_builder_test.go @@ -0,0 +1,58 @@ +package controller + +import ( + "testing" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/controller-utils/pkg/api" + "github.com/openmcp-project/controller-utils/pkg/clientconfig" + "github.com/stretchr/testify/assert" + "k8s.io/client-go/rest" +) + +func TestNewRemoteConfigBuilder(t *testing.T) { + fn := NewRemoteConfigBuilder() + assert.NotNil(t, fn) + testCases := []struct { + name string + target v1beta1.Target + expectedError error + validateReloadFunc func(t *testing.T, reloadFunc clientconfig.ReloadFunc) error + validateTarget func(t *testing.T, config *rest.Config) error + }{ + { + name: "not valid target", + target: v1beta1.Target{ + Target: api.Target{ + Kubeconfig: nil, + KubeconfigRef: nil, + ServiceAccount: nil, + }, + }, + expectedError: clientconfig.ErrInvalidConnectionMethod, + validateTarget: func(t *testing.T, config *rest.Config) error { + assert.Nil(t, config) + return nil + }, + validateReloadFunc: func(t *testing.T, reloadFunc clientconfig.ReloadFunc) error { + assert.Nil(t, reloadFunc) + return nil + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + target, reloadFunc, err := fn(tc.target) + assert.Equal(t, tc.expectedError, err) + if tc.validateTarget != nil { + err := tc.validateTarget(t, target) + assert.NoErrorf(t, err, "validation failed unexpectedly") + } + if tc.validateReloadFunc != nil { + err := tc.validateReloadFunc(t, reloadFunc) + assert.NoErrorf(t, err, "validation failed unexpectedly") + } + }) + } +} diff --git a/internal/controller/secret_controller.go b/internal/controller/secret_controller.go new file mode 100644 index 0000000..9f79744 --- /dev/null +++ b/internal/controller/secret_controller.go @@ -0,0 +1,173 @@ +package controller + +import ( + "context" + "errors" + "strings" + + "github.com/openmcp-project/control-plane-operator/pkg/constants" + + corev1beta1 "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + finalizerOrphan = corev1beta1.Finalizer + "/orphan" +) + +var ( + errFailedToCopySecret = errors.New("failed to copy secret") + errFailedToDeleteCopy = errors.New("failed to delete secret copy") +) + +// SecretReconciler reconciles a Secret object +type SecretReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *SecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + + secret := &corev1.Secret{} + if err := r.Get(ctx, req.NamespacedName, secret); err != nil { + if apierrors.IsNotFound(err) { + log.Info("Secret not found") + return ctrl.Result{}, nil + } + log.Error(err, "unable to fetch Secret") + return ctrl.Result{}, err + } + + // set labels to a non-nil value because we read from them multiple times + if secret.Labels == nil { + secret.Labels = map[string]string{} + } + + if !r.shouldReconcile(secret) { + return ctrl.Result{}, nil + } + + if !secret.DeletionTimestamp.IsZero() || secret.Labels[constants.LabelCopyToCPNamespace] != "true" { + return r.handleDeletion(ctx, secret) + } + + if err := r.ensureFinalizer(ctx, secret); err != nil { + return ctrl.Result{}, err + } + + return r.handleSync(ctx, secret) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *SecretReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For( + &corev1.Secret{}, + builder.WithPredicates(r.buildFilterPredicate()), + ). + Complete(r) +} + +func (r *SecretReconciler) handleDeletion(ctx context.Context, secret *corev1.Secret) (ctrl.Result, error) { + matchingLabels := client.MatchingLabels{ + constants.LabelCopySourceName: secret.Name, + constants.LabelCopySourceNamespace: secret.Namespace, + } + + copies := &corev1.SecretList{} + if err := r.List(ctx, copies, matchingLabels); err != nil { + return ctrl.Result{}, err + } + + if len(copies.Items) == 0 { + return ctrl.Result{}, r.removeFinalizer(ctx, secret) + } + + for _, copy := range copies.Items { + if err := r.Delete(ctx, ©); err != nil { + return ctrl.Result{}, errors.Join(errFailedToDeleteCopy, err) + } + } + + return ctrl.Result{RequeueAfter: requeueAfterError}, nil +} + +func (r *SecretReconciler) handleSync(ctx context.Context, secret *corev1.Secret) (ctrl.Result, error) { + namespaces := &corev1.NamespaceList{} + if err := r.List(ctx, namespaces); err != nil { + return ctrl.Result{}, err + } + + for _, ns := range namespaces.Items { + if !strings.HasPrefix(ns.Name, cpNamespacePrefix) { + continue + } + + copy := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secret.Name, + Namespace: ns.Name, + }, + } + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, copy, func() error { + copy.Type = secret.Type + copy.Data = secret.Data + metav1.SetMetaDataLabel(©.ObjectMeta, constants.LabelCopySourceName, secret.Name) + metav1.SetMetaDataLabel(©.ObjectMeta, constants.LabelCopySourceNamespace, secret.Namespace) + utils.SetManagedBy(copy) + return nil + }) + if err != nil { + return ctrl.Result{}, errors.Join(errFailedToCopySecret, err) + } + } + + return ctrl.Result{RequeueAfter: requeueAfter}, nil +} + +func (r *SecretReconciler) shouldReconcile(o client.Object) bool { + labels := o.GetLabels() + if labels == nil { + labels = map[string]string{} + } + _, hasLabel := labels[constants.LabelCopyToCPNamespace] + + return hasLabel || r.hasFinalizer(o) +} + +func (r *SecretReconciler) ensureFinalizer(ctx context.Context, o client.Object) error { + updated := controllerutil.AddFinalizer(o, finalizerOrphan) + if updated { + return r.Update(ctx, o) + } + return nil +} + +func (r *SecretReconciler) removeFinalizer(ctx context.Context, o client.Object) error { + updated := controllerutil.RemoveFinalizer(o, finalizerOrphan) + if updated { + return r.Update(ctx, o) + } + return nil +} + +func (r *SecretReconciler) hasFinalizer(o client.Object) bool { + return controllerutil.ContainsFinalizer(o, finalizerOrphan) +} + +func (r *SecretReconciler) buildFilterPredicate() filterObjectPredicate { + return filterObjectPredicate{filterFunc: r.shouldReconcile} +} diff --git a/internal/controller/secret_controller_test.go b/internal/controller/secret_controller_test.go new file mode 100644 index 0000000..b70bdc9 --- /dev/null +++ b/internal/controller/secret_controller_test.go @@ -0,0 +1,268 @@ +package controller + +import ( + "context" + "errors" + "testing" + + "github.com/openmcp-project/control-plane-operator/pkg/constants" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +var ( + secretWithLabel = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + Namespace: corev1.NamespaceDefault, + Labels: map[string]string{ + constants.LabelCopyToCPNamespace: "true", + }, + }, + } + secretDeletedWithLabelAndFinalizer = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + Namespace: corev1.NamespaceDefault, + Labels: map[string]string{ + constants.LabelCopyToCPNamespace: "true", + }, + DeletionTimestamp: ptr.To(metav1.Now()), + Finalizers: []string{ + finalizerOrphan, + }, + }, + } + secretWithoutLabel = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + Namespace: corev1.NamespaceDefault, + }, + } + secretWithFinalizer = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + Namespace: corev1.NamespaceDefault, + Finalizers: []string{ + finalizerOrphan, + }, + }, + } + replicatedSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + Namespace: tenantNamespace.Name, + Labels: map[string]string{ + constants.LabelCopySourceName: secretWithLabel.Name, + constants.LabelCopySourceNamespace: secretWithLabel.Namespace, + }, + }, + } + unrelatedSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unrelated-secret", + Namespace: tenantNamespace.Name, + }, + } + tenantNamespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: cpNamespacePrefix + "example", + }, + } + otherNamespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-system", + }, + } + + errTest = errors.New("some error") +) + +func Test_SecretReconciler_Reconcile(t *testing.T) { + testCases := []struct { + desc string + initObjs []client.Object + interceptorFuncs interceptor.Funcs + expectedResult ctrl.Result + expectedErr error + validate func(t *testing.T, ctx context.Context, c client.Client) error + }{ + { + desc: "should not return error when object is not found", + initObjs: []client.Object{ + secretWithLabel.DeepCopy(), + tenantNamespace.DeepCopy(), + }, + interceptorFuncs: interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return apierrors.NewNotFound(corev1.Resource("secrets"), "some-secret") + }, + }, + }, + { + desc: "should return error when client returns unknown error", + initObjs: []client.Object{ + secretWithLabel.DeepCopy(), + tenantNamespace.DeepCopy(), + }, + interceptorFuncs: interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return errTest + }, + }, + expectedErr: errTest, + }, + { + desc: "should not do anything when secret has no label", + initObjs: []client.Object{ + secretWithoutLabel.DeepCopy(), + tenantNamespace.DeepCopy(), + }, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + secrets := &corev1.SecretList{} + if err := c.List(ctx, secrets); err != nil { + return err + } + assert.Len(t, secrets.Items, 1) + return nil + }, + }, + { + desc: "should replicate to tenant namespace", + initObjs: []client.Object{ + secretWithLabel.DeepCopy(), + tenantNamespace.DeepCopy(), + otherNamespace.DeepCopy(), + }, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + secrets := &corev1.SecretList{} + if err := c.List(ctx, secrets); err != nil { + return err + } + assert.Len(t, secrets.Items, 2) + return nil + }, + expectedResult: ctrl.Result{ + RequeueAfter: requeueAfter, + }, + }, + { + desc: "should delete secret from tenant namespace", + initObjs: []client.Object{ + secretDeletedWithLabelAndFinalizer.DeepCopy(), + tenantNamespace.DeepCopy(), + otherNamespace.DeepCopy(), + replicatedSecret.DeepCopy(), + unrelatedSecret.DeepCopy(), + }, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + secrets := &corev1.SecretList{} + if err := c.List(ctx, secrets); err != nil { + return err + } + assert.Len(t, secrets.Items, 2) + + secretDeleted := &corev1.Secret{} + if err := c.Get(ctx, client.ObjectKeyFromObject(secretDeletedWithLabelAndFinalizer), secretDeleted); err != nil { + return err + } + // Finalizer will stay until next reconciliation + assert.Equal(t, secretDeleted.Finalizers, []string{finalizerOrphan}) + return nil + }, + expectedResult: ctrl.Result{ + RequeueAfter: requeueAfterError, + }, + }, + { + desc: "should remove finalizer when no replicated secrets left", + initObjs: []client.Object{ + secretDeletedWithLabelAndFinalizer.DeepCopy(), + tenantNamespace.DeepCopy(), + otherNamespace.DeepCopy(), + unrelatedSecret.DeepCopy(), + }, + validate: func(t *testing.T, ctx context.Context, c client.Client) error { + secrets := &corev1.SecretList{} + if err := c.List(ctx, secrets); err != nil { + return err + } + // only "unrelatedSecret" should remain + assert.Len(t, secrets.Items, 1) + return nil + }, + expectedResult: ctrl.Result{ + RequeueAfter: 0, + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + c := fake.NewClientBuilder().WithObjects(tC.initObjs...).WithInterceptorFuncs(tC.interceptorFuncs).Build() + ctx := newContext() + req := newRequest(tC.initObjs[0]) + + sr := &SecretReconciler{ + Client: c, + Scheme: c.Scheme(), + } + result, err := sr.Reconcile(ctx, req) + + assert.Equal(t, tC.expectedResult, result) + assert.Equal(t, tC.expectedErr, err) + + if tC.validate != nil { + assert.NoError(t, tC.validate(t, ctx, c)) + } + }) + } +} + +func Test_buildFilterPredicate(t *testing.T) { + testCases := []struct { + desc string + objectOther, object client.Object + expected bool + }{ + { + desc: "should reconcile secret with label", + object: secretWithLabel, + objectOther: secretWithoutLabel, + expected: true, + }, + { + desc: "should not reconcile secret without label", + object: secretWithoutLabel, + expected: false, + }, + { + desc: "should reconcile secret with finalizer", + object: secretWithFinalizer, + objectOther: secretWithoutLabel, + expected: true, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + if tC.objectOther == nil { + tC.objectOther = tC.object + } + + sr := &SecretReconciler{} + predicate := sr.buildFilterPredicate() + assert.Equal(t, tC.expected, predicate.Create(event.CreateEvent{Object: tC.object})) + assert.Equal(t, tC.expected, predicate.Delete(event.DeleteEvent{Object: tC.object})) + assert.Equal(t, tC.expected, predicate.Generic(event.GenericEvent{Object: tC.object})) + assert.Equal(t, tC.expected, predicate.Update(event.UpdateEvent{ObjectOld: tC.object, ObjectNew: tC.objectOther})) + assert.Equal(t, tC.expected, predicate.Update(event.UpdateEvent{ObjectOld: tC.objectOther, ObjectNew: tC.object})) + }) + } +} diff --git a/internal/controller/utils.go b/internal/controller/utils.go new file mode 100644 index 0000000..f5ec8e2 --- /dev/null +++ b/internal/controller/utils.go @@ -0,0 +1,50 @@ +package controller + +import ( + "fmt" + "hash/fnv" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +var _ predicate.Predicate = filterObjectPredicate{} + +type filterObjectPredicate struct { + filterFunc func(o client.Object) bool +} + +// Create implements predicate.Predicate. +func (p filterObjectPredicate) Create(evt event.CreateEvent) bool { + return p.filterFunc(evt.Object) +} + +// Delete implements predicate.Predicate. +func (p filterObjectPredicate) Delete(evt event.DeleteEvent) bool { + return p.filterFunc(evt.Object) +} + +// Generic implements predicate.Predicate. +func (p filterObjectPredicate) Generic(evt event.GenericEvent) bool { + return p.filterFunc(evt.Object) +} + +// Update implements predicate.Predicate. +func (p filterObjectPredicate) Update(evt event.UpdateEvent) bool { + return p.filterFunc(evt.ObjectNew) || p.filterFunc(evt.ObjectOld) +} + +func shortenToXCharacters(input string, maxLen int) string { + if len(input) <= maxLen { + return input + } + + hash := fnv.New32a() + hash.Write([]byte(input)) + + suffix := fmt.Sprintf("--%x", hash.Sum32()) + trimLength := maxLen - len(suffix) + + return input[:trimLength] + suffix +} diff --git a/internal/controller/utils_test.go b/internal/controller/utils_test.go new file mode 100644 index 0000000..2b6cd5a --- /dev/null +++ b/internal/controller/utils_test.go @@ -0,0 +1,54 @@ +package controller + +import ( + "context" + "testing" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/stretchr/testify/assert" +) + +func Test_shortenToXCharacters(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + expected string + }{ + { + name: "short string", + input: "short", + expected: "short", + maxLen: 100, + }, + { + name: "long string", + input: "this-is-a-very-a-very-a-very-long-string-that-is-over-63-characters", + expected: "this-is-a-very-a-very-a-very-long-string-that-is-over--4b610537", + maxLen: 63, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := shortenToXCharacters(tt.input, tt.maxLen) + assert.Equal(t, tt.expected, actual) + assert.LessOrEqual(t, len(actual), tt.maxLen) + }) + } +} + +func newContext() context.Context { + ctx := context.Background() + ctx = log.IntoContext(ctx, log.Log) + return ctx +} + +func newRequest(obj client.Object) ctrl.Request { + return ctrl.Request{ + NamespacedName: client.ObjectKeyFromObject(obj), + } +} diff --git a/internal/ocm/ocm.go b/internal/ocm/ocm.go new file mode 100644 index 0000000..1d21a5d --- /dev/null +++ b/internal/ocm/ocm.go @@ -0,0 +1,210 @@ +package ocm + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + corev1 "k8s.io/api/core/v1" + "ocm.software/ocm/api/ocm" + "ocm.software/ocm/api/ocm/extensions/accessmethods/helm" + "ocm.software/ocm/api/ocm/extensions/accessmethods/ociartifact" + "ocm.software/ocm/api/ocm/extensions/repositories/ctf" + "ocm.software/ocm/api/ocm/extensions/repositories/ocireg" + "ocm.software/ocm/api/ocm/ocmutils" + "ocm.software/ocm/api/tech/oci/identity" + "ocm.software/ocm/api/utils/accessobj" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Create an ocm.Repository entity out of an url specified in the ocmRegistry parameter. +// With the secret parameter you provide the necessary username and password for accessing the ocm Repository. +// +// Additionally the method returns a list of components in the second return parameter which are part of the ocm Repository. +// This can be filtered using the prefixFilter parameter. +func GetOCMRemoteRepo(ocmRegistry string, secret corev1.Secret, prefixFilter string) (ocm.Repository, []string, error) { + var secretData RegistryCredentials + // Decode the secret to get the username and password + username, ok := secret.Data["username"] + if !ok { + return nil, nil, errors.New("secret does not contain username") + } + password, ok := secret.Data["password"] + if !ok { + return nil, nil, errors.New("secret does not contain password") + } + + secretData.Username = string(username) + secretData.Password = string(password) + + octx := ocm.DefaultContext() + creds := identity.SimpleCredentials(secretData.Username, secretData.Password) + + spec := ocireg.NewRepositorySpec(ocmRegistry, nil) + repo, err := octx.RepositoryForSpec(spec, creds) + if err != nil { + return nil, nil, errors.New("failed to get ocm registry with provided credentials") + } + + // Currently workaround, as OCM does not support listing components for oci registries + // Hopefully there will be something in the future + // For now, we first get a list of all repositories in the oci registry and then use them as components + registryHost := strings.Split(ocmRegistry, "/")[0] + path := strings.TrimPrefix(ocmRegistry, registryHost+"/") + + prefix := path + "/component-descriptors/" + repositories, err := GetRepositoriesInOCIRegistry(registryHost, secretData, prefix, "https") + if err != nil { + return nil, nil, errors.New("can't get repositories in OCI registry") + } + + var components []string + for _, repository := range repositories { + if prefixFilter == "" || strings.HasPrefix(repository, prefixFilter) { + components = append(components, repository) + } + } + + return repo, components, nil +} + +// Create an ocm.Repository entity out a byte array of a tar based ocm registry. +// +// Additionally the method returns a list of components in the second return parameter which are part of the ocm Repository. +// This can be filtered using the prefixFilter parameter. +func GetOCMLocalRepo(ocmRegistry []byte, prefixFilter string) (ocm.Repository, []string, error) { + f, err := os.CreateTemp("", "ocm-registry") + if err != nil { + return nil, nil, err + } + defer func(name string) { + err := os.Remove(name) + if err != nil { + fmt.Printf("Failed to remove file %s: %v\n", name, err) + } + }(f.Name()) + + // Save the ocmRegistry to a file + _, err = f.Write(ocmRegistry) + if err != nil { + return nil, nil, err + } + + octx := ocm.DefaultContext() + fileRepo, err := ctf.NewRepositorySpec(accessobj.ACC_READONLY, f.Name()) + if err != nil { + return nil, nil, err + } + + repo, err := octx.RepositoryForSpec(fileRepo, nil) + if err != nil { + return nil, nil, err + } + + components, err := repo.ComponentLister().GetComponents(prefixFilter, true) + if err != nil { + return nil, nil, err + } + + return repo, components, nil +} + +// This method takes a ocm repository and a list of components to create our Component struct out of it +// In that process, the ocm Repository will be searched for the specificed component names and +// their available versions will be put into the resulting component array. +// +// The prefixFilter must be specified as it will be cut off every componentName in the end +// so the resulting Component names are without it. +func GetOCMComponentsWithVersions(repo ocm.Repository, components []string, prefixFilter string) ([]v1beta1.Component, error) { + octx := ocm.DefaultContext() + + var componentList = make([]v1beta1.Component, 0, len(components)) + for _, componentName := range components { + component, _ := repo.LookupComponent(componentName) + versions, _ := component.ListVersions() + + formattedComponentName := strings.TrimPrefix(componentName, prefixFilter) + formattedComponentName = strings.TrimPrefix(formattedComponentName, "/") + comp := v1beta1.Component{ + Name: formattedComponentName, + Versions: make([]v1beta1.ComponentVersion, 0), + } + + for _, version := range versions { + cva, err := component.LookupVersion(version) + if err != nil { + return nil, err + } + + resources := cva.GetResources() + access, err := resources[0].Access() + if err != nil { + return nil, err + } + + switch access.GetKind() { + case ociartifact.Type: + ref, err := ocmutils.GetOCIArtifactRef(octx, resources[0]) + if err != nil { + return nil, err + } + + comp.Versions = append(comp.Versions, v1beta1.ComponentVersion{ + Version: version, + DockerRef: ref, + }) + case helm.Type: + accessSpec, ok := access.(*helm.AccessSpec) + if !ok { + return nil, errors.New("failed to get Helm repository reference") + } + + chartname := strings.Split(accessSpec.HelmChart, ":")[0] + comp.Versions = append(comp.Versions, v1beta1.ComponentVersion{ + Version: version, + HelmRepo: accessSpec.HelmRepository, + HelmChart: chartname, + }) + default: + return nil, errors.New("unsupported access method") + } + } + + componentList = append(componentList, comp) + } + + return componentList, nil +} + +// GetOCMComponent takes a component name and a version as input and searches in an OCM registry if the component +// with version is available. The function returns a Component object with the repository and version of the component. +func GetOCMComponent( + ctx context.Context, + client client.Client, + componentName string, + version string, +) (v1beta1.ComponentVersion, error) { + releasechannels := v1beta1.ReleaseChannelList{} + err := client.List(ctx, &releasechannels) + if err != nil { + return v1beta1.ComponentVersion{}, err + } + + for _, releasechannel := range releasechannels.Items { + components := releasechannel.Status.Components + for _, component := range components { + if component.Name == componentName { + for _, componentVersion := range component.Versions { + if componentVersion.Version == version { + return componentVersion, nil + } + } + } + } + } + + return v1beta1.ComponentVersion{}, fmt.Errorf("Component %s with version %s not found.", componentName, version) +} diff --git a/internal/ocm/ocm_test.go b/internal/ocm/ocm_test.go new file mode 100644 index 0000000..ae193d5 --- /dev/null +++ b/internal/ocm/ocm_test.go @@ -0,0 +1,145 @@ +package ocm + +import ( + "context" + "errors" + "testing" + + "github.com/openmcp-project/control-plane-operator/internal/schemes" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/log" + + testutils "github.com/openmcp-project/control-plane-operator/test/utils" + + corev1beta1 "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/stretchr/testify/assert" +) + +func TestGetOCMComponent(t *testing.T) { + type input struct { + componentName string + version string + dockerconfigjson []byte + validLocalRepo bool + } + type want struct { + component corev1beta1.ComponentVersion + err error + } + tests := []struct { + name string + input input + want want + }{ + { + name: "Error: Can't find nonexistent component in ocm registry", + input: input{ + dockerconfigjson: []byte("{}"), + validLocalRepo: true, + componentName: "invalidComponent", + version: "", + }, + want: want{ + component: corev1beta1.ComponentVersion{}, + err: errors.New("Component %s with version %s not found."), + }, + }, + { + name: "Error: Can't find nonexistent version of valid component in ocm registry", + input: input{ + dockerconfigjson: []byte("{}"), + validLocalRepo: true, + componentName: "crossplane", + version: "0.0.0", + }, + want: want{ + component: corev1beta1.ComponentVersion{}, + err: errors.New("Component %s with version %s not found."), + }, + }, + { + name: "Get helm component from ocm registry", + input: input{ + dockerconfigjson: []byte("{}"), + validLocalRepo: true, + componentName: "crossplane", + version: "1.15.0", + }, + want: want{ + component: corev1beta1.ComponentVersion{ + Version: "1.15.0", + HelmRepo: "https://charts.crossplane.io/stable", + HelmChart: "crossplane", + }, + }, + }, + { + name: "Get oci component from ocm registry", + input: input{ + dockerconfigjson: []byte("{}"), + validLocalRepo: true, + componentName: "provider-helm", + version: "0.19.0", + }, + want: want{ + component: corev1beta1.ComponentVersion{ + Version: "0.19.0", + DockerRef: "xpkg.upbound.io/crossplane-contrib/provider-helm:v0.19.0", + }, + }, + }, + } + + initObjs := []client.Object{ + &corev1beta1.ReleaseChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-releasechannel", + }, + Status: corev1beta1.ReleaseChannelStatus{Components: []corev1beta1.Component{ + { + Name: "crossplane", + Versions: []corev1beta1.ComponentVersion{ + {Version: "1.15.0", HelmRepo: "https://charts.crossplane.io/stable", HelmChart: "crossplane"}, + }, + }, + { + Name: "provider-helm", + Versions: []corev1beta1.ComponentVersion{ + {Version: "0.19.0", DockerRef: "xpkg.upbound.io/crossplane-contrib/provider-helm:v0.19.0"}, + }, + }, + }}, + }, + } + + c := fake.NewClientBuilder().WithObjects(initObjs...).WithStatusSubresource(initObjs[0]).WithScheme(schemes.Local).Build() //nolint:lll + + ctx := newContext() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.input.validLocalRepo { + assert.NoError(t, testutils.SetEnvironmentVariableForLocalOCMTar(testutils.LocalOCMRepositoryPathValid)) + } else { + assert.NoError(t, testutils.SetEnvironmentVariableForLocalOCMTar(testutils.RepositoryPathInvalid)) + } + + got, err := GetOCMComponent(ctx, c, tt.input.componentName, tt.input.version) + + if tt.want.err == nil { + assert.NoError(t, err) + } else { + assert.Errorf(t, err, tt.want.err.Error()) + } + assert.Equal(t, got, tt.want.component) + }) + } +} + +func newContext() context.Context { + ctx := context.Background() + ctx = log.IntoContext(ctx, log.Log) + return ctx +} diff --git a/internal/ocm/utils.go b/internal/ocm/utils.go new file mode 100644 index 0000000..cceafc9 --- /dev/null +++ b/internal/ocm/utils.go @@ -0,0 +1,74 @@ +package ocm + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type RegistryCredentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type CatalogResponse struct { + Repositories []string `json:"repositories"` +} + +/** + * @Description: GetRepositoriesInOCIRegistry returns the repositories in the OCI registry + * @param ociRegistry string - the OCI registry URL without paths + * @param creds RegistryCredentials - the credentials to access the OCI registry + * @param prefixFilter string - the prefix to filter the repositories + * @param protocol string - the protocol to use to access the OCI registry, http or https + * @return []string - the list of repositories in the OCI registry + */ +func GetRepositoriesInOCIRegistry(ociRegistry string, creds RegistryCredentials, prefixFilter string, protocol string) ([]string, error) { + if protocol == "" { + protocol = "https" + } + + url := protocol + "://" + ociRegistry + "/v2/_catalog" + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.SetBasicAuth(creds.Username, creds.Password) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + fmt.Printf("Failed to close body: %v\n", err) + } + }(resp.Body) + + buf := new(strings.Builder) + _, err = io.Copy(buf, resp.Body) + if err != nil { + return nil, err + } + + var data CatalogResponse + err = json.Unmarshal([]byte(buf.String()), &data) + if err != nil { + return nil, err + } + + repositories := data.Repositories + + var filteredRepositories []string + for _, repository := range repositories { + if prefixFilter == "" || strings.HasPrefix(repository, prefixFilter) { + filteredRepositories = append(filteredRepositories, strings.TrimPrefix(repository, prefixFilter)) + } + } + + return filteredRepositories, nil +} diff --git a/internal/schemes/schemes.go b/internal/schemes/schemes.go new file mode 100644 index 0000000..7609d65 --- /dev/null +++ b/internal/schemes/schemes.go @@ -0,0 +1,56 @@ +package schemes + +import ( + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + + crossplanev1 "github.com/crossplane/crossplane/apis/pkg/v1" + crossplanev1beta1 "github.com/crossplane/crossplane/apis/pkg/v1beta1" + helmv2 "github.com/fluxcd/helm-controller/api/v2" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +var ( + // Local is the Scheme used when communicating with the cluster where the controller is running (Core). + Local = runtime.NewScheme() + + // Remote is the Scheme used when communicating with the cluster where the workload is running (Target). + Remote = runtime.NewScheme() +) + +// initLocal adds types to Local scheme. +func initLocal() { + // Standard go client types + utilruntime.Must(clientgoscheme.AddToScheme(Local)) + + // Controller types + utilruntime.Must(v1beta1.AddToScheme(Local)) + + // Flux CD + utilruntime.Must(helmv2.AddToScheme(Local)) + utilruntime.Must(kustomizev1.AddToScheme(Local)) + utilruntime.Must(sourcev1.AddToScheme(Local)) +} + +// initRemote adds types to Remote scheme. +func initRemote() { + // Standard go client types + utilruntime.Must(clientgoscheme.AddToScheme(Remote)) + utilruntime.Must(apiextensionsv1.AddToScheme(Remote)) + + // Controller types + utilruntime.Must(v1beta1.AddToScheme(Remote)) + + // Crossplane + utilruntime.Must(crossplanev1.AddToScheme(Remote)) + utilruntime.Must(crossplanev1beta1.AddToScheme(Remote)) +} + +func init() { + initLocal() + initRemote() +} diff --git a/pkg/constants/annotation.go b/pkg/constants/annotation.go new file mode 100644 index 0000000..be88a67 --- /dev/null +++ b/pkg/constants/annotation.go @@ -0,0 +1,5 @@ +package constants + +const ( + AnnotationCredentialsForUrl = "core.orchestrate.cloud.sap/credentials-for-url" +) diff --git a/pkg/constants/label.go b/pkg/constants/label.go new file mode 100644 index 0000000..a5a1d42 --- /dev/null +++ b/pkg/constants/label.go @@ -0,0 +1,8 @@ +package constants + +const ( + LabelCopyToCPNamespace = "core.orchestrate.cloud.sap/copy-to-cp-namespaces" + LabelCopyToCP = "core.orchestrate.cloud.sap/copy-to-cp" + LabelCopySourceName = "core.orchestrate.cloud.sap/copy-source-name" + LabelCopySourceNamespace = "core.orchestrate.cloud.sap/copy-source-namespace" +) diff --git a/pkg/constants/ocm.go b/pkg/constants/ocm.go new file mode 100644 index 0000000..5f02249 --- /dev/null +++ b/pkg/constants/ocm.go @@ -0,0 +1,6 @@ +package constants + +const ( + OCMComponentNamePrefix = "core.orchestrate.cloud.sap/" + OCMRepositoryURL = "openmcp.common.repositories.cloud.sap/ocm" +) diff --git a/pkg/controlplane/components/btpso_component.go b/pkg/controlplane/components/btpso_component.go new file mode 100644 index 0000000..67af07e --- /dev/null +++ b/pkg/controlplane/components/btpso_component.go @@ -0,0 +1,196 @@ +package components + +import ( + "context" + "encoding/json" + "strings" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/fluxcd" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/hooks" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + "github.com/openmcp-project/control-plane-operator/pkg/utils/rcontext" + rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + btpServiceOperatorNamespace = "sap-btp-service-operator" + btpServiceOperatorRelease = "sap-btp-service-operator" + ComponentNameBTPSO = "BTPServiceOperator" +) + +var _ fluxcd.FluxComponent = &BTPServiceOperator{} +var _ TargetComponent = &BTPServiceOperator{} +var _ PolicyRulesComponent = &BTPServiceOperator{} + +// BTPServiceOperator is the add-on for https://github.com/SAP/sap-btp-service-operator. +type BTPServiceOperator struct { + Config *v1beta1.BTPServiceOperatorConfig +} + +// GetPolicyRules implements PolicyRulesComponent. +func (btp *BTPServiceOperator) GetPolicyRules() PolicyRules { + return PolicyRules{ + Admin: []rbacv1.PolicyRule{ + { + APIGroups: []string{"services.cloud.sap.com"}, + Resources: []string{ + "servicebindings", + "serviceinstances", + }, + Verbs: VerbsAdmin, + }, + }, + View: []rbacv1.PolicyRule{ + { + APIGroups: []string{"services.cloud.sap.com"}, + Resources: []string{ + "servicebindings", + "serviceinstances", + }, + Verbs: VerbsView, + }, + }, + } +} + +// GetNamespace implements TargetComponent. +func (btp *BTPServiceOperator) GetNamespace() string { + return btpServiceOperatorNamespace +} + +func (btp *BTPServiceOperator) IsInstallable(ctx context.Context) (bool, error) { + rfn := rcontext.VersionResolver(ctx) + if _, err := rfn(btpServiceOperatorRelease, btp.Config.Version); err != nil { + return false, err + } + return true, nil +} + +func (btp *BTPServiceOperator) BuildSourceRepository(ctx context.Context) (fluxcd.SourceAdapter, error) { + rfn := rcontext.VersionResolver(ctx) + btp.applyDefaultChartSpec(rfn) + + repo := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameBTPSO), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: btp.Config.Chart.Repository, + }, + } + + adapter := &fluxcd.HelmRepositoryAdapter{Source: repo} + adapter.ApplyDefaults() + return adapter, nil +} + +//nolint:dupl +func (btp *BTPServiceOperator) BuildManifesto(ctx context.Context) (fluxcd.Manifesto, error) { + if err := btp.applyDefaultValues(); err != nil { + return nil, err + } + + release := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameBTPSO), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: helmv2.HelmReleaseSpec{ + Chart: &helmv2.HelmChartTemplate{ + Spec: helmv2.HelmChartTemplateSpec{ + Chart: btp.Config.Chart.Name, + Version: btp.Config.Chart.Version, + SourceRef: helmv2.CrossNamespaceObjectReference{ + Kind: "HelmRepository", + Name: strings.ToLower(ComponentNameBTPSO), // repo name + }, + }, + }, + ReleaseName: btpServiceOperatorRelease, + TargetNamespace: btpServiceOperatorNamespace, + StorageNamespace: btpServiceOperatorNamespace, + KubeConfig: rcontext.FluxKubeconfigRef(ctx), + Values: btp.Config.Values, + }, + } + + adapter := &fluxcd.HelmReleaseManifesto{Manifest: release} + adapter.ApplyDefaults() + return adapter, nil +} + +// GetName implements Component. +func (btp *BTPServiceOperator) GetName() string { + return ComponentNameBTPSO +} + +// GetDependencies implements Component. +func (btp *BTPServiceOperator) GetDependencies() []juggler.Component { + return []juggler.Component{&CertManager{}} +} + +// IsEnabled implements Component. +func (btp *BTPServiceOperator) IsEnabled() bool { + return btp.Config != nil && btp.Config.Version != "" +} + +func (btp *BTPServiceOperator) applyDefaultChartSpec(rfn v1beta1.VersionResolverFn) { + if btp.Config == nil { + btp.Config = &v1beta1.BTPServiceOperatorConfig{} + } + + comp, _ := rfn(btpServiceOperatorRelease, btp.Config.Version) + + if btp.Config.Chart == nil { + btp.Config.Chart = &v1beta1.ChartSpec{ + Repository: "https://sap.github.io/sap-btp-service-operator", + Name: "sap-btp-operator", + Version: comp.Version, + } + } +} + +// Hooks implements Component. +func (btp *BTPServiceOperator) Hooks() juggler.ComponentHooks { + return juggler.ComponentHooks{ + PreUninstall: hooks.PreventOrphanedResources([]schema.GroupVersionKind{ + {Group: "services.cloud.sap.com", Version: "v1", Kind: "ServiceBinding"}, + {Group: "services.cloud.sap.com", Version: "v1", Kind: "ServiceInstance"}, + }), + } +} + +func (btp *BTPServiceOperator) applyDefaultValues() error { + if btp.Config == nil { + return nil + } + + // Read user-provided values + values := map[string]any{} + if btp.Config.Values != nil { + if err := json.Unmarshal(btp.Config.Values.Raw, &values); err != nil { + return err + } + } + + // Apply defaults + if err := utils.SetNestedDefault(values, "sap-btp-service-operator", "cluster", "id"); err != nil { + return err + } + if err := utils.SetNestedDefault(values, 1, "manager", "replica_count"); err != nil { + return err + } + + // Write updated values + encoded, err := json.Marshal(values) + btp.Config.Values = &apiextensionsv1.JSON{Raw: encoded} + return err +} diff --git a/pkg/controlplane/components/btpso_component_test.go b/pkg/controlplane/components/btpso_component_test.go new file mode 100644 index 0000000..c25f508 --- /dev/null +++ b/pkg/controlplane/components/btpso_component_test.go @@ -0,0 +1,76 @@ +//nolint:dupl +package components + +import ( + "testing" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func Test_BTPServiceOperator(t *testing.T) { + testCases := []struct { + desc string + config *v1beta1.BTPServiceOperatorConfig + versionResolver v1beta1.VersionResolverFn + validationFuncs []validationFunc + }{ + { + desc: "should be disabled", + validationFuncs: []validationFunc{ + hasName("BTPServiceOperator"), + isEnabled(false), + }, + }, + { + desc: "should not be allowed", + config: &v1beta1.BTPServiceOperatorConfig{ + Version: "1.2.3", + }, + versionResolver: fakeVersionResolver(true), + validationFuncs: []validationFunc{ + hasName("BTPServiceOperator"), + isEnabled(true), + isAllowed(false), + }, + }, + { + desc: "should be enabled", + config: &v1beta1.BTPServiceOperatorConfig{ + Version: "1.2.3", + Values: &apiextensionsv1.JSON{Raw: []byte(`{"manager":{"replica_count":2}}`)}, + }, + versionResolver: fakeVersionResolver(false), + validationFuncs: []validationFunc{ + hasName("BTPServiceOperator"), + isEnabled(true), + isAllowed(true), + hasPreUninstallHook(), + hasDependencies(1), + isTargetComponent( + hasNamespace("sap-btp-service-operator"), + ), + isFluxComponent( + returnsHelmRepo(), + returnsHelmRelease( + hasKubeconfigRef(), + hasHelmValue(2, "manager", "replica_count"), // override default + hasHelmValue("sap-btp-service-operator", "cluster", "id"), // default + ), + ), + isPolicyRulesComponent( + hasPolicyRules(), + ), + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + ctx := newContext(nil, tC.versionResolver) + c := &BTPServiceOperator{Config: tC.config} + for _, vfn := range tC.validationFuncs { + vfn(t, ctx, c) + } + }) + } +} diff --git a/pkg/controlplane/components/cert_manager_component.go b/pkg/controlplane/components/cert_manager_component.go new file mode 100644 index 0000000..a967ba1 --- /dev/null +++ b/pkg/controlplane/components/cert_manager_component.go @@ -0,0 +1,165 @@ +package components + +import ( + "context" + "encoding/json" + "strings" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/fluxcd" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/hooks" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + "github.com/openmcp-project/control-plane-operator/pkg/utils/rcontext" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + certManagerRelease = "cert-manager" + certManagerNamespace = "cert-manager" + ComponentNameCertManager = "CertManager" +) + +var _ fluxcd.FluxComponent = &CertManager{} +var _ TargetComponent = &CertManager{} + +type CertManager struct { + Config *v1beta1.CertManagerConfig +} + +// GetNamespace implements TargetComponent. +func (c *CertManager) GetNamespace() string { + return certManagerNamespace +} + +func (c *CertManager) IsInstallable(ctx context.Context) (bool, error) { + rfn := rcontext.VersionResolver(ctx) + if _, err := rfn(certManagerRelease, c.Config.Version); err != nil { + return false, err + } + return true, nil +} + +func (c *CertManager) BuildSourceRepository(ctx context.Context) (fluxcd.SourceAdapter, error) { + rfn := rcontext.VersionResolver(ctx) + c.applyDefaultChartSpec(rfn) + + repo := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameCertManager), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: c.Config.Chart.Repository, + }, + } + + adapter := &fluxcd.HelmRepositoryAdapter{Source: repo} + adapter.ApplyDefaults() + return adapter, nil +} + +//nolint:dupl +func (c *CertManager) BuildManifesto(ctx context.Context) (fluxcd.Manifesto, error) { + if err := c.applyDefaultValues(); err != nil { + return nil, err + } + + release := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameCertManager), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: helmv2.HelmReleaseSpec{ + Chart: &helmv2.HelmChartTemplate{ + Spec: helmv2.HelmChartTemplateSpec{ + Chart: c.Config.Chart.Name, + Version: c.Config.Chart.Version, + SourceRef: helmv2.CrossNamespaceObjectReference{ + Kind: "HelmRepository", + Name: strings.ToLower(ComponentNameCertManager), + }, + }, + }, + ReleaseName: certManagerRelease, + TargetNamespace: certManagerNamespace, + StorageNamespace: certManagerNamespace, + KubeConfig: rcontext.FluxKubeconfigRef(ctx), + Values: c.Config.Values, + }, + } + + adapter := &fluxcd.HelmReleaseManifesto{Manifest: release} + adapter.ApplyDefaults() + return adapter, nil +} + +// GetDependencies implements Component. +func (*CertManager) GetDependencies() []juggler.Component { + return []juggler.Component{} +} + +// GetName implements Component. +func (*CertManager) GetName() string { + return ComponentNameCertManager +} + +// IsEnabled implements Component. +func (c *CertManager) IsEnabled() bool { + return c.Config != nil && c.Config.Version != "" +} + +func (c *CertManager) applyDefaultChartSpec(rfn v1beta1.VersionResolverFn) { + if c.Config == nil { + c.Config = &v1beta1.CertManagerConfig{} + } + + comp, _ := rfn(certManagerRelease, c.Config.Version) + + if c.Config.Chart == nil { + c.Config.Chart = &v1beta1.ChartSpec{ + Repository: "https://charts.jetstack.io", + Name: "cert-manager", + Version: comp.Version, + } + } +} + +// Hooks implements Component. +func (*CertManager) Hooks() juggler.ComponentHooks { + return juggler.ComponentHooks{ + PreUninstall: hooks.PreventOrphanedResources([]schema.GroupVersionKind{ + {Group: "cert-manager.io", Version: "v1", Kind: "Certificate"}, + {Group: "cert-manager.io", Version: "v1", Kind: "Issuer"}, + {Group: "cert-manager.io", Version: "v1", Kind: "ClusterIssuer"}, + }), + } +} + +func (c *CertManager) applyDefaultValues() error { + if c.Config == nil { + return nil + } + + // Read user-provided values + values := map[string]any{} + if c.Config.Values != nil { + if err := json.Unmarshal(c.Config.Values.Raw, &values); err != nil { + return err + } + } + + // Apply defaults + if err := utils.SetNestedDefault(values, true, "installCRDs"); err != nil { + return err + } + + // Write updated values + encoded, err := json.Marshal(values) + c.Config.Values = &apiextensionsv1.JSON{Raw: encoded} + return err +} diff --git a/pkg/controlplane/components/cert_manager_component_test.go b/pkg/controlplane/components/cert_manager_component_test.go new file mode 100644 index 0000000..4096a5d --- /dev/null +++ b/pkg/controlplane/components/cert_manager_component_test.go @@ -0,0 +1,73 @@ +//nolint:dupl +package components + +import ( + "testing" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func Test_CertManager(t *testing.T) { + testCases := []struct { + desc string + config *v1beta1.CertManagerConfig + versionResolver v1beta1.VersionResolverFn + validationFuncs []validationFunc + }{ + { + desc: "should be disabled", + validationFuncs: []validationFunc{ + hasName("CertManager"), + isEnabled(false), + }, + }, + { + desc: "should not be allowed", + config: &v1beta1.CertManagerConfig{ + Version: "1.2.3", + }, + versionResolver: fakeVersionResolver(true), + validationFuncs: []validationFunc{ + hasName("CertManager"), + isEnabled(true), + isAllowed(false), + }, + }, + { + desc: "should be enabled", + config: &v1beta1.CertManagerConfig{ + Version: "1.2.3", + Values: &apiextensionsv1.JSON{Raw: []byte(`{"global":{"logLevel": 3}}`)}, + }, + versionResolver: fakeVersionResolver(false), + validationFuncs: []validationFunc{ + hasName("CertManager"), + isEnabled(true), + isAllowed(true), + hasPreUninstallHook(), + hasDependencies(0), + isTargetComponent( + hasNamespace("cert-manager"), + ), + isFluxComponent( + returnsHelmRepo(), + returnsHelmRelease( + hasKubeconfigRef(), + hasHelmValue(true, "installCRDs"), // default + hasHelmValue(3, "global", "logLevel"), // custom value + ), + ), + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + ctx := newContext(nil, tC.versionResolver) + c := &CertManager{Config: tC.config} + for _, vfn := range tC.validationFuncs { + vfn(t, ctx, c) + } + }) + } +} diff --git a/pkg/controlplane/components/clusterrole_component.go b/pkg/controlplane/components/clusterrole_component.go new file mode 100644 index 0000000..0997cf4 --- /dev/null +++ b/pkg/controlplane/components/clusterrole_component.go @@ -0,0 +1,122 @@ +package components + +import ( + "context" + "fmt" + "strings" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/object" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + "golang.org/x/text/cases" + "golang.org/x/text/language" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ object.ObjectComponent = &ClusterRole{} +var _ object.OrphanedObjectsDetector = &ClusterRole{} +var _ TargetComponent = &ClusterRole{} +var _ juggler.KeepOnUninstall = &ClusterRole{} +var _ juggler.StatusVisibility = &ClusterRole{} + +type ClusterRole struct { + Name string + Rules []rbacv1.PolicyRule + Enabled bool + KeepInstalled bool +} + +// KeepOnUninstall implements juggler.KeepOnUninstall. +func (c *ClusterRole) KeepOnUninstall() bool { + return c.KeepInstalled +} + +// BuildObjectToReconcile implements object.ObjectComponent. +func (c *ClusterRole) BuildObjectToReconcile(ctx context.Context) (client.Object, types.NamespacedName, error) { + return &rbacv1.ClusterRole{}, types.NamespacedName{ + Name: fmt.Sprintf("%s:%s", v1beta1.GroupVersion.Group, strings.ToLower(c.Name)), + }, nil +} + +// ReconcileObject implements object.ObjectComponent. +func (c *ClusterRole) ReconcileObject(ctx context.Context, obj client.Object) error { + objCR := obj.(*rbacv1.ClusterRole) + + aggregateLabel := fmt.Sprintf("%s/aggregate-to-%s", v1beta1.GroupVersion.Group, strings.ToLower(c.Name)) + metav1.SetMetaDataLabel(&objCR.ObjectMeta, aggregateLabel, "true") + + objCR.Rules = c.Rules + return nil +} + +// OrphanDetectorContext implements object.OrphanedObjectsDetector. +func (*ClusterRole) OrphanDetectorContext() object.DetectorContext { + return object.DetectorContext{ + ListType: &rbacv1.ClusterRoleList{}, + FilterCriteria: object.FilterCriteria{ + utils.IsManaged(), + object.HasComponentLabel(), + }, + ConvertFunc: func(list client.ObjectList) []juggler.Component { + clusterRoles := []juggler.Component{} + for _, role := range (list.(*rbacv1.ClusterRoleList)).Items { + name, _ := strings.CutPrefix(role.Name, fmt.Sprintf("%s:", v1beta1.GroupVersion.Group)) + clusterRoles = append(clusterRoles, &ClusterRole{Name: name, Rules: role.Rules}) + } + return clusterRoles + }, + SameFunc: func(configured, detected juggler.Component) bool { + configuredCR := configured.(*ClusterRole) + detectedCR := detected.(*ClusterRole) + return strings.EqualFold(configuredCR.Name, detectedCR.Name) + }, + } +} + +// GetDependencies implements object.ObjectComponent. +func (c *ClusterRole) GetDependencies() []juggler.Component { + return []juggler.Component{} +} + +// GetName implements object.ObjectComponent. +func (c *ClusterRole) GetName() string { + name := cases.Title(language.English).String(c.Name) + return "ClusterRole" + name +} + +// Hooks implements object.ObjectComponent. +func (c *ClusterRole) Hooks() juggler.ComponentHooks { + return juggler.ComponentHooks{} +} + +func (c *ClusterRole) IsInstallable(_ context.Context) (bool, error) { + return true, nil +} + +// IsEnabled implements object.ObjectComponent. +func (c *ClusterRole) IsEnabled() bool { + return c.Enabled +} + +// IsObjectHealthy implements object.ObjectComponent. +func (c *ClusterRole) IsObjectHealthy(obj client.Object) juggler.ResourceHealthiness { + return juggler.ResourceHealthiness{ + // ClusterRole has no status field. + Healthy: obj.GetDeletionTimestamp() == nil, + } +} + +// GetNamespace implements TargetComponent. +func (c *ClusterRole) GetNamespace() string { + // ClusterRole is cluster-scoped. + return "" +} + +// IsStatusInternal implements StatusVisibility interface. +func (c *ClusterRole) IsStatusInternal() bool { + return true +} diff --git a/pkg/controlplane/components/clusterrole_component_test.go b/pkg/controlplane/components/clusterrole_component_test.go new file mode 100644 index 0000000..29a8d15 --- /dev/null +++ b/pkg/controlplane/components/clusterrole_component_test.go @@ -0,0 +1,106 @@ +//nolint:dupl +package components + +import ( + "testing" + + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +var ( + clusterRoleHealthy = &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-healthy", + }, + } + clusterRoleUnhealthy = &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-unhealthy", + DeletionTimestamp: ptr.To(metav1.Now()), + }, + } + clusterRoleA = &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-a", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{corev1.GroupName}, + Resources: []string{"secrets"}, + Verbs: VerbsAdmin, + }, + }, + } +) + +func Test_ClusterRole(t *testing.T) { + testCases := []struct { + desc string + enabled bool + name string + rules []rbacv1.PolicyRule + validationFuncs []validationFunc + }{ + { + desc: "should be disabled", + enabled: false, + name: "Admin", + validationFuncs: []validationFunc{ + hasName("ClusterRoleAdmin"), + isEnabled(false), + }, + }, + { + desc: "should be enabled", + enabled: true, + name: "Admin", + rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{corev1.GroupName}, + Resources: []string{"secrets"}, + Verbs: VerbsAdmin, + }, + }, + validationFuncs: []validationFunc{ + hasName("ClusterRoleAdmin"), + isEnabled(true), + isAllowed(true), + hasDependencies(0), + hasNoHooks(), + isTargetComponent( + hasNamespace(""), + ), + isObjectComponent( + objectIsType(&rbacv1.ClusterRole{}), + canCheckHealthiness(clusterRoleUnhealthy, juggler.ResourceHealthiness{ + Healthy: false, + }), + canCheckHealthiness(clusterRoleHealthy, juggler.ResourceHealthiness{ + Healthy: true, + }), + canBuildAndReconcile(nil), + implementsOrphanedObjectsDetector( + listTypeIs(&rbacv1.ClusterRoleList{}), + hasFilterCriteria(2), + canConvert(&rbacv1.ClusterRoleList{Items: []rbacv1.ClusterRole{*clusterRoleA}}, 1), + canCheckSame(&ClusterRole{Name: "A"}, &ClusterRole{Name: "A"}, true), + canCheckSame(&ClusterRole{Name: "A"}, &ClusterRole{Name: "B"}, false), + ), + ), + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + ctx := newContext(nil, nil) + c := &ClusterRole{Name: tC.name, Rules: tC.rules, Enabled: tC.enabled} + for _, vfn := range tC.validationFuncs { + vfn(t, ctx, c) + } + }) + } +} diff --git a/pkg/controlplane/components/clusterroles/clusterroles.go b/pkg/controlplane/components/clusterroles/clusterroles.go new file mode 100644 index 0000000..7ba0f9a --- /dev/null +++ b/pkg/controlplane/components/clusterroles/clusterroles.go @@ -0,0 +1,26 @@ +package clusterroles + +import ( + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/components" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" +) + +func RegisterAsComponents(jug *juggler.Juggler, cpComponents []juggler.Component, enabled bool) { + policyRules := components.AggregatePolicyRules(cpComponents) + jug.RegisterComponent(&components.ClusterRole{ + Name: "Admin", + Rules: policyRules.Admin, + Enabled: enabled, + // Workaround to prevent users from losing access to resources + // until other ControlPlane components are uninstalled. + KeepInstalled: true, + }) + jug.RegisterComponent(&components.ClusterRole{ + Name: "View", + Rules: policyRules.View, + Enabled: enabled, + // Workaround to prevent users from losing access to resources + // until other ControlPlane components are uninstalled. + KeepInstalled: true, + }) +} diff --git a/pkg/controlplane/components/clusterroles/clusterroles_test.go b/pkg/controlplane/components/clusterroles/clusterroles_test.go new file mode 100644 index 0000000..4588001 --- /dev/null +++ b/pkg/controlplane/components/clusterroles/clusterroles_test.go @@ -0,0 +1,15 @@ +package clusterroles + +import ( + "testing" + + "github.com/go-logr/logr" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/stretchr/testify/assert" +) + +func Test_RegisterAsComponents(t *testing.T) { + j := juggler.NewJuggler(logr.Logger{}, nil) + RegisterAsComponents(j, []juggler.Component{}, true) + assert.Equal(t, 2, j.RegisteredComponents()) +} diff --git a/pkg/controlplane/components/common.go b/pkg/controlplane/components/common.go new file mode 100644 index 0000000..9cac226 --- /dev/null +++ b/pkg/controlplane/components/common.go @@ -0,0 +1,39 @@ +package components + +import ( + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + rbacv1 "k8s.io/api/rbac/v1" +) + +// TargetComponent is a component that should be installed on the Target (remote/workload) cluster. +type TargetComponent interface { + GetNamespace() string +} + +// PolicyRulesComponent is a component that provides rules which will be added to ClusterRoles. +type PolicyRulesComponent interface { + GetPolicyRules() PolicyRules +} + +type PolicyRules struct { + Admin []rbacv1.PolicyRule + View []rbacv1.PolicyRule +} + +var ( + VerbsAdmin = []string{rbacv1.VerbAll} + VerbsView = []string{"get", "watch", "list"} + VerbsModify = []string{"get", "watch", "list", "update", "patch"} +) + +func AggregatePolicyRules(components []juggler.Component) PolicyRules { + result := PolicyRules{} + for _, c := range components { + if prComp, ok := c.(PolicyRulesComponent); ok { + compRules := prComp.GetPolicyRules() + result.Admin = append(result.Admin, compRules.Admin...) + result.View = append(result.View, compRules.View...) + } + } + return result +} diff --git a/pkg/controlplane/components/crds/crds.go b/pkg/controlplane/components/crds/crds.go new file mode 100644 index 0000000..cb589b0 --- /dev/null +++ b/pkg/controlplane/components/crds/crds.go @@ -0,0 +1,113 @@ +package crds + +import ( + "bytes" + "context" + "embed" + "path" + + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/components" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/yaml" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func RegisterAsComponents(jug *juggler.Juggler, crdFiles embed.FS, enabled bool, names ...string) error { + allCRDs, err := readAllCRDs(crdFiles) + if err != nil { + return err + } + + for _, crd := range filterCRDs(allCRDs, names...) { + comp := &components.GenericObjectComponent{ + NamespacedName: types.NamespacedName{ + Name: crd.Name, + }, + NameOverride: "CRD" + crd.Spec.Names.Kind, + Type: &apiextv1.CustomResourceDefinition{}, + Enabled: enabled, + KeepInstalled: true, + IsObjectHealthyFunc: func(obj client.Object) juggler.ResourceHealthiness { + actual := obj.(*apiextv1.CustomResourceDefinition) + return juggler.ResourceHealthiness{ + // TODO: Choose something more meaningful? + Healthy: len(actual.Status.StoredVersions) > 0, + } + }, + ReconcileObjectFunc: func(ctx context.Context, obj client.Object) error { + actual := obj.(*apiextv1.CustomResourceDefinition) + actual.Spec = crd.Spec + return nil + }, + } + jug.RegisterComponent(comp) + } + return nil +} + +func filterCRDs(input []*apiextv1.CustomResourceDefinition, names ...string) []*apiextv1.CustomResourceDefinition { + out := []*apiextv1.CustomResourceDefinition{} + for _, crd := range input { + for _, name := range names { + if crd.Name == name { + out = append(out, crd) + } + } + } + return out +} + +func readAllCRDs(crdFiles embed.FS) ([]*apiextv1.CustomResourceDefinition, error) { + crds := []*apiextv1.CustomResourceDefinition{} + + yamls, err := readAllFiles(crdFiles, ".") + if err != nil { + return nil, err + } + for _, yaml := range yamls { + crd, err := decodeCRD(yaml) + if err != nil { + return nil, err + } + crds = append(crds, crd) + } + + return crds, nil +} + +func decodeCRD(yamlBytes []byte) (*apiextv1.CustomResourceDefinition, error) { + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(yamlBytes), 100) + crd := &apiextv1.CustomResourceDefinition{} + return crd, decoder.Decode(crd) +} + +func readAllFiles(fs embed.FS, dir string) ([][]byte, error) { + fileContents := [][]byte{} + + entries, err := fs.ReadDir(dir) + if err != nil { + return nil, err + } + + for _, entry := range entries { + fullpath := path.Join(dir, entry.Name()) + if entry.IsDir() { + subdirContents, err := readAllFiles(fs, fullpath) + if err != nil { + return nil, err + } + fileContents = append(fileContents, subdirContents...) + continue + } + + content, err := fs.ReadFile(fullpath) + if err != nil { + return nil, err + } + fileContents = append(fileContents, content) + } + + return fileContents, nil +} diff --git a/pkg/controlplane/components/crds/crds_test.go b/pkg/controlplane/components/crds/crds_test.go new file mode 100644 index 0000000..59d2d90 --- /dev/null +++ b/pkg/controlplane/components/crds/crds_test.go @@ -0,0 +1,53 @@ +package crds + +import ( + "embed" + "testing" + + "github.com/go-logr/logr" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/stretchr/testify/assert" +) + +var ( + //go:embed testdata + crdFiles embed.FS +) + +func Test_readAllCRDs(t *testing.T) { + crds, err := readAllCRDs(crdFiles) + assert.NoError(t, err) + assert.Len(t, crds, 2) + assert.Equal(t, "ControlPlane", crds[0].Spec.Names.Kind) + assert.Equal(t, "CrossplanePackageRestriction", crds[1].Spec.Names.Kind) +} + +func Test_RegisterAsComponents(t *testing.T) { + testCases := []struct { + desc string + names []string + expectedErr error + expectedComponents int + }{ + { + desc: "should register CrossplanePackageRestriction", + names: []string{"crossplanepackagerestrictions.core.orchestrate.cloud.sap"}, + expectedComponents: 1, + expectedErr: nil, + }, + { + desc: "should not register anything", + names: []string{"doesnotexist"}, + expectedComponents: 0, + expectedErr: nil, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + j := juggler.NewJuggler(logr.Logger{}, nil) + actualErr := RegisterAsComponents(j, crdFiles, true, tC.names...) + assert.Equal(t, tC.expectedErr, actualErr) + assert.Equal(t, tC.expectedComponents, j.RegisteredComponents()) + }) + } +} diff --git a/pkg/controlplane/components/crds/testdata/core.orchestrate.cloud.sap_controlplanes.yaml b/pkg/controlplane/components/crds/testdata/core.orchestrate.cloud.sap_controlplanes.yaml new file mode 100644 index 0000000..66e8d58 --- /dev/null +++ b/pkg/controlplane/components/crds/testdata/core.orchestrate.cloud.sap_controlplanes.yaml @@ -0,0 +1,539 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: controlplanes.core.orchestrate.cloud.sap +spec: + group: core.orchestrate.cloud.sap + names: + kind: ControlPlane + listKind: ControlPlaneList + plural: controlplanes + shortNames: + - cp + singular: controlplane + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.componentsHealthy + name: Components Healthy + type: integer + - jsonPath: .status.componentsEnabled + name: Components Enabled + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: ControlPlane is the Schema for the ControlPlane API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ControlPlaneSpec defines the desired state of ControlPlane + properties: + btpServiceOperator: + description: |- + Configuration for the BTP Service Operator. More info: + https://github.com/SAP/sap-btp-service-operator + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the BTP Service Operator Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of BTP Service Operator to install. + type: string + required: + - version + type: object + certManager: + description: |- + CertManager configures the cert-manager component. More info: + https://cert-manager.io/ + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the cert-manager Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of the cert-manager to install. + type: string + required: + - version + type: object + coreRef: + default: + name: default + description: Reference to a core configuration + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + crossplane: + description: Configuration for the Crossplane installation of this + ControlPlane. + properties: + chart: + description: Optional custom Helm chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + providers: + description: List of Crossplane providers to be installed. + items: + description: |- + CrossplaneProviderConfig represents configuration for Crossplane providers in a ControlPlane. + Primarily based on the Crossplane open source API. + properties: + controllerConfigRef: + description: |- + ControllerConfigRef references a ControllerConfig resource that will be + used to configure the packaged controller Deployment. + Deprecated: Use RuntimeConfigReference instead. + properties: + name: + description: Name of the ControllerConfig. + type: string + required: + - name + type: object + name: + description: |- + Name of the provider. + Using a well-known name will automatically configure the "package" field. + type: string + package: + description: |- + Provider package to be installed. + If "name" is set to a well-known value, this field will be configured automatically. + type: string + packagePullPolicy: + default: IfNotPresent + description: |- + Pull policy for the provider. + One of Always, Never, IfNotPresent. + enum: + - Always + - Never + - IfNotPresent + type: string + packagePullSecrets: + description: PackagePullSecrets are named secrets in the + same namespace that can be used to fetch packages from + private registries. + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + runtimeConfigRef: + default: + name: default + description: |- + RuntimeConfigRef references a RuntimeConfig resource that will be used + to configure the package runtime. + properties: + apiVersion: + default: pkg.crossplane.io/v1beta1 + description: API version of the referent. + type: string + kind: + default: DeploymentRuntimeConfig + description: Kind of the referent. + type: string + name: + description: Name of the RuntimeConfig. + type: string + required: + - name + type: object + version: + description: Version of the provider to install. + type: string + required: + - name + - version + type: object + type: array + values: + description: Optional additional values that should be passed + to the Crossplane Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of Crossplane to install. + type: string + required: + - version + type: object + externalSecretsOperator: + description: |- + Configuration for the External Secrets Operator. More info: + https://external-secrets.io + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the External Secrets Operator Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of External Secrets Operator to install. + type: string + required: + - version + type: object + flux: + description: |- + Configuration for Flux. More info: + https://fluxcd.io/ + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the Flux Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of Flux to install. + type: string + required: + - version + type: object + kyverno: + description: |- + Configuration for Kyverno. More info: + https://kyverno.io/ + properties: + chart: + description: Optional custom chart configuration. + properties: + name: + description: Name of the Helm chart + type: string + repository: + description: Repository is the URL to a Helm repository + type: string + version: + description: Version of the Helm chart, latest version if + not set + type: string + type: object + values: + description: Optional additional values that should be passed + to the Kyverno Helm chart. + x-kubernetes-preserve-unknown-fields: true + version: + description: The Version of Kyverno to install. + type: string + required: + - version + type: object + pullSecrets: + description: Pull secrets which will be used when pulling charts, + providers, etc. + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + target: + description: Configuration of the ControlPlane target (local or remote + cluster) + properties: + fluxServiceAccount: + description: FluxServiceAccount is a reference to a service account + that should be used by Flux. + properties: + name: + description: Name is the name of the service account. + minLength: 1 + type: string + namespace: + description: Namespace is the namespace of the service account. + minLength: 1 + type: string + overrides: + description: Overrides specifies fields that should be overwritten + when a kubeconfig is generated from this ServiceAccountReference. + properties: + host: + description: Host must be a host string, a host:port pair, + or a URL to the base of the apiserver. + type: string + type: object + required: + - name + - namespace + type: object + kubeconfig: + description: Kubeconfig is an inline kubeconfig. + x-kubernetes-preserve-unknown-fields: true + kubeconfigFile: + description: KubeconfigFile is a path to a file containing a kubeconfig. + type: string + kubeconfigRef: + description: KubeconfigRef is a reference to a Kubernetes secret + that contains a kubeconfig. + properties: + key: + default: kubeconfig + description: The key of the secret to select from. Must be + a valid secret key. + type: string + name: + description: name is unique within a namespace to reference + a secret resource. + type: string + namespace: + description: namespace defines the space within which the + secret name must be unique. + type: string + required: + - key + type: object + x-kubernetes-map-type: atomic + serviceAccount: + description: ServiceAccount references a local service account. + properties: + caData: + description: |- + CAData holds (Base64-)PEM-encoded bytes. + CAData takes precedence over CAFile. + This value is optional. If not provided, the CAData of the in-cluster config will be used. + Providing an empty string means that the operating system's defaults root certificates will be used. + type: string + caFile: + description: |- + CAFile points to a file containing the root certificates for the API server. + This value is optional. If not provided, the value of CAData will be used. + type: string + host: + description: |- + Host must be a host string, a host:port pair, or a URL to the base of the apiserver. + This value is optional. If not provided, the local API server will be used. + type: string + name: + description: |- + Name is the name of the service account. + This value is optional. If not provided, the pod's service account will be used. + type: string + namespace: + description: |- + Namespace is the name of the service account. + This value is optional. If not provided, the pod's service account will be used. + type: string + tokenFile: + description: |- + TokenFile points to a file containing a bearer token (e.g. projected service account token (PSAT) with custom audience) to be used for authentication against the API server. + If provided, all other authentication methods (Basic, client-side TLS, etc.) will be disabled. + type: string + type: object + required: + - fluxServiceAccount + type: object + telemetry: + description: Configuration for the telemetry. + properties: + enabled: + description: Enables or disables telemetry. + type: boolean + type: object + required: + - target + type: object + status: + description: ControlPlaneStatus defines the observed state of ControlPlane + properties: + componentsEnabled: + description: Number of enabled components. + type: integer + componentsHealthy: + description: Number of healthy components. + type: integer + conditions: + description: Current service state of the ControlPlane. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + namespace: + description: Namespace that contains resources related to the ControlPlane. + type: string + required: + - componentsEnabled + - componentsHealthy + - conditions + - namespace + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/controlplane/components/crds/testdata/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml b/pkg/controlplane/components/crds/testdata/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml new file mode 100644 index 0000000..516b788 --- /dev/null +++ b/pkg/controlplane/components/crds/testdata/core.orchestrate.cloud.sap_crossplanepackagerestrictions.yaml @@ -0,0 +1,108 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: crossplanepackagerestrictions.core.orchestrate.cloud.sap +spec: + group: core.orchestrate.cloud.sap + names: + kind: CrossplanePackageRestriction + listKind: CrossplanePackageRestrictionList + plural: crossplanepackagerestrictions + singular: crossplanepackagerestriction + scope: Cluster + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: CrossplanePackageRestriction is the Schema for the crossplanepackagerestrictions + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: CrossplanePackageRestrictionSpec defines the desired state + of CrossplanePackageRestriction + properties: + configurations: + description: |- + PackageRestriction restricts a package type (e.g. providers) to certain registries or literal packages. + If both Registries and Packages are empty, no packages of this type will be allowed. + properties: + packages: + items: + type: string + type: array + registries: + items: + type: string + type: array + required: + - packages + - registries + type: object + functions: + description: |- + PackageRestriction restricts a package type (e.g. providers) to certain registries or literal packages. + If both Registries and Packages are empty, no packages of this type will be allowed. + properties: + packages: + items: + type: string + type: array + registries: + items: + type: string + type: array + required: + - packages + - registries + type: object + providers: + description: |- + PackageRestriction restricts a package type (e.g. providers) to certain registries or literal packages. + If both Registries and Packages are empty, no packages of this type will be allowed. + properties: + packages: + items: + type: string + type: array + registries: + items: + type: string + type: array + required: + - packages + - registries + type: object + required: + - configurations + - functions + - providers + type: object + status: + description: CrossplanePackageRestrictionStatus defines the observed state + of CrossplanePackageRestriction + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/pkg/controlplane/components/crossplane_component.go b/pkg/controlplane/components/crossplane_component.go new file mode 100644 index 0000000..076a443 --- /dev/null +++ b/pkg/controlplane/components/crossplane_component.go @@ -0,0 +1,230 @@ +package components + +import ( + "context" + "encoding/json" + "strings" + "time" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/cmd/options" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/fluxcd" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/hooks" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + "github.com/openmcp-project/control-plane-operator/pkg/utils/rcontext" + rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + crossplaneRelease = "crossplane" + CrossplaneNamespace = "crossplane-system" + ComponentNameCrossplane = "Crossplane" +) + +var _ fluxcd.FluxComponent = &Crossplane{} +var _ TargetComponent = &Crossplane{} +var _ PolicyRulesComponent = &Crossplane{} + +type Crossplane struct { + Config *v1beta1.CrossplaneConfig +} + +// GetPolicyRules implements PolicyRulesComponent. +func (c *Crossplane) GetPolicyRules() PolicyRules { + rules := PolicyRules{ + Admin: []rbacv1.PolicyRule{ + { + APIGroups: []string{"pkg.crossplane.io"}, + Resources: []string{ + "configurations", + "functions", + "providers", + }, + Verbs: VerbsAdmin, + }, + { + APIGroups: []string{"apiextensions.crossplane.io"}, + Resources: []string{ + "compositeresourcedefinitions", + "compositions", + "environmentconfigs", + }, + Verbs: VerbsAdmin, + }, + { + APIGroups: []string{"pkg.crossplane.io"}, + Resources: []string{rbacv1.ResourceAll}, + Verbs: VerbsView, + }, + { + APIGroups: []string{"apiextensions.crossplane.io"}, + Resources: []string{rbacv1.ResourceAll}, + Verbs: VerbsView, + }, + }, + View: []rbacv1.PolicyRule{ + { + APIGroups: []string{"pkg.crossplane.io"}, + Resources: []string{rbacv1.ResourceAll}, + Verbs: VerbsView, + }, + { + APIGroups: []string{"apiextensions.crossplane.io"}, + Resources: []string{rbacv1.ResourceAll}, + Verbs: VerbsView, + }, + }, + } + + if options.IsDeploymentRuntimeConfigProtectionEnabled() { + rules.Admin = append(rules.Admin, rbacv1.PolicyRule{ + APIGroups: []string{"pkg.crossplane.io"}, + Resources: []string{ + "deploymentruntimeconfigs", + }, + Verbs: VerbsModify, + }) + } + + return rules +} + +// GetNamespace implements TargetComponent. +func (c *Crossplane) GetNamespace() string { + return CrossplaneNamespace +} + +func (c *Crossplane) IsInstallable(ctx context.Context) (bool, error) { + rfn := rcontext.VersionResolver(ctx) + if _, err := rfn(crossplaneRelease, c.Config.Version); err != nil { + return false, err + } + return true, nil +} + +func (c *Crossplane) BuildSourceRepository(ctx context.Context) (fluxcd.SourceAdapter, error) { + rfn := rcontext.VersionResolver(ctx) + c.applyDefaultChartSpec(rfn) + + repo := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameCrossplane), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: c.Config.Chart.Repository, + Timeout: &metav1.Duration{Duration: 1 * time.Minute}, + }, + } + + adapter := &fluxcd.HelmRepositoryAdapter{Source: repo} + adapter.ApplyDefaults() + return adapter, nil +} + +//nolint:dupl +func (c *Crossplane) BuildManifesto(ctx context.Context) (fluxcd.Manifesto, error) { + if err := c.applyDefaultValues(); err != nil { + return nil, err + } + + release := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameCrossplane), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: helmv2.HelmReleaseSpec{ + Chart: &helmv2.HelmChartTemplate{ + Spec: helmv2.HelmChartTemplateSpec{ + Chart: c.Config.Chart.Name, + Version: c.Config.Chart.Version, + SourceRef: helmv2.CrossNamespaceObjectReference{ + Kind: "HelmRepository", + Name: strings.ToLower(ComponentNameCrossplane), + }, + }, + }, + ReleaseName: crossplaneRelease, + TargetNamespace: CrossplaneNamespace, + StorageNamespace: CrossplaneNamespace, + KubeConfig: rcontext.FluxKubeconfigRef(ctx), + Values: c.Config.Values, + }, + } + + adapter := &fluxcd.HelmReleaseManifesto{Manifest: release} + adapter.ApplyDefaults() + return adapter, nil +} + +// GetName implements Component. +func (*Crossplane) GetName() string { + return ComponentNameCrossplane +} + +// GetDependencies implements Component. +func (*Crossplane) GetDependencies() []juggler.Component { + // No dependencies + return []juggler.Component{} +} + +// IsEnabled implements Component. +func (c *Crossplane) IsEnabled() bool { + return c.Config != nil && c.Config.Version != "" +} + +func (c *Crossplane) applyDefaultChartSpec(rfn v1beta1.VersionResolverFn) { + if c.Config == nil { + c.Config = &v1beta1.CrossplaneConfig{} + } + + comp, _ := rfn(crossplaneRelease, c.Config.Version) + + if c.Config.Chart == nil { + c.Config.Chart = &v1beta1.ChartSpec{ + Repository: comp.HelmRepo, + Name: comp.HelmChart, + Version: comp.Version, + } + } +} + +func (c *Crossplane) applyDefaultValues() error { + if c.Config == nil { + return nil + } + + // Read user-provided values + values := map[string]any{} + if c.Config.Values != nil { + if err := json.Unmarshal(c.Config.Values.Raw, &values); err != nil { + return err + } + } + + // Apply defaults + if err := utils.SetNestedDefault(values, true, "rbacManager", "skipAggregatedClusterRoles"); err != nil { + return err + } + + // Write updated values + encoded, err := json.Marshal(values) + c.Config.Values = &apiextensionsv1.JSON{Raw: encoded} + return err +} + +// Hooks implements Component. +func (*Crossplane) Hooks() juggler.ComponentHooks { + return juggler.ComponentHooks{ + PreUninstall: hooks.PreventOrphanedResources([]schema.GroupVersionKind{ + {Group: "pkg.crossplane.io", Version: "v1", Kind: "Provider"}, + {Group: "pkg.crossplane.io", Version: "v1", Kind: "ProviderRevision"}, + }), + } +} diff --git a/pkg/controlplane/components/crossplane_component_test.go b/pkg/controlplane/components/crossplane_component_test.go new file mode 100644 index 0000000..ef34eff --- /dev/null +++ b/pkg/controlplane/components/crossplane_component_test.go @@ -0,0 +1,72 @@ +//nolint:dupl +package components + +import ( + "testing" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func Test_Crossplane(t *testing.T) { + testCases := []struct { + desc string + config *v1beta1.CrossplaneConfig + versionResolver v1beta1.VersionResolverFn + validationFuncs []validationFunc + }{ + { + desc: "should be disabled", + validationFuncs: []validationFunc{ + hasName("Crossplane"), + isEnabled(false), + }, + }, + { + desc: "should not be allowed", + config: &v1beta1.CrossplaneConfig{ + Version: "1.2.3", + }, + versionResolver: fakeVersionResolver(true), + validationFuncs: []validationFunc{ + hasName("Crossplane"), + isEnabled(true), + isAllowed(false), + }, + }, + { + desc: "should be enabled", + config: &v1beta1.CrossplaneConfig{ + Version: "1.2.3", + Values: &apiextensionsv1.JSON{Raw: []byte(`{"replicas":2}`)}, + }, + versionResolver: fakeVersionResolver(false), + validationFuncs: []validationFunc{ + hasName("Crossplane"), + isEnabled(true), + isAllowed(true), + hasPreUninstallHook(), + hasDependencies(0), + isTargetComponent( + hasNamespace("crossplane-system"), + ), + isFluxComponent( + returnsHelmRepo(), + returnsHelmRelease( + hasKubeconfigRef(), + hasHelmValue(2, "replicas"), // custom value + ), + ), + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + ctx := newContext(nil, tC.versionResolver) + c := &Crossplane{Config: tC.config} + for _, vfn := range tC.validationFuncs { + vfn(t, ctx, c) + } + }) + } +} diff --git a/pkg/controlplane/components/crossplanedeploymentruntimeconfig_component.go b/pkg/controlplane/components/crossplanedeploymentruntimeconfig_component.go new file mode 100644 index 0000000..26e7705 --- /dev/null +++ b/pkg/controlplane/components/crossplanedeploymentruntimeconfig_component.go @@ -0,0 +1,190 @@ +package components + +import ( + "context" + "fmt" + "strings" + + crossplanev1beta1 "github.com/crossplane/crossplane/apis/pkg/v1beta1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/crossplane" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/object" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ object.ObjectComponent = &CrossplaneDeploymentRuntimeConfig{} +var _ TargetComponent = &CrossplaneDeploymentRuntimeConfig{} +var _ object.OrphanedObjectsDetector = &CrossplaneDeploymentRuntimeConfig{} +var _ juggler.StatusVisibility = &CrossplaneDeploymentRuntimeConfig{} + +type CrossplaneDeploymentRuntimeConfig struct { + Name string + Enabled bool +} + +// BuildObjectToReconcile implements object.ObjectComponent. +func (c *CrossplaneDeploymentRuntimeConfig) BuildObjectToReconcile(ctx context.Context) (client.Object, + types.NamespacedName, error) { + nsn := types.NamespacedName{ + Name: c.Name, + Namespace: "", // we can leave this empty, because DeploymentRungimeConfigs are cluster scoped + } + return &crossplanev1beta1.DeploymentRuntimeConfig{}, nsn, nil +} + +// ReconcileObject implements object.ObjectComponent. +func (c *CrossplaneDeploymentRuntimeConfig) ReconcileObject(ctx context.Context, obj client.Object) error { + cdrc := obj.(*crossplanev1beta1.DeploymentRuntimeConfig) + + cdrc.Spec.ServiceAccountTemplate = applyServiceAccountTemplateDefaults(cdrc.Spec.ServiceAccountTemplate, c.Name) + + // We need to set defaults here on creation, because otherwise we would need + // to relax our policy to allow for them to be modified by the end-user, which + // we don't want to do. See + // https://docs.crossplane.io/latest/concepts/providers/#runtime-configuration + // for reference. + cdrc.Spec.DeploymentTemplate = applyDeploymentTemplateDefaults(cdrc.Spec.DeploymentTemplate) + + return nil +} + +// IsObjectHealthy implements object.ObjectComponent. +func (c *CrossplaneDeploymentRuntimeConfig) IsObjectHealthy(obj client.Object) juggler.ResourceHealthiness { + return juggler.ResourceHealthiness{ + Healthy: true, + Message: "DeploymentRuntimeConfig applied", + } +} + +// IsInstallable implements Component. +func (c *CrossplaneDeploymentRuntimeConfig) IsInstallable(ctx context.Context) (bool, error) { + // CrossplaneDeploymentRuntimeConfigs are always installable + return true, nil +} + +// GetName implements Component. +func (c *CrossplaneDeploymentRuntimeConfig) GetName() string { + return "DeploymentRuntimeConfig" + formatProviderName(c.Name) +} + +// GetDependencies implements Component. +func (c *CrossplaneDeploymentRuntimeConfig) GetDependencies() []juggler.Component { + return []juggler.Component{&Crossplane{}} +} + +// IsEnabled implements Component. +func (c *CrossplaneDeploymentRuntimeConfig) IsEnabled() bool { + return c.Enabled +} + +// Hooks implements Component. +func (*CrossplaneDeploymentRuntimeConfig) Hooks() juggler.ComponentHooks { + return juggler.ComponentHooks{ + PreInstall: crossplane.CheckIfPolicyIsInstalled(crossplane.Providers), + PreUpdate: crossplane.CheckIfPolicyIsInstalled(crossplane.Providers), + } +} + +// GetNamespace implements TargetComponent. +func (c *CrossplaneDeploymentRuntimeConfig) GetNamespace() string { + // CrossplaneDeploymentRuntimeConfig is cluster-scoped. + return "" +} + +// OrphanDetectorContext implements object.OrphanedObjectsDetector. +func (c *CrossplaneDeploymentRuntimeConfig) OrphanDetectorContext() object.DetectorContext { + return object.DetectorContext{ + ListType: &crossplanev1beta1.DeploymentRuntimeConfigList{}, + FilterCriteria: object.FilterCriteria{ + utils.IsManaged(), + object.HasComponentLabel(), + }, + ConvertFunc: func(list client.ObjectList) []juggler.Component { + cdrcs := []juggler.Component{} + for _, role := range (list.(*crossplanev1beta1.DeploymentRuntimeConfigList)).Items { + name, _ := strings.CutPrefix(role.Name, fmt.Sprintf("%s:", v1beta1.GroupVersion.Group)) + cdrcs = append(cdrcs, &CrossplaneDeploymentRuntimeConfig{Name: name}) + } + return cdrcs + }, + SameFunc: func(configured, detected juggler.Component) bool { + configuredCR := configured.(*CrossplaneDeploymentRuntimeConfig) + detectedCR := detected.(*CrossplaneDeploymentRuntimeConfig) + return strings.EqualFold(configuredCR.Name, detectedCR.Name) + }, + } +} + +// IsStatusInternal implements juggler.StatusVisibility +func (c *CrossplaneDeploymentRuntimeConfig) IsStatusInternal() bool { + return true +} + +// applyDeploymentTemplateDefaults makes sure that all required fields are set +// on a DeploymentTemplate. Specifically this means that we need to set +// template.spec, template.spec.selector, and template.spec.template.containers +// to be empty, but not nil values. This is because spec.DeploymentTemplate is +// validated against the k8s deployment type, which requires these fields to be +// set (even though they will be properly filled with values by the crossplane +// controller later). +func applyDeploymentTemplateDefaults(in *crossplanev1beta1.DeploymentTemplate) *crossplanev1beta1.DeploymentTemplate { + out := &crossplanev1beta1.DeploymentTemplate{} + if in != nil { + *out = *in + } + + if out.Spec == nil { + out.Spec = &appsv1.DeploymentSpec{} + } + + if out.Spec.Selector == nil { + // we don't need to set this to anything meaningful, crossplane is going to + // do this for us when creating the deployment. However we still need to set + // it to non-nil so the validation on the DeploymentRuntimeConfig does not + // fail + out.Spec.Selector = &metav1.LabelSelector{} + } + + if len(out.Spec.Template.Spec.Containers) == 0 { + out.Spec.Template.Spec.Containers = []v1.Container{ + { + // we need to hardcode this, since crossplane keeps it private. + // It is the default, and only, name a provider container can have + // nolint:lll + // https://github.com/crossplane/crossplane/blob/bee7c095b2c8b2e157a3154cbb85bfc8e54ace6f/internal/controller/pkg/revision/runtime.go#L35 + Name: "package-runtime", + Args: []string{}, + }, + } + } + + return out +} + +// applyServiceAccountTemplateDefaults makes sure that the name of a +// serviceAccount matches the name of the passed in provider +func applyServiceAccountTemplateDefaults( + in *crossplanev1beta1.ServiceAccountTemplate, + providerName string, +) *crossplanev1beta1.ServiceAccountTemplate { + out := &crossplanev1beta1.ServiceAccountTemplate{} + if in != nil { + *out = *in + } + + if out.Metadata == nil { + out.Metadata = &crossplanev1beta1.ObjectMeta{} + } + + if out.Metadata.Name == nil { + out.Metadata.Name = &providerName + } + + return out +} diff --git a/pkg/controlplane/components/crossplanedeploymentruntimeconfig_component_test.go b/pkg/controlplane/components/crossplanedeploymentruntimeconfig_component_test.go new file mode 100644 index 0000000..aff0e02 --- /dev/null +++ b/pkg/controlplane/components/crossplanedeploymentruntimeconfig_component_test.go @@ -0,0 +1,222 @@ +//nolint:dupl,lll +package components + +import ( + "context" + "testing" + + crossplanev1beta1 "github.com/crossplane/crossplane/apis/pkg/v1beta1" + "github.com/google/go-cmp/cmp" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestCrossplaneDeploymentRuntimConfig(t *testing.T) { + testCases := []struct { + desc string + enabled bool + name string + validationFuncs []validationFunc + }{ + { + desc: "should be enabled", + enabled: true, + name: "test", + validationFuncs: []validationFunc{ + hasName("DeploymentRuntimeConfigProviderTest"), + isEnabled(true), + isAllowed(true), + hasDependencies(1), + hasPreInstallHook(), + hasPreUpdateHook(), + isTargetComponent( + hasNamespace(""), + ), + isObjectComponent( + objectIsType(&crossplanev1beta1.DeploymentRuntimeConfig{}), + // sample does not matter, since we always assert healthy + canCheckHealthiness(nil, juggler.ResourceHealthiness{ + Healthy: true, + Message: "DeploymentRuntimeConfig applied", + }), + canBuildAndReconcile(nil), + implementsOrphanedObjectsDetector( + listTypeIs(&crossplanev1beta1.DeploymentRuntimeConfigList{}), + hasFilterCriteria(2), + canConvert(&crossplanev1beta1.DeploymentRuntimeConfigList{Items: []crossplanev1beta1.DeploymentRuntimeConfig{{ + ObjectMeta: metav1.ObjectMeta{Name: "Test"}}}}, 1), + canCheckSame(&CrossplaneDeploymentRuntimeConfig{Name: "A"}, &CrossplaneDeploymentRuntimeConfig{Name: "A"}, true), + canCheckSame(&CrossplaneDeploymentRuntimeConfig{Name: "A"}, &CrossplaneDeploymentRuntimeConfig{Name: "B"}, false), + ), + ), + }, + }, + } + + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + c := &CrossplaneDeploymentRuntimeConfig{Name: tC.name, Enabled: tC.enabled} + for _, vfn := range tC.validationFuncs { + vfn(t, context.Background(), c) + } + }) + } +} + +func TestApplyDeploymentTemplateDefaults(t *testing.T) { + // Test if the deployment coming from Reconcile is valid + // At this point we really wanted to build the final Deployment + // using crossplane and then use k8s native validation. + // However this is currently not possible due to two reasons + // 1) Crossplane hides the Deploymentbuilder behind its internal package: + // see https://github.com/crossplane/crossplane/blob/f714904bc18d02ca1749e884fd31ab738e792c6f/internal/controller/pkg/revision/runtime.go#L112 + // The required function is inside an `internal` filepath, making it not possible + // to easily import it internally. + // 2) Kubernetes is semi hiding the deployment validation logic: + // More specifically: the validation logic is only available inside the + // `k8s.io/kubernetes` package https://github.com/kubernetes/kubernetes/blob/48dce2e9b3def93556cd7694edf22a74ecb34aa9/pkg/apis/apps/validation/validation.go#L560 + // This would require us to import the whole top-level package and set up required replacements for common modules as + // `k8s.io/kubernetes` by default replaces them with local instances (see https://github.com/kubernetes/kubernetes/blob/48dce2e9b3def93556cd7694edf22a74ecb34aa9/go.mod#L225 + // for reference). This would create a rather large dependency. + // Additionally the validation is based on an internal DeploymentSpec type, which we would need to write a converter for, for it to be used with appsv1.Deployment + var defaultDeployTemplate = func() *crossplanev1beta1.DeploymentTemplate { + return &crossplanev1beta1.DeploymentTemplate{ + Spec: &appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{}, + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Containers: []v1.Container{ + { + Name: "package-runtime", + Args: []string{}, + }, + }, + }, + }, + }, + } + } + + testCases := []struct { + desc string + inDt *crossplanev1beta1.DeploymentTemplate + expDt func() *crossplanev1beta1.DeploymentTemplate + }{ + { + desc: "if no DeploymentTemplate exist, default should be returned", + inDt: nil, + expDt: defaultDeployTemplate, + }, + { + desc: "on empty DeploymentTemplate, default should be returned", + inDt: &crossplanev1beta1.DeploymentTemplate{}, + expDt: defaultDeployTemplate, + }, + { + desc: "custom selector should be respected, other fields defaulted", + inDt: &crossplanev1beta1.DeploymentTemplate{ + Spec: &appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"test": "test"}, + }, + }, + }, + expDt: func() *crossplanev1beta1.DeploymentTemplate { + dt := defaultDeployTemplate() + dt.Spec.Selector.MatchLabels = map[string]string{"test": "test"} + return dt + }, + }, + { + desc: "custom spec.template.spec should be respected, other fields defaulted", + inDt: &crossplanev1beta1.DeploymentTemplate{ + Spec: &appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + DNSPolicy: "test", + }, + }, + }, + }, + expDt: func() *crossplanev1beta1.DeploymentTemplate { + dt := defaultDeployTemplate() + dt.Spec.Template.Spec.DNSPolicy = "test" + return dt + }, + }, + } + + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + res := applyDeploymentTemplateDefaults(tC.inDt) + exp := tC.expDt() + + if !cmp.Equal(res, exp) { + t.Error(cmp.Diff(res, exp)) + } + + }) + } +} + +func TestApplyServiceAccountTemplateDefaults(t *testing.T) { + providerName := "test" + var defaultServiceAccountTemplate = func() *crossplanev1beta1.ServiceAccountTemplate { + return &crossplanev1beta1.ServiceAccountTemplate{ + Metadata: &crossplanev1beta1.ObjectMeta{ + Name: &providerName, + }, + } + } + + testCases := []struct { + desc string + inSt *crossplanev1beta1.ServiceAccountTemplate + expSt func() *crossplanev1beta1.ServiceAccountTemplate + }{ + { + desc: "if no ServiceAccountTemplate exist, default should be returned", + inSt: nil, + expSt: defaultServiceAccountTemplate, + }, + { + desc: "on empty ServiceAccountTemplate, default should be returned", + inSt: &crossplanev1beta1.ServiceAccountTemplate{}, + expSt: defaultServiceAccountTemplate, + }, + { + desc: "on empty ServiceAccountTemplate, default should be returned", + inSt: &crossplanev1beta1.ServiceAccountTemplate{}, + expSt: defaultServiceAccountTemplate, + }, + { + desc: "custom metadata.name is respected", + inSt: &crossplanev1beta1.ServiceAccountTemplate{ + Metadata: &crossplanev1beta1.ObjectMeta{ + Name: ptr.To("test"), + }, + }, + expSt: func() *crossplanev1beta1.ServiceAccountTemplate { + st := defaultServiceAccountTemplate() + st.Metadata.Name = ptr.To("test") + return st + }, + }, + } + + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + res := applyServiceAccountTemplateDefaults(tC.inSt, providerName) + exp := tC.expSt() + + if !cmp.Equal(res, exp) { + t.Error(cmp.Diff(res, exp)) + } + + }) + } + +} diff --git a/pkg/controlplane/components/crossplaneprovider_component.go b/pkg/controlplane/components/crossplaneprovider_component.go new file mode 100644 index 0000000..3867eec --- /dev/null +++ b/pkg/controlplane/components/crossplaneprovider_component.go @@ -0,0 +1,149 @@ +package components + +import ( + "context" + "fmt" + "strings" + + crossplanev1 "github.com/crossplane/crossplane/apis/pkg/v1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/crossplane" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/object" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + "github.com/openmcp-project/control-plane-operator/pkg/utils/rcontext" + "golang.org/x/text/cases" + "golang.org/x/text/language" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ object.ObjectComponent = &CrossplaneProvider{} +var _ object.OrphanedObjectsDetector = &CrossplaneProvider{} +var _ TargetComponent = &CrossplaneProvider{} + +type CrossplaneProvider struct { + Config *v1beta1.CrossplaneProviderConfig + Enabled bool + PullSecrets []corev1.LocalObjectReference +} + +// BuildObjectToReconcile implements object.ObjectComponent. +func (c *CrossplaneProvider) BuildObjectToReconcile(ctx context.Context) (client.Object, types.NamespacedName, error) { + obj, key := crossplane.EmptyFromConfig(*c.Config) + return obj, key, nil +} + +// ReconcileObject implements object.ObjectComponent. +func (c *CrossplaneProvider) ReconcileObject(ctx context.Context, obj client.Object) error { + versionResolveFn := rcontext.VersionResolver(ctx) + copy := *c.Config + + // When uninstalling a provider, we don't need to resolve the version. + if c.IsEnabled() { + // Resolve package and version by provider name + comp, err := versionResolveFn(crossplane.ProviderNameForProviderConfig(c.Config), c.Config.Version) + if err != nil { + return err + } + + copy.Package = comp.DockerRef + copy.Version = comp.Version + } + + objProvider := obj.(*crossplanev1.Provider) + return crossplane.ReconcileProvider(objProvider, copy) +} + +// OrphanDetectorContext implements object.OrphanedObjectsDetector. +func (*CrossplaneProvider) OrphanDetectorContext() object.DetectorContext { + return object.DetectorContext{ + ListType: &crossplanev1.ProviderList{}, + FilterCriteria: object.FilterCriteria{ + utils.IsManaged(), + object.HasComponentLabel(), + }, + ConvertFunc: func(list client.ObjectList) []juggler.Component { + providers := []juggler.Component{} + for _, provider := range (list.(*crossplanev1.ProviderList)).Items { + // since we only need the name for the SameFunc, there is no need to copy the whole object + cp := &CrossplaneProvider{Config: &v1beta1.CrossplaneProviderConfig{ + Name: crossplane.TrimProviderPrefix(provider.Name), + }} + providers = append(providers, cp) + } + return providers + }, + SameFunc: func(configured, detected juggler.Component) bool { + configuredP := configured.(*CrossplaneProvider) + detectedP := detected.(*CrossplaneProvider) + return crossplane.TrimProviderPrefix(configuredP.Config.Name) == detectedP.Config.Name + }, + } +} + +// IsObjectHealthy implements object.ObjectComponent. +func (c *CrossplaneProvider) IsObjectHealthy(obj client.Object) juggler.ResourceHealthiness { + provider := obj.(*crossplanev1.Provider) + + installed := provider.GetCondition(crossplanev1.TypeInstalled) + if installed.Status != corev1.ConditionTrue { + return juggler.ResourceHealthiness{ + Healthy: false, + Message: fmt.Sprintf("Provider installation is pending (%s). %s", installed.Reason, installed.Message), + } + } + + healthy := provider.GetCondition(crossplanev1.TypeHealthy) + return juggler.ResourceHealthiness{ + Healthy: healthy.Status == corev1.ConditionTrue, + Message: fmt.Sprintf("%s: %s", healthy.Reason, healthy.Message), + } +} + +// GetNamespace implements TargetComponent. +func (c *CrossplaneProvider) GetNamespace() string { + return CrossplaneNamespace +} + +// IsInstallable implements Component. +func (c *CrossplaneProvider) IsInstallable(ctx context.Context) (bool, error) { + rfn := rcontext.VersionResolver(ctx) + if _, err := rfn(crossplane.ProviderNameForProviderConfig(c.Config), c.Config.Version); err != nil { + return false, err + } + return true, nil +} + +// GetName implements Component. +func (c *CrossplaneProvider) GetName() string { + return formatProviderName(c.Config.Name) +} + +// GetDependencies implements Component. +func (c *CrossplaneProvider) GetDependencies() []juggler.Component { + return []juggler.Component{&Crossplane{}} +} + +// IsEnabled implements Component. +func (c *CrossplaneProvider) IsEnabled() bool { + return c.Enabled +} + +// Hooks implements Component. +func (*CrossplaneProvider) Hooks() juggler.ComponentHooks { + return juggler.ComponentHooks{ + PreInstall: crossplane.CheckIfPolicyIsInstalled(crossplane.Providers), + PreUpdate: crossplane.CheckIfPolicyIsInstalled(crossplane.Providers), + } +} + +func formatProviderName(providerName string) string { + providerName = crossplane.AddProviderPrefix(providerName) + parts := strings.Split(providerName, "-") + for i, part := range parts { + parts[i] = cases.Title(language.English).String(part) + } + return strings.Join(parts, "") +} diff --git a/pkg/controlplane/components/crossplaneprovider_component_test.go b/pkg/controlplane/components/crossplaneprovider_component_test.go new file mode 100644 index 0000000..9ea96c2 --- /dev/null +++ b/pkg/controlplane/components/crossplaneprovider_component_test.go @@ -0,0 +1,226 @@ +//nolint:dupl,lll +package components + +import ( + "errors" + "fmt" + "testing" + + commonv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + crossplanev1 "github.com/crossplane/crossplane/apis/pkg/v1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/crossplane" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/secretresolver" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +var ( + errFake = errors.New("some error") + + providerInstallPending = &crossplanev1.Provider{} + providerHealthy = &crossplanev1.Provider{ + Status: crossplanev1.ProviderStatus{ + ConditionedStatus: commonv1.ConditionedStatus{ + Conditions: []commonv1.Condition{ + { + Type: crossplanev1.TypeInstalled, + Status: corev1.ConditionTrue, + }, + { + Type: crossplanev1.TypeHealthy, + Status: corev1.ConditionTrue, + Reason: "Healthy", + Message: "Healthy", + }, + }, + }, + }, + } + providerA = &crossplanev1.Provider{ + ObjectMeta: metav1.ObjectMeta{ + Name: "provider-a", + }, + Spec: crossplanev1.ProviderSpec{ + PackageSpec: crossplanev1.PackageSpec{ + Package: "xpkg.example.com/example/provider-example:v1.0.0", + }, + }, + } + providerB = &crossplanev1.Provider{ + ObjectMeta: metav1.ObjectMeta{ + Name: "provider-b", + }, + Spec: crossplanev1.ProviderSpec{ + PackageSpec: crossplanev1.PackageSpec{ + Package: "xpkg.example.com/example/provider-example:v1.0.0", + // irrelevant field, should still be equal to providerA. + RevisionHistoryLimit: ptr.To[int64](5), + }, + }, + } +) + +func Test_formatProviderName(t *testing.T) { + testCases := []struct { + providerName string + expected string + }{ + { + providerName: "provider-kubernetes", + expected: "ProviderKubernetes", + }, + { + providerName: "kubernetes", + expected: "ProviderKubernetes", + }, + { + providerName: "a", + expected: "ProviderA", + }, + { + providerName: "provider-btp-account", + expected: "ProviderBtpAccount", + }, + { + providerName: "btp-account", + expected: "ProviderBtpAccount", + }, + } + for _, tC := range testCases { + tName := fmt.Sprintf("%s -> %s", tC.providerName, tC.expected) + t.Run(tName, func(t *testing.T) { + actual := formatProviderName(tC.providerName) + assert.Equal(t, tC.expected, actual) + }) + } +} + +func Test_CrossplaneProvider(t *testing.T) { + testCases := []struct { + desc string + enabled bool + config *v1beta1.CrossplaneProviderConfig + versionResolver v1beta1.VersionResolverFn + secretRefResolver secretresolver.ResolveFunc + validationFuncs []validationFunc + }{ + { + desc: "should be disabled", + enabled: false, + validationFuncs: []validationFunc{ + isEnabled(false), + }, + }, + { + desc: "should not be allowed", + enabled: true, + config: &v1beta1.CrossplaneProviderConfig{ + Name: "kubernetes", + }, + versionResolver: fakeVersionResolver(true), + validationFuncs: []validationFunc{ + hasName("ProviderKubernetes"), + isEnabled(true), + isAllowed(false), + }, + }, + { + desc: "should be allowed with prefix", + enabled: true, + config: &v1beta1.CrossplaneProviderConfig{ + Name: "provider-kubernetes", + }, + versionResolver: func(componentName string, channelName string) (v1beta1.ComponentVersion, error) { + if componentName == "provider-kubernetes" { + return v1beta1.ComponentVersion{}, nil + } + return v1beta1.ComponentVersion{}, errFake + }, + validationFuncs: []validationFunc{ + hasName("ProviderKubernetes"), + isEnabled(true), + isAllowed(true), + }, + }, + { + desc: "should be allowed without prefix", + enabled: true, + config: &v1beta1.CrossplaneProviderConfig{ + Name: "kubernetes", + }, + versionResolver: func(componentName string, channelName string) (v1beta1.ComponentVersion, error) { + if componentName == "provider-kubernetes" { + return v1beta1.ComponentVersion{}, nil + } + return v1beta1.ComponentVersion{}, errFake + }, + validationFuncs: []validationFunc{ + hasName("ProviderKubernetes"), + isEnabled(true), + isAllowed(true), + }, + }, + { + desc: "should be enabled", + enabled: true, + config: &v1beta1.CrossplaneProviderConfig{ + Name: "kubernetes", + }, + versionResolver: fakeVersionResolver(false), + secretRefResolver: fakeSecretRefResolver(false, true), + validationFuncs: []validationFunc{ + hasName("ProviderKubernetes"), + isEnabled(true), + isAllowed(true), + hasDependencies(1), + hasPreInstallHook(), + hasPreUpdateHook(), + isTargetComponent( + hasNamespace("crossplane-system"), + ), + isObjectComponent( + objectIsType(&crossplanev1.Provider{}), + canCheckHealthiness(providerInstallPending, juggler.ResourceHealthiness{ + Healthy: false, + Message: "Provider installation is pending (). ", + }), + canCheckHealthiness(providerHealthy, juggler.ResourceHealthiness{ + Healthy: true, + Message: "Healthy: Healthy", + }), + canBuildAndReconcile(nil), + implementsOrphanedObjectsDetector( + listTypeIs(&crossplanev1.ProviderList{}), + hasFilterCriteria(2), + canConvert(&crossplanev1.ProviderList{Items: []crossplanev1.Provider{*providerA}}, 1), + canCheckSame( + &CrossplaneProvider{Config: &v1beta1.CrossplaneProviderConfig{Name: crossplane.TrimProviderPrefix(providerA.Name)}}, + &CrossplaneProvider{Config: &v1beta1.CrossplaneProviderConfig{Name: crossplane.TrimProviderPrefix(providerA.Name)}}, + true), + canCheckSame( + &CrossplaneProvider{Config: &v1beta1.CrossplaneProviderConfig{Name: providerA.Name}}, + &CrossplaneProvider{Config: &v1beta1.CrossplaneProviderConfig{Name: crossplane.TrimProviderPrefix(providerA.Name)}}, + true), + canCheckSame( + &CrossplaneProvider{Config: &v1beta1.CrossplaneProviderConfig{Name: crossplane.TrimProviderPrefix(providerA.Name)}}, + &CrossplaneProvider{Config: &v1beta1.CrossplaneProviderConfig{Name: crossplane.TrimProviderPrefix(providerB.Name)}}, + false), + ), + ), + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + ctx := newContext(tC.secretRefResolver, tC.versionResolver) + c := &CrossplaneProvider{Config: tC.config, Enabled: tC.enabled} + for _, vfn := range tC.validationFuncs { + vfn(t, ctx, c) + } + }) + } +} diff --git a/pkg/controlplane/components/eso_component.go b/pkg/controlplane/components/eso_component.go new file mode 100644 index 0000000..c9c6086 --- /dev/null +++ b/pkg/controlplane/components/eso_component.go @@ -0,0 +1,178 @@ +package components + +import ( + "context" + "strings" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/fluxcd" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/hooks" + "github.com/openmcp-project/control-plane-operator/pkg/utils/rcontext" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + esoRelease = "external-secrets" + esoNamespace = "external-secrets" + ComponentNameESO = "ExternalSecretsOperator" +) + +var _ fluxcd.FluxComponent = &ExternalSecretsOperator{} +var _ TargetComponent = &ExternalSecretsOperator{} +var _ PolicyRulesComponent = &ExternalSecretsOperator{} + +// ExternalSecretsOperator is the add-on for https://github.com/external-secrets/external-secrets. +type ExternalSecretsOperator struct { + Config *v1beta1.ExternalSecretsOperatorConfig +} + +// GetPolicyRules implements PolicyRulesComponent. +func (e *ExternalSecretsOperator) GetPolicyRules() PolicyRules { + return PolicyRules{ + Admin: []rbacv1.PolicyRule{ + { + APIGroups: []string{"external-secrets.io"}, + Resources: []string{ + "externalsecrets", + "secretstores", + "clustersecretstores", + "pushsecrets", + }, + Verbs: VerbsAdmin, + }, + { + APIGroups: []string{"generators.external-secrets.io"}, + Resources: []string{ + "vaultdynamicsecrets", + }, + Verbs: VerbsAdmin, + }, + }, + View: []rbacv1.PolicyRule{ + { + APIGroups: []string{"external-secrets.io"}, + Resources: []string{ + "externalsecrets", + "secretstores", + "clustersecretstores", + "pushsecrets", + }, + Verbs: VerbsView, + }, + { + APIGroups: []string{"generators.external-secrets.io"}, + Resources: []string{ + "vaultdynamicsecrets", + }, + Verbs: VerbsView, + }, + }, + } +} + +// GetNamespace implements TargetComponent. +func (e *ExternalSecretsOperator) GetNamespace() string { + return esoNamespace +} + +func (e *ExternalSecretsOperator) IsInstallable(ctx context.Context) (bool, error) { + rfn := rcontext.VersionResolver(ctx) + if _, err := rfn(esoRelease, e.Config.Version); err != nil { + return false, err + } + return true, nil +} + +func (e *ExternalSecretsOperator) BuildSourceRepository(ctx context.Context) (fluxcd.SourceAdapter, error) { + rfn := rcontext.VersionResolver(ctx) + e.applyDefaultChartSpec(rfn) + + repo := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameESO), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: e.Config.Chart.Repository, + }, + } + + adapter := &fluxcd.HelmRepositoryAdapter{Source: repo} + adapter.ApplyDefaults() + return adapter, nil +} + +func (e *ExternalSecretsOperator) BuildManifesto(ctx context.Context) (fluxcd.Manifesto, error) { + release := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameESO), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: helmv2.HelmReleaseSpec{ + Chart: &helmv2.HelmChartTemplate{ + Spec: helmv2.HelmChartTemplateSpec{ + Chart: e.Config.Chart.Name, + Version: e.Config.Chart.Version, + SourceRef: helmv2.CrossNamespaceObjectReference{ + Kind: "HelmRepository", + Name: strings.ToLower(ComponentNameESO), + }, + }, + }, + ReleaseName: esoRelease, + TargetNamespace: esoNamespace, + StorageNamespace: esoNamespace, + KubeConfig: rcontext.FluxKubeconfigRef(ctx), + Values: e.Config.Values, + }, + } + + adapter := &fluxcd.HelmReleaseManifesto{Manifest: release} + adapter.ApplyDefaults() + return adapter, nil +} + +func (e *ExternalSecretsOperator) GetName() string { + return ComponentNameESO +} + +func (e *ExternalSecretsOperator) GetDependencies() []juggler.Component { + // No dependencies + return []juggler.Component{} +} + +func (e *ExternalSecretsOperator) IsEnabled() bool { + return e.Config != nil && e.Config.Version != "" +} + +func (e *ExternalSecretsOperator) applyDefaultChartSpec(rfn v1beta1.VersionResolverFn) { + if e.Config == nil { + e.Config = &v1beta1.ExternalSecretsOperatorConfig{} + } + + comp, _ := rfn(esoRelease, e.Config.Version) + + if e.Config.Chart == nil { + e.Config.Chart = &v1beta1.ChartSpec{ + Repository: "https://charts.external-secrets.io", + Name: "external-secrets", + Version: comp.Version, + } + } +} + +// Hooks implements Component. +func (e *ExternalSecretsOperator) Hooks() juggler.ComponentHooks { + return juggler.ComponentHooks{ + PreUninstall: hooks.PreventOrphanedResources([]schema.GroupVersionKind{ + {Group: "external-secrets.io", Version: "v1beta1", Kind: "ExternalSecret"}, + {Group: "external-secrets.io", Version: "v1beta1", Kind: "SecretStore"}, + {Group: "external-secrets.io", Version: "v1beta1", Kind: "ClusterSecretStore"}, + }), + } +} diff --git a/pkg/controlplane/components/eso_component_test.go b/pkg/controlplane/components/eso_component_test.go new file mode 100644 index 0000000..e8c3f83 --- /dev/null +++ b/pkg/controlplane/components/eso_component_test.go @@ -0,0 +1,75 @@ +//nolint:dupl +package components + +import ( + "testing" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func Test_ExternalSecretsOperator(t *testing.T) { + testCases := []struct { + desc string + config *v1beta1.ExternalSecretsOperatorConfig + versionResolver v1beta1.VersionResolverFn + validationFuncs []validationFunc + }{ + { + desc: "should be disabled", + validationFuncs: []validationFunc{ + hasName("ExternalSecretsOperator"), + isEnabled(false), + }, + }, + { + desc: "should not be allowed", + config: &v1beta1.ExternalSecretsOperatorConfig{ + Version: "1.2.3", + }, + versionResolver: fakeVersionResolver(true), + validationFuncs: []validationFunc{ + hasName("ExternalSecretsOperator"), + isEnabled(true), + isAllowed(false), + }, + }, + { + desc: "should be enabled", + config: &v1beta1.ExternalSecretsOperatorConfig{ + Version: "1.2.3", + Values: &apiextensionsv1.JSON{Raw: []byte(`{"replicaCount":2}`)}, + }, + versionResolver: fakeVersionResolver(false), + validationFuncs: []validationFunc{ + hasName("ExternalSecretsOperator"), + isEnabled(true), + isAllowed(true), + hasPreUninstallHook(), + hasDependencies(0), + isTargetComponent( + hasNamespace("external-secrets"), + ), + isFluxComponent( + returnsHelmRepo(), + returnsHelmRelease( + hasKubeconfigRef(), + hasHelmValue(2, "replicaCount"), + ), + ), + isPolicyRulesComponent( + hasPolicyRules(), + ), + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + ctx := newContext(nil, tC.versionResolver) + c := &ExternalSecretsOperator{Config: tC.config} + for _, vfn := range tC.validationFuncs { + vfn(t, ctx, c) + } + }) + } +} diff --git a/pkg/controlplane/components/flux_component.go b/pkg/controlplane/components/flux_component.go new file mode 100644 index 0000000..8b49f2b --- /dev/null +++ b/pkg/controlplane/components/flux_component.go @@ -0,0 +1,164 @@ +package components + +import ( + "context" + "strings" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/fluxcd" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/hooks" + "github.com/openmcp-project/control-plane-operator/pkg/utils/rcontext" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + fluxRelease = "flux" + fluxNamespace = "flux-system" + ComponentNameFlux = "Flux" +) + +var _ fluxcd.FluxComponent = &Flux{} +var _ TargetComponent = &Flux{} +var _ PolicyRulesComponent = &Flux{} + +type Flux struct { + Config *v1beta1.FluxConfig +} + +func (f *Flux) GetPolicyRules() PolicyRules { + return PolicyRules{ + Admin: []rbacv1.PolicyRule{ + { + APIGroups: []string{ + "notification.toolkit.fluxcd.io", + "source.toolkit.fluxcd.io", + "helm.toolkit.fluxcd.io", + "image.toolkit.fluxcd.io", + "kustomize.toolkit.fluxcd.io", + }, + Resources: []string{ + "*", + }, + Verbs: VerbsAdmin, + }, + }, + View: []rbacv1.PolicyRule{ + { + APIGroups: []string{ + "notification.toolkit.fluxcd.io", + "source.toolkit.fluxcd.io", + "helm.toolkit.fluxcd.io", + "image.toolkit.fluxcd.io", + "kustomize.toolkit.fluxcd.io", + }, + Resources: []string{ + "*", + }, + Verbs: VerbsView, + }, + }, + } +} + +func (f *Flux) GetNamespace() string { + return fluxNamespace +} + +func (f *Flux) GetName() string { + return ComponentNameFlux +} + +func (f *Flux) GetDependencies() []juggler.Component { + return []juggler.Component{} +} + +func (f *Flux) IsEnabled() bool { + return f.Config != nil && f.Config.Version != "" +} + +func (f *Flux) Hooks() juggler.ComponentHooks { + return juggler.ComponentHooks{ + PreUninstall: hooks.PreventOrphanedResources([]schema.GroupVersionKind{ + {Group: "helm.toolkit.fluxcd.io", Version: "v2", Kind: "HelmRelease"}, + {Group: "kustomize.toolkit.fluxcd.io", Version: "v1", Kind: "Kustomization"}, + }), + } +} + +func (f *Flux) IsInstallable(ctx context.Context) (bool, error) { + rfn := rcontext.VersionResolver(ctx) + if _, err := rfn(fluxRelease, f.Config.Version); err != nil { + return false, err + } + return true, nil +} + +func (f *Flux) applyDefaultChartSpec(rfn v1beta1.VersionResolverFn) { + if f.Config == nil { + f.Config = &v1beta1.FluxConfig{} + } + + comp, _ := rfn(fluxRelease, f.Config.Version) + + if f.Config.Chart == nil { + f.Config.Chart = &v1beta1.ChartSpec{ + Repository: "https://fluxcd-community.github.io/helm-charts", + Name: "flux2", + Version: comp.Version, + } + } +} + +func (f *Flux) BuildSourceRepository(ctx context.Context) (fluxcd.SourceAdapter, error) { + rfn := rcontext.VersionResolver(ctx) + f.applyDefaultChartSpec(rfn) + + repo := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameFlux), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: f.Config.Chart.Repository, + }, + } + + adapter := &fluxcd.HelmRepositoryAdapter{Source: repo} + adapter.ApplyDefaults() + return adapter, nil +} + +func (f *Flux) BuildManifesto(ctx context.Context) (fluxcd.Manifesto, error) { + release := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameFlux), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: helmv2.HelmReleaseSpec{ + Chart: &helmv2.HelmChartTemplate{ + Spec: helmv2.HelmChartTemplateSpec{ + Chart: f.Config.Chart.Name, + Version: f.Config.Chart.Version, + SourceRef: helmv2.CrossNamespaceObjectReference{ + Kind: "HelmRepository", + Name: strings.ToLower(ComponentNameFlux), + }, + }, + }, + ReleaseName: fluxRelease, + TargetNamespace: fluxNamespace, + StorageNamespace: fluxNamespace, + KubeConfig: rcontext.FluxKubeconfigRef(ctx), + Values: f.Config.Values, + }, + } + + adapter := &fluxcd.HelmReleaseManifesto{Manifest: release} + adapter.ApplyDefaults() + return adapter, nil +} diff --git a/pkg/controlplane/components/flux_component_test.go b/pkg/controlplane/components/flux_component_test.go new file mode 100644 index 0000000..c7987ac --- /dev/null +++ b/pkg/controlplane/components/flux_component_test.go @@ -0,0 +1,75 @@ +//nolint:dupl +package components + +import ( + "testing" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func Test_Flux(t *testing.T) { + testCases := []struct { + desc string + config *v1beta1.FluxConfig + versionResolver v1beta1.VersionResolverFn + validationFuncs []validationFunc + }{ + { + desc: "should be disabled", + validationFuncs: []validationFunc{ + hasName("Flux"), + isEnabled(false), + }, + }, + { + desc: "should not be allowed", + config: &v1beta1.FluxConfig{ + Version: "1.2.3", + }, + versionResolver: fakeVersionResolver(true), + validationFuncs: []validationFunc{ + hasName("Flux"), + isEnabled(true), + isAllowed(false), + }, + }, + { + desc: "should be enabled", + config: &v1beta1.FluxConfig{ + Version: "1.2.3", + Values: &apiextensionsv1.JSON{Raw: []byte(`{"clusterDomain":"some-other.local"}`)}, + }, + versionResolver: fakeVersionResolver(false), + validationFuncs: []validationFunc{ + hasName("Flux"), + isEnabled(true), + isAllowed(true), + hasPreUninstallHook(), + hasDependencies(0), + isTargetComponent( + hasNamespace("flux-system"), + ), + isFluxComponent( + returnsHelmRepo(), + returnsHelmRelease( + hasKubeconfigRef(), + hasHelmValue("some-other.local", "clusterDomain"), + ), + ), + isPolicyRulesComponent( + hasPolicyRules(), + ), + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + ctx := newContext(nil, tC.versionResolver) + c := &Flux{Config: tC.config} + for _, vfn := range tC.validationFuncs { + vfn(t, ctx, c) + } + }) + } +} diff --git a/pkg/controlplane/components/generic_object_component.go b/pkg/controlplane/components/generic_object_component.go new file mode 100644 index 0000000..66998f6 --- /dev/null +++ b/pkg/controlplane/components/generic_object_component.go @@ -0,0 +1,103 @@ +package components + +import ( + "context" + "reflect" + "strings" + + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/object" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ object.ObjectComponent = &GenericObjectComponent{} +var _ TargetComponent = &GenericObjectComponent{} +var _ juggler.KeepOnUninstall = &GenericObjectComponent{} +var _ juggler.StatusVisibility = &GenericObjectComponent{} + +type GenericObjectComponent struct { + types.NamespacedName + + NameOverride string + TypeNameOverride string + Enabled bool + Type client.Object + Dependencies []juggler.Component + IsObjectHealthyFunc func(obj client.Object) juggler.ResourceHealthiness + ReconcileObjectFunc func(ctx context.Context, obj client.Object) error + KeepInstalled bool +} + +// KeepOnUninstall implements juggler.KeepOnUninstall. +func (g *GenericObjectComponent) KeepOnUninstall() bool { + return g.KeepInstalled +} + +// BuildObjectToReconcile implements object.ObjectComponent. +func (g *GenericObjectComponent) BuildObjectToReconcile( + ctx context.Context, +) (client.Object, types.NamespacedName, error) { + return g.Type.DeepCopyObject().(client.Object), g.NamespacedName, nil +} + +// GetDependencies implements object.ObjectComponent. +func (g *GenericObjectComponent) GetDependencies() []juggler.Component { + return g.Dependencies +} + +// GetName implements object.ObjectComponent. +func (g *GenericObjectComponent) GetName() string { + if g.NameOverride != "" { + return g.NameOverride + } + + parts := strings.Split(g.Name, "-") + for i, part := range parts { + parts[i] = cases.Title(language.English).String(part) + } + + typeName := reflect.TypeOf(g.Type).Elem().Name() + if g.TypeNameOverride != "" { + typeName = g.TypeNameOverride + } + + return typeName + strings.Join(parts, "") +} + +// Hooks implements object.ObjectComponent. +func (g *GenericObjectComponent) Hooks() juggler.ComponentHooks { + return juggler.ComponentHooks{} +} + +// IsInstallable implements object.ObjectComponent. +func (g *GenericObjectComponent) IsInstallable(ctx context.Context) (bool, error) { + return true, nil +} + +// IsEnabled implements object.ObjectComponent. +func (g *GenericObjectComponent) IsEnabled() bool { + return g.Enabled +} + +// IsObjectHealthy implements object.ObjectComponent. +func (g *GenericObjectComponent) IsObjectHealthy(obj client.Object) juggler.ResourceHealthiness { + return g.IsObjectHealthyFunc(obj) +} + +// ReconcileObject implements object.ObjectComponent. +func (g *GenericObjectComponent) ReconcileObject(ctx context.Context, obj client.Object) error { + return g.ReconcileObjectFunc(ctx, obj) +} + +// GetNamespace implements TargetComponent. +func (g *GenericObjectComponent) GetNamespace() string { + return g.Namespace +} + +// IsStatusInternal implements StatusVisibility interface. +func (g *GenericObjectComponent) IsStatusInternal() bool { + return true +} diff --git a/pkg/controlplane/components/generic_object_component_test.go b/pkg/controlplane/components/generic_object_component_test.go new file mode 100644 index 0000000..5185ec7 --- /dev/null +++ b/pkg/controlplane/components/generic_object_component_test.go @@ -0,0 +1,113 @@ +package components + +import ( + "context" + "testing" + + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func genComponent(enabled, healthy bool, reconcileErr error) *GenericObjectComponent { + return &GenericObjectComponent{ + NamespacedName: types.NamespacedName{ + Name: "example", + Namespace: "some-namespace", + }, + Enabled: enabled, + Type: &corev1.Secret{}, + IsObjectHealthyFunc: func(obj client.Object) juggler.ResourceHealthiness { + return juggler.ResourceHealthiness{Healthy: healthy} + }, + ReconcileObjectFunc: func(ctx context.Context, obj client.Object) error { + return reconcileErr + }, + } +} + +func Test_GenericObjectComponent(t *testing.T) { + testCases := []struct { + desc string + comp *GenericObjectComponent + validationFuncs []validationFunc + }{ + { + desc: "should be disabled", + comp: genComponent(false, true, nil), + validationFuncs: []validationFunc{ + hasName("SecretExample"), + isEnabled(false), + }, + }, + { + desc: "should be enabled", + comp: genComponent(true, true, nil), + validationFuncs: []validationFunc{ + hasName("SecretExample"), + isEnabled(true), + isAllowed(true), + hasDependencies(0), + hasNoHooks(), + isTargetComponent( + hasNamespace("some-namespace"), + ), + isObjectComponent( + objectIsType(&corev1.Secret{}), + canBuildAndReconcile(nil), + canCheckHealthiness(nil, juggler.ResourceHealthiness{ + Healthy: true, + }), + ), + }, + }, + { + desc: "should fail to reconcile and is unhealthy", + comp: genComponent(true, false, errFake), + validationFuncs: []validationFunc{ + hasName("SecretExample"), + isObjectComponent( + canBuildAndReconcile(errFake), + canCheckHealthiness(nil, juggler.ResourceHealthiness{ + Healthy: false, + }), + ), + }, + }, + { + desc: "should use name override", + comp: &GenericObjectComponent{ + NamespacedName: types.NamespacedName{ + Name: "example", + }, + Type: &corev1.Secret{}, + NameOverride: "SomeCustomName", + }, + validationFuncs: []validationFunc{ + hasName("SomeCustomName"), + }, + }, + { + desc: "should use type name override", + comp: &GenericObjectComponent{ + NamespacedName: types.NamespacedName{ + Name: "example", + }, + Type: &corev1.Secret{}, + TypeNameOverride: "SomeCustomType", + }, + validationFuncs: []validationFunc{ + hasName("SomeCustomTypeExample"), + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + ctx := newContext(nil, nil) + for _, vfn := range tC.validationFuncs { + vfn(t, ctx, tC.comp) + } + }) + } +} diff --git a/pkg/controlplane/components/kyverno_component.go b/pkg/controlplane/components/kyverno_component.go new file mode 100644 index 0000000..29701cf --- /dev/null +++ b/pkg/controlplane/components/kyverno_component.go @@ -0,0 +1,202 @@ +package components + +import ( + "context" + "strings" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/fluxcd" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/hooks" + "github.com/openmcp-project/control-plane-operator/pkg/utils/rcontext" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + kyvernoRelease = "kyverno" + kyvernoNamespace = "kyverno-system" + ComponentNameKyverno = "Kyverno" +) + +var _ fluxcd.FluxComponent = &Kyverno{} +var _ TargetComponent = &Kyverno{} +var _ PolicyRulesComponent = &Kyverno{} + +type Kyverno struct { + Config *v1beta1.KyvernoConfig +} + +// GetPolicyRules implements PolicyRulesComponent. +func (k *Kyverno) GetPolicyRules() PolicyRules { + return PolicyRules{ + Admin: []rbacv1.PolicyRule{ + { + APIGroups: []string{"kyverno.io"}, + Resources: []string{ + "cleanuppolicies", + "clustercleanuppolicies", + "policies", + "clusterpolicies", + "admissionreports", + "clusteradmissionreports", + "backgroundscanreports", + "clusterbackgroundscanreports", + "updaterequests", + }, + Verbs: VerbsAdmin, + }, + { + APIGroups: []string{"wgpolicyk8s.io"}, + Resources: []string{ + "policyreports", + "clusterpolicyreports", + }, + Verbs: VerbsAdmin, + }, + { + APIGroups: []string{"reports.kyverno.io"}, + Resources: []string{ + "ephemeralreports", + "clusterephemeralreports", + }, + Verbs: VerbsAdmin, + }, + }, + View: []rbacv1.PolicyRule{ + { + APIGroups: []string{"kyverno.io"}, + Resources: []string{ + "cleanuppolicies", + "clustercleanuppolicies", + "policies", + "clusterpolicies", + "admissionreports", + "clusteradmissionreports", + "backgroundscanreports", + "clusterbackgroundscanreports", + "updaterequests", + }, + Verbs: VerbsView, + }, + { + APIGroups: []string{"wgpolicyk8s.io"}, + Resources: []string{ + "policyreports", + "clusterpolicyreports", + }, + Verbs: VerbsView, + }, + { + APIGroups: []string{"reports.kyverno.io"}, + Resources: []string{ + "ephemeralreports", + "clusterephemeralreports", + }, + Verbs: VerbsView, + }, + }, + } +} + +// GetNamespace implements TargetComponent. +func (k *Kyverno) GetNamespace() string { + return kyvernoNamespace +} + +func (k *Kyverno) GetName() string { + return ComponentNameKyverno +} + +func (k *Kyverno) GetDependencies() []juggler.Component { + return []juggler.Component{} +} + +func (k *Kyverno) IsEnabled() bool { + return k.Config != nil && k.Config.Version != "" +} + +func (k *Kyverno) Hooks() juggler.ComponentHooks { + return juggler.ComponentHooks{ + PreUninstall: hooks.PreventOrphanedResources([]schema.GroupVersionKind{ + {Group: "kyverno.io", Version: "v1", Kind: "ClusterPolicy"}, + {Group: "kyverno.io", Version: "v1", Kind: "Policy"}, + }), + } +} + +func (k *Kyverno) IsInstallable(ctx context.Context) (bool, error) { + rfn := rcontext.VersionResolver(ctx) + if _, err := rfn(kyvernoRelease, k.Config.Version); err != nil { + return false, err + } + return true, nil +} + +func (k *Kyverno) BuildSourceRepository(ctx context.Context) (fluxcd.SourceAdapter, error) { + rfn := rcontext.VersionResolver(ctx) + k.applyDefaultChartSpec(rfn) + + repo := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameKyverno), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: k.Config.Chart.Repository, + }, + } + + adapter := &fluxcd.HelmRepositoryAdapter{Source: repo} + adapter.ApplyDefaults() + return adapter, nil +} + +func (k *Kyverno) applyDefaultChartSpec(rfn v1beta1.VersionResolverFn) { + if k.Config == nil { + k.Config = &v1beta1.KyvernoConfig{} + } + + comp, _ := rfn(kyvernoRelease, k.Config.Version) + + if k.Config.Chart == nil { + k.Config.Chart = &v1beta1.ChartSpec{ + Repository: "https://kyverno.github.io/kyverno", + Name: "kyverno", + Version: comp.Version, + } + } +} + +func (k *Kyverno) BuildManifesto(ctx context.Context) (fluxcd.Manifesto, error) { + release := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(ComponentNameKyverno), + Namespace: rcontext.TenantNamespace(ctx), + }, + Spec: helmv2.HelmReleaseSpec{ + Chart: &helmv2.HelmChartTemplate{ + Spec: helmv2.HelmChartTemplateSpec{ + Chart: k.Config.Chart.Name, + Version: k.Config.Chart.Version, + SourceRef: helmv2.CrossNamespaceObjectReference{ + Kind: "HelmRepository", + Name: strings.ToLower(ComponentNameKyverno), + }, + }, + }, + ReleaseName: kyvernoRelease, + TargetNamespace: kyvernoNamespace, + StorageNamespace: kyvernoNamespace, + KubeConfig: rcontext.FluxKubeconfigRef(ctx), + Values: k.Config.Values, + }, + } + + adapter := &fluxcd.HelmReleaseManifesto{Manifest: release} + adapter.ApplyDefaults() + return adapter, nil +} diff --git a/pkg/controlplane/components/kyverno_component_test.go b/pkg/controlplane/components/kyverno_component_test.go new file mode 100644 index 0000000..26f8c1f --- /dev/null +++ b/pkg/controlplane/components/kyverno_component_test.go @@ -0,0 +1,75 @@ +//nolint:dupl +package components + +import ( + "testing" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func Test_Kyverno(t *testing.T) { + testCases := []struct { + desc string + config *v1beta1.KyvernoConfig + versionResolver v1beta1.VersionResolverFn + validationFuncs []validationFunc + }{ + { + desc: "should be disabled", + validationFuncs: []validationFunc{ + hasName("Kyverno"), + isEnabled(false), + }, + }, + { + desc: "should not be allowed", + config: &v1beta1.KyvernoConfig{ + Version: "1.2.3", + }, + versionResolver: fakeVersionResolver(true), + validationFuncs: []validationFunc{ + hasName("Kyverno"), + isEnabled(true), + isAllowed(false), + }, + }, + { + desc: "should be enabled", + config: &v1beta1.KyvernoConfig{ + Version: "1.2.3", + Values: &apiextensionsv1.JSON{Raw: []byte(`{"crds":{"install":true}}`)}, + }, + versionResolver: fakeVersionResolver(false), + validationFuncs: []validationFunc{ + hasName("Kyverno"), + isEnabled(true), + isAllowed(true), + hasPreUninstallHook(), + hasDependencies(0), + isTargetComponent( + hasNamespace("kyverno-system"), + ), + isFluxComponent( + returnsHelmRepo(), + returnsHelmRelease( + hasKubeconfigRef(), + hasHelmValue(true, "crds", "install"), + ), + ), + isPolicyRulesComponent( + hasPolicyRules(), + ), + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + ctx := newContext(nil, tC.versionResolver) + c := &Kyverno{Config: tC.config} + for _, vfn := range tC.validationFuncs { + vfn(t, ctx, c) + } + }) + } +} diff --git a/pkg/controlplane/components/policies/Readme.md b/pkg/controlplane/components/policies/Readme.md new file mode 100644 index 0000000..f6de46a --- /dev/null +++ b/pkg/controlplane/components/policies/Readme.md @@ -0,0 +1,13 @@ +# Policies + +## DeploymentRuntimeConfiguration + +The DeploymentRuntimeConfigurationPolicy component applies a policy to prevent end-users from editing all fields of a crossplane DeploymentRuntimeConfig. This is done to ensure that end-users cannot run arbitrary workload by introducing new containers or changed images. Additionally this prevents users to run crossplane providers in unintended configurations, which could affect other MCP users (e.g. increasing resource limits, putting unnecessary strain on a provider by running it with undesirable configuration options). + +It works in conjunction with the eso- and defaultDeploymentRuntimeConfiguration-components in the following way: + +* Whenever a managed provider is being rolled out, defaultDeploymentRuntimeConfiguration will create a DeploymentRuntimeConfig for this component +* End-users are allowed to edit this DeploymentRuntimeConfig (but not create new ones) +* For every modification (patch or update), the policy will only allow the following fields of a DeploymentRuntimeConfig to be edited. All other changes, will be rejected: + * `spec.deploymentTemplate.spec.template.spec.containers.args` + * `spec.serviceAccountTemplate.metadata.name` diff --git a/pkg/controlplane/components/policies/policies.go b/pkg/controlplane/components/policies/policies.go new file mode 100644 index 0000000..093b97f --- /dev/null +++ b/pkg/controlplane/components/policies/policies.go @@ -0,0 +1,139 @@ +package policies + +import ( + "context" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/components" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/crossplane" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + arv1 "k8s.io/api/admissionregistration/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + CrossplanePackageRestrictionName = "default" +) + +func RegisterAsComponents(jug *juggler.Juggler, sourceClient client.Client, enabled bool) error { + cpr := &components.GenericObjectComponent{ + NamespacedName: types.NamespacedName{ + Name: CrossplanePackageRestrictionName, + }, + Enabled: enabled, + Type: &v1beta1.CrossplanePackageRestriction{}, + ReconcileObjectFunc: func(ctx context.Context, obj client.Object) error { + sourceCPR := &v1beta1.CrossplanePackageRestriction{} + if err := sourceClient.Get(ctx, types.NamespacedName{ + Name: CrossplanePackageRestrictionName, + }, sourceCPR); err != nil { + return err + } + + objCPR := obj.(*v1beta1.CrossplanePackageRestriction) + objCPR.Spec = sourceCPR.Spec + + return nil + }, + IsObjectHealthyFunc: func(obj client.Object) juggler.ResourceHealthiness { + return juggler.ResourceHealthiness{ + // CrossplanePackageRestriction has no status field. + Healthy: obj.GetDeletionTimestamp() == nil, + } + }, + } + jug.RegisterComponent(cpr) + + for _, pt := range crossplane.PackageTypes { + policy := &components.GenericObjectComponent{ + NamespacedName: types.NamespacedName{ + Name: crossplane.GetPolicyName(pt), + }, + Enabled: enabled, + Type: &arv1.ValidatingAdmissionPolicy{}, + TypeNameOverride: "Policy", + ReconcileObjectFunc: func(ctx context.Context, obj client.Object) error { + vap := obj.(*arv1.ValidatingAdmissionPolicy) + return crossplane.ReconcilePolicy(pt, vap) + }, + IsObjectHealthyFunc: func(obj client.Object) juggler.ResourceHealthiness { + vap := obj.(*arv1.ValidatingAdmissionPolicy) + return juggler.ResourceHealthiness{ + Healthy: crossplane.IsPolicyHealthy(vap), + } + }, + } + jug.RegisterComponent(policy) + + policyBinding := &components.GenericObjectComponent{ + NamespacedName: types.NamespacedName{ + Name: crossplane.GetPolicyName(pt), + }, + Enabled: enabled, + Type: &arv1.ValidatingAdmissionPolicyBinding{}, + TypeNameOverride: "PolicyBinding", + ReconcileObjectFunc: func(ctx context.Context, obj client.Object) error { + vapb := obj.(*arv1.ValidatingAdmissionPolicyBinding) + return crossplane.ReconcilePolicyBinding(cpr.Name, policy.Name, vapb) + }, + IsObjectHealthyFunc: func(obj client.Object) juggler.ResourceHealthiness { + vapb := obj.(*arv1.ValidatingAdmissionPolicyBinding) + return juggler.ResourceHealthiness{ + Healthy: crossplane.IsPolicyBindingHealthy(vapb), + } + }, + } + jug.RegisterComponent(policyBinding) + } + return nil +} + +// RegisterDeploymentRuntimeConfigProtection adds a ValidatingAdmissionPolicy, +// which only allows certain fields of DeploymentRuntimeConfig to be edited and +// which excludes the ServiceAccount of the controlplane-operator +func RegisterDeploymentRuntimeConfigProtection(jug *juggler.Juggler, sourceClient client.Client, enabled bool) error { + policy := &components.GenericObjectComponent{ + NamespacedName: types.NamespacedName{ + Name: "restrict-crossplane-deploymentruntimeconfig", + }, + Enabled: enabled, + Type: &arv1.ValidatingAdmissionPolicy{}, + TypeNameOverride: "Policy", + ReconcileObjectFunc: func(ctx context.Context, obj client.Object) error { + vap := obj.(*arv1.ValidatingAdmissionPolicy) + crossplane.ReconcileDeploymentConfigRuntimeProtectionPolicy(vap) + return nil + }, + IsObjectHealthyFunc: func(obj client.Object) juggler.ResourceHealthiness { + vap := obj.(*arv1.ValidatingAdmissionPolicy) + return juggler.ResourceHealthiness{ + Healthy: crossplane.IsPolicyHealthy(vap), + } + }, + } + jug.RegisterComponent(policy) + + policyBinding := &components.GenericObjectComponent{ + NamespacedName: types.NamespacedName{ + Name: "restrict-crossplane-deploymentruntimeconfig", + }, + Enabled: enabled, + Type: &arv1.ValidatingAdmissionPolicyBinding{}, + TypeNameOverride: "PolicyBinding", + ReconcileObjectFunc: func(ctx context.Context, obj client.Object) error { + vapd := obj.(*arv1.ValidatingAdmissionPolicyBinding) + crossplane.ReconcileDeploymentConfigRuntimeProtectionPolicyBinding(policy.Name, vapd) + return nil + }, + IsObjectHealthyFunc: func(obj client.Object) juggler.ResourceHealthiness { + vapb := obj.(*arv1.ValidatingAdmissionPolicyBinding) + return juggler.ResourceHealthiness{ + Healthy: crossplane.IsPolicyBindingHealthy(vapb), + } + }, + } + jug.RegisterComponent(policyBinding) + + return nil +} diff --git a/pkg/controlplane/components/policies/policies_test.go b/pkg/controlplane/components/policies/policies_test.go new file mode 100644 index 0000000..491e396 --- /dev/null +++ b/pkg/controlplane/components/policies/policies_test.go @@ -0,0 +1,25 @@ +package policies + +import ( + "testing" + + "github.com/go-logr/logr" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/stretchr/testify/assert" +) + +func Test_RegisterAsComponents(t *testing.T) { + j := juggler.NewJuggler(logr.Logger{}, nil) + err := RegisterAsComponents(j, nil, true) + assert.NoError(t, err) + // 1 * CrossplanePackageRestriction + (3 PackageTypes * 2 GenericObjectComponent) = 7 + assert.Equal(t, 7, j.RegisteredComponents()) +} + +func TestRegisterDeploymentRuntimeConfigProtection(t *testing.T) { + j := juggler.NewJuggler(logr.Logger{}, nil) + err := RegisterDeploymentRuntimeConfigProtection(j, nil, true) + assert.NoError(t, err) + // Policy + Policybinding = 2 in total + assert.Equal(t, 2, j.RegisteredComponents()) +} diff --git a/pkg/controlplane/components/secret_component.go b/pkg/controlplane/components/secret_component.go new file mode 100644 index 0000000..400fa77 --- /dev/null +++ b/pkg/controlplane/components/secret_component.go @@ -0,0 +1,139 @@ +package components + +import ( + "context" + "errors" + "strings" + + "github.com/openmcp-project/control-plane-operator/pkg/constants" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/object" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + "golang.org/x/text/cases" + "golang.org/x/text/language" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + ErrSecretTargetNamespaceEmpty = errors.New("secret target namespace must not be empty") +) + +var _ object.ObjectComponent = &Secret{} +var _ object.OrphanedObjectsDetector = &Secret{} +var _ TargetComponent = &Secret{} +var _ juggler.StatusVisibility = &Secret{} + +type Secret struct { + SourceClient client.Client + Source, Target types.NamespacedName + Enabled bool +} + +// BuildObjectToReconcile implements object.ObjectComponent. +func (s *Secret) BuildObjectToReconcile(ctx context.Context) (client.Object, types.NamespacedName, error) { + if s.Target.Namespace == "" { + return nil, types.NamespacedName{}, ErrSecretTargetNamespaceEmpty + } + + return &corev1.Secret{}, types.NamespacedName{ + Name: s.Target.Name, + Namespace: s.Target.Namespace, + }, nil +} + +// ReconcileObject implements object.ObjectComponent. +func (s *Secret) ReconcileObject(ctx context.Context, obj client.Object) error { + sourceSecret := &corev1.Secret{} + // If secret is not enabled (= should be deleted), then we don't need to get it from the API server. + if s.Enabled { + if err := s.SourceClient.Get(ctx, s.Source, sourceSecret); err != nil { + return err + } + } + + objSecret := obj.(*corev1.Secret) + + metav1.SetMetaDataLabel(&objSecret.ObjectMeta, constants.LabelCopySourceName, s.Source.Name) + metav1.SetMetaDataLabel(&objSecret.ObjectMeta, constants.LabelCopySourceNamespace, s.Source.Namespace) + + objSecret.Type = sourceSecret.Type + objSecret.Data = sourceSecret.Data + return nil +} + +// OrphanDetectorContext implements object.OrphanedObjectsDetector. +func (*Secret) OrphanDetectorContext() object.DetectorContext { + return object.DetectorContext{ + ListType: &corev1.SecretList{}, + FilterCriteria: object.FilterCriteria{ + utils.IsManaged(), + object.HasComponentLabel(), + }, + ConvertFunc: func(list client.ObjectList) []juggler.Component { + secrets := []juggler.Component{} + for _, secret := range (list.(*corev1.SecretList)).Items { + secrets = append(secrets, &Secret{Target: client.ObjectKeyFromObject(&secret)}) + } + return secrets + }, + SameFunc: func(configured, detected juggler.Component) bool { + configuredS := configured.(*Secret) + detectedS := detected.(*Secret) + return configuredS.Target == detectedS.Target + }, + } +} + +// GetDependencies implements object.ObjectComponent. +func (s *Secret) GetDependencies() []juggler.Component { + return []juggler.Component{} +} + +// GetName implements object.ObjectComponent. +func (s *Secret) GetName() string { + return formatSecretName(s.Target.Name) +} + +// Hooks implements object.ObjectComponent. +func (s *Secret) Hooks() juggler.ComponentHooks { + return juggler.ComponentHooks{} +} + +func (s *Secret) IsInstallable(_ context.Context) (bool, error) { + return true, nil +} + +// IsEnabled implements object.ObjectComponent. +func (s *Secret) IsEnabled() bool { + return s.Enabled +} + +// IsObjectHealthy implements object.ObjectComponent. +func (s *Secret) IsObjectHealthy(obj client.Object) juggler.ResourceHealthiness { + return juggler.ResourceHealthiness{ + // Secret has no status field. + Healthy: obj.GetDeletionTimestamp() == nil, + } +} + +// GetNamespace implements TargetComponent. +func (s *Secret) GetNamespace() string { + return s.Target.Namespace +} + +func formatSecretName(name string) string { + parts := strings.Split(name, "-") + for i, part := range parts { + parts[i] = cases.Title(language.English).String(part) + } + parts = append([]string{"Secret"}, parts...) + return strings.Join(parts, "") +} + +// IsStatusInternal implements StatusVisibility interface. +func (s *Secret) IsStatusInternal() bool { + return true +} diff --git a/pkg/controlplane/components/secret_component_test.go b/pkg/controlplane/components/secret_component_test.go new file mode 100644 index 0000000..19c71b3 --- /dev/null +++ b/pkg/controlplane/components/secret_component_test.go @@ -0,0 +1,173 @@ +//nolint:dupl,lll +package components + +import ( + "context" + "testing" + + "github.com/openmcp-project/control-plane-operator/pkg/constants" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +var ( + secretHealthy = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-healthy", + }, + } + secretUnhealthy = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-unhealthy", + DeletionTimestamp: ptr.To(metav1.Now()), + }, + } + secretA = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-a", + Namespace: corev1.NamespaceDefault, + }, + Type: corev1.SecretTypeBasicAuth, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("admin"), + corev1.BasicAuthPasswordKey: []byte("very_Secure"), + }, + } + secretB = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-b", + }, + Type: corev1.SecretTypeBasicAuth, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("admin"), + corev1.BasicAuthPasswordKey: []byte("very_Secure"), + }, + } + sourceSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-source", + Namespace: "some-namespace", + Labels: map[string]string{ + constants.LabelCopyToCP: "true", + }, + }, + Type: corev1.SecretTypeBasicAuth, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("admin"), + corev1.BasicAuthPasswordKey: []byte("very_Secure"), + }, + } +) + +func Test_Secret(t *testing.T) { + testCases := []struct { + desc string + enabled bool + source, target types.NamespacedName + validationFuncs []validationFunc + interceptorFuncs interceptor.Funcs + }{ + { + desc: "should be disabled", + enabled: false, + source: client.ObjectKeyFromObject(sourceSecret), + target: client.ObjectKeyFromObject(secretA), + validationFuncs: []validationFunc{ + hasName("SecretTestA"), + isEnabled(false), + }, + }, + { + desc: "should be enabled", + enabled: true, + source: client.ObjectKeyFromObject(sourceSecret), + target: client.ObjectKeyFromObject(secretA), + validationFuncs: []validationFunc{ + hasName("SecretTestA"), + isEnabled(true), + isAllowed(true), + hasDependencies(0), + hasNoHooks(), + isTargetComponent( + hasNamespace(corev1.NamespaceDefault), + ), + isObjectComponent( + objectIsType(&corev1.Secret{}), + canCheckHealthiness(secretUnhealthy, juggler.ResourceHealthiness{ + Healthy: false, + }), + canCheckHealthiness(secretHealthy, juggler.ResourceHealthiness{ + Healthy: true, + }), + canBuildAndReconcile(nil), + implementsOrphanedObjectsDetector( + listTypeIs(&corev1.SecretList{}), + hasFilterCriteria(2), + canConvert(&corev1.SecretList{Items: []corev1.Secret{*secretA}}, 1), + canCheckSame(&Secret{Target: client.ObjectKeyFromObject(secretA)}, &Secret{Target: client.ObjectKeyFromObject(secretA)}, true), + canCheckSame(&Secret{Target: client.ObjectKeyFromObject(secretA)}, &Secret{Target: client.ObjectKeyFromObject(secretB)}, false), + ), + ), + }, + }, + { + desc: "should fail when client returns error", + enabled: true, + source: client.ObjectKeyFromObject(sourceSecret), + target: client.ObjectKeyFromObject(secretA), + interceptorFuncs: interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return errFake + }, + }, + validationFuncs: []validationFunc{ + hasName("SecretTestA"), + isEnabled(true), + isAllowed(true), + hasDependencies(0), + hasNoHooks(), + isTargetComponent( + hasNamespace(corev1.NamespaceDefault), + ), + isObjectComponent( + canBuildAndReconcile(errFake), + ), + }, + }, + { + desc: "should fail when namespace is empty", + enabled: true, + target: types.NamespacedName{Name: "some-name"}, + validationFuncs: []validationFunc{ + hasName("SecretSomeName"), + isTargetComponent( + hasNamespace(""), + ), + isObjectComponent( + canBuildAndReconcile(ErrSecretTargetNamespaceEmpty), + ), + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + ctx := newContext(nil, nil) + fakeClient := fake.NewClientBuilder().WithInterceptorFuncs(tC.interceptorFuncs).WithObjects(sourceSecret).Build() + c := &Secret{ + Enabled: tC.enabled, + SourceClient: fakeClient, + Source: tC.source, + Target: tC.target, + } + for _, vfn := range tC.validationFuncs { + vfn(t, ctx, c) + } + }) + } +} diff --git a/pkg/controlplane/components/utils_test.go b/pkg/controlplane/components/utils_test.go new file mode 100644 index 0000000..e9b8942 --- /dev/null +++ b/pkg/controlplane/components/utils_test.go @@ -0,0 +1,302 @@ +//nolint:lll +package components + +import ( + "context" + "strings" + "testing" + + "github.com/fluxcd/pkg/apis/meta" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/secretresolver" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/fluxcd" + "github.com/openmcp-project/control-plane-operator/pkg/juggler/object" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + "github.com/openmcp-project/control-plane-operator/pkg/utils/rcontext" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + tenantNamespace = "tenant-namespace" + fluxSecretRef = &meta.KubeConfigReference{ + SecretRef: meta.SecretKeyReference{ + Name: "some-secret", + Key: "kubeconfig", + }, + } +) + +func fakeVersionResolver(shouldFail bool) v1beta1.VersionResolverFn { + return func(componentName string, channelName string) (v1beta1.ComponentVersion, error) { + if shouldFail { + return v1beta1.ComponentVersion{}, errFake + } + return v1beta1.ComponentVersion{ + DockerRef: strings.ToLower(componentName), + Version: "v1.0.0", + }, nil + } +} + +func fakeSecretRefResolver(shouldFail, shouldReturn bool) secretresolver.ResolveFunc { + return func(urlType secretresolver.UrlSecretType) (*corev1.LocalObjectReference, error) { + if shouldFail { + return nil, errFake + } + if shouldReturn { + return &corev1.LocalObjectReference{Name: "some-secret"}, nil + } + return nil, nil + } +} + +type validationFunc func(t *testing.T, ctx context.Context, c juggler.Component) +type targetValidationFunc func(t *testing.T, ctx context.Context, c TargetComponent) +type policyRulesValidationFunc func(t *testing.T, ctx context.Context, c PolicyRulesComponent) +type fluxValidationFunc func(t *testing.T, ctx context.Context, c fluxcd.FluxComponent) +type helmReleaseValidationFunc func(t *testing.T, ctx context.Context, h *fluxcd.HelmReleaseManifesto) +type objectValidationFunc func(t *testing.T, ctx context.Context, c object.ObjectComponent) +type orphanedObjectsDetectorValidationFunc func(t *testing.T, ctx context.Context, dc object.DetectorContext) + +func hasName(expected string) validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + assert.Equal(t, expected, c.GetName(), "GetName does not match") + } +} + +func isEnabled(expected bool) validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + assert.Equal(t, expected, c.IsEnabled(), "IsEnabled does not match") + } +} + +func isAllowed(expected bool) validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + actual, _ := c.IsInstallable(ctx) + assert.Equal(t, expected, actual, "IsInstallable does not match") + } +} + +func hasPreUninstallHook() validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + assert.NotNil(t, c.Hooks().PreUninstall, "PreUninstall hook is nil") + } +} + +func hasPreInstallHook() validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + assert.NotNil(t, c.Hooks().PreInstall, "PreInstall hook is nil") + } +} + +func hasPreUpdateHook() validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + assert.NotNil(t, c.Hooks().PreUpdate, "PreUpdate hook is nil") + } +} + +func hasNoHooks() validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + assert.Equal(t, juggler.ComponentHooks{}, c.Hooks()) + } +} + +func hasDependencies(count int) validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + assert.Len(t, c.GetDependencies(), count, "len(GetDependencies) count does not match") + } +} + +func newContext(fn secretresolver.ResolveFunc, fn2 v1beta1.VersionResolverFn) context.Context { + ctx := context.Background() + ctx = rcontext.WithTenantNamespace(ctx, tenantNamespace) + ctx = rcontext.WithFluxKubeconfigRef(ctx, &corev1.SecretReference{Name: fluxSecretRef.SecretRef.Name}) + ctx = rcontext.WithSecretRefResolver(ctx, fn) + ctx = rcontext.WithVersionResolver(ctx, fn2) + return ctx +} + +func isTargetComponent(additionalValidations ...targetValidationFunc) validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + tc, ok := c.(TargetComponent) + if !assert.True(t, ok, "not a TargetComponent") { + return + } + + for _, v := range additionalValidations { + v(t, ctx, tc) + } + } +} + +func hasNamespace(namespace string) targetValidationFunc { + return func(t *testing.T, ctx context.Context, c TargetComponent) { + assert.Equal(t, namespace, c.GetNamespace(), "GetNamespace does not match") + } +} + +func isFluxComponent(additionalValidations ...fluxValidationFunc) validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + fc, ok := c.(fluxcd.FluxComponent) + if !assert.True(t, ok, "not a FluxComponent") { + return + } + + for _, v := range additionalValidations { + v(t, ctx, fc) + } + } +} + +func returnsHelmRepo() fluxValidationFunc { + return func(t *testing.T, ctx context.Context, c fluxcd.FluxComponent) { + s, err := c.BuildSourceRepository(ctx) + assert.NoError(t, err) + + h, ok := s.(*fluxcd.HelmRepositoryAdapter) + if !assert.True(t, ok, "not a HelmRepositoryAdapter") { + return + } + assert.NotNil(t, h.Source) + } +} + +func returnsHelmRelease(additionalValidations ...helmReleaseValidationFunc) fluxValidationFunc { + return func(t *testing.T, ctx context.Context, c fluxcd.FluxComponent) { + m, err := c.BuildManifesto(ctx) + assert.NoError(t, err) + + h, ok := m.(*fluxcd.HelmReleaseManifesto) + if !assert.True(t, ok, "not a HelmReleaseManifesto") { + return + } + assert.NotNil(t, h.Manifest) + + for _, v := range additionalValidations { + v(t, ctx, h) + } + } +} + +func hasKubeconfigRef() helmReleaseValidationFunc { + return func(t *testing.T, ctx context.Context, h *fluxcd.HelmReleaseManifesto) { + assert.NotNil(t, h.Manifest.Spec.KubeConfig) + assert.Equal(t, "kubeconfig", h.Manifest.Spec.KubeConfig.SecretRef.Key, "KubeConfig.SecretRef.Key does not match") + } +} + +func hasHelmValue(expected any, path ...string) helmReleaseValidationFunc { + return func(t *testing.T, ctx context.Context, h *fluxcd.HelmReleaseManifesto) { + if assert.NotNil(t, h.Manifest.Spec.Values, "values are nil") { + actual, err := utils.GetNestedValue(h.Manifest.GetValues(), path...) + assert.NoError(t, err) + assert.EqualValues(t, expected, actual) + } + } +} + +func isObjectComponent(additionalValidations ...objectValidationFunc) validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + oc, ok := c.(object.ObjectComponent) + if !assert.True(t, ok, "not an ObjectComponent") { + return + } + + for _, v := range additionalValidations { + v(t, ctx, oc) + } + } +} + +func objectIsType(sample client.Object) objectValidationFunc { + return func(t *testing.T, ctx context.Context, c object.ObjectComponent) { + obj, _, err := c.BuildObjectToReconcile(ctx) + if !assert.NoError(t, err) { + return + } + + assert.IsType(t, sample, obj) + } +} + +func implementsOrphanedObjectsDetector(additionalValidations ...orphanedObjectsDetectorValidationFunc) objectValidationFunc { + return func(t *testing.T, ctx context.Context, c object.ObjectComponent) { + ood, ok := c.(object.OrphanedObjectsDetector) + if !assert.True(t, ok, "not a OrphanedObjectsDetector") { + return + } + + for _, v := range additionalValidations { + v(t, ctx, ood.OrphanDetectorContext()) + } + } +} + +func listTypeIs(sample client.ObjectList) orphanedObjectsDetectorValidationFunc { + return func(t *testing.T, ctx context.Context, dc object.DetectorContext) { + assert.IsType(t, sample, dc.ListType) + } +} + +func hasFilterCriteria(count int) orphanedObjectsDetectorValidationFunc { + return func(t *testing.T, ctx context.Context, dc object.DetectorContext) { + assert.Len(t, dc.FilterCriteria, count) + } +} + +func canConvert(sample client.ObjectList, count int) orphanedObjectsDetectorValidationFunc { + return func(t *testing.T, ctx context.Context, dc object.DetectorContext) { + result := dc.ConvertFunc(sample) + assert.Len(t, result, count) + } +} + +func canCheckSame(configured, detected juggler.Component, expected bool) orphanedObjectsDetectorValidationFunc { + return func(t *testing.T, ctx context.Context, dc object.DetectorContext) { + actual := dc.SameFunc(configured, detected) + assert.Equal(t, expected, actual, "components %s and %s are not the same", configured.GetName(), detected.GetName()) + } +} + +func canCheckHealthiness(sample client.Object, expected juggler.ResourceHealthiness) objectValidationFunc { + return func(t *testing.T, ctx context.Context, c object.ObjectComponent) { + actual := c.IsObjectHealthy(sample) + assert.Equal(t, expected, actual) + } +} + +func canBuildAndReconcile(expectedErr error) objectValidationFunc { + return func(t *testing.T, ctx context.Context, c object.ObjectComponent) { + obj, _, err := c.BuildObjectToReconcile(ctx) + if err != nil { + assert.ErrorIs(t, err, expectedErr) + return + } + + err = c.ReconcileObject(ctx, obj) + assert.Equal(t, expectedErr, err) + } +} + +func isPolicyRulesComponent(additionalValidations ...policyRulesValidationFunc) validationFunc { + return func(t *testing.T, ctx context.Context, c juggler.Component) { + oc, ok := c.(PolicyRulesComponent) + if !assert.True(t, ok, "not a PolicyRulesComponent") { + return + } + + for _, v := range additionalValidations { + v(t, ctx, oc) + } + } +} + +func hasPolicyRules() policyRulesValidationFunc { + return func(t *testing.T, ctx context.Context, c PolicyRulesComponent) { + assert.NotEmpty(t, c.GetPolicyRules().Admin, "Admin policy rules are empty") + assert.NotEmpty(t, c.GetPolicyRules().View, "View policy rules are empty") + } +} diff --git a/pkg/controlplane/crossplane/policies.go b/pkg/controlplane/crossplane/policies.go new file mode 100644 index 0000000..3167502 --- /dev/null +++ b/pkg/controlplane/crossplane/policies.go @@ -0,0 +1,388 @@ +package crossplane + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + crossplanev1 "github.com/crossplane/crossplane/apis/pkg/v1" + crossplanev1beta "github.com/crossplane/crossplane/apis/pkg/v1beta1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + arv1 "k8s.io/api/admissionregistration/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type PackageType string + +const ( + Providers PackageType = "providers" + Configurations PackageType = "configurations" + Functions PackageType = "functions" +) + +var ( + PackageTypes = []PackageType{ + Providers, + Configurations, + Functions, + } + + ErrPolicyNotFound = errors.New("policy is not installed yet") + ErrFailedToGetPolicy = errors.New("failed to get policy") + ErrPolicyUnhealthy = errors.New("policy is unhealthy") + ErrPolicyBindingNotFound = errors.New("policy binding is not installed yet") + ErrFailedToGetPolicyBinding = errors.New("failed to get policy binding") + ErrPolicyBindingUnhealthy = errors.New("policy binding is unhealthy") +) + +//nolint:lll +func ReconcilePolicy(pt PackageType, policy *arv1.ValidatingAdmissionPolicy) error { + // Reconcile ValidatingAdmissionPolicy + policy.Spec.FailurePolicy = ptr.To(arv1.Fail) + policy.Spec.ParamKind = &arv1.ParamKind{ + APIVersion: v1beta1.GroupVersion.String(), + Kind: "CrossplanePackageRestriction", + } + policy.Spec.MatchConstraints = &arv1.MatchResources{ + ResourceRules: []arv1.NamedRuleWithOperations{ + { + RuleWithOperations: arv1.RuleWithOperations{ + Operations: []arv1.OperationType{ + arv1.Create, + arv1.Update, + }, + Rule: arv1.Rule{ + APIGroups: []string{crossplanev1.Group}, + APIVersions: []string{"*"}, + Resources: []string{strings.ToLower(string(pt))}, + }, + }, + }, + }, + } + policy.Spec.Variables = []arv1.Variable{ + { + Name: "allPackagesAllowed", + // Check if "packages" contains an asterisk. + Expression: fmt.Sprintf("'*' in params.spec.%s.packages", pt), + }, + { + Name: "allRegistriesAllowed", + // Check if "registries" contains an asterisk. + Expression: fmt.Sprintf("'*' in params.spec.%s.registries", pt), + }, + { + Name: "packageAllowed", + // Check if "packages" contains "package" (including version => only specific version allowed) or "packages" contains "package" (excluding version => any version allowed). + Expression: fmt.Sprintf("object.spec.package in params.spec.%s.packages || object.spec.package.split(':', 2)[0] in params.spec.%s.packages", pt, pt), + }, + { + Name: "registryAllowed", + // Check if "package" starts with any of the allowed registries. + Expression: fmt.Sprintf("params.spec.%s.registries.exists(registry, object.spec.package.startsWith(registry + '/'))", pt), + }, + } + policy.Spec.Validations = []arv1.Validation{ + { + Expression: "variables.allPackagesAllowed || variables.allRegistriesAllowed || variables.packageAllowed || variables.registryAllowed", + Message: "Package or registry not allowed", + Reason: ptr.To(metav1.StatusReasonForbidden), + }, + } + + // All good + return nil +} + +func ReconcilePolicyBinding(cprName, policyName string, binding *arv1.ValidatingAdmissionPolicyBinding) error { + // Reconcile ValidatingAdmissionPolicyBinding + binding.Spec.PolicyName = policyName + binding.Spec.ParamRef = &arv1.ParamRef{ + Name: cprName, + ParameterNotFoundAction: ptr.To(arv1.DenyAction), + } + binding.Spec.ValidationActions = []arv1.ValidationAction{arv1.Deny} + + // All good + return nil +} + +func IsPolicyHealthy(policy *arv1.ValidatingAdmissionPolicy) bool { + // Generation and ObservedGeneration must be the same and greater than zero. + return policy.Status.ObservedGeneration > 0 && + policy.Generation > 0 && + policy.Generation == policy.Status.ObservedGeneration && + policy.DeletionTimestamp == nil +} + +func IsPolicyBindingHealthy(binding *arv1.ValidatingAdmissionPolicyBinding) bool { + // ValidatingAdmissionPolicyBinding has no status field. + return binding.Generation > 0 && + binding.DeletionTimestamp == nil +} + +func GetPolicyName(pt PackageType) string { + return fmt.Sprintf("restrict-crossplane-%s", strings.ToLower(string(pt))) +} + +// CheckIfPolicyIsInstalled checks if the policy and binding for the given PackageType is installed. +// If not, we cannot safely install or update the package and the hook will fail. +func CheckIfPolicyIsInstalled(pt PackageType) func(ctx context.Context, c client.Client) error { + key := types.NamespacedName{ + Name: GetPolicyName(pt), + } + + return func(ctx context.Context, c client.Client) error { + // Check if policy exists. + policy := &arv1.ValidatingAdmissionPolicy{} + err := c.Get(ctx, key, policy) + if apierrors.IsNotFound(err) { + return ErrPolicyNotFound + } + if err != nil { + // some unknown error occurred. + return errors.Join(ErrFailedToGetPolicy, err) + } + if !IsPolicyHealthy(policy) { + return ErrPolicyUnhealthy + } + + // Check if policy binding exists. + policyBinding := &arv1.ValidatingAdmissionPolicyBinding{} + err = c.Get(ctx, key, policyBinding) + if apierrors.IsNotFound(err) { + return ErrPolicyBindingNotFound + } + if err != nil { + // some unknown error occurred. + return errors.Join(ErrFailedToGetPolicyBinding, err) + } + if !IsPolicyBindingHealthy(policyBinding) { + return ErrPolicyBindingUnhealthy + } + + // All good. + return nil + } +} + +func ReconcileDeploymentConfigRuntimeProtectionPolicy(avp *arv1.ValidatingAdmissionPolicy) { + serviceAccountName := fmt.Sprintf( + "system:serviceaccount:%s:%s", + os.Getenv("POD_NAMESPACE"), + os.Getenv("POD_SERVICE_ACCOUNT"), + ) + + avp.Spec = arv1.ValidatingAdmissionPolicySpec{ + FailurePolicy: ptr.To(arv1.Fail), + MatchConditions: []arv1.MatchCondition{ + { + Name: "exclude-co-operator", + Expression: fmt.Sprintf("request.userInfo.username != %q", serviceAccountName), + }, + }, + MatchConstraints: &arv1.MatchResources{ + ResourceRules: []arv1.NamedRuleWithOperations{ + { + RuleWithOperations: arv1.RuleWithOperations{ + Operations: []arv1.OperationType{ + arv1.Update, + }, + Rule: arv1.Rule{ + APIGroups: []string{crossplanev1beta.Group}, + APIVersions: []string{crossplanev1beta.Version}, + Resources: []string{"deploymentruntimeconfigs"}, // need lower-case plural here, so we need to hardcode it + }, + }, + }, + }, + }, + Validations: getDeploymentRuntimeConfigExpressionBuilder().Build(), + } +} + +func ReconcileDeploymentConfigRuntimeProtectionPolicyBinding(policyName string, + avpd *arv1.ValidatingAdmissionPolicyBinding) { + + avpd.ObjectMeta.Name = policyName + + avpd.Spec = arv1.ValidatingAdmissionPolicyBindingSpec{ + ValidationActions: []arv1.ValidationAction{arv1.Deny}, + PolicyName: policyName, + } + +} + +// groupedFields represents a group of fields which all share +// the same prefix. E.g ".spec.serviceAccountTemplate.metadata" and ".spec.serviceAccountTemplate.annotations" +type groupedFields struct { + // PrefixPath defines the path which all fields should be prefixed by. + // Separate by dots. E.g. ".spec.serviceAccountTemplate.metadata" + // Should not include "object." or "oldObject." + PrefixPath string + + // Fields describes the names of the fields which should be used + Fields []string +} + +// oldObjectCompareValidationBuilder builds expressions to compare all fields with their old +// version of that field. Syntactically, it automatically generates expressions with the +// following syntax for you: +// oldObject.?.? == object.?.? +type oldObjectCompareValidationBuilder struct { + // GroupedFields describes for which groups of fields the validations should be build + GroupedFields []groupedFields + // AdditionalValidations allows to add additional custom validations. These will be + // added to Build() output before the generated validations + AdditionalValidations []arv1.Validation +} + +func (om *oldObjectCompareValidationBuilder) Build() []arv1.Validation { + ret := []arv1.Validation{} + + if om.AdditionalValidations != nil { + ret = append(ret, om.AdditionalValidations...) + } + + for _, expr := range om.GroupedFields { + // turn any paths into optional paths + prefix := strings.ReplaceAll(expr.PrefixPath, ".", ".?") + + for _, field := range expr.Fields { + exp := fmt.Sprintf("oldObject.?%s.?%s == object.?%s.?%s", prefix, field, prefix, field) + ret = append(ret, arv1.Validation{ + Expression: exp, + Message: fmt.Sprintf("field \"%s.%s\" is not allowed to be modified", expr.PrefixPath, field), + }) + } + } + return ret +} + +// String prints out all expressions as a nice list, so they can directly be copied +// into a ValidatingAdmissionPolicy manifest for local testing. +func (om *oldObjectCompareValidationBuilder) String() string { + var sb strings.Builder + vs := om.Build() + + for _, v := range vs { + sb.WriteString(fmt.Sprintf(" - expression: %q\n", v.Expression)) + } + return sb.String() +} + +func getDeploymentRuntimeConfigExpressionBuilder() *oldObjectCompareValidationBuilder { + b := &oldObjectCompareValidationBuilder{} + + b.AdditionalValidations = []arv1.Validation{ + // exit early if another container has been added. + // This also allows us to use .spec.containers[0] below as we can guarantee that there + // is only one container. + { + Expression: "size(object.spec.deploymentTemplate.spec.template.spec.containers) == 1", + Message: "The number of containers is not allowed to be changed", + }, + } + + b.GroupedFields = append(b.GroupedFields, groupedFields{ + PrefixPath: "spec.serviceAccountTemplate.metadata", + Fields: []string{ + "annotations", + "labels", + // "name", name is allowed to be edited + }, + }) + + b.GroupedFields = append(b.GroupedFields, groupedFields{ + PrefixPath: "spec.deploymentTemplate.spec", + Fields: []string{ + "selector", + "replicas", + "minReadySeconds", + "strategy", + "revisionHistoryLimit", + "progressDeadlineSeconds", + "paused", + }, + }) + + b.GroupedFields = append(b.GroupedFields, groupedFields{ + PrefixPath: "spec.deploymentTemplate.spec.template", + Fields: []string{ + "metadata", + }, + }) + + b.GroupedFields = append(b.GroupedFields, groupedFields{ + PrefixPath: "spec.deploymentTemplate.spec.template.spec", + Fields: []string{ + // "args", args is allowed to be edited + "volumes", + "nodeSelector", + "nodeName", + "affinity", + "tolerations", + "schedulerName", + "runtimeClassName", + "priorityClassName", + "priority", + "preemptionPolicy", + "topologySpreadConstraints", + "overhead", + "restartPolicy", + "terminationGracePeriodSeconds", + "activeDeadlineSeconds", + "readinessGates", + "hostname", + "setHostnameAsFQDN", + "subdomain", + "hostAliases", + "dnsConfig", + "dnsPolicy", + "hostNetwork", + "hostPID", + "hostIPC", + "shareProcessNamespace", + "serviceAccountName", + "automountServiceAccountToken", + "securityContext", + }, + }) + + b.GroupedFields = append(b.GroupedFields, groupedFields{ + PrefixPath: "spec.deploymentTemplate.spec.template.spec.containers[0]", + Fields: []string{ + "command", + "env", + "envFrom", + "image", + "imagePullPolicy", + "lifecycle", + "livenessProbe", + "name", + "ports", + "readinessProbe", + "resizePolicy", + "resources", + "restartPolicy", + "securityContext", + "startupProbe", + "stdin", + "stdinOnce", + "terminationMessagePath", + "terminationMessagePolicy", + "tty", + "volumeDevices", + "volumeMounts", + "workingDir", + }, + }) + + return b +} diff --git a/pkg/controlplane/crossplane/policies_test.go b/pkg/controlplane/crossplane/policies_test.go new file mode 100644 index 0000000..a55626c --- /dev/null +++ b/pkg/controlplane/crossplane/policies_test.go @@ -0,0 +1,297 @@ +package crossplane + +import ( + "bufio" + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + arv1 "k8s.io/api/admissionregistration/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +var ( + errFake = errors.New("fake") +) + +func Test_ReconcilePolicy(t *testing.T) { + policy := &arv1.ValidatingAdmissionPolicy{} + err := ReconcilePolicy(Providers, policy) + assert.NoError(t, err) + + assert.Len(t, policy.Spec.MatchConstraints.ResourceRules, 1) + assert.Len(t, policy.Spec.Variables, 4) + for _, v := range policy.Spec.Variables { + assert.Contains(t, v.Expression, Providers) + assert.NotContains(t, v.Expression, Functions) + assert.NotContains(t, v.Expression, Configurations) + } + assert.Len(t, policy.Spec.Validations, 1) + assert.Equal(t, policy.Spec.FailurePolicy, ptr.To(arv1.Fail)) +} + +func Test_ReconcilePolicyBinding(t *testing.T) { + binding := &arv1.ValidatingAdmissionPolicyBinding{} + err := ReconcilePolicyBinding("cpr1", "policy1", binding) + assert.NoError(t, err) + + assert.Equal(t, "cpr1", binding.Spec.ParamRef.Name) + assert.Equal(t, ptr.To(arv1.DenyAction), binding.Spec.ParamRef.ParameterNotFoundAction) + assert.Equal(t, "policy1", binding.Spec.PolicyName) + assert.Contains(t, binding.Spec.ValidationActions, arv1.Deny) +} + +func Test_CheckIfPolicyIsInstalled(t *testing.T) { + testCases := []struct { + desc string + pt PackageType + expected error + interceptorFuncs interceptor.Funcs + initObjs []client.Object + }{ + { + desc: "should fail when validating admission policy is missing", + pt: Providers, + expected: ErrPolicyNotFound, + }, + { + desc: "should fail when validating admission policy is unhealthy", + pt: Providers, + expected: ErrPolicyUnhealthy, + initObjs: []client.Object{ + &arv1.ValidatingAdmissionPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetPolicyName(Providers), + }, + }, + }, + }, + { + desc: "should fail when validating admission policy binding is missing", + pt: Providers, + expected: ErrPolicyBindingNotFound, + initObjs: []client.Object{ + &arv1.ValidatingAdmissionPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetPolicyName(Providers), + Generation: 1, + }, + Status: arv1.ValidatingAdmissionPolicyStatus{ + ObservedGeneration: 1, + }, + }, + }, + }, + { + desc: "should fail when validating admission policy binding is unhealthy", + pt: Providers, + expected: ErrPolicyBindingUnhealthy, + initObjs: []client.Object{ + &arv1.ValidatingAdmissionPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetPolicyName(Providers), + Generation: 1, + }, + Status: arv1.ValidatingAdmissionPolicyStatus{ + ObservedGeneration: 1, + }, + }, + &arv1.ValidatingAdmissionPolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetPolicyName(Providers), + }, + }, + }, + }, + { + desc: "should pass when validating admission policy and binding are present", + pt: Providers, + expected: nil, + initObjs: []client.Object{ + &arv1.ValidatingAdmissionPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetPolicyName(Providers), + Generation: 1, + }, + Status: arv1.ValidatingAdmissionPolicyStatus{ + ObservedGeneration: 1, + }, + }, + &arv1.ValidatingAdmissionPolicyBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetPolicyName(Providers), + Generation: 1, + }, + }, + }, + }, + { + desc: "should fail when fetching validating admission policy fails", + pt: Providers, + expected: ErrFailedToGetPolicy, + interceptorFuncs: interceptor.Funcs{ + //nolint:lll + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*arv1.ValidatingAdmissionPolicy); ok { + return errFake + } + return client.Get(ctx, key, obj, opts...) + }, + }, + }, + { + desc: "should fail when fetching validating admission policy binding fails", + pt: Providers, + expected: ErrFailedToGetPolicyBinding, + initObjs: []client.Object{ + &arv1.ValidatingAdmissionPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: GetPolicyName(Providers), + Generation: 1, + }, + Status: arv1.ValidatingAdmissionPolicyStatus{ + ObservedGeneration: 1, + }, + }, + }, + interceptorFuncs: interceptor.Funcs{ + //nolint:lll + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*arv1.ValidatingAdmissionPolicyBinding); ok { + return errFake + } + return client.Get(ctx, key, obj, opts...) + }, + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + c := fake.NewClientBuilder().WithInterceptorFuncs(tC.interceptorFuncs).WithObjects(tC.initObjs...).Build() + fn := CheckIfPolicyIsInstalled(tC.pt) + assert.NotNil(t, fn) + actual := fn(context.Background(), c) + assert.ErrorIs(t, actual, tC.expected) + }) + } +} + +func TestOldObjectCompareValidationBuilder(t *testing.T) { + testCases := []struct { + desc string + gf []groupedFields + addVal []arv1.Validation + expOut []string + }{ + { + desc: "no additional validations", + gf: []groupedFields{ + { + PrefixPath: "test", + Fields: []string{"myfield1", "myfield2"}, + }, + { + PrefixPath: "test.nested", + Fields: []string{"myfield1", "myfield2"}, + }, + }, + expOut: []string{ + " - expression: \"oldObject.?test.?myfield1 == object.?test.?myfield1\"", + " - expression: \"oldObject.?test.?myfield2 == object.?test.?myfield2\"", + " - expression: \"oldObject.?test.?nested.?myfield1 == object.?test.?nested.?myfield1\"", + " - expression: \"oldObject.?test.?nested.?myfield2 == object.?test.?nested.?myfield2\"", + }, + }, + { + desc: "place additional validations on top", + gf: []groupedFields{ + { + PrefixPath: "test", + Fields: []string{"myfield1", "myfield2"}, + }, + { + PrefixPath: "test.nested", + Fields: []string{"myfield1", "myfield2"}, + }, + }, + addVal: []arv1.Validation{ + { + Expression: "custom == customTest", + }, + }, + expOut: []string{ + " - expression: \"custom == customTest\"", + " - expression: \"oldObject.?test.?myfield1 == object.?test.?myfield1\"", + " - expression: \"oldObject.?test.?myfield2 == object.?test.?myfield2\"", + " - expression: \"oldObject.?test.?nested.?myfield1 == object.?test.?nested.?myfield1\"", + " - expression: \"oldObject.?test.?nested.?myfield2 == object.?test.?nested.?myfield2\"", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + b := &oldObjectCompareValidationBuilder{} + b.GroupedFields = tc.gf + b.AdditionalValidations = tc.addVal + + res := b.String() + scanner := bufio.NewScanner(strings.NewReader(res)) + l := 0 + for scanner.Scan() { + got := scanner.Text() + exp := tc.expOut[l] + if got != exp { + t.Errorf("Err in line %d: exp %q, got %q", l, exp, got) + } + l++ + } + }) + } +} + +func TestGetDeploymentRuntimeConfigExpressionBuilder(t *testing.T) { + allowedFields := []string{ + "spec.deploymentTemplate.spec.template.spec.containers.args", + "spec.serviceAccountTemplate.metadata.name", + } + for i, f := range allowedFields { + allowedFields[i] = strings.ReplaceAll(f, ".", ".?") + } + + b := getDeploymentRuntimeConfigExpressionBuilder() + + // make sure that the allowed fields are not part of the expressions returned from the builder + for _, val := range b.Build() { + for _, af := range allowedFields { + if strings.Contains(val.Expression, af) { + t.Errorf("Expected field %q, not to be part of a validation deny expr, but got %q", af, val.Expression) + } + } + } + +} + +func TestReconcileDeploymentConfigRuntimeProtectionPolicy(t *testing.T) { + expExprLen := 63 + policy := &arv1.ValidatingAdmissionPolicy{} + ReconcileDeploymentConfigRuntimeProtectionPolicy(policy) + + assert.Len(t, policy.Spec.Validations, expExprLen) + assert.Equal(t, policy.Spec.FailurePolicy, ptr.To(arv1.Fail)) +} + +func TestReconcileDeploymentConfigRuntimeProtectionPolicyBinding(t *testing.T) { + polName := "testpolicy" + binding := &arv1.ValidatingAdmissionPolicyBinding{} + ReconcileDeploymentConfigRuntimeProtectionPolicyBinding(polName, binding) + + assert.Equal(t, polName, binding.Spec.PolicyName) + assert.Contains(t, binding.Spec.ValidationActions, arv1.Deny) +} diff --git a/pkg/controlplane/crossplane/providers.go b/pkg/controlplane/crossplane/providers.go new file mode 100644 index 0000000..15f4bc7 --- /dev/null +++ b/pkg/controlplane/crossplane/providers.go @@ -0,0 +1,57 @@ +package crossplane + +import ( + "fmt" + "strings" + + crossplanev1 "github.com/crossplane/crossplane/apis/pkg/v1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + "k8s.io/apimachinery/pkg/types" +) + +const ( + providerPrefix = "provider-" +) + +// EmptyFromConfig converts a CrossplaneProviderConfig to a Crossplane Provider resource. +func EmptyFromConfig(c v1beta1.CrossplaneProviderConfig) (*crossplanev1.Provider, types.NamespacedName) { + return &crossplanev1.Provider{}, types.NamespacedName{ + Name: ProviderNameForProviderConfig(&c), + } +} + +func ReconcileProvider(provider *crossplanev1.Provider, config v1beta1.CrossplaneProviderConfig) error { + utils.SetManagedBy(provider) + provider.Spec.Package = config.Package + provider.Spec.PackagePullPolicy = config.PackagePullPolicy + provider.Spec.PackagePullSecrets = config.PackagePullSecrets + // Set the CrossplaneConfig to use a DeploymentRuntimeConfig with the same name. + // The corresponding DeploymentRuntimeConfig is generated in crossplanedeploymentruntimeconfig_component.go + provider.Spec.RuntimeConfigReference = &crossplanev1.RuntimeConfigReference{ + Name: DeploymentRuntimeNameForProviderConfig(&config)} + return nil +} + +func AddProviderPrefix(providerName string) string { + if strings.HasPrefix(providerName, providerPrefix) { + return providerName + } + return fmt.Sprintf("%s%s", providerPrefix, providerName) +} + +func TrimProviderPrefix(providerName string) string { + return strings.TrimPrefix(providerName, providerPrefix) +} + +// ProviderNameForProviderConfig returns the name of a Provider crossplane manifest for a ProviderConfig. +// It consists of the name of the provider with a prefix. +func ProviderNameForProviderConfig(p *v1beta1.CrossplaneProviderConfig) string { + return AddProviderPrefix(p.Name) +} + +// DeploymentRuntimeNameForProviderConfig returns the name of a DeploymentRuntimeConfig manifest for a ProviderConfig. +// Currently the name is the same as the name of the provider. +func DeploymentRuntimeNameForProviderConfig(p *v1beta1.CrossplaneProviderConfig) string { + return ProviderNameForProviderConfig(p) +} diff --git a/pkg/controlplane/crossplane/providers_test.go b/pkg/controlplane/crossplane/providers_test.go new file mode 100644 index 0000000..e420df2 --- /dev/null +++ b/pkg/controlplane/crossplane/providers_test.go @@ -0,0 +1,113 @@ +package crossplane + +import ( + "testing" + + crossplanev1 "github.com/crossplane/crossplane/apis/pkg/v1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestReconcileProvider(t *testing.T) { + input := v1beta1.CrossplaneProviderConfig{ + Name: "sample", + Version: "v1.5.2", + Package: "repo.example.com/provider/sample:v1.5.2", + PackagePullPolicy: ptr.To(corev1.PullAlways), + PackagePullSecrets: []corev1.LocalObjectReference{ + {Name: "my-secret"}, + }, + PackageRuntimeSpec: crossplanev1.PackageRuntimeSpec{ + RuntimeConfigReference: &crossplanev1.RuntimeConfigReference{ + Name: "custom-runtime-config", + }, + }, + } + + expected := &crossplanev1.Provider{ + ObjectMeta: metav1.ObjectMeta{ + Name: "provider-sample", + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "control-plane-operator", + }, + }, + Spec: crossplanev1.ProviderSpec{ + PackageSpec: crossplanev1.PackageSpec{ + Package: "repo.example.com/provider/sample:v1.5.2", + PackagePullPolicy: ptr.To(corev1.PullAlways), + PackagePullSecrets: []corev1.LocalObjectReference{ + {Name: "my-secret"}, + }, + }, + PackageRuntimeSpec: crossplanev1.PackageRuntimeSpec{ + RuntimeConfigReference: &crossplanev1.RuntimeConfigReference{ + Name: "provider-sample", // expect to match provider name + }, + }, + }, + } + + provider, key := EmptyFromConfig(input) + provider.SetName(key.Name) + provider.SetNamespace(key.Namespace) + + err := ReconcileProvider(provider, input) + assert.NoError(t, err) + assert.Equal(t, expected, provider) +} + +func TestAddProviderPrefix(t *testing.T) { + tt := []struct { + desc string + in string + exp string + }{ + { + desc: "no prefix", + in: "myprovider", + exp: providerPrefix + "myprovider", + }, + { + desc: "don't double prefix", + in: providerPrefix + "myprovider", + exp: providerPrefix + "myprovider", + }, + } + + for _, tc := range tt { + t.Run(tc.desc, func(t *testing.T) { + actual := AddProviderPrefix(tc.in) + assert.Equal(t, tc.exp, actual) + }) + } + +} + +func TestTrimProviderPrefix(t *testing.T) { + tt := []struct { + desc string + in string + exp string + }{ + { + desc: "prefix", + in: providerPrefix + "myprovider", + exp: "myprovider", + }, + { + desc: "no prefix", + in: "myprovider", + exp: "myprovider", + }, + } + + for _, tc := range tt { + t.Run(tc.desc, func(t *testing.T) { + actual := TrimProviderPrefix(tc.in) + assert.Equal(t, tc.exp, actual) + }) + } +} diff --git a/pkg/controlplane/kubeconfiggen/generate.go b/pkg/controlplane/kubeconfiggen/generate.go new file mode 100644 index 0000000..2a72d28 --- /dev/null +++ b/pkg/controlplane/kubeconfiggen/generate.go @@ -0,0 +1,102 @@ +package kubeconfiggen + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + corev1beta1 "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/internal/schemes" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + ErrSANameOrNamespaceEmpty = errors.New("name or namespace in service account reference must not be empty") + ErrRestConfigNil = errors.New("rest config must not be nil") + ErrExpirationInvalid = errors.New("must not specify a duration less than 10 minutes") +) + +type Generator interface { + ForServiceAccount( + ctx context.Context, + cfg *rest.Config, + svcAccRef corev1beta1.ServiceAccountReference, + expiration time.Duration, + ) (*clientcmdapi.Config, *time.Time, error) +} + +type Default struct{} + +func (*Default) ForServiceAccount( + ctx context.Context, + cfg *rest.Config, + svcAccRef corev1beta1.ServiceAccountReference, + expiration time.Duration, +) (*clientcmdapi.Config, *time.Time, error) { + if svcAccRef.Name == "" || svcAccRef.Namespace == "" { + return nil, nil, ErrSANameOrNamespaceEmpty + } + if cfg == nil { + return nil, nil, ErrRestConfigNil + } + if expiration < 10*time.Minute { + return nil, nil, ErrExpirationInvalid + } + + client, err := client.New(cfg, client.Options{Scheme: schemes.Local}) + if err != nil { + return nil, nil, err + } + + sa := &corev1.ServiceAccount{} + if err := client.Get(ctx, types.NamespacedName{Name: svcAccRef.Name, Namespace: svcAccRef.Namespace}, sa); err != nil { + return nil, nil, err + } + + req := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + ExpirationSeconds: ptr.To(int64(expiration.Seconds())), + }, + } + if err := client.SubResource("token").Create(ctx, sa, req); err != nil { + return nil, nil, err + } + + host := cfg.Host + if svcAccRef.Overrides.Host != "" { + host = svcAccRef.Overrides.Host + } + + ctxName := fmt.Sprintf("%s--%s", sa.Name, sa.Namespace) + kubeconfig := clientcmdapi.NewConfig() + kubeconfig.CurrentContext = ctxName + kubeconfig.Clusters[ctxName] = &clientcmdapi.Cluster{ + Server: host, + CertificateAuthorityData: cfg.CAData, + } + kubeconfig.AuthInfos[ctxName] = &clientcmdapi.AuthInfo{ + Token: req.Status.Token, + } + kubeconfig.Contexts[ctxName] = &clientcmdapi.Context{ + Cluster: ctxName, + AuthInfo: ctxName, + } + + if cfg.CAFile != "" { + caBytes, err := os.ReadFile(cfg.CAFile) + if err != nil { + return nil, nil, err + } + kubeconfig.Clusters[ctxName].CertificateAuthorityData = caBytes + } + + return kubeconfig, &req.Status.ExpirationTimestamp.Time, nil +} diff --git a/pkg/controlplane/kubeconfiggen/generate_test.go b/pkg/controlplane/kubeconfiggen/generate_test.go new file mode 100644 index 0000000..60def17 --- /dev/null +++ b/pkg/controlplane/kubeconfiggen/generate_test.go @@ -0,0 +1,213 @@ +package kubeconfiggen + +import ( + "bytes" + "context" + "io" + "log" + "os" + "testing" + "time" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + envtestutil "github.com/openmcp-project/control-plane-operator/pkg/utils/envtest" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +var ( + testSvcAcc = &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kubeconfiggen-test", + Namespace: metav1.NamespaceDefault, + }, + } +) + +func TestMain(m *testing.M) { + if err := envtestutil.Install(); err != nil { + log.Fatalln(err) + } + os.Exit(m.Run()) +} + +func Test_ForServiceAccount(t *testing.T) { + testCases := []struct { + desc string + svcAccRef v1beta1.ServiceAccountReference + tryConnect bool + writeCACertToTempFile bool + hostOverride *string + }{ + { + desc: "should generate kubeconfig and be able to connect to API server", + tryConnect: true, + svcAccRef: v1beta1.ServiceAccountReference{ + Name: "kubeconfiggen-test", + Namespace: metav1.NamespaceDefault, + }, + }, + { + desc: "should generate kubeconfig with host override", + svcAccRef: v1beta1.ServiceAccountReference{ + Name: "kubeconfiggen-test", + Namespace: metav1.NamespaceDefault, + Overrides: v1beta1.KubeconfigOverrides{ + Host: "http://custom-host.example.com", + }, + }, + hostOverride: ptr.To("http://custom-host.example.com"), + }, + { + desc: "should generate kubeconfig with host override", + svcAccRef: v1beta1.ServiceAccountReference{ + Name: "kubeconfiggen-test", + Namespace: metav1.NamespaceDefault, + }, + writeCACertToTempFile: true, + }, + } + + testEnv := &envtest.Environment{} + testCfg, err := testEnv.Start() + if err != nil { + t.Fatal(err) + } + defer func() { + assert.NoError(t, testEnv.Stop()) + }() + + ctx := context.Background() + if err := setupTestServiceAcc(ctx, testCfg); err != nil { + t.Fatal(err) + } + + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + if tC.writeCACertToTempFile { + cleanupFn, err := writeCACertToTempFile(testCfg) + assert.NoError(t, err) + defer func() { + assert.NoError(t, cleanupFn()) + }() + } + + d := &Default{} + apiConfig, expTime, err := d.ForServiceAccount(ctx, testCfg, tC.svcAccRef, 1*time.Hour) + + assert.NoError(t, err) + // check if exp time is in 55-65 min (1h +/- 10min) + assert.WithinRange(t, *expTime, time.Now().Add(55*time.Minute), time.Now().Add(65*time.Minute)) + + saClientConfig := clientcmd.NewDefaultClientConfig(*apiConfig, nil) + saRestConfig, err := saClientConfig.ClientConfig() + assert.NoError(t, err) + + if tC.tryConnect { + testClient, err := client.New(saRestConfig, client.Options{Scheme: clientgoscheme.Scheme}) + assert.NoError(t, err) + + // try to list namespaces, expect authorization error + err = testClient.List(ctx, &corev1.NamespaceList{}) + //nolint:lll + assert.ErrorContains(t, err, "User \"system:serviceaccount:default:kubeconfiggen-test\" cannot list resource \"namespaces\"") + } + + if tC.hostOverride != nil { + assert.Equal(t, *tC.hostOverride, saRestConfig.Host) + } else { + assert.Equal(t, testCfg.Host, saRestConfig.Host) + } + }) + } +} + +func Test_ForServiceAccount_Validation(t *testing.T) { + testCases := []struct { + desc string + cfg *rest.Config + svcAccRef v1beta1.ServiceAccountReference + expiration time.Duration + expected error + }{ + { + desc: "should fail with invalid service account name", + cfg: &rest.Config{}, + svcAccRef: v1beta1.ServiceAccountReference{Name: "", Namespace: "some-namespace"}, + expiration: time.Hour, + expected: ErrSANameOrNamespaceEmpty, + }, + { + desc: "should fail with invalid service account namespace", + cfg: &rest.Config{}, + svcAccRef: v1beta1.ServiceAccountReference{Name: "some-name", Namespace: ""}, + expiration: time.Hour, + expected: ErrSANameOrNamespaceEmpty, + }, + { + desc: "should fail with invalid rest config", + cfg: nil, + svcAccRef: v1beta1.ServiceAccountReference{Name: "some-name", Namespace: "some-namespace"}, + expiration: time.Hour, + expected: ErrRestConfigNil, + }, + { + desc: "should fail with invalid expiration - negative", + cfg: &rest.Config{}, + svcAccRef: v1beta1.ServiceAccountReference{Name: "some-name", Namespace: "some-namespace"}, + expiration: -time.Hour, + expected: ErrExpirationInvalid, + }, + { + desc: "should fail with invalid expiration - too short", + cfg: &rest.Config{}, + svcAccRef: v1beta1.ServiceAccountReference{Name: "some-name", Namespace: "some-namespace"}, + expiration: time.Minute, + expected: ErrExpirationInvalid, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + d := &Default{} + _, _, actual := d.ForServiceAccount(context.Background(), tC.cfg, tC.svcAccRef, tC.expiration) + assert.ErrorIs(t, actual, tC.expected) + }) + } +} + +func setupTestServiceAcc(ctx context.Context, cfg *rest.Config) error { + c, err := client.New(cfg, client.Options{Scheme: clientgoscheme.Scheme}) + if err != nil { + return err + } + + return c.Create(ctx, testSvcAcc) +} + +func writeCACertToTempFile(cfg *rest.Config) (func() error, error) { + file, err := os.CreateTemp("", "kubeconfiggen-test") + if err != nil { + return nil, err + } + + if _, err := io.Copy(file, bytes.NewReader(cfg.CAData)); err != nil { + return nil, err + } + if err := file.Close(); err != nil { + return nil, err + } + + cfg.CAFile = file.Name() + cfg.CAData = nil + + return func() error { + return os.Remove(file.Name()) + }, nil +} diff --git a/pkg/controlplane/secretresolver/secretresolver.go b/pkg/controlplane/secretresolver/secretresolver.go new file mode 100644 index 0000000..c4f3780 --- /dev/null +++ b/pkg/controlplane/secretresolver/secretresolver.go @@ -0,0 +1,85 @@ +package secretresolver + +import ( + "context" + "strings" + + "github.com/openmcp-project/control-plane-operator/pkg/constants" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type ResolveFunc func(urlType UrlSecretType) (*corev1.LocalObjectReference, error) + +// SecretResolver defines methods on resolving fluxSecrets +type SecretResolver interface { + // Start starts an initial run in scanning the Secrets + Start(ctx context.Context) error + + // Resolve returns the LocalObjectReference to a Secret + Resolve(urlType UrlSecretType) (*corev1.LocalObjectReference, error) +} + +// NewFluxSecretResolver creates a new FluxSecretResolver +func NewFluxSecretResolver(c client.Client) SecretResolver { + return &FluxSecretResolver{ + client: c, + secrets: map[UrlSecretType]string{}, + } +} + +// FluxSecretResolver is a struct that implements the SecretResolver interface. +// It resolves the fluxSecrets for the Helm and Docker repositories. +type FluxSecretResolver struct { + client client.Client + secrets map[UrlSecretType]string +} + +// UrlSecretType is a struct that holds the URL and the SecretType +type UrlSecretType struct { + URL string + SecretType corev1.SecretType +} + +var _ SecretResolver = &FluxSecretResolver{} + +// Start implements SecretResolver +func (f *FluxSecretResolver) Start(ctx context.Context) error { + matchingLabels := client.MatchingLabels{ + constants.LabelCopyToCPNamespace: "true", + } + secretList := &corev1.SecretList{} + + // get Secrets with matching labels + err := f.client.List(ctx, secretList, matchingLabels) + if err != nil { + return err + } + + for _, secret := range secretList.Items { + joinedURLs, hasURL := secret.Annotations[constants.AnnotationCredentialsForUrl] + if !hasURL { + continue + } + + urls := strings.Split(joinedURLs, ",") + for _, url := range urls { + urlType := UrlSecretType{ + URL: url, + SecretType: secret.Type, + } + + f.secrets[urlType] = secret.Name + } + } + return nil +} + +// Resolve implements SecretResolver +func (f *FluxSecretResolver) Resolve(urlType UrlSecretType) (*corev1.LocalObjectReference, error) { + secretName, hasSecret := f.secrets[urlType] + if hasSecret { + return &corev1.LocalObjectReference{Name: secretName}, nil + } + return nil, nil +} diff --git a/pkg/controlplane/secretresolver/secretresolver_test.go b/pkg/controlplane/secretresolver/secretresolver_test.go new file mode 100644 index 0000000..74e1c6c --- /dev/null +++ b/pkg/controlplane/secretresolver/secretresolver_test.go @@ -0,0 +1,199 @@ +package secretresolver + +import ( + "context" + "testing" + + "github.com/openmcp-project/control-plane-operator/pkg/constants" + "github.com/stretchr/testify/assert" + assert2 "gotest.tools/v3/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var ( + helmSecret = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "helm-secret", + Namespace: "default", + Annotations: map[string]string{ + constants.AnnotationCredentialsForUrl: "https://test.com", + }, + Labels: map[string]string{ + constants.LabelCopyToCPNamespace: "true", + }, + }, + Type: corev1.SecretTypeBasicAuth, + } + dockerSecret = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "docker-secret", + Namespace: "default", + Annotations: map[string]string{ + constants.AnnotationCredentialsForUrl: "https://test.com", + }, + Labels: map[string]string{ + constants.LabelCopyToCPNamespace: "true", + }, + }, + Type: corev1.SecretTypeDockerConfigJson, + } +) + +//nolint:lll +func TestFluxSecretResolver_Start(t *testing.T) { + tests := []struct { + name string + setup func(ctx context.Context, c client.Client) error + validateStart func(ctx context.Context, t *testing.T, c client.Client, r SecretResolver, err error) error + }{ + { + name: "Start - two secrets found and secrets map is filled - no errors", + setup: func(ctx context.Context, c client.Client) error { + secret1 := helmSecret.DeepCopy() + if err := c.Create(ctx, secret1); err != nil { + return err + } + secret2 := dockerSecret.DeepCopy() + if err := c.Create(ctx, secret2); err != nil { + return err + } + return nil + }, + validateStart: func(ctx context.Context, t *testing.T, c client.Client, r SecretResolver, err error) error { + assert.Len(t, r.(*FluxSecretResolver).secrets, 2) + assert.Equal(t, "helm-secret", r.(*FluxSecretResolver).secrets[UrlSecretType{URL: "https://test.com", SecretType: corev1.SecretTypeBasicAuth}]) + assert.Equal(t, "docker-secret", r.(*FluxSecretResolver).secrets[UrlSecretType{URL: "https://test.com", SecretType: corev1.SecretTypeDockerConfigJson}]) + assert.NoError(t, err) + return nil + }, + }, + { + name: "Start - no secrets found - no errors", + setup: func(ctx context.Context, c client.Client) error { + return nil + }, + validateStart: func(ctx context.Context, t *testing.T, c client.Client, r SecretResolver, err error) error { + assert.Len(t, r.(*FluxSecretResolver).secrets, 0) + assert.NoError(t, err) + return nil + }, + }, + { + name: "Start - one secret found - no errors", + setup: func(ctx context.Context, c client.Client) error { + secret1 := helmSecret.DeepCopy() + if err := c.Create(ctx, secret1); err != nil { + return err + } + return nil + }, + validateStart: func(ctx context.Context, t *testing.T, c client.Client, r SecretResolver, err error) error { + assert.Len(t, r.(*FluxSecretResolver).secrets, 1) + assert.Equal(t, "helm-secret", r.(*FluxSecretResolver).secrets[UrlSecretType{URL: "https://test.com", SecretType: corev1.SecretTypeBasicAuth}]) + assert.NoError(t, err) + return nil + }, + }, + { + name: "Start - one secret found with no url annotation - error", + setup: func(ctx context.Context, c client.Client) error { + secret := helmSecret.DeepCopy() + secret.Annotations = map[string]string{} + if err := c.Create(ctx, secret); err != nil { + return err + } + return nil + }, + validateStart: func(ctx context.Context, t *testing.T, c client.Client, r SecretResolver, err error) error { + assert.Len(t, r.(*FluxSecretResolver).secrets, 0) + assert.NoError(t, err) + return nil + }, + }, + } + for _, tC := range tests { + t.Run(tC.name, func(t *testing.T) { + c := fake.NewClientBuilder().Build() + ctx := context.Background() + if err := tC.setup(ctx, c); err != nil { + t.Fatal(err) + } + + r := NewFluxSecretResolver(c) + + testErr := r.Start(ctx) + + if err := tC.validateStart(ctx, t, c, r, testErr); err != nil { + t.Fatal(err) + } + }) + } +} + +//nolint:lll +func TestFluxSecretResolver_Resolve(t *testing.T) { + tests := []struct { + name string + setup func(ctx context.Context, c client.Client) error + input UrlSecretType + validateResolve func(ctx context.Context, t *testing.T, c client.Client, r SecretResolver, actual *corev1.LocalObjectReference, err error) error + }{ + { + name: "Resolve - secret found - no errors", + setup: func(ctx context.Context, c client.Client) error { + secret1 := helmSecret.DeepCopy() + if err := c.Create(ctx, secret1); err != nil { + return err + } + secret2 := dockerSecret.DeepCopy() + if err := c.Create(ctx, secret2); err != nil { + return err + } + return nil + }, + input: UrlSecretType{URL: "https://test.com", SecretType: corev1.SecretTypeBasicAuth}, + validateResolve: func(ctx context.Context, t *testing.T, c client.Client, r SecretResolver, actual *corev1.LocalObjectReference, err error) error { + assert2.DeepEqual(t, &corev1.LocalObjectReference{Name: "helm-secret"}, actual) + assert.NoError(t, err) + return nil + }, + }, + { + name: "Resolve - secret not found - no errors", + setup: func(ctx context.Context, c client.Client) error { + return nil + }, + input: UrlSecretType{URL: "https://test.com", SecretType: corev1.SecretTypeBasicAuth}, + validateResolve: func(ctx context.Context, t *testing.T, c client.Client, r SecretResolver, actual *corev1.LocalObjectReference, err error) error { + assert.Nil(t, actual) + assert.NoError(t, err) + return nil + }, + }, + } + for _, tC := range tests { + t.Run(tC.name, func(t *testing.T) { + c := fake.NewClientBuilder().Build() + ctx := context.Background() + if err := tC.setup(ctx, c); err != nil { + t.Fatal(err) + } + + r := NewFluxSecretResolver(c) + + if err := r.Start(ctx); err != nil { + t.Fatal(err) + } + + // testing the Resolve function + actual, err := r.Resolve(tC.input) + + if err := tC.validateResolve(ctx, t, c, r, actual, err); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/pkg/controlplane/secrets/pullsecrets.go b/pkg/controlplane/secrets/pullsecrets.go new file mode 100644 index 0000000..4126f69 --- /dev/null +++ b/pkg/controlplane/secrets/pullsecrets.go @@ -0,0 +1,33 @@ +package secrets + +import ( + "context" + + "github.com/openmcp-project/control-plane-operator/pkg/constants" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// AvailablePullSecrets returns a list of secrets that are labeled with `constants.LabelCopyToCP` +// and are of type `kubernetes.io/dockerconfigjson`. +func AvailablePullSecrets(ctx context.Context, c client.Client) ([]types.NamespacedName, error) { + pullSecrets := []types.NamespacedName{} + + matchingLabels := client.MatchingLabels{ + constants.LabelCopyToCP: "true", + } + secretList := &corev1.SecretList{} + err := c.List(ctx, secretList, matchingLabels) + if err != nil { + return nil, err + } + + for _, secret := range secretList.Items { + if secret.Type == corev1.SecretTypeDockerConfigJson { + pullSecrets = append(pullSecrets, client.ObjectKeyFromObject(&secret)) + } + } + + return pullSecrets, nil +} diff --git a/pkg/controlplane/secrets/pullsecrets_test.go b/pkg/controlplane/secrets/pullsecrets_test.go new file mode 100644 index 0000000..0fcbdd8 --- /dev/null +++ b/pkg/controlplane/secrets/pullsecrets_test.go @@ -0,0 +1,91 @@ +package secrets + +import ( + "context" + "errors" + "testing" + + "github.com/openmcp-project/control-plane-operator/pkg/constants" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +var ( + errFake = errors.New("some error") +) + +func Test_AvailablePullSecrets(t *testing.T) { + testCases := []struct { + desc string + initObjs []client.Object + interceptorFuncs interceptor.Funcs + expectedResult []types.NamespacedName + expectedErr error + }{ + { + desc: "should return an empty result when no secrets are present", + expectedResult: []types.NamespacedName{}, + }, + { + desc: "should return error when client fails to list secrets", + interceptorFuncs: interceptor.Funcs{ + List: func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + return errFake + }, + }, + expectedResult: nil, + expectedErr: errFake, + }, + { + desc: "should return only one secret", + initObjs: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pull-secret", + Namespace: corev1.NamespaceDefault, + Labels: map[string]string{ + constants.LabelCopyToCP: "true", + }, + }, + Type: corev1.SecretTypeDockerConfigJson, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pull-secret-without-label", + Namespace: corev1.NamespaceDefault, + }, + Type: corev1.SecretTypeDockerConfigJson, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-other-secret", + Namespace: corev1.NamespaceDefault, + Labels: map[string]string{ + constants.LabelCopyToCP: "true", + }, + }, + Type: corev1.SecretTypeBasicAuth, + }, + }, + expectedResult: []types.NamespacedName{ + { + Name: "pull-secret", + Namespace: corev1.NamespaceDefault, + }, + }, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + fakeClient := fake.NewClientBuilder().WithObjects(tC.initObjs...).WithInterceptorFuncs(tC.interceptorFuncs).Build() + result, err := AvailablePullSecrets(context.Background(), fakeClient) + assert.Equal(t, tC.expectedResult, result) + assert.Equal(t, tC.expectedErr, err) + }) + } +} diff --git a/pkg/controlplane/targetrbac/rbac.go b/pkg/controlplane/targetrbac/rbac.go new file mode 100644 index 0000000..0d9b289 --- /dev/null +++ b/pkg/controlplane/targetrbac/rbac.go @@ -0,0 +1,114 @@ +package targetrbac + +import ( + "context" + "errors" + "fmt" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +var ( + errSANameOrNamespaceEmpty = errors.New("name or namespace in service account reference must not be empty") +) + +// checkServiceAccountReference checks if the ServiceAccountReference is valid +func checkServiceAccountReference(svcAccRef v1beta1.ServiceAccountReference) error { + if svcAccRef.Name == "" || svcAccRef.Namespace == "" { + return errSANameOrNamespaceEmpty + } + return nil +} + +// serviceAccount creates a ServiceAccount for the given ServiceAccountReference +func serviceAccount(svcAccRef v1beta1.ServiceAccountReference) *corev1.ServiceAccount { + return &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: svcAccRef.Name, + Namespace: svcAccRef.Namespace, + }, + AutomountServiceAccountToken: ptr.To(false), + } +} + +// clusterRoleBinding creates a ClusterRoleBinding for the given ServiceAccountReference +func clusterRoleBinding(svcAccRef v1beta1.ServiceAccountReference) *rbacv1.ClusterRoleBinding { + return &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s:%s", svcAccRef.Namespace, svcAccRef.Name), + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "cluster-admin", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: svcAccRef.Name, + Namespace: svcAccRef.Namespace, + }, + }, + } +} + +// Apply creates or updates the ServiceAccount and ClusterRoleBinding for the given ServiceAccountReference +func Apply(ctx context.Context, c client.Client, svcAccRef v1beta1.ServiceAccountReference) error { + if err := checkServiceAccountReference(svcAccRef); err != nil { + return err + } + sa := serviceAccount(svcAccRef) + _, err := controllerutil.CreateOrUpdate(ctx, c, sa, func() error { + desired := serviceAccount(svcAccRef) + + sa.AutomountServiceAccountToken = desired.AutomountServiceAccountToken + + utils.SetManagedBy(sa) + return nil + }) + if err != nil { + return err + } + + crb := clusterRoleBinding(svcAccRef) + _, err = controllerutil.CreateOrUpdate(ctx, c, crb, func() error { + desired := clusterRoleBinding(svcAccRef) + crb.RoleRef = desired.RoleRef + crb.Subjects = desired.Subjects + + utils.SetManagedBy(crb) + return nil + }) + if err != nil { + return err + } + + return nil +} + +func Delete(ctx context.Context, c client.Client) error { + if err := c.DeleteAllOf(ctx, &rbacv1.ClusterRoleBinding{}, utils.IsManaged()); err != nil { + return err + } + + // DeleteAllOf does not work across namespaces + // https://github.com/kubernetes-sigs/controller-runtime/issues/1842#issuecomment-1244857876 + svcAccs := &corev1.ServiceAccountList{} + if err := c.List(ctx, svcAccs, utils.IsManaged()); err != nil { + return err + } + for _, sa := range svcAccs.Items { + if err := c.Delete(ctx, &sa); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/controlplane/targetrbac/rbac_test.go b/pkg/controlplane/targetrbac/rbac_test.go new file mode 100644 index 0000000..1a568dd --- /dev/null +++ b/pkg/controlplane/targetrbac/rbac_test.go @@ -0,0 +1,264 @@ +//nolint:lll +package targetrbac + +import ( + "context" + "errors" + "testing" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func TestApply(t *testing.T) { + tests := []struct { + name string + serviceAccountReference v1beta1.ServiceAccountReference + expectedError *string + validateFunc func(ctx context.Context, client client.Client) error + interceptorFuncs interceptor.Funcs + }{ + { + name: "Check ServiceAccountReference - invalid - error", + serviceAccountReference: v1beta1.ServiceAccountReference{ + Namespace: "", + Name: "", + }, + expectedError: ptr.To(errSANameOrNamespaceEmpty.Error()), + }, + { + name: "Check ServiceAccountReference - valid - no error", + serviceAccountReference: v1beta1.ServiceAccountReference{ + Namespace: "default", + Name: "test", + }, + + expectedError: nil, + }, + { + name: "Check ServiceAccountReference - valid - Create ServiceAccount error", + serviceAccountReference: v1beta1.ServiceAccountReference{ + Namespace: "default", + Name: "test", + }, + interceptorFuncs: interceptor.Funcs{ + Create: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + if _, ok := obj.(*corev1.ServiceAccount); ok { + return errors.New("some create error") + } + return nil + }, + }, + expectedError: ptr.To("some create error"), + }, + { + name: "Check ServiceAccountReference - valid, Create ClusterRoleBinding error", + serviceAccountReference: v1beta1.ServiceAccountReference{ + Namespace: "default", + Name: "test", + }, + interceptorFuncs: interceptor.Funcs{ + Create: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + if _, ok := obj.(*rbacv1.ClusterRoleBinding); ok { + return errors.New("some create error") + } + return nil + }, + }, + expectedError: ptr.To("some create error"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + c := fake.NewClientBuilder().WithInterceptorFuncs(tt.interceptorFuncs).Build() + actualError := Apply(ctx, c, tt.serviceAccountReference) + + if tt.expectedError != nil { + assert.EqualError(t, actualError, *tt.expectedError) + } + + if tt.validateFunc != nil { + if err := tt.validateFunc(ctx, c); err != nil { + t.Errorf("validation failed: %v", err) + } + } + }) + } +} + +func TestDelete(t *testing.T) { + tests := []struct { + name string + serviceAccountReference v1beta1.ServiceAccountReference + objs []client.Object + interceptorFuncs interceptor.Funcs + expectedError *string + validateFunc func(ctx context.Context, client client.Client) error + }{ + { + name: "Check all ClusterRoleBindings deleted with IsManaged label", + serviceAccountReference: v1beta1.ServiceAccountReference{}, + objs: []client.Object{ + &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default:managed", + Labels: utils.IsManaged(), + }, + }, + &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default:not-managed", + }, + }, + }, + expectedError: nil, + validateFunc: func(ctx context.Context, client client.Client) error { + crbList := &rbacv1.ClusterRoleBindingList{} + err := client.List(ctx, crbList) + if err != nil { + return err + } + + if len(crbList.Items) != 1 { + return errors.New("expected 1 ClusterRoleBinding") + } + return nil + }, + }, + { + name: "Check all ServiceAccounts deleted with IsManaged label", + serviceAccountReference: v1beta1.ServiceAccountReference{}, + objs: []client.Object{ + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "managed", + Labels: utils.IsManaged(), + }, + }, + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "not-managed", + }, + }, + }, + expectedError: nil, + validateFunc: func(ctx context.Context, client client.Client) error { + crbList := &corev1.ServiceAccountList{} + err := client.List(ctx, crbList) + if err != nil { + return err + } + + if len(crbList.Items) != 1 { + return errors.New("expected 1 ServiceAccount") + } + return nil + }, + }, + { + name: "Check all ClusterRoleBindings - some delete error", + serviceAccountReference: v1beta1.ServiceAccountReference{}, + objs: []client.Object{ + &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default:managed", + Labels: utils.IsManaged(), + }, + }, + &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default:not-managed", + }, + }, + }, + interceptorFuncs: interceptor.Funcs{ + DeleteAllOf: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.DeleteAllOfOption) error { + if _, ok := obj.(*rbacv1.ClusterRoleBinding); ok { + return errors.New("some delete error") + } + return nil + }, + }, + expectedError: ptr.To("some delete error"), + validateFunc: nil, + }, + { + name: "List all ServiceAccounts - error", + serviceAccountReference: v1beta1.ServiceAccountReference{}, + objs: []client.Object{ + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "managed", + Labels: utils.IsManaged(), + }, + }, + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "not-managed", + }, + }, + }, + interceptorFuncs: interceptor.Funcs{ + List: func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + if _, ok := list.(*corev1.ServiceAccountList); ok { + return errors.New("some list error") + } + return nil + }, + }, + expectedError: ptr.To("some list error"), + validateFunc: nil, + }, + { + name: "Delete all ServiceAccounts - error", + serviceAccountReference: v1beta1.ServiceAccountReference{}, + objs: []client.Object{ + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "managed", + Labels: utils.IsManaged(), + }, + }, + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "not-managed", + }, + }, + }, + interceptorFuncs: interceptor.Funcs{ + Delete: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.DeleteOption) error { + if _, ok := obj.(*corev1.ServiceAccount); ok { + return errors.New("some delete error") + } + return nil + }, + }, + expectedError: ptr.To("some delete error"), + validateFunc: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + c := fake.NewClientBuilder().WithInterceptorFuncs(tt.interceptorFuncs).WithObjects(tt.objs...).Build() + actualError := Delete(ctx, c) + if tt.expectedError != nil { + assert.EqualError(t, actualError, *tt.expectedError) + } + if tt.validateFunc != nil { + if err := tt.validateFunc(ctx, c); err != nil { + t.Errorf("validation failed: %v", err) + } + } + }) + } +} diff --git a/pkg/juggler/component.go b/pkg/juggler/component.go new file mode 100644 index 0000000..dc6d11c --- /dev/null +++ b/pkg/juggler/component.go @@ -0,0 +1,151 @@ +package juggler + +import ( + "context" + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Component is an interface for manageable components. +type Component interface { + // GetName returns the name of the component. + GetName() string + + // GetDependencies returns all dependencies of the component. + GetDependencies() []Component + + // IsEnabled returns if a component is enabled. + IsEnabled() bool + + // Hooks returns the hooks for a component. + Hooks() ComponentHooks + + // IsInstallable returns if a component is installable. + // And if not, the error which describes why it is not installable. + IsInstallable(ctx context.Context) (bool, error) +} + +// StatusVisibility interface defines methods that can be optionally +// implemented by components. A component can implement this interface +// to indicate that the component is to be considered internal. If the +// component does not implement this interface, it is considered as +// external. +type StatusVisibility interface { + // IsStatusInternal method implemented by a component + // indicates that the component is internal or external. + IsStatusInternal() bool +} + +// isComponentInternal function checks whether a component is internal +// or not. A component is internal only if it implements the +// StatusVisibility interface and its IsStatusInternal method returns +// true. +func isComponentInternal(component Component) bool { + c, ok := component.(StatusVisibility) + if ok { + return c.IsStatusInternal() + } + return false +} + +// KeepOnUninstall can be implemented by components that should not be uninstalled, e.g. CRDs. +type KeepOnUninstall interface { + KeepOnUninstall() bool +} + +// ComponentStatus indicates the status of a component. +type ComponentStatus struct { + Name string + IsReady bool + EmitsEvent ComponentEventType +} + +//nolint:lll +var ( + // StatusReconcilerNotFound states that no registered reconciler was able to handle the component. + StatusReconcilerNotFound = ComponentStatus{Name: "ReconcilerNotFound", IsReady: false, EmitsEvent: ComponentEventWarning} + + // StatusObservationFailed states that the current state (installed, healthy, etc.) + // of the component could not be determined. + StatusObservationFailed = ComponentStatus{Name: "ObservationFailed", IsReady: false, EmitsEvent: ComponentEventWarning} + + // StatusUninstallFailed states that a component could not be uninstalled. + StatusUninstallFailed = ComponentStatus{Name: "UninstallFailed", IsReady: false, EmitsEvent: ComponentEventWarning} + + // StatusUninstalled states that a component has been uninstalled. + StatusUninstalled = ComponentStatus{Name: "Uninstalled", IsReady: false, EmitsEvent: ComponentEventNormal} + + // StatusDisabled states that a component is disabled and no action has been taken. + StatusDisabled = ComponentStatus{Name: "Disabled", IsReady: false, EmitsEvent: ComponentEventNone} + + // StatusComponentNotAllowed states that a component is not allowed to be installed. + StatusComponentNotAllowed = ComponentStatus{Name: "ComponentNotAllowed", IsReady: false, EmitsEvent: ComponentEventWarning} + + // StatusDependencyCheckFailed states that a dependency check failed (e.g. dependency not enabled) + StatusDependencyCheckFailed = ComponentStatus{Name: "DependencyCheckFailed", IsReady: false, EmitsEvent: ComponentEventWarning} + + // StatusInstallFailed states that a component could not be installed. + StatusInstallFailed = ComponentStatus{Name: "InstallFailed", IsReady: false, EmitsEvent: ComponentEventWarning} + + // StatusInstalled states that a component has been installed. + StatusInstalled = ComponentStatus{Name: "Installed", IsReady: false, EmitsEvent: ComponentEventNormal} + + // StatusUpdateFailed states that a component could not be updated. + StatusUpdateFailed = ComponentStatus{Name: "UpdateFailed", IsReady: false, EmitsEvent: ComponentEventWarning} + + // StatusUnhealthy states that a component is unhealthy. + StatusUnhealthy = ComponentStatus{Name: "Unhealthy", IsReady: false, EmitsEvent: ComponentEventNone} + + // StatusHealthy states that a component is healthy and no action has been taken. + StatusHealthy = ComponentStatus{Name: "Healthy", IsReady: true, EmitsEvent: ComponentEventNone} +) + +// ComponentResult contains information about an operation performed by the component manager. +type ComponentResult struct { + Component Component + Result ComponentStatus + Message string +} + +// ComponentHooks defines hooks for a Component. +type ComponentHooks struct { + PreUninstall func(ctx context.Context, c client.Client) error + PreInstall func(ctx context.Context, c client.Client) error + PreUpdate func(ctx context.Context, c client.Client) error +} + +// ToCondition converts a ComponentResult to a Kubernetes Condition. +func (r ComponentResult) ToCondition() metav1.Condition { + message := r.Message + status := metav1.ConditionFalse + if r.Result.IsReady { + status = metav1.ConditionTrue + } + reason := r.Result.Name + if reason == "" { + reason = "Unknown" + status = metav1.ConditionFalse + } + + return metav1.Condition{ + Type: r.conditionType(), + Status: status, + Message: message, + LastTransitionTime: metav1.Now(), + Reason: reason, + } +} + +// conditionType is a helper method. It calculates the Condition.Type +// field. For internal components, the value starts with a lowercase +// letter. +func (r ComponentResult) conditionType() string { + componentName := r.Component.GetName() + if isComponentInternal(r.Component) && len(componentName) > 0 { + componentName = strings.ToLower(componentName[0:1]) + componentName[1:] + } + return fmt.Sprintf("%sReady", componentName) +} diff --git a/pkg/juggler/component_test.go b/pkg/juggler/component_test.go new file mode 100644 index 0000000..481e56f --- /dev/null +++ b/pkg/juggler/component_test.go @@ -0,0 +1,133 @@ +package juggler + +import ( + "testing" + + "github.com/stretchr/testify/assert" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type test_externalImplementType struct { + FakeComponent +} + +var _ StatusVisibility = test_externalImplementType{} +var _ Component = test_externalImplementType{} + +func (c test_externalImplementType) IsStatusInternal() bool { + return false +} + +type test_internalImplementType struct { + FakeComponent +} + +var _ StatusVisibility = test_internalImplementType{} + +func (c test_internalImplementType) IsStatusInternal() bool { + return true +} + +func Test_isComponentInternal(t *testing.T) { + // externalImplement implements StatusVisibility but reports + // external + externalImplement := test_externalImplementType{} + + if isComponentInternal(externalImplement) { + t.Errorf("externalImplement should be considered as external") + } + + // internalImplement implements StatusVisibility and reports + // internal + internalImplement := test_internalImplementType{} + + if !isComponentInternal(internalImplement) { + t.Errorf("internalImplement should be considered as internal") + } + + // FakeComponent has an Internal field that works + fakeInternalComponent := FakeComponent{Internal: true} + if !isComponentInternal(fakeInternalComponent) { + t.Errorf("fakeInternalComponent should be considered as internal") + } + +} + +func TestComponentResult_ToCondition(t *testing.T) { + type fields struct { + Component Component + Result ComponentStatus + Message string + } + tests := []struct { + name string + fields fields + want v1.Condition + }{ + { + name: "errored component maps to failing condition", + fields: fields{ + Component: FakeComponent{Name: "Foo"}, + Result: ComponentStatus{Name: "FailedStatus", IsReady: false}, + Message: "boom", + }, + want: v1.Condition{ + Type: "FooReady", + Status: "False", + Reason: "FailedStatus", + Message: "boom", + }, + }, + { + name: "No error component maps to good condition", + fields: fields{ + Component: FakeComponent{Name: "Foo"}, + Result: ComponentStatus{Name: "GoodStatus", IsReady: true}, + }, + want: v1.Condition{ + Type: "FooReady", + Status: "True", + Reason: "GoodStatus", + }, + }, + { + name: "No result returns unknown condition", + fields: fields{ + Component: FakeComponent{Name: "Foo"}, + Result: ComponentStatus{IsReady: false}, + }, + want: v1.Condition{ + Type: "FooReady", + Status: "False", + Reason: "Unknown", + }, + }, + { + name: "Internal component's type starts with lowercase letter", + fields: fields{ + Component: FakeComponent{Name: "Foo", Internal: true}, + Result: ComponentStatus{IsReady: false}, + }, + want: v1.Condition{ + Type: "fooReady", + Status: "False", + Reason: "Unknown", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := ComponentResult{ + Component: tt.fields.Component, + Result: tt.fields.Result, + Message: tt.fields.Message, + } + + is := r.ToCondition() + assert.Equalf(t, tt.want.Type, is.Type, "ToCondition()") + assert.Equalf(t, tt.want.Reason, is.Reason, "ToCondition()") + assert.Equalf(t, tt.want.Message, is.Message, "ToCondition()") + assert.Equalf(t, tt.want.Status, is.Status, "ToCondition()") + }) + } +} diff --git a/pkg/juggler/events.go b/pkg/juggler/events.go new file mode 100644 index 0000000..10aa483 --- /dev/null +++ b/pkg/juggler/events.go @@ -0,0 +1,89 @@ +package juggler + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" +) + +type EventType string + +const ( + // Information only and will not cause any problems + EventNormal EventType = EventType(corev1.EventTypeNormal) + + // These events are to warn that something might go wrong + EventWarning EventType = EventType(corev1.EventTypeWarning) +) + +type ComponentEventType string + +const ( + // No event + ComponentEventNone ComponentEventType = "" + + // Information only and will not cause any problems + ComponentEventNormal ComponentEventType = ComponentEventType(EventNormal) + + // These events are to warn that something might go wrong + ComponentEventWarning ComponentEventType = ComponentEventType(EventWarning) +) + +// EventRecorder is an opinionated record.EventRecorder for the Juggler. +type EventRecorder interface { + Event(eventtype EventType, reason, message string) + + // Eventf is just like Event, but with Sprintf for the message field. + Eventf(eventtype EventType, reason, messageFmt string, args ...interface{}) + + // AnnotatedEventf is just like eventf, but with annotations attached + AnnotatedEventf(annotations map[string]string, eventtype EventType, reason, messageFmt string, args ...interface{}) +} + +// NewEventRecorder returns a new (opinionated) EventRecorder for a given record.EventRecorder and a runtime.Object. +func NewEventRecorder(recorder record.EventRecorder, object runtime.Object) EventRecorder { + return &ObjectEventRecorder{ + recorder: recorder, + object: object, + } +} + +// ObjectEventRecorder is the EventRecorder for the Juggler. It encapsulates the record.EventRecorder interface. +type ObjectEventRecorder struct { + recorder record.EventRecorder + object runtime.Object +} + +func (oer *ObjectEventRecorder) Event(eventtype EventType, reason, message string) { + oer.recorder.Event(oer.object, string(eventtype), reason, message) +} + +func (oer *ObjectEventRecorder) Eventf(eventtype EventType, reason, messageFmt string, args ...interface{}) { + oer.recorder.Eventf(oer.object, string(eventtype), reason, messageFmt, args...) +} + +func (oer *ObjectEventRecorder) AnnotatedEventf(annotations map[string]string, eventtype EventType, reason, + messageFmt string, args ...interface{}) { + + oer.recorder.AnnotatedEventf(oer.object, annotations, string(eventtype), reason, messageFmt, args...) +} + +// ComponentEventRecorder returns a new (opinionated) EventRecorder for a given EventRecorder and a Component. +func NewComponentEventRecorder(recorder EventRecorder, component Component) *ComponentEventRecorder { + return &ComponentEventRecorder{ + recorder: recorder, + component: component, + } +} + +type ComponentEventRecorder struct { + recorder EventRecorder + component Component +} + +func (c *ComponentEventRecorder) Event(status ComponentStatus, message string) { + if status.EmitsEvent == ComponentEventNone { + return + } + c.recorder.Event(EventType(status.EmitsEvent), c.component.GetName()+status.Name, message) +} diff --git a/pkg/juggler/events_test.go b/pkg/juggler/events_test.go new file mode 100644 index 0000000..a117f51 --- /dev/null +++ b/pkg/juggler/events_test.go @@ -0,0 +1,48 @@ +package juggler + +import ( + "testing" + "time" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/record" +) + +func TestObjectEventRecorder_Event(t *testing.T) { + cp := v1beta1.ControlPlane{} + recorder := record.NewFakeRecorder(1) + fer := ObjectEventRecorder{object: &cp, recorder: recorder} + fer.Event("FakeType", "FakeReason", "FakeMessage") + + expected := []string{"FakeType FakeReason FakeMessage"} + c := time.After(wait.ForeverTestTimeout) + for _, e := range expected { + select { + case a := <-recorder.Events: + assert.Equal(t, e, a) + case <-c: + t.Errorf("Expected event %q, got nothing", e) + // continue iterating to print all expected events + } + } +} + +func TestObjectEventRecorder_Eventf(t *testing.T) { + cp := v1beta1.ControlPlane{} + recorder := record.NewFakeRecorder(1) + fer := ObjectEventRecorder{object: &cp, recorder: recorder} + fer.Eventf("FakeType", "FakeReason", "FakeMessage with %s", "FakeAddition") + + expected := []string{"FakeType FakeReason FakeMessage with FakeAddition"} + c := time.After(wait.ForeverTestTimeout) + for _, e := range expected { + select { + case a := <-recorder.Events: + assert.Equal(t, e, a) + case <-c: + t.Errorf("Expected event %q, got nothing", e) + } + } +} diff --git a/pkg/juggler/fakes_test.go b/pkg/juggler/fakes_test.go new file mode 100644 index 0000000..e23bbf0 --- /dev/null +++ b/pkg/juggler/fakes_test.go @@ -0,0 +1,160 @@ +//nolint:lll +package juggler + +import ( + "context" + "reflect" +) + +var _ Component = FakeComponent{} +var _ KeepOnUninstall = FakeComponent{} +var _ StatusVisibility = FakeComponent{} + +type FakeComponent struct { + Name string + Dependencies []Component + Enabled bool + HookFuncs ComponentHooks + Allowed bool + KeepInstalled bool + Internal bool +} + +// KeepOnUninstall implements KeepOnUninstall. +func (f FakeComponent) KeepOnUninstall() bool { + return f.KeepInstalled +} + +func (f FakeComponent) IsInstallable(context.Context) (bool, error) { + return f.Allowed, nil +} + +func (f FakeComponent) GetName() string { + if f.Name != "" { + return f.Name + } + return "FakeComponent" +} + +func (f FakeComponent) GetDependencies() []Component { + return f.Dependencies +} + +func (f FakeComponent) IsEnabled() bool { + return f.Enabled +} + +// Hooks implements Component. +func (f FakeComponent) Hooks() ComponentHooks { + return f.HookFuncs +} + +func (f FakeComponent) IsStatusInternal() bool { + return f.Internal +} + +// --------------------------------------------------------------------------------------------------- + +var _ Component = FakeComponent2{} + +type FakeComponent2 struct { + Name string + Dependencies []Component + Enabled bool + HookFuncs ComponentHooks + Allowed bool +} + +func (f FakeComponent2) IsInstallable(context.Context) (bool, error) { + return f.Allowed, nil +} + +func (f FakeComponent2) GetName() string { + if f.Name != "" { + return f.Name + } + return "FakeComponent2" +} + +func (f FakeComponent2) GetDependencies() []Component { + return f.Dependencies +} + +func (f FakeComponent2) IsEnabled() bool { + return f.Enabled +} + +// Hooks implements Component. +func (f FakeComponent2) Hooks() ComponentHooks { + return f.HookFuncs +} + +// --------------------------------------------------------------------------------------------------- + +var _ ComponentReconciler = FakeReconciler{} +var _ OrphanedComponentsDetector = FakeReconciler{} + +type FakeReconciler struct { + ObserverFunc func(ctx context.Context, component Component) (ComponentObservation, error) + UninstallFunc func(ctx context.Context, component Component) error + UpdateFunc func(ctx context.Context, component Component) error + InstallFunc func(ctx context.Context, component Component) error + PreUninstallFunc func(ctx context.Context, component Component) error + PreInstallFunc func(ctx context.Context, component Component) error + PreUpdateFunc func(ctx context.Context, component Component) error + KnownTypesFunc func() []reflect.Type + DetectOrphanedComponentsFunc func(_ context.Context, configuredComponents []Component) ([]Component, error) +} + +// DetectOrphanedComponents implements OrphanedComponentsDetector. +func (f FakeReconciler) DetectOrphanedComponents(ctx context.Context, configuredComponents []Component) ([]Component, error) { + return f.DetectOrphanedComponentsFunc(ctx, configuredComponents) +} + +// KnownTypes implements ComponentReconciler. +func (f FakeReconciler) KnownTypes() []reflect.Type { + return f.KnownTypesFunc() +} + +// PreUninstall implements juggler.ComponentReconciler. +func (f FakeReconciler) PreUninstall(ctx context.Context, component Component) error { + if f.PreUninstallFunc == nil { + return nil + } + return f.PreUninstallFunc(ctx, component) +} + +// PreInstall implements juggler.ComponentReconciler. +func (f FakeReconciler) PreInstall(ctx context.Context, component Component) error { + if f.PreInstallFunc == nil { + return nil + } + return f.PreInstallFunc(ctx, component) +} + +// PreUpdate implements juggler.ComponentReconciler. +func (f FakeReconciler) PreUpdate(ctx context.Context, component Component) error { + if f.PreUpdateFunc == nil { + return nil + } + return f.PreUpdateFunc(ctx, component) +} + +func (f FakeReconciler) Observe(ctx context.Context, component Component) (ComponentObservation, error) { + return f.ObserverFunc(ctx, component) +} + +func (f FakeReconciler) Uninstall(ctx context.Context, component Component) error { + return f.UninstallFunc(ctx, component) +} + +func (f FakeReconciler) Update(ctx context.Context, component Component) error { + if f.UpdateFunc == nil { + return nil + } + return f.UpdateFunc(ctx, component) +} + +func (f FakeReconciler) Install(ctx context.Context, component Component) error { + return f.InstallFunc(ctx, component) +} diff --git a/pkg/juggler/fluxcd/component_flux.go b/pkg/juggler/fluxcd/component_flux.go new file mode 100644 index 0000000..b83aaf8 --- /dev/null +++ b/pkg/juggler/fluxcd/component_flux.go @@ -0,0 +1,42 @@ +package fluxcd + +import ( + "context" + + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type FluxComponent interface { + juggler.Component + + BuildSourceRepository(ctx context.Context) (SourceAdapter, error) + BuildManifesto(ctx context.Context) (Manifesto, error) +} + +type FluxResource interface { + GetObject() client.Object + GetObjectKey() client.ObjectKey + GetHealthiness() juggler.ResourceHealthiness + + Reconcile(desired FluxResource) error + ApplyDefaults() +} + +type SourceAdapter interface { + FluxResource + + // Empty returns a new adapter that wraps the same type. + // It contains an empty (non-nil) client.Object retaining only the name and namespace. + // This is useful when working with controllerutil.CreateOrUpdate(...). + Empty() SourceAdapter +} + +type Manifesto interface { + FluxResource + + // Empty returns a new adapter that wraps the same type. + // It contains an empty (non-nil) client.Object retaining only the name and namespace. + // This is useful when working with controllerutil.CreateOrUpdate(...). + Empty() Manifesto +} diff --git a/pkg/juggler/fluxcd/fake_test.go b/pkg/juggler/fluxcd/fake_test.go new file mode 100644 index 0000000..a795d73 --- /dev/null +++ b/pkg/juggler/fluxcd/fake_test.go @@ -0,0 +1,143 @@ +package fluxcd + +import ( + "context" + "reflect" + + "github.com/openmcp-project/control-plane-operator/pkg/juggler" +) + +var _ juggler.Component = FakeComponent{} + +type FakeComponent struct { + IsAllowedToBeInstalledFunc func(ctx context.Context) bool + GetNameFunc string + GetDependenciesFunc []juggler.Component + IsEnabledFunc bool + HookFunc juggler.ComponentHooks +} + +func (f FakeComponent) IsInstallable(ctx context.Context) (bool, error) { + return f.IsAllowedToBeInstalledFunc(ctx), nil +} + +func (f FakeComponent) GetName() string { + return f.GetNameFunc +} + +func (f FakeComponent) GetDependencies() []juggler.Component { + return f.GetDependenciesFunc +} + +func (f FakeComponent) IsEnabled() bool { + return f.IsEnabledFunc +} + +// Hooks implements Component. +func (f FakeComponent) Hooks() juggler.ComponentHooks { + return f.HookFunc +} + +// --------------------------------------------------------------------------------------------------- + +var _ FluxComponent = FakeFluxComponent{} + +type FakeFluxComponent struct { + BuildSourceRepositoryFunc func(ctx context.Context) (SourceAdapter, error) + BuildManifestoFunc func(ctx context.Context) (Manifesto, error) + IsAllowedToBeInstalledFunc func(ctx context.Context) bool + GetNameFunc string + GetDependenciesFunc []juggler.Component + IsEnabledFunc bool + HookFunc juggler.ComponentHooks +} + +func (f FakeFluxComponent) BuildSourceRepository(ctx context.Context) (SourceAdapter, error) { + return f.BuildSourceRepositoryFunc(ctx) +} + +func (f FakeFluxComponent) BuildManifesto(ctx context.Context) (Manifesto, error) { + return f.BuildManifestoFunc(ctx) +} + +func (f FakeFluxComponent) IsInstallable(ctx context.Context) (bool, error) { + return f.IsAllowedToBeInstalledFunc(ctx), nil +} + +func (f FakeFluxComponent) GetName() string { + return f.GetNameFunc +} + +func (f FakeFluxComponent) GetDependencies() []juggler.Component { + return f.GetDependenciesFunc +} + +func (f FakeFluxComponent) IsEnabled() bool { + return f.IsEnabledFunc +} + +// Hooks implements Component. +func (f FakeFluxComponent) Hooks() juggler.ComponentHooks { + return f.HookFunc +} + +// --------------------------------------------------------------------------------------------------- + +var _ juggler.ComponentReconciler = FakeReconciler{} + +type FakeReconciler struct { + ObserverFunc func(ctx context.Context, component juggler.Component) (juggler.ComponentObservation, error) + UninstallFunc func(ctx context.Context, component juggler.Component) error + UpdateFunc func(ctx context.Context, component juggler.Component) error + InstallFunc func(ctx context.Context, component juggler.Component) error + PreUninstallFunc func(ctx context.Context, component juggler.Component) error + PreInstallFunc func(ctx context.Context, component juggler.Component) error + PreUpdateFunc func(ctx context.Context, component juggler.Component) error + KnownTypesFunc func() []reflect.Type +} + +// KnownTypes implements juggler.ComponentReconciler. +func (f FakeReconciler) KnownTypes() []reflect.Type { + return f.KnownTypesFunc() +} + +// PreUninstall implements juggler.ComponentReconciler. +func (f FakeReconciler) PreUninstall(ctx context.Context, component juggler.Component) error { + if f.PreUninstallFunc == nil { + return nil + } + return f.PreUninstallFunc(ctx, component) +} + +// PreInstall implements juggler.ComponentReconciler. +func (f FakeReconciler) PreInstall(ctx context.Context, component juggler.Component) error { + if f.PreInstallFunc == nil { + return nil + } + return f.PreInstallFunc(ctx, component) +} + +// PreUpdate implements juggler.ComponentReconciler. +func (f FakeReconciler) PreUpdate(ctx context.Context, component juggler.Component) error { + if f.PreUpdateFunc == nil { + return nil + } + return f.PreUpdateFunc(ctx, component) +} + +//nolint:lll +func (f FakeReconciler) Observe(ctx context.Context, component juggler.Component) (juggler.ComponentObservation, error) { + return f.ObserverFunc(ctx, component) +} + +func (f FakeReconciler) Uninstall(ctx context.Context, component juggler.Component) error { + return f.UninstallFunc(ctx, component) +} + +func (f FakeReconciler) Update(ctx context.Context, component juggler.Component) error { + return f.UpdateFunc(ctx, component) +} + +func (f FakeReconciler) Install(ctx context.Context, component juggler.Component) error { + return f.InstallFunc(ctx, component) +} diff --git a/pkg/juggler/fluxcd/flux_manifesto.go b/pkg/juggler/fluxcd/flux_manifesto.go new file mode 100644 index 0000000..11d8bab --- /dev/null +++ b/pkg/juggler/fluxcd/flux_manifesto.go @@ -0,0 +1,103 @@ +package fluxcd + +import ( + "errors" + "time" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + fluxmeta "github.com/fluxcd/pkg/apis/meta" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + errNotAHelmReleaseManifesto = errors.New("FluxResource is not a HelmReleaseManifesto") +) + +var _ Manifesto = &HelmReleaseManifesto{} + +type HelmReleaseManifesto struct { + Manifest *helmv2.HelmRelease +} + +// Reconcile implements Manifesto. +func (h *HelmReleaseManifesto) Reconcile(desired FluxResource) error { + desiredManifesto, ok := desired.(*HelmReleaseManifesto) + if !ok { + return errNotAHelmReleaseManifesto + } + + preserved := h.Manifest.Spec.DeepCopy() + h.Manifest.Spec = desiredManifesto.Manifest.Spec + // Give suspension precedence + h.Manifest.Spec.Suspend = preserved.Suspend + return nil +} + +// Empty implements Manifesto. +func (h *HelmReleaseManifesto) Empty() Manifesto { + return &HelmReleaseManifesto{&helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: h.Manifest.Name, + Namespace: h.Manifest.Namespace, + }, + }} +} + +// GetHealthiness implements Manifesto. +func (h *HelmReleaseManifesto) GetHealthiness() juggler.ResourceHealthiness { + cond := apimeta.FindStatusCondition(h.Manifest.Status.Conditions, fluxmeta.ReadyCondition) + if cond == nil { + return juggler.ResourceHealthiness{ + Healthy: false, + Message: msgReadyNotPresent, + } + } + return juggler.ResourceHealthiness{ + Healthy: cond.Status == metav1.ConditionTrue, + Message: cond.Message, + } +} + +func (h *HelmReleaseManifesto) GetObjectKey() client.ObjectKey { + return client.ObjectKey{ + Namespace: h.Manifest.Namespace, + Name: h.Manifest.Name, + } +} + +func (h *HelmReleaseManifesto) GetObject() client.Object { + return h.Manifest +} + +func (h *HelmReleaseManifesto) ApplyDefaults() { + // This usually does nothing but we can keep it here in case Flux resources will have + // a defaulting func in the future. + scheme.Default(h.Manifest) + + h.Manifest.Spec.Interval = metav1.Duration{Duration: 5 * time.Minute} + + if h.Manifest.Spec.Install == nil { + h.Manifest.Spec.Install = &helmv2.Install{} + } + + h.Manifest.Spec.Install.CreateNamespace = true + + if h.Manifest.Spec.Install.Remediation == nil { + h.Manifest.Spec.Install.Remediation = &helmv2.InstallRemediation{} + } + + h.Manifest.Spec.Install.Remediation.Retries = -1 + + if h.Manifest.Spec.Upgrade == nil { + h.Manifest.Spec.Upgrade = &helmv2.Upgrade{} + } + + if h.Manifest.Spec.Upgrade.Remediation == nil { + h.Manifest.Spec.Upgrade.Remediation = &helmv2.UpgradeRemediation{} + } + + h.Manifest.Spec.Upgrade.Remediation.Retries = -1 +} diff --git a/pkg/juggler/fluxcd/flux_manifesto_test.go b/pkg/juggler/fluxcd/flux_manifesto_test.go new file mode 100644 index 0000000..acfe801 --- /dev/null +++ b/pkg/juggler/fluxcd/flux_manifesto_test.go @@ -0,0 +1,103 @@ +//nolint:dupl +package fluxcd + +import ( + "testing" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + fluxmeta "github.com/fluxcd/pkg/apis/meta" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestHelmReleaseManifesto_GetHealthiness(t *testing.T) { + tests := []struct { + name string + manifesto HelmReleaseManifesto + expected juggler.ResourceHealthiness + }{ + { + name: "HelmReleaseManifesto - Status Condition nil - Ready condition not present", + manifesto: HelmReleaseManifesto{ + Manifest: &helmv2.HelmRelease{ + Status: helmv2.HelmReleaseStatus{ + Conditions: nil, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: false, + Message: msgReadyNotPresent, + }, + }, + { + name: "HelmReleaseManifesto - Status Condition Ready not found", + manifesto: HelmReleaseManifesto{ + Manifest: &helmv2.HelmRelease{ + Status: helmv2.HelmReleaseStatus{ + Conditions: []metav1.Condition{ + { + Type: "NotReady", // can not be found + Status: metav1.ConditionTrue, + Message: "The release is ready", + }, + }, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: false, + Message: msgReadyNotPresent, + }, + }, + { + name: "HelmReleaseManifesto - Status Condition Ready = True", + manifesto: HelmReleaseManifesto{ + Manifest: &helmv2.HelmRelease{ + Status: helmv2.HelmReleaseStatus{ + Conditions: []metav1.Condition{ + { + Type: fluxmeta.ReadyCondition, + Status: metav1.ConditionTrue, + Message: "The release is ready", + }, + }, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: true, + Message: "The release is ready", + }, + }, + { + name: "HelmReleaseManifesto - Status Condition Ready = False", + manifesto: HelmReleaseManifesto{ + Manifest: &helmv2.HelmRelease{ + Status: helmv2.HelmReleaseStatus{ + Conditions: []metav1.Condition{ + { + Type: fluxmeta.ReadyCondition, + Status: metav1.ConditionFalse, + Message: "The release is not ready", + }, + }, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: false, + Message: "The release is not ready", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := tt.manifesto.GetHealthiness() + if !assert.Equal(t, tt.expected, actual) { + t.Errorf("HelmReleaseManifesto.GetHealthiness() = %v, want %v", actual, tt.expected) + } + }) + } +} diff --git a/pkg/juggler/fluxcd/flux_reconciler.go b/pkg/juggler/fluxcd/flux_reconciler.go new file mode 100644 index 0000000..336b86f --- /dev/null +++ b/pkg/juggler/fluxcd/flux_reconciler.go @@ -0,0 +1,272 @@ +package fluxcd + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + + "github.com/go-logr/logr" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const ( + labelComponentName = "controlplane.core.orchestrate.cloud.sap/component" +) + +var ( + errNotFluxComponent = errors.New("not a flux component") +) + +var _ juggler.ComponentReconciler = &FluxReconciler{} + +func NewFluxReconciler(logger logr.Logger, localClient client.Client, remoteClient client.Client) *FluxReconciler { + return &FluxReconciler{ + logger: logger, + localClient: localClient, + remoteClient: remoteClient, + knownTypes: sets.Set[reflect.Type]{}, + } +} + +type FluxReconciler struct { + localClient client.Client + remoteClient client.Client + logger logr.Logger + knownTypes sets.Set[reflect.Type] +} + +// KnownTypes implements juggler.ComponentReconciler. +func (r *FluxReconciler) KnownTypes() []reflect.Type { + return r.knownTypes.UnsortedList() +} + +func (r *FluxReconciler) RegisterType(comps ...FluxComponent) { + for _, c := range comps { + cType := reflect.TypeOf(c) + r.knownTypes.Insert(cType) + } +} + +//nolint:lll +func (r *FluxReconciler) Observe(ctx context.Context, component juggler.Component) (juggler.ComponentObservation, error) { + fluxComponent, ok := component.(FluxComponent) + if !ok { + return juggler.ComponentObservation{}, errNotFluxComponent + } + + sourceObservation, errSource := r.observeSource(ctx, fluxComponent) + if errSource != nil { + return juggler.ComponentObservation{}, errSource + } + + manifestoObservation, errManifest := r.observeManifesto(ctx, fluxComponent) + if errManifest != nil { + return juggler.ComponentObservation{}, errManifest + } + + return juggler.ComponentObservation{ + ResourceExists: sourceObservation.ResourceExists || manifestoObservation.ResourceExists, + ResourceHealthiness: aggregateHealthiness(manifestoObservation.ResourceHealthiness, sourceObservation.ResourceHealthiness), + }, nil +} + +//nolint:lll +func (r *FluxReconciler) observeManifesto(ctx context.Context, fluxComponent FluxComponent) (juggler.ComponentObservation, error) { + desiredManifesto, err := fluxComponent.BuildManifesto(ctx) + if err != nil { + return juggler.ComponentObservation{}, err + } + + actualManifest := desiredManifesto.Empty() + + errGetM := r.localClient.Get(ctx, actualManifest.GetObjectKey(), actualManifest.GetObject()) + if apierrors.IsNotFound(errGetM) { + // CAUTION: NotFound is not an error!!! + return juggler.ComponentObservation{ResourceExists: false}, nil + } + + return juggler.ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: actualManifest.GetHealthiness(), + }, nil +} + +//nolint:lll +func (r *FluxReconciler) observeSource(ctx context.Context, fluxComponent FluxComponent) (juggler.ComponentObservation, error) { + desiredSource, err := fluxComponent.BuildSourceRepository(ctx) + if err != nil { + return juggler.ComponentObservation{}, err + } + + actualSource := desiredSource.Empty() + + errGetS := r.localClient.Get(ctx, actualSource.GetObjectKey(), actualSource.GetObject()) + if apierrors.IsNotFound(errGetS) { + // CAUTION: NotFound is not an error!!! + return juggler.ComponentObservation{ResourceExists: false}, nil + } + + // resource exists, check if it is healthy, return observation + return juggler.ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: actualSource.GetHealthiness(), + }, nil +} + +func (r *FluxReconciler) Uninstall(ctx context.Context, component juggler.Component) error { + fluxComponent, ok := component.(FluxComponent) + if !ok { + return errNotFluxComponent + } + + errSource := r.deleteSource(ctx, fluxComponent) + if errSource != nil { + return errSource + } + + return r.deleteManifesto(ctx, fluxComponent) +} + +func (r *FluxReconciler) deleteSource(ctx context.Context, fluxComponent FluxComponent) error { + desiredRepository, err := fluxComponent.BuildSourceRepository(ctx) + if err != nil { + return err + } + + resourceRepository := desiredRepository.GetObject() + + err = r.localClient.Delete(ctx, resourceRepository) + return client.IgnoreNotFound(err) +} + +func (r *FluxReconciler) deleteManifesto(ctx context.Context, fluxComponent FluxComponent) error { + desiredManifesto, err := fluxComponent.BuildManifesto(ctx) + if err != nil { + return err + } + + resourceManifesto := desiredManifesto.GetObject() + + err = r.localClient.Delete(ctx, resourceManifesto) + return client.IgnoreNotFound(err) +} + +func (r *FluxReconciler) Update(ctx context.Context, component juggler.Component) error { + return r.installOrUpdate(ctx, component) +} + +func (r *FluxReconciler) Install(ctx context.Context, component juggler.Component) error { + return r.installOrUpdate(ctx, component) +} + +func (r *FluxReconciler) PreUninstall(ctx context.Context, component juggler.Component) error { + if component.Hooks().PreUninstall != nil { + return component.Hooks().PreUninstall(ctx, r.remoteClient) + } + return nil +} + +// PreInstall implements juggler.ComponentReconciler. +func (r *FluxReconciler) PreInstall(ctx context.Context, component juggler.Component) error { + if component.Hooks().PreInstall != nil { + return component.Hooks().PreInstall(ctx, r.remoteClient) + } + return nil +} + +// PreUpdate implements juggler.ComponentReconciler. +func (r *FluxReconciler) PreUpdate(ctx context.Context, component juggler.Component) error { + if component.Hooks().PreUpdate != nil { + return component.Hooks().PreUpdate(ctx, r.remoteClient) + } + return nil +} + +func (r *FluxReconciler) installOrUpdate(ctx context.Context, component juggler.Component) error { + fluxComponent, ok := component.(FluxComponent) + if !ok { + return errNotFluxComponent + } + + errSource := r.installOrUpdateSource(ctx, fluxComponent) + if errSource != nil { + return errSource + } + + return r.installOrUpdateManifesto(ctx, fluxComponent) +} + +func (r *FluxReconciler) installOrUpdateManifesto(ctx context.Context, fluxComponent FluxComponent) error { + desired, errMan := fluxComponent.BuildManifesto(ctx) + if errMan != nil { + return errMan + } + + actual := desired.Empty() + obj := actual.GetObject() + result, errCU := controllerutil.CreateOrUpdate(ctx, r.localClient, obj, func() error { + if err := actual.Reconcile(desired); err != nil { + return err + } + + utils.SetManagedBy(obj) + utils.SetLabel(obj, labelComponentName, fluxComponent.GetName()) + return nil + }) + + if errCU != nil { + return errCU + } + + r.logger.Info(fmt.Sprintf("%T %s/%s %s", obj, obj.GetNamespace(), obj.GetName(), result)) + + return nil +} + +func (r *FluxReconciler) installOrUpdateSource(ctx context.Context, fluxComponent FluxComponent) error { + desired, err := fluxComponent.BuildSourceRepository(ctx) + if err != nil { + return err + } + + actual := desired.Empty() + obj := actual.GetObject() + result, errCU := controllerutil.CreateOrUpdate(ctx, r.localClient, obj, func() error { + if err := actual.Reconcile(desired); err != nil { + return err + } + + utils.SetManagedBy(obj) + utils.SetLabel(obj, labelComponentName, fluxComponent.GetName()) + return nil + }) + + if errCU != nil { + return errCU + } + + r.logger.Info(fmt.Sprintf("%T %s/%s %s", obj, obj.GetNamespace(), obj.GetName(), result)) + + return nil +} + +func aggregateHealthiness(states ...juggler.ResourceHealthiness) juggler.ResourceHealthiness { + result := juggler.ResourceHealthiness{Healthy: true} + + for _, rh := range states { + if !rh.Healthy { + result.Healthy = false + result.Message = fmt.Sprintf("%s\n%s", result.Message, rh.Message) + } + } + + result.Message = strings.TrimSpace(result.Message) + return result +} diff --git a/pkg/juggler/fluxcd/flux_reconciler_test.go b/pkg/juggler/fluxcd/flux_reconciler_test.go new file mode 100644 index 0000000..939b25e --- /dev/null +++ b/pkg/juggler/fluxcd/flux_reconciler_test.go @@ -0,0 +1,753 @@ +//nolint:lll,dupl +package fluxcd + +import ( + "context" + "errors" + "reflect" + "testing" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/go-logr/logr" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var errBoom = errors.New("boom") + +func TestNewFluxReconciler(t *testing.T) { + tests := []struct { + name string + logger logr.Logger + localClient client.Client + remoteClient client.Client + expected *FluxReconciler + }{ + { + name: "New empty FluxReconciler", + localClient: nil, + remoteClient: nil, + logger: logr.Logger{}, + expected: &FluxReconciler{ + logger: logr.Logger{}, + localClient: nil, + remoteClient: nil, + knownTypes: sets.Set[reflect.Type]{}, + }, + }, + { + name: "New FluxReconciler with localClient and remoteClient", + localClient: fake.NewFakeClient(), + remoteClient: fake.NewFakeClient(), + logger: logr.Logger{}, + expected: &FluxReconciler{ + logger: logr.Logger{}, + localClient: fake.NewFakeClient(), + remoteClient: fake.NewFakeClient(), + knownTypes: sets.Set[reflect.Type]{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := NewFluxReconciler(tt.logger, tt.localClient, tt.remoteClient) + if !assert.Equal(t, actual, tt.expected) { + t.Errorf("NewReconciler() = %v, want %v", actual, tt.expected) + } + }) + } +} + +func TestFluxReconciler_Observe(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + localObjects []client.Object + expectedObservation juggler.ComponentObservation + expectedError error + }{ + { + name: "Error not a FluxComponent", + obj: FakeComponent{}, + localObjects: nil, + expectedObservation: juggler.ComponentObservation{}, + expectedError: errNotFluxComponent, + }, + { + name: "Error Source", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return nil, errBoom + }}, + localObjects: nil, + expectedObservation: juggler.ComponentObservation{ + ResourceExists: false, + }, + expectedError: errBoom, + }, + { + name: "Source and Manifesto not found - resource does not exist, not healthy", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return &HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + BuildManifestoFunc: func(ctx context.Context) (Manifesto, error) { + return &HelmReleaseManifesto{ + Manifest: &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + }, + localObjects: []client.Object{}, // no objects in the cluster + expectedObservation: juggler.ComponentObservation{ + ResourceExists: false, + ResourceHealthiness: juggler.ResourceHealthiness{ + Healthy: false, + Message: "", + }, + }, + expectedError: nil, + }, + { + name: "Source exists, BuildManifesto error", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return &HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + BuildManifestoFunc: func(ctx context.Context) (Manifesto, error) { + return nil, errBoom + + }}, + localObjects: []client.Object{ + &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, + expectedObservation: juggler.ComponentObservation{}, + expectedError: errBoom, + }, + { + name: "Source exists, Manifesto not found - resource exists, not healthy", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return &HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + BuildManifestoFunc: func(ctx context.Context) (Manifesto, error) { + return &HelmReleaseManifesto{ + Manifest: &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + + }}, + localObjects: []client.Object{ + &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, + expectedObservation: juggler.ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: juggler.ResourceHealthiness{ + Healthy: false, + Message: "Unable to check healthiness. Ready condition is not present.", + }, + }, + expectedError: nil, + }, + { + name: "Source exists, Manifesto exists - unable to check healthiness", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return &HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + BuildManifestoFunc: func(ctx context.Context) (Manifesto, error) { + return &HelmReleaseManifesto{ + Manifest: &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + }, + localObjects: []client.Object{ + &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, + expectedObservation: juggler.ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: juggler.ResourceHealthiness{ + Healthy: false, + Message: "Unable to check healthiness. Ready condition is not present.\nUnable to check healthiness. Ready condition is not present.", + }, + }, + expectedError: nil, + }, + { + name: "Source exists, Manifesto exists, Source is out of date, Manifesto is up to date - resource exists, not up to date and not healthy", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return &HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + BuildManifestoFunc: func(ctx context.Context) (Manifesto, error) { + return &HelmReleaseManifesto{ + Manifest: &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + }, + localObjects: []client.Object{ + &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: "out-of-date-maker", + }, + Status: sourcev1.HelmRepositoryStatus{ + Conditions: []metav1.Condition{ + { + Type: "Ready", + Status: metav1.ConditionTrue, + }, + }, + }, + }, + &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, + expectedObservation: juggler.ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: juggler.ResourceHealthiness{ + Healthy: false, + Message: "Unable to check healthiness. Ready condition is not present.", // expected: because manifesto is not ready in status + }, + }, + expectedError: nil, + }, + { + name: "Source exists, Manifesto exists, Source is up to date, Manifesto is out of date - resource exists, not up to date and not healthy", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return &HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + BuildManifestoFunc: func(ctx context.Context) (Manifesto, error) { + return &HelmReleaseManifesto{ + Manifest: &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + }, + localObjects: []client.Object{ + &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: helmv2.HelmReleaseSpec{ + TargetNamespace: "out-of-date-maker", + }, + Status: helmv2.HelmReleaseStatus{ + Conditions: []metav1.Condition{ + { + Type: "Ready", + Status: metav1.ConditionTrue, + }, + }, + }, + }, + }, + expectedObservation: juggler.ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: juggler.ResourceHealthiness{ + Healthy: false, + Message: "Unable to check healthiness. Ready condition is not present.", // expected: because source is not ready in status + }, + }, + expectedError: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + r := NewFluxReconciler(logr.Logger{}, fake.NewClientBuilder().WithScheme(scheme).WithObjects(tt.localObjects...).Build(), nil) + actualObservation, actualError := r.Observe(context.TODO(), tt.obj) + if !assert.Equal(t, tt.expectedObservation, actualObservation) { + t.Errorf("ObjectReconciler.Observe() = %v, want %v", actualObservation, tt.expectedObservation) + } + if !assert.Equal(t, tt.expectedError, actualError) { + t.Errorf("ObjectReconciler.Observe() = %v, want %v", actualError, tt.expectedError) + } + }) + } +} + +func TestFluxReconciler_PreUninstall(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + expected error + }{ + { + name: "FakeFluxComponent no PreUninstall Hooks - no error", + obj: FakeFluxComponent{ + HookFunc: juggler.ComponentHooks{ + PreUninstall: nil, + }, + }, + expected: nil, + }, + { + name: "FakeFluxComponent PreUninstall error", + obj: FakeFluxComponent{ + HookFunc: juggler.ComponentHooks{ + PreUninstall: func(ctx context.Context, client client.Client) error { + return errBoom + }, + }, + }, + expected: errBoom, + }, + { + name: "FakeFluxComponent PreUninstall no error", + obj: FakeFluxComponent{ + HookFunc: juggler.ComponentHooks{ + PreUninstall: func(ctx context.Context, client client.Client) error { + return nil + }, + }, + }, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &FluxReconciler{} + actual := r.PreUninstall(context.TODO(), tt.obj) + if !errors.Is(actual, tt.expected) { + t.Errorf("ObjectReconciler.PreUninstall() = %v, want %v", actual, tt.expected) + } + }) + } +} + +func TestFluxReconciler_PreInstall(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + expected error + }{ + { + name: "FakeFluxComponent no PreInstall Hooks - no error", + obj: FakeFluxComponent{ + HookFunc: juggler.ComponentHooks{ + PreInstall: nil, + }, + }, + expected: nil, + }, + { + name: "FakeFluxComponent PreInstall error", + obj: FakeFluxComponent{ + HookFunc: juggler.ComponentHooks{ + PreInstall: func(ctx context.Context, client client.Client) error { + return errBoom + }, + }, + }, + expected: errBoom, + }, + { + name: "FakeFluxComponent PreInstall no error", + obj: FakeFluxComponent{ + HookFunc: juggler.ComponentHooks{ + PreInstall: func(ctx context.Context, client client.Client) error { + return nil + }, + }, + }, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &FluxReconciler{} + actual := r.PreInstall(context.TODO(), tt.obj) + if !errors.Is(actual, tt.expected) { + t.Errorf("ObjectReconciler.PreInstall() = %v, want %v", actual, tt.expected) + } + }) + } +} + +func TestFluxReconciler_PreUpdate(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + expected error + }{ + { + name: "FakeFluxComponent no PreUpdate Hooks - no error", + obj: FakeFluxComponent{ + HookFunc: juggler.ComponentHooks{ + PreUpdate: nil, + }, + }, + expected: nil, + }, + { + name: "FakeFluxComponent PreUpdate error", + obj: FakeFluxComponent{ + HookFunc: juggler.ComponentHooks{ + PreUpdate: func(ctx context.Context, client client.Client) error { + return errBoom + }, + }, + }, + expected: errBoom, + }, + { + name: "FakeFluxComponent PreUpdate no error", + obj: FakeFluxComponent{ + HookFunc: juggler.ComponentHooks{ + PreUpdate: func(ctx context.Context, client client.Client) error { + return nil + }, + }, + }, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &FluxReconciler{} + actual := r.PreUpdate(context.TODO(), tt.obj) + if !errors.Is(actual, tt.expected) { + t.Errorf("ObjectReconciler.PreUpdate() = %v, want %v", actual, tt.expected) + } + }) + } +} + +func TestFluxReconciler_Uninstall(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + localObjects []client.Object + expected error + }{ + { + name: "Not a FluxComponent", + obj: FakeComponent{}, + localObjects: nil, + expected: errNotFluxComponent, + }, + { + name: "Error Build Source Repository", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return nil, errBoom + }, + }, + localObjects: nil, + expected: errBoom, + }, + { + name: "Build Source Repository successful - delete Source successful, Build Manifesto error", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return &HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + BuildManifestoFunc: func(ctx context.Context) (Manifesto, error) { + return nil, errBoom + }, + }, + localObjects: []client.Object{ + &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, + expected: errBoom, + }, + { + name: "Build Manifesto successful - delete Source successful, delete Manifesto successful", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return &HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + BuildManifestoFunc: func(ctx context.Context) (Manifesto, error) { + return &HelmReleaseManifesto{ + Manifest: &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, nil + }, + }, + localObjects: []client.Object{ + &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewFluxReconciler(logr.Logger{}, fake.NewClientBuilder().WithScheme(scheme).WithObjects(tt.localObjects...).Build(), nil) + actual := r.Uninstall(context.TODO(), tt.obj) + if !errors.Is(actual, tt.expected) { + t.Errorf("ObjectReconciler.Uninstall() = %v, want %v", actual, tt.expected) + } + }) + } +} + +func TestFluxReconciler_Install(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + validateFunc func(ctx context.Context, c client.Client, component juggler.Component) error + expected error + }{ + { + name: "Not a FluxComponent", + obj: FakeComponent{}, + expected: errNotFluxComponent, + }, + { + name: "Error Build Source Repository", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return nil, errBoom + }, + }, + expected: errBoom, + }, + { + name: "Build Source Repository successful - create Source successful, Build Manifesto error", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return &HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: "test-url", + }, + }, + }, nil + }, + BuildManifestoFunc: func(ctx context.Context) (Manifesto, error) { + return nil, errBoom + }, + GetNameFunc: "FakeFluxComponent", + }, + validateFunc: func(ctx context.Context, c client.Client, component juggler.Component) error { + helmRepo := &sourcev1.HelmRepository{} + err := c.Get(ctx, client.ObjectKey{Name: "test", Namespace: "default"}, helmRepo) + if err != nil { + return err + } + if !assert.Equal(t, helmRepo.GetLabels(), map[string]string{ + "app.kubernetes.io/managed-by": "control-plane-operator", + labelComponentName: component.GetName(), + }) { + return errors.New("labels not equal") + } + return nil + }, + expected: errBoom, // because of Build Manifesto error + }, + { + name: "Build Manifesto successful, create Manifesto successful", + obj: FakeFluxComponent{ + BuildSourceRepositoryFunc: func(ctx context.Context) (SourceAdapter, error) { + return &HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: "test-url", + }, + }, + }, nil + }, + BuildManifestoFunc: func(ctx context.Context) (Manifesto, error) { + return &HelmReleaseManifesto{ + Manifest: &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: helmv2.HelmReleaseSpec{ReleaseName: "test-name"}, + }, + }, nil + }, + GetNameFunc: "FakeFluxComponent", + }, + validateFunc: func(ctx context.Context, c client.Client, component juggler.Component) error { + helmRelease := &helmv2.HelmRelease{} + err := c.Get(ctx, client.ObjectKey{Name: "test", Namespace: "default"}, helmRelease) + if err != nil { + return err + } + if !assert.Equal(t, helmRelease.GetLabels(), map[string]string{ + "app.kubernetes.io/managed-by": "control-plane-operator", + labelComponentName: component.GetName(), + }) { + return errors.New("labels not equal") + } + return nil + }, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeLocalClient := fake.NewClientBuilder().WithScheme(scheme).Build() + r := NewFluxReconciler(logr.Logger{}, fakeLocalClient, nil) + ctx := context.TODO() + actual := r.Install(ctx, tt.obj) + + if !errors.Is(actual, tt.expected) { + t.Errorf("ObjectReconciler.Install() = %v, want %v", actual, tt.expected) + } + // validates if the object was created correctly + if tt.validateFunc != nil { + if err := tt.validateFunc(ctx, fakeLocalClient, tt.obj); err != nil { + t.Errorf("ObjectReconciler.Install() = %v", err) + } + } + }) + } +} + +func Test_FluxReconciler_Types(t *testing.T) { + r := NewFluxReconciler(logr.Logger{}, nil, nil) + r.RegisterType(FakeFluxComponent{}, FakeFluxComponent{}) + assert.Len(t, r.KnownTypes(), 1) + assert.Equal(t, r.KnownTypes()[0], reflect.TypeOf(FakeFluxComponent{})) +} diff --git a/pkg/juggler/fluxcd/flux_sources.go b/pkg/juggler/fluxcd/flux_sources.go new file mode 100644 index 0000000..b06f5c6 --- /dev/null +++ b/pkg/juggler/fluxcd/flux_sources.go @@ -0,0 +1,164 @@ +package fluxcd + +import ( + "errors" + "time" + + fluxmeta "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + errNotAHelmRepositoryAdapter = errors.New("FluxResource is not a HelmRepositoryAdapter") + errNotAGitRepositoryAdapter = errors.New("FluxResource is not a GitRepositoryAdapter") +) + +const ( + msgReadyNotPresent = "Unable to check healthiness. Ready condition is not present." +) + +/// use strategy for with adapter pattern +// adapter for third party types to conform to a common interface +// use strategy for the reconcilers on how each can be reconciled e.g. for the applyResources method in flux-reconciler + +var _ SourceAdapter = &HelmRepositoryAdapter{} + +// HelmRepositoryAdapter implements SourceAdapter +type HelmRepositoryAdapter struct { + Source *sourcev1.HelmRepository +} + +// Reconcile implements SourceAdapter. +func (h *HelmRepositoryAdapter) Reconcile(desired FluxResource) error { + desiredAdapter, ok := desired.(*HelmRepositoryAdapter) + if !ok { + return errNotAHelmRepositoryAdapter + } + + preserved := h.Source.Spec.DeepCopy() + h.Source.Spec = desiredAdapter.Source.Spec + // Give suspension precedence + h.Source.Spec.Suspend = preserved.Suspend + return nil +} + +// Empty implements SourceAdapter. +func (h *HelmRepositoryAdapter) Empty() SourceAdapter { + return &HelmRepositoryAdapter{&sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: h.Source.Name, + Namespace: h.Source.Namespace, + }, + }} +} + +// GetHealthiness implements SourceAdapter. +func (h *HelmRepositoryAdapter) GetHealthiness() juggler.ResourceHealthiness { + cond := apimeta.FindStatusCondition(h.Source.Status.Conditions, fluxmeta.ReadyCondition) + if cond == nil { + return juggler.ResourceHealthiness{ + Healthy: false, + Message: msgReadyNotPresent, + } + } + return juggler.ResourceHealthiness{ + Healthy: cond.Status == metav1.ConditionTrue, + Message: cond.Message, + } +} + +// GetObject returns the HelmRepository as a client.Object to be used with the client.Client or alike interfaces +func (h *HelmRepositoryAdapter) GetObject() client.Object { + return h.Source +} + +// GetObjectKey returns the HelmRepository as a client.ObjectKey to be used with client.Client.Get +func (h *HelmRepositoryAdapter) GetObjectKey() client.ObjectKey { + return client.ObjectKey{ + Namespace: h.Source.Namespace, + Name: h.Source.Name, + } +} + +func (h *HelmRepositoryAdapter) ApplyDefaults() { + // This usually does nothing but we can keep it here in case Flux resources will have + // a defaulting func in the future. + scheme.Default(h.Source) + + h.Source.Spec.Interval = metav1.Duration{Duration: 1 * time.Hour} +} + +// +// ----------------------------------- +// + +var _ SourceAdapter = &GitRepositoryAdapter{} + +// GitRepositoryAdapter implements SourceAdapter +type GitRepositoryAdapter struct { + Source *sourcev1.GitRepository +} + +// Reconcile implements SourceAdapter. +func (g *GitRepositoryAdapter) Reconcile(desired FluxResource) error { + desiredAdapter, ok := desired.(*GitRepositoryAdapter) + if !ok { + return errNotAGitRepositoryAdapter + } + + preserved := g.Source.Spec.DeepCopy() + g.Source.Spec = desiredAdapter.Source.Spec + // Give suspension precedence + g.Source.Spec.Suspend = preserved.Suspend + return nil +} + +// Empty implements SourceAdapter. +func (g *GitRepositoryAdapter) Empty() SourceAdapter { + return &GitRepositoryAdapter{&sourcev1.GitRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: g.Source.Name, + Namespace: g.Source.Namespace, + }, + }} +} + +// GetHealthiness implements SourceAdapter. +func (g *GitRepositoryAdapter) GetHealthiness() juggler.ResourceHealthiness { + cond := apimeta.FindStatusCondition(g.Source.Status.Conditions, fluxmeta.ReadyCondition) + if cond == nil { + return juggler.ResourceHealthiness{ + Healthy: false, + Message: msgReadyNotPresent, + } + } + return juggler.ResourceHealthiness{ + Healthy: cond.Status == metav1.ConditionTrue, + Message: cond.Message, + } +} + +// GetObject returns the GitRepository as a client.Object to be used with the client.Client or alike interfaces +func (g *GitRepositoryAdapter) GetObject() client.Object { + return g.Source +} + +// GetObjectKey returns the GitRepository as a client.ObjectKey to be used with client.Client.Get +func (g *GitRepositoryAdapter) GetObjectKey() client.ObjectKey { + return client.ObjectKey{ + Namespace: g.Source.Namespace, + Name: g.Source.Name, + } +} + +func (g *GitRepositoryAdapter) ApplyDefaults() { + // This usually does nothing but we can keep it here in case Flux resources will have + // a defaulting func in the future. + scheme.Default(g.Source) + + g.Source.Spec.Interval = metav1.Duration{Duration: 1 * time.Hour} +} diff --git a/pkg/juggler/fluxcd/flux_sources_test.go b/pkg/juggler/fluxcd/flux_sources_test.go new file mode 100644 index 0000000..4874a33 --- /dev/null +++ b/pkg/juggler/fluxcd/flux_sources_test.go @@ -0,0 +1,194 @@ +//nolint:dupl +package fluxcd + +import ( + "testing" + + fluxmeta "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestHelmRepositoryAdapter_GetHealthiness(t *testing.T) { + tests := []struct { + name string + adapter HelmRepositoryAdapter + expected juggler.ResourceHealthiness + }{ + { + name: "HelmRepositoryAdapter - Status Condition nil - Ready condition not present", + adapter: HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + Status: sourcev1.HelmRepositoryStatus{ + Conditions: nil, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: false, + Message: msgReadyNotPresent, + }, + }, + { + name: "HelmRepositoryAdapter - Status Condition Ready not found", + adapter: HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + Status: sourcev1.HelmRepositoryStatus{ + Conditions: []metav1.Condition{ + { + Type: "NotReady", // can not be found + Status: metav1.ConditionTrue, + Message: "The release is ready", + }, + }, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: false, + Message: msgReadyNotPresent, + }, + }, + { + name: "HelmRepositoryAdapter - Status Condition Ready = True", + adapter: HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + Status: sourcev1.HelmRepositoryStatus{ + Conditions: []metav1.Condition{ + { + Type: fluxmeta.ReadyCondition, + Status: metav1.ConditionTrue, + Message: "The release is ready", + }, + }, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: true, + Message: "The release is ready", + }, + }, + { + name: "HelmRepositoryAdapter - Status Condition Ready = False", + adapter: HelmRepositoryAdapter{ + Source: &sourcev1.HelmRepository{ + Status: sourcev1.HelmRepositoryStatus{ + Conditions: []metav1.Condition{ + { + Type: fluxmeta.ReadyCondition, + Status: metav1.ConditionFalse, + Message: "The release is not ready", + }, + }, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: false, + Message: "The release is not ready", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := tt.adapter.GetHealthiness() + if !assert.Equal(t, tt.expected, actual) { + t.Errorf("HelmRepositoryAdapter.GetHealthiness() = %v, want %v", actual, tt.expected) + } + }) + } +} + +func TestGitRepositoryAdapter_GetHealthiness(t *testing.T) { + tests := []struct { + name string + adapter GitRepositoryAdapter + expected juggler.ResourceHealthiness + }{ + { + name: "GitRepositoryAdapter - Status Condition nil - Ready condition not present", + adapter: GitRepositoryAdapter{ + Source: &sourcev1.GitRepository{ + Status: sourcev1.GitRepositoryStatus{ + Conditions: nil, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: false, + Message: msgReadyNotPresent, + }, + }, + { + name: "GitRepositoryAdapter - Status Condition Ready not found", + adapter: GitRepositoryAdapter{ + Source: &sourcev1.GitRepository{ + Status: sourcev1.GitRepositoryStatus{ + Conditions: []metav1.Condition{ + { + Type: "NotReady", // can not be found + Status: metav1.ConditionTrue, + Message: "The release is ready", + }, + }, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: false, + Message: msgReadyNotPresent, + }, + }, + { + name: "GitRepositoryAdapter - Status Condition Ready = True", + adapter: GitRepositoryAdapter{ + Source: &sourcev1.GitRepository{ + Status: sourcev1.GitRepositoryStatus{ + Conditions: []metav1.Condition{ + { + Type: fluxmeta.ReadyCondition, + Status: metav1.ConditionTrue, + Message: "The release is ready", + }, + }, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: true, + Message: "The release is ready", + }, + }, + { + name: "GitRepositoryAdapter - Status Condition Ready = False", + adapter: GitRepositoryAdapter{ + Source: &sourcev1.GitRepository{ + Status: sourcev1.GitRepositoryStatus{ + Conditions: []metav1.Condition{ + { + Type: fluxmeta.ReadyCondition, + Status: metav1.ConditionFalse, + Message: "The release is not ready", + }, + }, + }, + }, + }, + expected: juggler.ResourceHealthiness{ + Healthy: false, + Message: "The release is not ready", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := tt.adapter.GetHealthiness() + if !assert.Equal(t, tt.expected, actual) { + t.Errorf("GitRepositoryAdapter.GetHealthiness() = %v, want %v", actual, tt.expected) + } + }) + } +} diff --git a/pkg/juggler/fluxcd/scheme.go b/pkg/juggler/fluxcd/scheme.go new file mode 100644 index 0000000..a3b3251 --- /dev/null +++ b/pkg/juggler/fluxcd/scheme.go @@ -0,0 +1,7 @@ +package fluxcd + +import ( + "github.com/openmcp-project/control-plane-operator/internal/schemes" +) + +var scheme = schemes.Local diff --git a/pkg/juggler/hooks/orphans.go b/pkg/juggler/hooks/orphans.go new file mode 100644 index 0000000..779e325 --- /dev/null +++ b/pkg/juggler/hooks/orphans.go @@ -0,0 +1,45 @@ +package hooks + +import ( + "context" + "fmt" + + "github.com/openmcp-project/control-plane-operator/pkg/utils" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// checkForResources checks if there are any resources of the given GroupVersionKind remaining in the cluster +func checkForResources(gvk schema.GroupVersionKind, ctx context.Context, c client.Client) error { + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(gvk) + + err := c.List(ctx, list, client.Limit(1)) + if utils.IsCRDNotFound(err) { + // CRD not found, so no resources can exist. + return nil + } + if err != nil { + return err + } + + if len(list.Items) > 0 { + return fmt.Errorf("cannot uninstall because there is a least one object of %s remaining", gvk) + } + + return nil +} + +// PreventOrphanedResources can be used as a pre-uninstall hook to prevent CRDs from being deleted +// before any corresponding resources are deleted first. +func PreventOrphanedResources(gvks []schema.GroupVersionKind) func(ctx context.Context, c client.Client) error { + return func(ctx context.Context, c client.Client) error { + for _, gvk := range gvks { + if err := checkForResources(gvk, ctx, c); err != nil { + return err + } + } + return nil + } +} diff --git a/pkg/juggler/hooks/orphans_test.go b/pkg/juggler/hooks/orphans_test.go new file mode 100644 index 0000000..18ca668 --- /dev/null +++ b/pkg/juggler/hooks/orphans_test.go @@ -0,0 +1,93 @@ +package hooks + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func Test_PreventOrphanedResources(t *testing.T) { + testCases := []struct { + desc string + gvks []schema.GroupVersionKind + initObjs []client.Object + interceptorFuncs interceptor.Funcs + expectedErr *string + }{ + { + desc: "should not return error when list of GVKs is empty", + }, + { + desc: "should not return error when CRD is not installed", + gvks: []schema.GroupVersionKind{ + {Group: "cert-manager.io", Version: "v1", Kind: "Certificate"}, + {Group: "cert-manager.io", Version: "v1", Kind: "Issuer"}, + {Group: "cert-manager.io", Version: "v1", Kind: "ClusterIssuer"}, + }, + interceptorFuncs: interceptor.Funcs{ + List: func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + return &apiutil.ErrResourceDiscoveryFailed{ + list.GetObjectKind().GroupVersionKind().GroupVersion(): apierrors.NewNotFound(schema.GroupResource{}, ""), + } + }, + }, + }, + { + desc: "should not return error when no resource of type exists", + gvks: []schema.GroupVersionKind{ + {Group: "", Version: "v1", Kind: "Secret"}, + }, + }, + { + desc: "should return error when API server returns unknown error", + gvks: []schema.GroupVersionKind{ + {Group: "", Version: "v1", Kind: "Secret"}, + }, + interceptorFuncs: interceptor.Funcs{ + List: func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("some unknown error") + }, + }, + expectedErr: ptr.To("some unknown error"), + }, + { + desc: "should return error when resource of type exists", + gvks: []schema.GroupVersionKind{ + {Group: "", Version: "v1", Kind: "Secret"}, + }, + initObjs: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-secret", + Namespace: metav1.NamespaceDefault, + }, + }, + }, + expectedErr: ptr.To("cannot uninstall because there is a least one object of /v1, Kind=Secret remaining"), + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + c := fake.NewClientBuilder().WithObjects(tC.initObjs...).WithInterceptorFuncs(tC.interceptorFuncs).Build() + fn := PreventOrphanedResources(tC.gvks) + err := fn(context.Background(), c) + + if tC.expectedErr != nil { + assert.EqualError(t, err, *tC.expectedErr) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/juggler/juggler.go b/pkg/juggler/juggler.go new file mode 100644 index 0000000..0e77ee6 --- /dev/null +++ b/pkg/juggler/juggler.go @@ -0,0 +1,295 @@ +package juggler + +import ( + "context" + "errors" + "fmt" + "reflect" + + "github.com/go-logr/logr" +) + +var ( + errNoReconcilerForComponentType = errors.New("no reconciler available for component") + errTooManyReconcilers = errors.New("more than one reconciler available for component") + errDependencyNotRegistered = errors.New("one or more dependencies are not registered") + errDependencyNotEnabled = errors.New("one or more dependencies are registered but not enabled") + errHookFailed = errors.New("hook failed") +) + +// NewJuggler initializes a new Juggler. +func NewJuggler(logger logr.Logger, recorder EventRecorder) *Juggler { + return &Juggler{ + logger: logger.WithName("Juggler"), + components: []Component{}, + reconcilers: []ComponentReconciler{}, + recorder: recorder, + } +} + +// Juggler manages components. +type Juggler struct { + logger logr.Logger + components []Component + reconcilers []ComponentReconciler + recorder EventRecorder +} + +// RegisterComponent makes the Juggler aware of new components. +func (am *Juggler) RegisterComponent(component ...Component) { + am.components = append(am.components, component...) +} + +// RegisterReconciler makes the Juggler aware of new reconcilers. +func (am *Juggler) RegisterReconciler(reconciler ...ComponentReconciler) { + am.reconcilers = append(am.reconcilers, reconciler...) +} + +// RegisterOrphanedComponents calls registered reconcilers that implement the optional +// `OrphanedComponentsDetector` interface and registers orphaned components so that they can be uninstalled. +func (am *Juggler) RegisterOrphanedComponents(ctx context.Context) error { + for _, rec := range am.reconcilers { + if ocd, ok := rec.(OrphanedComponentsDetector); ok { + configuredComponents := am.componentsOfReconciler(rec) + orphaned, err := ocd.DetectOrphanedComponents(ctx, configuredComponents) + if err != nil { + return err + } + am.RegisterComponent(orphaned...) + } + } + return nil +} + +// RegisteredComponents returns the number of registered components. +func (am *Juggler) RegisteredComponents() int { + return len(am.components) +} + +// Reconcile compares the current and desired state for each registered +// component and takes measures to reach the desired state if necessary. +// The implementation is inspired by Crossplanes Managed resource reconciler +func (am *Juggler) Reconcile(ctx context.Context) []ComponentResult { + results := make([]ComponentResult, 0, len(am.components)) + + for _, component := range am.components { + cr := am.reconcileComponent(ctx, component) + NewComponentEventRecorder(am.recorder, component).Event(cr.Result, cr.Message) + results = append(results, cr) + } + + return results +} + +func (am *Juggler) reconcileComponent(ctx context.Context, component Component) ComponentResult { + // Find reconciler for component + reconciler, err := am.findReconcilerFor(component) + if err != nil { + return ComponentResult{ + Component: component, + Result: StatusReconcilerNotFound, + Message: err.Error(), + } + } + + // Observe Component + observation, err := reconciler.Observe(ctx, component) + if err != nil { + return ComponentResult{ + Component: component, + Result: StatusObservationFailed, + Message: err.Error(), + } + } + + // Resource exists, but should be uninstalled + if observation.ResourceExists && !component.IsEnabled() { + if kou, ok := component.(KeepOnUninstall); ok && kou.KeepOnUninstall() { + // pretend to uninstall the component + return ComponentResult{ + Component: component, + Result: StatusUninstalled, + Message: fmt.Sprintf("%s is marked as 'keep on uninstall'.", component.GetName()), + } + } + + if err := reconciler.PreUninstall(ctx, component); err != nil { + wrappedErr := errors.Join(errHookFailed, err).Error() + return ComponentResult{ + Component: component, + Result: StatusUninstallFailed, + Message: wrappedErr, + } + } + + if err := reconciler.Uninstall(ctx, component); err != nil { + return ComponentResult{ + Component: component, + Result: StatusUninstallFailed, + Message: err.Error(), + } + } + + return ComponentResult{ + Component: component, + Result: StatusUninstalled, + Message: fmt.Sprintf("%s has been uninstalled successfully.", component.GetName()), + } + } + + // If component disabled, do nothing + if !component.IsEnabled() { + return ComponentResult{ + Component: component, + Result: StatusDisabled, + Message: fmt.Sprintf("%s is not enabled.", component.GetName()), + } + } + + is, err := component.IsInstallable(ctx) + if err != nil { + return ComponentResult{ + Component: component, + Result: StatusInstallFailed, + Message: fmt.Sprintf("%s not installable: %s", component.GetName(), err.Error()), + } + } + if !is { + return ComponentResult{ + Component: component, + Result: StatusComponentNotAllowed, + Message: fmt.Sprintf("%s not installable.", component.GetName()), + } + } + + if err := am.checkDependencies(component); err != nil { + return ComponentResult{ + Component: component, + Result: StatusDependencyCheckFailed, + Message: err.Error(), + } + } + + // Resource does not exist but is enabled, then install + if !observation.ResourceExists { + if err := reconciler.PreInstall(ctx, component); err != nil { + wrappedErr := errors.Join(errHookFailed, err).Error() + return ComponentResult{ + Component: component, + Result: StatusInstallFailed, + Message: wrappedErr, + } + } + + err := reconciler.Install(ctx, component) + if err != nil { + return ComponentResult{ + Component: component, + Result: StatusInstallFailed, + Message: err.Error(), + } + } + return ComponentResult{ + Component: component, + Result: StatusInstalled, + Message: fmt.Sprintf("%s has been installed successfully.", component.GetName()), + } + } + + if err := reconciler.PreUpdate(ctx, component); err != nil { + wrappedErr := errors.Join(errHookFailed, err).Error() + return ComponentResult{ + Component: component, + Result: StatusUpdateFailed, + Message: wrappedErr, + } + } + + // Always run update. Should be a no-op if component is already up-to-date. + err = reconciler.Update(ctx, component) + if err != nil { + return ComponentResult{ + Component: component, + Result: StatusUpdateFailed, + Message: err.Error(), + } + } + + // Observe Component again after updating it + observation, err = reconciler.Observe(ctx, component) + if err != nil { + return ComponentResult{ + Component: component, + Result: StatusObservationFailed, + Message: err.Error(), + } + } + + // Resource is not healthy + if !observation.ResourceHealthiness.Healthy { + return ComponentResult{ + Component: component, + Result: StatusUnhealthy, + Message: observation.ResourceHealthiness.Message, + } + } + + // Resource is healthy + return ComponentResult{ + Component: component, + Result: StatusHealthy, + Message: fmt.Sprintf("%s is healthy.", component.GetName()), + } +} + +func (am *Juggler) findReconcilerFor(component Component) (ComponentReconciler, error) { + var selectedReconciler ComponentReconciler + for _, cr := range am.reconcilers { + for _, kt := range cr.KnownTypes() { + if reflect.TypeOf(component) == kt { + if selectedReconciler != nil { + return nil, errTooManyReconcilers + } + selectedReconciler = cr + } + } + } + if selectedReconciler != nil { + return selectedReconciler, nil + } + return nil, errNoReconcilerForComponentType +} + +func (am *Juggler) findRegisteredComponent(sample Component) Component { + for _, registered := range am.components { + if reflect.TypeOf(registered) == reflect.TypeOf(sample) { + return registered + } + } + return nil +} + +func (am *Juggler) checkDependencies(component Component) error { + for _, dep := range component.GetDependencies() { + registered := am.findRegisteredComponent(dep) + if registered == nil { + return errDependencyNotRegistered + } + if !registered.IsEnabled() { + return errDependencyNotEnabled + } + } + return nil +} + +func (am *Juggler) componentsOfReconciler(r ComponentReconciler) []Component { + configuredComponents := []Component{} + for _, cc := range am.components { + for _, kt := range r.KnownTypes() { + if reflect.TypeOf(cc) == kt { + configuredComponents = append(configuredComponents, cc) + } + } + } + return configuredComponents +} diff --git a/pkg/juggler/juggler_test.go b/pkg/juggler/juggler_test.go new file mode 100644 index 0000000..706958b --- /dev/null +++ b/pkg/juggler/juggler_test.go @@ -0,0 +1,581 @@ +package juggler + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "k8s.io/client-go/tools/record" + + "github.com/go-logr/logr" + "github.com/go-logr/logr/testr" + "github.com/stretchr/testify/assert" +) + +var errBoom = errors.New("boom") + +// Test for function Reconcile +func TestJuggler_Reconcile(t *testing.T) { + type fields struct { + components []Component + reconcilers []ComponentReconciler + } + tests := []struct { + name string + fields fields + want []ComponentResult + }{ + { + name: "Success, one component, one result", + fields: fields{ + components: []Component{FakeComponent{Enabled: true, Allowed: true}}, + reconcilers: []ComponentReconciler{ + FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: ResourceHealthiness{ + Healthy: true, + }, + }, nil + }, + }}, + }, + want: []ComponentResult{ + { + Component: FakeComponent{Enabled: true, Allowed: true}, + Result: StatusHealthy, + Message: "FakeComponent is healthy.", + }, + }, + }, + { + name: "Component is not healthy", + fields: fields{ + components: []Component{FakeComponent{Enabled: true, Allowed: true}}, + reconcilers: []ComponentReconciler{ + FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: ResourceHealthiness{ + Healthy: false, + Message: "not healthy", + }, + }, nil + }, + }, + }, + }, + want: []ComponentResult{ + { + Component: FakeComponent{Enabled: true, Allowed: true}, + Result: StatusUnhealthy, + Message: "not healthy", + }, + }, + }, + { + name: "error - too many reconcilers for one type", + fields: fields{ + components: []Component{FakeComponent{Enabled: true, Allowed: true}}, + reconcilers: []ComponentReconciler{ + FakeReconciler{ + KnownTypesFunc: knowsAll(), + }, + FakeReconciler{ + KnownTypesFunc: knowsAll(), + }}, + }, + want: []ComponentResult{ + { + Component: FakeComponent{Enabled: true, Allowed: true}, + Result: StatusReconcilerNotFound, + Message: errTooManyReconcilers.Error(), + }, + }, + }, + { + name: "error, no reconciler found", + fields: fields{ + components: []Component{ + FakeComponent{Enabled: true, Allowed: true}, + }, + reconcilers: []ComponentReconciler{ + FakeReconciler{ + KnownTypesFunc: func() []reflect.Type { return []reflect.Type{} }, + }}, + }, + want: []ComponentResult{ + { + Component: FakeComponent{Enabled: true, Allowed: true}, + Result: StatusReconcilerNotFound, + Message: errNoReconcilerForComponentType.Error(), + }, + }, + }, + { + name: "error, dependency not registered", + fields: fields{ + components: []Component{ + FakeComponent{Enabled: true, Dependencies: []Component{FakeComponent2{}}, Allowed: true}, + }, + reconcilers: []ComponentReconciler{ + FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ResourceExists: false}, nil + }, + }}, + }, + want: []ComponentResult{ + { + Component: FakeComponent{Enabled: true, Dependencies: []Component{FakeComponent2{}}, Allowed: true}, + Result: StatusDependencyCheckFailed, + Message: errDependencyNotRegistered.Error(), + }, + }, + }, + { + name: "error, dependency not enabled", + fields: fields{ + components: []Component{ + FakeComponent{Enabled: true, Dependencies: []Component{FakeComponent2{}}, Allowed: true}, + FakeComponent2{Enabled: false, Allowed: true}, + }, + reconcilers: []ComponentReconciler{ + FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ResourceExists: false}, nil + }, + }}, + }, + want: []ComponentResult{ + { + Component: FakeComponent{Enabled: true, Dependencies: []Component{FakeComponent2{}}, Allowed: true}, + Result: StatusDependencyCheckFailed, + Message: errDependencyNotEnabled.Error(), + }, + { + Component: FakeComponent2{Enabled: false, Allowed: true}, + Result: StatusDisabled, + Message: "FakeComponent2 is not enabled.", + }, + }, + }, + { + name: "Success - dependencies satisfied", + fields: fields{ + components: []Component{ + FakeComponent{Enabled: true, Dependencies: []Component{FakeComponent2{}}, Allowed: true}, + FakeComponent2{Enabled: true, Allowed: true}, + }, + reconcilers: []ComponentReconciler{ + FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: ResourceHealthiness{ + Healthy: true, + }, + }, nil + }, + }}, + }, + want: []ComponentResult{ + { + Component: FakeComponent{Enabled: true, Dependencies: []Component{FakeComponent2{}}, Allowed: true}, + Result: StatusHealthy, + Message: "FakeComponent is healthy.", + }, + { + Component: FakeComponent2{Enabled: true, Allowed: true}, + Result: StatusHealthy, + Message: "FakeComponent2 is healthy.", + }, + }, + }, + { + name: "error, component is not allowed to be installed", + fields: fields{ + components: []Component{ + FakeComponent{Enabled: true, Allowed: false}, + }, + reconcilers: []ComponentReconciler{ + FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ResourceExists: false}, nil + }, + }, + }, + }, + want: []ComponentResult{ + { + Component: FakeComponent{Enabled: true, Allowed: false}, + Result: StatusComponentNotAllowed, + Message: "FakeComponent not installable.", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cp := v1beta1.ControlPlane{} + am := NewJuggler(testr.New(t), &ObjectEventRecorder{ + recorder: record.NewFakeRecorder(3), + object: &cp, + }) + am.RegisterComponent(tt.fields.components...) + am.RegisterReconciler(tt.fields.reconcilers...) + result := am.Reconcile(context.TODO()) + assert.Equal(t, tt.want, result) + }) + } +} + +func knowsAll() func() []reflect.Type { + return func() []reflect.Type { + return []reflect.Type{ + reflect.TypeOf(FakeComponent{}), + reflect.TypeOf(FakeComponent2{}), + } + } +} + +// Test for function RegisterComponent +func TestJuggler_RegisterComponent(t *testing.T) { + componentList1 := []Component{ + FakeComponent{ + Name: "FakeComponent1", + Dependencies: nil, + Enabled: true, + }, + } + + componentList2 := []Component{ + FakeComponent{ + Name: "FakeComponent1", + Dependencies: nil, + Enabled: true, + }, + FakeComponent{ + Name: "FakeComponent2", + Dependencies: nil, + Enabled: false, + }, + } + + tests := []struct { + name string + fields []Component + want []Component + }{ + {name: "Register one component", fields: componentList1, want: componentList1}, + {name: "Register more components", fields: componentList2, want: componentList2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cp := v1beta1.ControlPlane{} + am := NewJuggler(testr.New(t), &ObjectEventRecorder{ + recorder: record.NewFakeRecorder(3), + object: &cp, + }) + am.RegisterComponent(tt.fields...) + assert.Equal(t, tt.want, am.components) + }) + } +} + +func TestJuggler_reconcileComponent(t *testing.T) { + type args struct { + component Component + reconciler ComponentReconciler + } + tests := []struct { + name string + args args + want ComponentResult + }{ + { + name: "error Observing", + args: args{ + component: FakeComponent{Enabled: true, Allowed: true}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{}, errBoom + }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: true, Allowed: true}, + Result: StatusObservationFailed, + Message: errBoom.Error(), + }, + }, + { + name: "needs uninstall, uninstall error", args: args{ + component: FakeComponent{Enabled: false, Allowed: true}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ + ResourceExists: true, + }, nil + }, + UninstallFunc: func(ctx context.Context, component Component) error { + return errBoom + }, + PreUninstallFunc: func(ctx context.Context, component Component) error { return nil }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: false, Allowed: true}, + Result: StatusUninstallFailed, + Message: errBoom.Error(), + }, + }, + { + name: "needs uninstall, pre-uninstall hook failed", args: args{ + component: FakeComponent{Enabled: false, Allowed: true}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ + ResourceExists: true, + }, nil + }, + PreUninstallFunc: func(ctx context.Context, component Component) error { return errBoom }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: false, Allowed: true}, + Result: StatusUninstallFailed, + Message: errors.Join(errHookFailed, errBoom).Error(), + }, + }, + { + name: "need uninstall, uninstall ok", args: args{ + component: FakeComponent{Enabled: false, Allowed: true}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ResourceExists: true}, nil + }, + UninstallFunc: func(ctx context.Context, component Component) error { + return nil + }, + PreUninstallFunc: func(ctx context.Context, component Component) error { return nil }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: false, Allowed: true}, + Result: StatusUninstalled, + Message: "FakeComponent has been uninstalled successfully.", + }, + }, + { + name: "need uninstall, keep on uninstall enabled", args: args{ + component: FakeComponent{Enabled: false, Allowed: true, KeepInstalled: true}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ResourceExists: true}, nil + }, + UninstallFunc: func(ctx context.Context, component Component) error { + return nil + }, + PreUninstallFunc: func(ctx context.Context, component Component) error { return nil }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: false, Allowed: true, KeepInstalled: true}, + Result: StatusUninstalled, + Message: "FakeComponent is marked as 'keep on uninstall'.", + }, + }, + { + name: "error - component is not allowed to be installed", + args: args{ + component: FakeComponent{Enabled: true, Allowed: false}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ResourceExists: false}, nil + }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: true, Allowed: false}, + Result: StatusComponentNotAllowed, + Message: "FakeComponent not installable.", + }, + }, + { + name: "needs install, install error", args: args{ + component: FakeComponent{Enabled: true, Allowed: true}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ResourceExists: false}, nil + }, + InstallFunc: func(ctx context.Context, component Component) error { + return errBoom + }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: true, Allowed: true}, + Result: StatusInstallFailed, + Message: errBoom.Error(), + }, + }, + { + name: "needs install, pre-install hook failed", args: args{ + component: FakeComponent{Enabled: true, Allowed: true}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ResourceExists: false}, nil + }, + PreInstallFunc: func(ctx context.Context, component Component) error { + return errBoom + }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: true, Allowed: true}, + Result: StatusInstallFailed, + Message: errors.Join(errHookFailed, errBoom).Error(), + }, + }, + { + name: "needs update, update error", args: args{ + component: FakeComponent{Enabled: true, Allowed: true}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ResourceExists: true}, nil + }, + UpdateFunc: func(ctx context.Context, component Component) error { + return errBoom + }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: true, Allowed: true}, + Result: StatusUpdateFailed, + Message: errBoom.Error(), + }, + }, + { + name: "needs update, pre-update hook failed", args: args{ + component: FakeComponent{Enabled: true, Allowed: true}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ResourceExists: true}, nil + }, + PreUpdateFunc: func(ctx context.Context, component Component) error { + return errBoom + }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: true, Allowed: true}, + Result: StatusUpdateFailed, + Message: errors.Join(errHookFailed, errBoom).Error(), + }, + }, + { + name: "is disabled, no-op", args: args{ + component: FakeComponent{Enabled: false, Allowed: true}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ + ResourceExists: false, + }, nil + }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: false, Allowed: true}, + Result: StatusDisabled, + Message: "FakeComponent is not enabled.", + }, + }, + { + name: "is not healthy", + args: args{ + component: FakeComponent{Enabled: true, Allowed: true}, + reconciler: FakeReconciler{ + KnownTypesFunc: knowsAll(), + ObserverFunc: func(ctx context.Context, component Component) (ComponentObservation, error) { + return ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: ResourceHealthiness{ + Healthy: false, + Message: "not healthy", + }, + }, nil + }, + }, + }, + want: ComponentResult{ + Component: FakeComponent{Enabled: true, Allowed: true}, + Result: StatusUnhealthy, + Message: "not healthy", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cp := v1beta1.ControlPlane{} + am := NewJuggler(testr.New(t), &ObjectEventRecorder{ + recorder: record.NewFakeRecorder(3), + object: &cp, + }) + am.RegisterReconciler(tt.args.reconciler) + result := am.reconcileComponent(context.TODO(), tt.args.component) + assert.Equal(t, tt.want, result) + }) + } +} + +func Test_Juggler_RegisterOrphanedComponents(t *testing.T) { + juggler := NewJuggler(logr.Logger{}, nil) + + r1 := FakeReconciler{ + KnownTypesFunc: func() []reflect.Type { return []reflect.Type{reflect.TypeOf(FakeComponent{})} }, + DetectOrphanedComponentsFunc: func(_ context.Context, configuredComponents []Component) ([]Component, error) { + assert.Len(t, configuredComponents, 1) + return []Component{FakeComponent{}}, nil + }, + } + + r2 := FakeReconciler{ + KnownTypesFunc: func() []reflect.Type { return []reflect.Type{reflect.TypeOf(FakeComponent2{})} }, + DetectOrphanedComponentsFunc: func(_ context.Context, configuredComponents []Component) ([]Component, error) { + assert.Len(t, configuredComponents, 2) + return []Component{FakeComponent2{}, FakeComponent2{}}, nil + }, + } + + juggler.RegisterComponent(FakeComponent{}, FakeComponent2{}, FakeComponent2{}) + juggler.RegisterReconciler(r1, r2) + + err := juggler.RegisterOrphanedComponents(context.Background()) + assert.NoError(t, err) + assert.Len(t, juggler.components, 6) +} diff --git a/pkg/juggler/object/fake_test.go b/pkg/juggler/object/fake_test.go new file mode 100644 index 0000000..3c4e25a --- /dev/null +++ b/pkg/juggler/object/fake_test.go @@ -0,0 +1,177 @@ +package object + +import ( + "context" + "reflect" + "strings" + + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var fakeFilterLabel = "fake.object.component/managed" + +var _ ObjectComponent = FakeObjectComponent{} +var _ OrphanedObjectsDetector = FakeObjectComponent{} + +type FakeObjectComponent struct { + allowedToBeInstalled bool + name string + dependencies []juggler.Component + enabled bool + hooks juggler.ComponentHooks + BuildObjectToReconcileFunc func(ctx context.Context) (client.Object, types.NamespacedName, error) + ReconcileObjectFunc func(ctx context.Context, obj client.Object) error + IsObjectHealthyFunc func(obj client.Object) juggler.ResourceHealthiness +} + +// BuildObjectToReconcile implements ObjectComponent. +func (f FakeObjectComponent) BuildObjectToReconcile(ctx context.Context) (client.Object, types.NamespacedName, error) { + return f.BuildObjectToReconcileFunc(ctx) +} + +// ReconcileObject implements ObjectComponent. +func (f FakeObjectComponent) ReconcileObject(ctx context.Context, obj client.Object) error { + return f.ReconcileObjectFunc(ctx, obj) +} + +// OrphanDetectorContext implements OrphanedObjectsDetector. +func (f FakeObjectComponent) OrphanDetectorContext() DetectorContext { + return DetectorContext{ + ListType: &corev1.ConfigMapList{}, + FilterCriteria: FilterCriteria{ + client.HasLabels{fakeFilterLabel}, + }, + ConvertFunc: func(list client.ObjectList) []juggler.Component { + comps := []juggler.Component{} + for _, cm := range (list.(*corev1.ConfigMapList)).Items { + comps = append(comps, FakeObjectComponent{name: cm.Name}) + } + return comps + }, + SameFunc: func(configured, detected juggler.Component) bool { + return strings.EqualFold(configured.GetName(), detected.GetName()) + }, + } +} + +func (f FakeObjectComponent) IsInstallable(context.Context) (bool, error) { + return f.allowedToBeInstalled, nil +} + +func (f FakeObjectComponent) GetName() string { + return f.name +} + +func (f FakeObjectComponent) GetDependencies() []juggler.Component { + return f.dependencies +} + +func (f FakeObjectComponent) IsEnabled() bool { + return f.enabled +} + +// Hooks implements Component. +func (f FakeObjectComponent) Hooks() juggler.ComponentHooks { + return f.hooks +} + +func (f FakeObjectComponent) IsObjectHealthy(obj client.Object) juggler.ResourceHealthiness { + return f.IsObjectHealthyFunc(obj) +} + +// --------------------------------------------------------------------------------------------------- + +var _ juggler.Component = FakeComponent{} + +type FakeComponent struct { + IsAllowedToBeInstalledFunc func(ctx context.Context) bool + GetNameFunc string + GetDependenciesFunc []juggler.Component + IsEnabledFunc bool + HookFunc juggler.ComponentHooks +} + +func (f FakeComponent) IsInstallable(ctx context.Context) (bool, error) { + return f.IsAllowedToBeInstalledFunc(ctx), nil +} + +func (f FakeComponent) GetName() string { + return f.GetNameFunc +} + +func (f FakeComponent) GetDependencies() []juggler.Component { + return f.GetDependenciesFunc +} + +func (f FakeComponent) IsEnabled() bool { + return f.IsEnabledFunc +} + +// Hooks implements Component. +func (f FakeComponent) Hooks() juggler.ComponentHooks { + return f.HookFunc +} + +// --------------------------------------------------------------------------------------------------- + +var _ juggler.ComponentReconciler = FakeReconciler{} + +type FakeReconciler struct { + ObserverFunc func(ctx context.Context, component juggler.Component) (juggler.ComponentObservation, error) + UninstallFunc func(ctx context.Context, component juggler.Component) error + UpdateFunc func(ctx context.Context, component juggler.Component) error + InstallFunc func(ctx context.Context, component juggler.Component) error + PreUninstallFunc func(ctx context.Context, component juggler.Component) error + PreInstallFunc func(ctx context.Context, component juggler.Component) error + PreUpdateFunc func(ctx context.Context, component juggler.Component) error + KnownTypesFunc func() []reflect.Type +} + +// KnownTypes implements juggler.ComponentReconciler. +func (f FakeReconciler) KnownTypes() []reflect.Type { + return f.KnownTypesFunc() +} + +// PreUninstall implements juggler.ComponentReconciler. +func (f FakeReconciler) PreUninstall(ctx context.Context, component juggler.Component) error { + if f.PreUninstallFunc == nil { + return nil + } + return f.PreUninstallFunc(ctx, component) +} + +// PreInstall implements juggler.ComponentReconciler. +func (f FakeReconciler) PreInstall(ctx context.Context, component juggler.Component) error { + if f.PreInstallFunc == nil { + return nil + } + return f.PreInstallFunc(ctx, component) +} + +// PreUpdate implements juggler.ComponentReconciler. +func (f FakeReconciler) PreUpdate(ctx context.Context, component juggler.Component) error { + if f.PreUpdateFunc == nil { + return nil + } + return f.PreUpdateFunc(ctx, component) +} + +//nolint:lll +func (f FakeReconciler) Observe(ctx context.Context, component juggler.Component) (juggler.ComponentObservation, error) { + return f.ObserverFunc(ctx, component) +} + +func (f FakeReconciler) Uninstall(ctx context.Context, component juggler.Component) error { + return f.UninstallFunc(ctx, component) +} + +func (f FakeReconciler) Update(ctx context.Context, component juggler.Component) error { + return f.UpdateFunc(ctx, component) +} + +func (f FakeReconciler) Install(ctx context.Context, component juggler.Component) error { + return f.InstallFunc(ctx, component) +} diff --git a/pkg/juggler/object/object_component.go b/pkg/juggler/object/object_component.go new file mode 100644 index 0000000..d1b9412 --- /dev/null +++ b/pkg/juggler/object/object_component.go @@ -0,0 +1,56 @@ +package object + +import ( + "context" + + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ObjectComponent is an interface for manageable components, specifically for plain client.Objects. +type ObjectComponent interface { + juggler.Component + + // BuildObjectToReconcile returns an empty API object of the type that the ObjectComponent represents. + // The object's desired state must be reconciled with the existing state inside the ReconcileObject(...) function. + // It is called regardless of creating or updating an object. + // Any information (also in `metadata`, e.g. labels) will be overridden before ReconcileObject(...) is called. + BuildObjectToReconcile(ctx context.Context) (client.Object, types.NamespacedName, error) + + // ReconcileObject brings the client.Object closer to the desired state. + ReconcileObject(ctx context.Context, obj client.Object) error + + // IsObjectHealthy returns if the object is healthy. + IsObjectHealthy(obj client.Object) juggler.ResourceHealthiness +} + +type FilterCriteria []client.ListOption + +type ConvertFunc func(list client.ObjectList) []juggler.Component + +type SameFunc func(configured, detected juggler.Component) bool + +type DetectorContext struct { + // FilterCriteria describes a list of Options which identify all objects which can + // potentially become orphaned. This should exclude objects created by the end-user + FilterCriteria FilterCriteria + // ConvertFunc is a transformation-func which converts a list of orphaned objects + // into a list of known juggler components + ConvertFunc ConvertFunc + // SameFunc is a comparison-func which allows to compare two juggler components + SameFunc SameFunc + // ListType describes the type the applied object should have inside the cluster + ListType client.ObjectList +} + +// OrphanedObjectsDetector describes an interface for handling orphaned resources. +// It should be implemented by MCP components which can leave orphaned resources after +// said component is being removed from the MCP +type OrphanedObjectsDetector interface { + OrphanDetectorContext() DetectorContext +} + +func HasComponentLabel() client.ListOption { + return client.HasLabels{labelComponentName} +} diff --git a/pkg/juggler/object/object_reconciler.go b/pkg/juggler/object/object_reconciler.go new file mode 100644 index 0000000..d142b5c --- /dev/null +++ b/pkg/juggler/object/object_reconciler.go @@ -0,0 +1,206 @@ +package object + +import ( + "context" + "errors" + "reflect" + + "github.com/go-logr/logr" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "github.com/openmcp-project/control-plane-operator/pkg/utils" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const ( + labelComponentName = "controlplane.core.orchestrate.cloud.sap/component" +) + +var ( + errNotObjectComponent = errors.New("not an object component") +) + +var _ juggler.ComponentReconciler = &ObjectReconciler{} +var _ juggler.OrphanedComponentsDetector = &ObjectReconciler{} + +func NewReconciler(logger logr.Logger, remoteClient client.Client) *ObjectReconciler { + return &ObjectReconciler{ + logger: logger, + remoteClient: remoteClient, + knownTypes: sets.Set[reflect.Type]{}, + } +} + +type ObjectReconciler struct { + logger logr.Logger + remoteClient client.Client + knownTypes sets.Set[reflect.Type] +} + +// DetectOrphanedComponents implements juggler.OrphanedComponentsDetector. +func (r *ObjectReconciler) DetectOrphanedComponents( + ctx context.Context, + configuredComponents []juggler.Component, +) ([]juggler.Component, error) { + orphaned := []juggler.Component{} + for _, kt := range r.KnownTypes() { + newComp := reflect.New(kt).Elem().Interface() + if ood, ok := newComp.(OrphanedObjectsDetector); ok { + dc := ood.OrphanDetectorContext() + filtered, err := r.filterOrphanedObjects(ctx, configuredComponents, dc) + if err != nil { + return nil, err + } + orphaned = append(orphaned, filtered...) + } + } + return orphaned, nil +} + +// filterOrphanedObjects uses the `DetectorContext` to search for possibly orphaned objects, convert them +// to components and compare those to configured components to decide which of them are orphaned. +func (r *ObjectReconciler) filterOrphanedObjects( + ctx context.Context, + configuredComponents []juggler.Component, + dc DetectorContext, +) ([]juggler.Component, error) { + err := r.remoteClient.List(ctx, dc.ListType, dc.FilterCriteria...) + if utils.IsCRDNotFound(err) { + // CRD not installed, so there can't be any orphaned resources of this type. + return []juggler.Component{}, nil + } + if err != nil { + return nil, err + } + possiblyOrphanedComps := dc.ConvertFunc(dc.ListType) + orphaned := []juggler.Component{} + for _, possiblyOrphaned := range possiblyOrphanedComps { + found := false + for _, configured := range configuredComponents { + // only try to match component of the same type e.g. CrossplaneProvider <=> CrossplaneProvider + if reflect.TypeOf(configured) != reflect.TypeOf(possiblyOrphaned) { + continue + } + if dc.SameFunc(configured, possiblyOrphaned) { + found = true + } + } + if !found { + orphaned = append(orphaned, possiblyOrphaned) + } + } + return orphaned, nil +} + +// KnownTypes implements juggler.ComponentReconciler. +func (r *ObjectReconciler) KnownTypes() []reflect.Type { + return r.knownTypes.UnsortedList() +} + +func (r *ObjectReconciler) RegisterType(comps ...ObjectComponent) { + for _, c := range comps { + cType := reflect.TypeOf(c) + r.knownTypes.Insert(cType) + } +} + +// Install implements ComponentReconciler. +func (r *ObjectReconciler) Install(ctx context.Context, component juggler.Component) error { + return r.applyObject(ctx, component) +} + +// Observe implements ComponentReconciler. +func (r *ObjectReconciler) Observe(ctx context.Context, comp juggler.Component) (juggler.ComponentObservation, error) { + objectComponent, ok := comp.(ObjectComponent) + if !ok { + return juggler.ComponentObservation{}, errNotObjectComponent + } + + obj, key, err := objectComponent.BuildObjectToReconcile(ctx) + if err != nil { + return juggler.ComponentObservation{}, err + } + + err = r.remoteClient.Get(ctx, key, obj) + if apierrors.IsNotFound(err) || utils.IsCRDNotFound(err) { + r.logger.Info("Object not found") + return juggler.ComponentObservation{ResourceExists: false}, nil + } + if err != nil { + return juggler.ComponentObservation{}, err + } + + return juggler.ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: objectComponent.IsObjectHealthy(obj), + }, nil +} + +// PreUninstall implements ComponentReconciler. +func (r *ObjectReconciler) PreUninstall(ctx context.Context, component juggler.Component) error { + if component.Hooks().PreUninstall != nil { + return component.Hooks().PreUninstall(ctx, r.remoteClient) + } + return nil +} + +// PreInstall implements juggler.ComponentReconciler. +func (r *ObjectReconciler) PreInstall(ctx context.Context, component juggler.Component) error { + if component.Hooks().PreInstall != nil { + return component.Hooks().PreInstall(ctx, r.remoteClient) + } + return nil +} + +// PreUpdate implements juggler.ComponentReconciler. +func (r *ObjectReconciler) PreUpdate(ctx context.Context, component juggler.Component) error { + if component.Hooks().PreUpdate != nil { + return component.Hooks().PreUpdate(ctx, r.remoteClient) + } + return nil +} + +// Uninstall implements ComponentReconciler. +func (r *ObjectReconciler) Uninstall(ctx context.Context, component juggler.Component) error { + objectComponent, ok := component.(ObjectComponent) + if !ok { + return errNotObjectComponent + } + + obj, key, err := objectComponent.BuildObjectToReconcile(ctx) + if err != nil { + return err + } + obj.SetName(key.Name) + obj.SetNamespace(key.Namespace) + + return client.IgnoreNotFound(r.remoteClient.Delete(ctx, obj)) +} + +// Update implements ComponentReconciler. +func (r *ObjectReconciler) Update(ctx context.Context, component juggler.Component) error { + return r.applyObject(ctx, component) +} + +func (r *ObjectReconciler) applyObject(ctx context.Context, component juggler.Component) error { + objectComponent, ok := component.(ObjectComponent) + if !ok { + return errNotObjectComponent + } + + obj, key, err := objectComponent.BuildObjectToReconcile(ctx) + if err != nil { + return err + } + obj.SetName(key.Name) + obj.SetNamespace(key.Namespace) + + _, err = controllerutil.CreateOrUpdate(ctx, r.remoteClient, obj, func() error { + utils.SetManagedBy(obj) + utils.SetLabel(obj, labelComponentName, component.GetName()) + return objectComponent.ReconcileObject(ctx, obj) + }) + return err +} diff --git a/pkg/juggler/object/object_reconciler_test.go b/pkg/juggler/object/object_reconciler_test.go new file mode 100644 index 0000000..0eac7fa --- /dev/null +++ b/pkg/juggler/object/object_reconciler_test.go @@ -0,0 +1,644 @@ +//nolint:lll,dupl +package object + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +var errBoom = errors.New("boom") + +func TestObjectReconciler_Install(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + remoteObjects []client.Object + error error + validateFunc func(ctx context.Context, c client.Client, comp juggler.Component) error + }{ + { + name: "nil", + obj: nil, + + error: errNotObjectComponent, + }, + { + name: "nil", + obj: FakeComponent{}, + + error: errNotObjectComponent, + }, + { + name: "ObjectComponent BuildObject error", + obj: FakeObjectComponent{ + BuildObjectToReconcileFunc: func(ctx context.Context) (client.Object, types.NamespacedName, error) { + return nil, types.NamespacedName{}, errBoom + }, + }, + error: errBoom, + }, + { + name: "ObjectComponent BuildObject successful - Creation successful", + obj: FakeObjectComponent{ + BuildObjectToReconcileFunc: func(ctx context.Context) (client.Object, types.NamespacedName, error) { + return &corev1.Secret{}, types.NamespacedName{ + Name: "test", + Namespace: "default", + }, nil + }, + ReconcileObjectFunc: func(ctx context.Context, obj client.Object) error { + return nil + }, + name: "FakeObjectComponent", + }, + validateFunc: func(ctx context.Context, c client.Client, comp juggler.Component) error { + secret := &corev1.Secret{} + err := c.Get(ctx, client.ObjectKey{Name: "test", Namespace: "default"}, secret) + if err != nil { + return err + } + if !assert.Equal(t, secret.GetLabels(), map[string]string{ + "app.kubernetes.io/managed-by": "control-plane-operator", + labelComponentName: comp.GetName(), + }) { + return errors.New("labels not equal") + } + return nil + }, + }, + { + name: "ObjectComponent BuildObject successful - Object already there - Update successful", + obj: FakeObjectComponent{ + BuildObjectToReconcileFunc: func(ctx context.Context) (client.Object, types.NamespacedName, error) { + return &corev1.Secret{}, types.NamespacedName{ + Name: "test", + Namespace: "default", + }, nil + }, + ReconcileObjectFunc: func(ctx context.Context, obj client.Object) error { + secret := obj.(*corev1.Secret) + secret.Type = corev1.SecretTypeDockerConfigJson + return nil + }, + name: "FakeObjectComponent", + }, + remoteObjects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Type: corev1.SecretTypeOpaque, // different type + }, + }, + validateFunc: func(ctx context.Context, c client.Client, comp juggler.Component) error { + secret := &corev1.Secret{} + err := c.Get(ctx, client.ObjectKey{Name: "test", Namespace: "default"}, secret) + if err != nil { + return err + } + if !assert.Equal(t, secret.GetLabels(), map[string]string{ + "app.kubernetes.io/managed-by": "control-plane-operator", + labelComponentName: comp.GetName(), + }) { + return errors.New("labels not equal") + } + if !assert.Equal(t, secret.Type, corev1.SecretTypeDockerConfigJson) { + return errors.New("type not equal") + } + return nil + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeRemoteClient := fake.NewClientBuilder().WithObjects(tt.remoteObjects...).Build() + r := NewReconciler(logr.Logger{}, fakeRemoteClient) + ctx := context.TODO() + actual := r.Install(ctx, tt.obj) + if !errors.Is(actual, tt.error) { + t.Errorf("ObjectReconciler.Install() = %v, want %v", actual, tt.error) + } + // validates if the object was created correctly + if tt.validateFunc != nil { + if err := tt.validateFunc(ctx, fakeRemoteClient, tt.obj); err != nil { + t.Errorf("ObjectReconciler.Install() = %v, want %v", err, nil) + } + } + }) + } +} + +func TestObjectReconciler_PreUninstall(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + expected error + }{ + { + name: "ObjectComponent no PreUninstall Hooks - no error", + obj: FakeObjectComponent{ + hooks: juggler.ComponentHooks{ + PreUninstall: nil, + }, + }, + expected: nil, + }, + { + name: "ObjectComponent PreUninstall error", + obj: FakeObjectComponent{ + hooks: juggler.ComponentHooks{ + PreUninstall: func(ctx context.Context, client client.Client) error { + return errBoom + }, + }, + }, + expected: errBoom, + }, + { + name: "ObjectComponent PreUninstall no error", + obj: FakeObjectComponent{ + hooks: juggler.ComponentHooks{ + PreUninstall: func(ctx context.Context, client client.Client) error { + return nil + }, + }, + }, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &ObjectReconciler{} + actual := r.PreUninstall(context.TODO(), tt.obj) + if !errors.Is(actual, tt.expected) { + t.Errorf("ObjectReconciler.PreUninstall() = %v, want %v", actual, tt.expected) + } + }) + } +} + +func TestObjectReconciler_PreInstall(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + expected error + }{ + { + name: "ObjectComponent no PreInstall Hooks - no error", + obj: FakeObjectComponent{ + hooks: juggler.ComponentHooks{ + PreInstall: nil, + }, + }, + expected: nil, + }, + { + name: "ObjectComponent PreInstall error", + obj: FakeObjectComponent{ + hooks: juggler.ComponentHooks{ + PreInstall: func(ctx context.Context, client client.Client) error { + return errBoom + }, + }, + }, + expected: errBoom, + }, + { + name: "ObjectComponent PreInstall no error", + obj: FakeObjectComponent{ + hooks: juggler.ComponentHooks{ + PreInstall: func(ctx context.Context, client client.Client) error { + return nil + }, + }, + }, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &ObjectReconciler{} + actual := r.PreInstall(context.TODO(), tt.obj) + if !errors.Is(actual, tt.expected) { + t.Errorf("ObjectReconciler.PreInstall() = %v, want %v", actual, tt.expected) + } + }) + } +} + +func TestObjectReconciler_PreUpdate(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + expected error + }{ + { + name: "ObjectComponent no PreUpdate Hooks - no error", + obj: FakeObjectComponent{ + hooks: juggler.ComponentHooks{ + PreUpdate: nil, + }, + }, + expected: nil, + }, + { + name: "ObjectComponent PreUpdate error", + obj: FakeObjectComponent{ + hooks: juggler.ComponentHooks{ + PreUpdate: func(ctx context.Context, client client.Client) error { + return errBoom + }, + }, + }, + expected: errBoom, + }, + { + name: "ObjectComponent PreUpdate no error", + obj: FakeObjectComponent{ + hooks: juggler.ComponentHooks{ + PreUpdate: func(ctx context.Context, client client.Client) error { + return nil + }, + }, + }, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &ObjectReconciler{} + actual := r.PreUpdate(context.TODO(), tt.obj) + if !errors.Is(actual, tt.expected) { + t.Errorf("ObjectReconciler.PreUpdate() = %v, want %v", actual, tt.expected) + } + }) + } +} + +func TestObjectReconciler_Uninstall(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + remoteObjects []client.Object + validateFunc func(ctx context.Context, c client.Client, obj client.Object) error + expected error + }{ + { + name: "nil", + obj: nil, + remoteObjects: nil, + expected: errNotObjectComponent, + }, + { + name: "Component - error", + obj: FakeComponent{}, + remoteObjects: nil, + expected: errNotObjectComponent, + }, + { + name: "ObjectComponent BuildObject error", + obj: FakeObjectComponent{ + BuildObjectToReconcileFunc: func(ctx context.Context) (client.Object, types.NamespacedName, error) { + return nil, types.NamespacedName{}, errBoom + }, + }, + remoteObjects: nil, + expected: errBoom, + }, + { + name: "Uninstall not successful - Object (Secret) Not Found", + obj: FakeObjectComponent{ + BuildObjectToReconcileFunc: func(ctx context.Context) (client.Object, types.NamespacedName, error) { + return &corev1.Secret{}, types.NamespacedName{ + Name: "test", + Namespace: "default", + }, nil + }, + }, + remoteObjects: []client.Object{}, + expected: nil, + }, + { + name: "Uninstall not successful - Object found and deleted", + obj: FakeObjectComponent{ + BuildObjectToReconcileFunc: func(ctx context.Context) (client.Object, types.NamespacedName, error) { + return &corev1.Secret{}, types.NamespacedName{ + Name: "test", + Namespace: "default", + }, nil + }, + }, + remoteObjects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, + validateFunc: func(ctx context.Context, c client.Client, obj client.Object) error { + // checks whether the object is deleted + if err := c.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { + return nil + } + return errors.New("object not deleted") + }, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := fake.NewClientBuilder().WithObjects(tt.remoteObjects...).Build() + r := NewReconciler(logr.Logger{}, fakeClient) + ctx := context.TODO() + actual := r.Uninstall(ctx, tt.obj) + if !errors.Is(actual, tt.expected) { + t.Errorf("ObjectReconciler.Uninstall() = %v, want %v", actual, tt.expected) + } + if tt.validateFunc != nil { + if err := tt.validateFunc(ctx, fakeClient, tt.remoteObjects[0]); err != nil { + t.Errorf("ObjectReconciler.Uninstall() = %v, want %v", err, nil) + } + } + }) + } +} + +func TestNewReconciler(t *testing.T) { + tests := []struct { + name string + logger logr.Logger + remoteClient client.Client + expected *ObjectReconciler + }{ + { + name: "New ObjectReconciler", + logger: logr.Logger{}, + remoteClient: nil, + expected: &ObjectReconciler{ + remoteClient: nil, + logger: logr.Logger{}, + knownTypes: sets.Set[reflect.Type]{}, + }, + }, + { + name: "New ObjectReconciler with remoteClient", + logger: logr.Logger{}, + remoteClient: fake.NewFakeClient(), + expected: &ObjectReconciler{ + remoteClient: fake.NewFakeClient(), + logger: logr.Logger{}, + knownTypes: sets.Set[reflect.Type]{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := NewReconciler(tt.logger, tt.remoteClient) + if !assert.Equal(t, tt.expected, actual) { + t.Errorf("NewReconciler() = %v, want %v", actual, tt.expected) + } + }) + } +} + +func TestObjectReconciler_Observe(t *testing.T) { + tests := []struct { + name string + obj juggler.Component + remoteObjects []client.Object + expectedObservation juggler.ComponentObservation + expectedError error + }{ + { + name: "Error not a ObjectComponent", + obj: FakeComponent{}, + remoteObjects: nil, + expectedObservation: juggler.ComponentObservation{}, + expectedError: errNotObjectComponent, + }, + { + name: "ObjectComponent BuildObject Error", + obj: FakeObjectComponent{ + BuildObjectToReconcileFunc: func(ctx context.Context) (client.Object, types.NamespacedName, error) { + return nil, types.NamespacedName{}, errBoom + }, + }, + remoteObjects: nil, + expectedObservation: juggler.ComponentObservation{}, + expectedError: errBoom, + }, + { + name: "ObjectComponent BuildObject successful - Object not found", + obj: FakeObjectComponent{ + BuildObjectToReconcileFunc: func(ctx context.Context) (client.Object, types.NamespacedName, error) { + return &corev1.Secret{}, types.NamespacedName{ + Name: "test", + Namespace: "default", + }, nil + }, + }, + remoteObjects: nil, + expectedObservation: juggler.ComponentObservation{ResourceExists: false}, + expectedError: nil, + }, + { + name: "ObjectComponent BuildObject successful - Object found - Resource is not healthy", + obj: FakeObjectComponent{ + BuildObjectToReconcileFunc: func(ctx context.Context) (client.Object, types.NamespacedName, error) { + return &corev1.Secret{}, types.NamespacedName{ + Name: "test", + Namespace: "default", + }, nil + }, + IsObjectHealthyFunc: func(obj client.Object) juggler.ResourceHealthiness { + return juggler.ResourceHealthiness{ // not healthy + Healthy: false, + Message: "not healthy", + } + }, + }, + remoteObjects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, + expectedObservation: juggler.ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: juggler.ResourceHealthiness{ + Healthy: false, + Message: "not healthy", + }, + }, + }, + { + name: "ObjectComponent BuildObject successful - Object found - Resource is healthy", + obj: FakeObjectComponent{ + BuildObjectToReconcileFunc: func(ctx context.Context) (client.Object, types.NamespacedName, error) { + return &corev1.Secret{}, types.NamespacedName{ + Name: "test", + Namespace: "default", + }, nil + }, + IsObjectHealthyFunc: func(obj client.Object) juggler.ResourceHealthiness { + return juggler.ResourceHealthiness{ // healthy + Healthy: true, + Message: "", + } + }, + }, + remoteObjects: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + }, + expectedObservation: juggler.ComponentObservation{ + ResourceExists: true, + ResourceHealthiness: juggler.ResourceHealthiness{ + Healthy: true, + Message: "", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewReconciler(logr.Logger{}, fake.NewClientBuilder().WithObjects(tt.remoteObjects...).Build()) + ctx := context.TODO() + actualObservation, actualError := r.Observe(ctx, tt.obj) + if !assert.Equal(t, tt.expectedObservation, actualObservation) { + t.Errorf("ObjectReconciler.Observe() = %v, want %v", actualObservation, tt.expectedObservation) + } + if !assert.Equal(t, tt.expectedError, actualError) { + t.Errorf("ObjectReconciler.Observe() = %v, want %v", actualError, tt.expectedError) + } + }) + } +} + +func Test_ObjectReconciler_Types(t *testing.T) { + r := NewReconciler(logr.Logger{}, nil) + r.RegisterType(FakeObjectComponent{}, FakeObjectComponent{}) + assert.Len(t, r.KnownTypes(), 1) + assert.Equal(t, r.KnownTypes()[0], reflect.TypeOf(FakeObjectComponent{})) +} + +func Test_ObjectReconciler_DetectOrphanedComponents(t *testing.T) { + testCases := []struct { + desc string + initObjs []client.Object + interceptorFuncs interceptor.Funcs + configuredComponents []juggler.Component + expectedComps []juggler.Component + expectedErr error + }{ + { + desc: "should find exactly one orphaned component", + initObjs: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "orphaned-cm", + Labels: map[string]string{ + fakeFilterLabel: "true", + }, + }, + }, + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "configured-cm", + Labels: map[string]string{ + fakeFilterLabel: "true", + }, + }, + }, + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-unrelated-cm", + }, + }, + }, + configuredComponents: []juggler.Component{ + FakeObjectComponent{name: "configured-cm", enabled: true}, + }, + expectedComps: []juggler.Component{ + FakeObjectComponent{name: "orphaned-cm", enabled: false}, + }, + expectedErr: nil, + }, + { + desc: "should not return error when CRD is not installed", + configuredComponents: []juggler.Component{ + FakeObjectComponent{name: "configured-cm", enabled: true}, + }, + interceptorFuncs: interceptor.Funcs{ + List: func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + return &apiutil.ErrResourceDiscoveryFailed{ + schema.GroupVersion{Group: corev1.GroupName, Version: "v1"}: apierrors.NewNotFound(corev1.Resource("configmaps"), "ConfigMap"), + } + }, + }, + expectedComps: []juggler.Component{}, + expectedErr: nil, + }, + { + desc: "should return error when unexpected error happens", + configuredComponents: []juggler.Component{ + FakeObjectComponent{name: "configured-cm", enabled: true}, + }, + interceptorFuncs: interceptor.Funcs{ + List: func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + return errBoom + }, + }, + expectedComps: nil, + expectedErr: errBoom, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + fakeClient := fake.NewClientBuilder().WithObjects(tC.initObjs...).WithInterceptorFuncs(tC.interceptorFuncs).Build() + r := NewReconciler(logr.Logger{}, fakeClient) + for _, cc := range tC.configuredComponents { + r.RegisterType(cc.(ObjectComponent)) + } + actualComps, actualErr := r.DetectOrphanedComponents(context.Background(), tC.configuredComponents) + assert.Equal(t, tC.expectedErr, actualErr) + if tC.expectedComps == nil { + assert.Nil(t, actualComps) + return + } + if !assert.Len(t, actualComps, len(tC.expectedComps)) { + return + } + for i, ac := range actualComps { + ec := tC.expectedComps[i] + assert.Equal(t, ac.GetName(), ec.GetName()) + assert.Equal(t, ac.IsEnabled(), ec.IsEnabled()) + } + }) + } +} diff --git a/pkg/juggler/reconciler.go b/pkg/juggler/reconciler.go new file mode 100644 index 0000000..0b5465a --- /dev/null +++ b/pkg/juggler/reconciler.go @@ -0,0 +1,46 @@ +package juggler + +import ( + "context" + "reflect" +) + +type ComponentReconciler interface { + // KnownTypes returns a list of component types that are supported by this reconciler. + KnownTypes() []reflect.Type + // Observe returns a ComponentObservation to return the state of a Component + // inside the cluster to the ComponentReconciler + Observe(ctx context.Context, component Component) (ComponentObservation, error) + // Uninstall triggers an uninstall of a Component in the cluster + Uninstall(ctx context.Context, component Component) error + // Update updates a Component in the cluster + Update(ctx context.Context, component Component) error + // Install triggers an install of a Component in the cluster + Install(ctx context.Context, component Component) error + // PreUninstall calls the pre-uninstall hook of a Component. + PreUninstall(ctx context.Context, component Component) error + // PreUninstall calls the pre-install hook of a Component. + PreInstall(ctx context.Context, component Component) error + // PreUpdate calls the pre-update hook of a Component. + PreUpdate(ctx context.Context, component Component) error +} + +type ComponentObservation struct { + ResourceExists bool + ResourceHealthiness +} + +type ResourceHealthiness struct { + Healthy bool + Message string +} + +// OrphanedComponentsDetector can be implemented by a `ComponentReconciler` to signal that it +// supports the discovery of orphaned components. +type OrphanedComponentsDetector interface { + // DetectOrphanedComponents searches for orphaned components. The `configuredComponents` contains a list of + // configured components supported by the reconciler. It should be used to calculate the delta between + // existing and configured components. + // Orphaned=Existing\Configured (set difference D=M\N). + DetectOrphanedComponents(ctx context.Context, configuredComponents []Component) ([]Component, error) +} diff --git a/pkg/utils/conditions.go b/pkg/utils/conditions.go new file mode 100644 index 0000000..1ba24ef --- /dev/null +++ b/pkg/utils/conditions.go @@ -0,0 +1,29 @@ +package utils + +import ( + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// UpdateConditions merges the "newConditions" into "conditions" while keeping the "LastTransitionTime" of unchanged +// conditions and removing stale conditions that are not present in "newConditions" anymore. +func UpdateConditions(conditions *[]metav1.Condition, newConditions []metav1.Condition) bool { + changed := false + for _, newCond := range newConditions { + setChanged := meta.SetStatusCondition(conditions, newCond) + if setChanged { + changed = true + } + } + + for _, cond := range *conditions { + if meta.FindStatusCondition(newConditions, cond.Type) == nil { + removeChanged := meta.RemoveStatusCondition(conditions, cond.Type) + if removeChanged { + changed = true + } + } + } + + return changed +} diff --git a/pkg/utils/conditions_test.go b/pkg/utils/conditions_test.go new file mode 100644 index 0000000..d8bcbac --- /dev/null +++ b/pkg/utils/conditions_test.go @@ -0,0 +1,225 @@ +package utils + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + condType1 = "Condition1" + condType2 = "Condition2" + condType3 = "Condition3" + condType4 = "Condition4" +) + +var ( + transitionTime = metav1.Now() + transitionTimeLater = metav1.Time{Time: transitionTime.Add(1 * time.Hour)} +) + +func Test_UpdateConditions(t *testing.T) { + testCases := []struct { + desc string + conditions, newConditions, expectedConditions []metav1.Condition + expectedChanged bool + }{ + { + desc: "should add conditions to empty list", + newConditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTime, + }, + }, + expectedConditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTime, + }, + }, + expectedChanged: true, + }, + { + desc: "should add conditions to list with existing items", + conditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTime, + }, + }, + newConditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTimeLater, + }, + { + Type: condType2, + Status: metav1.ConditionTrue, + LastTransitionTime: transitionTimeLater, + }, + }, + expectedConditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTime, + }, + { + Type: condType2, + Status: metav1.ConditionTrue, + LastTransitionTime: transitionTimeLater, + }, + }, + expectedChanged: true, + }, + { + desc: "should override existing condition with same type", + conditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTime, + }, + }, + newConditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionTrue, + LastTransitionTime: transitionTimeLater, + }, + }, + expectedConditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionTrue, + LastTransitionTime: transitionTimeLater, + }, + }, + expectedChanged: true, + }, + { + desc: "should remove stale condition", + conditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTime, + }, + { + Type: condType2, + Status: metav1.ConditionTrue, + LastTransitionTime: transitionTime, + }, + }, + newConditions: []metav1.Condition{ + { + Type: condType2, + Status: metav1.ConditionTrue, + LastTransitionTime: transitionTimeLater, + }, + }, + expectedConditions: []metav1.Condition{ + { + Type: condType2, + Status: metav1.ConditionTrue, + LastTransitionTime: transitionTime, + }, + }, + expectedChanged: true, + }, + { + desc: "should do everything at once", + conditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTime, + }, + { + Type: condType2, + Status: metav1.ConditionTrue, + LastTransitionTime: transitionTime, + }, + { + Type: condType3, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTime, + }, + }, + newConditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTimeLater, + }, + { + Type: condType2, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTimeLater, + }, + { + Type: condType4, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTimeLater, + }, + }, + expectedConditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTime, + }, + { + Type: condType2, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTimeLater, + }, + { + Type: condType4, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTimeLater, + }, + }, + expectedChanged: true, + }, + { + desc: "should not do anything", + conditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTime, + }, + }, + newConditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTimeLater, + }, + }, + expectedConditions: []metav1.Condition{ + { + Type: condType1, + Status: metav1.ConditionFalse, + LastTransitionTime: transitionTime, + }, + }, + expectedChanged: false, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + actualChanged := UpdateConditions(&tC.conditions, tC.newConditions) + assert.Equal(t, tC.expectedChanged, actualChanged) + assert.EqualValues(t, tC.expectedConditions, tC.conditions) + }) + } +} diff --git a/pkg/utils/envtest/envtest.go b/pkg/utils/envtest/envtest.go new file mode 100644 index 0000000..5665f0c --- /dev/null +++ b/pkg/utils/envtest/envtest.go @@ -0,0 +1,123 @@ +package envtest + +import ( + "bytes" + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +var ( + errMakefileIsDir = errors.New("expected Makefile to be a file but it is a directory") + errFailedToGetWD = errors.New("failed to get working directory") + errFailedToFindMakefile = errors.New("failed to find Makefile") + errFailedToRunMake = errors.New("failed to run make") + errFailedToRunSetupEnvtest = errors.New("failed to run setup-envtest") + errMakefileNotFound = errors.New("reached fs root and did not find Makefile") + errFailedToReadMakefile = errors.New("failed to read Makefile") + errK8sVersionNotFound = errors.New("value of ENVTEST_K8S_VERSION not found") + + k8sVersionRegexp = regexp.MustCompile(`ENVTEST_K8S_VERSION\s*=\s*(.+)\n`) +) + +// Install uses make to install the envtest dependencies and sets the +// KUBEBUILDER_ASSETS environment variable. +func Install() error { + wd, err := os.Getwd() + if err != nil { + return errors.Join(errFailedToGetWD, err) + } + + makefilePath, err := findMakefile(wd) + if err != nil { + return errors.Join(errFailedToFindMakefile, err) + } + repoDir := filepath.Dir(makefilePath) + + if err := runMakeEnvtest(repoDir); err != nil { + return err + } + + assetsDir, err := runSetupEnvtest(repoDir, makefilePath) + if err != nil { + return err + } + + return os.Setenv("KUBEBUILDER_ASSETS", assetsDir) +} + +func runMakeEnvtest(repoDir string) error { + cmd := exec.Command("make", "envtest") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + return errors.Join(errFailedToRunMake, err) + } + return nil +} + +func runSetupEnvtest(repoDir, makefilePath string) (string, error) { + k8sVersion, err := readK8sVersion(makefilePath) + if err != nil { + return "", err + } + + binDir := filepath.Join(repoDir, "bin") + binary := filepath.Join(binDir, "setup-envtest") + cmd := exec.Command(binary, "use", k8sVersion, "--bin-dir", binDir, "-p", "path") + stdout := &bytes.Buffer{} + cmd.Stdout = stdout + cmd.Stderr = os.Stderr + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + return "", errors.Join(errFailedToRunSetupEnvtest, err) + } + return strings.TrimSpace(stdout.String()), nil +} + +func findMakefile(root string) (string, error) { + if !filepath.IsAbs(root) { + var err error + if root, err = filepath.Abs(root); err != nil { + return "", err + } + } + + if root == "/" { + return "", errMakefileNotFound + } + + makefilePath := filepath.Join(root, "Makefile") + finfo, err := os.Stat(makefilePath) + if errors.Is(err, fs.ErrNotExist) { + parent := filepath.Dir(root) + return findMakefile(parent) + } + if err != nil { + return "", err + } + if finfo.IsDir() { + return "", errMakefileIsDir + } + + return makefilePath, nil +} + +func readK8sVersion(makefilePath string) (string, error) { + bytes, err := os.ReadFile(makefilePath) + if err != nil { + return "", errors.Join(errFailedToReadMakefile, err) + } + + match := k8sVersionRegexp.FindSubmatch(bytes) + if match == nil || len(match) != 2 { + return "", errK8sVersionNotFound + } + + return string(match[1]), nil +} diff --git a/pkg/utils/envtest/envtest_test.go b/pkg/utils/envtest/envtest_test.go new file mode 100644 index 0000000..59eeeb2 --- /dev/null +++ b/pkg/utils/envtest/envtest_test.go @@ -0,0 +1,26 @@ +package envtest + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + k8senvtest "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +func Test_findMakefile(t *testing.T) { + actual, err := findMakefile(".") + assert.NoError(t, err) + + expected, err := filepath.Abs("../../../Makefile") + assert.NoError(t, err) + assert.Equal(t, expected, actual) +} + +func Test_Install(t *testing.T) { + assert.NoError(t, Install()) + testEnv := &k8senvtest.Environment{} + _, err := testEnv.Start() + assert.NoError(t, err) + assert.NoError(t, testEnv.Stop()) +} diff --git a/pkg/utils/errors.go b/pkg/utils/errors.go new file mode 100644 index 0000000..81e7c31 --- /dev/null +++ b/pkg/utils/errors.go @@ -0,0 +1,33 @@ +package utils + +import ( + "errors" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +// IsCRDNotFound checks if the given error is a CRD not found error. +func IsCRDNotFound(err error) bool { + // check if err tree contains a "NoKindMatchError" error. + if errors.Is(err, &meta.NoKindMatchError{}) { + return true + } + + // check if err tree contains a "ErrResourceDiscoveryFailed" error. + var rdfErr *apiutil.ErrResourceDiscoveryFailed + if !errors.As(err, &rdfErr) { + return false + } + + // all wrapped errors must be "NotFound" errors. + // only then the entire "ErrResourceDiscoveryFailed" is considered as "CRD not found". + for _, wrappedErr := range *rdfErr { + if !apierrors.IsNotFound(wrappedErr) { + return false + } + } + + return true +} diff --git a/pkg/utils/errors_test.go b/pkg/utils/errors_test.go new file mode 100644 index 0000000..bf95a10 --- /dev/null +++ b/pkg/utils/errors_test.go @@ -0,0 +1,64 @@ +package utils + +import ( + "testing" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +func TestIsCRDNotFound(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "no error", + err: nil, + want: false, + }, + { + name: "no kind match error", + err: &meta.NoKindMatchError{}, + want: true, + }, + { + name: "resource discovery failed error", + err: &apiutil.ErrResourceDiscoveryFailed{}, + want: true, + }, + { + name: "not found error", + err: &apiutil.ErrResourceDiscoveryFailed{ + schema.GroupVersion{}: errors.NewNotFound(schema.GroupResource{}, ""), + }, + want: true, + }, + { + name: "multiple not found error", + err: &apiutil.ErrResourceDiscoveryFailed{ + schema.GroupVersion{}: errors.NewNotFound(schema.GroupResource{}, ""), + schema.GroupVersion{}: errors.NewNotFound(schema.GroupResource{}, ""), + }, + want: true, + }, + { + name: "Not found and forbidden error", + err: &apiutil.ErrResourceDiscoveryFailed{ + schema.GroupVersion{}: errors.NewNotFound(schema.GroupResource{}, ""), + schema.GroupVersion{}: errors.NewForbidden(schema.GroupResource{}, "", nil), + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsCRDNotFound(tt.err); got != tt.want { + t.Errorf("IsCRDNotFound() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/utils/json.go b/pkg/utils/json.go new file mode 100644 index 0000000..0d0e31e --- /dev/null +++ b/pkg/utils/json.go @@ -0,0 +1,13 @@ +package utils + +import "encoding/json" + +// MustMarshal returns the JSON encoding of v and panics if there is an error. +func MustMarshal(v any) []byte { + jsonBytes, err := json.Marshal(v) + if err != nil { + panic(err) + } + + return jsonBytes +} diff --git a/pkg/utils/json_test.go b/pkg/utils/json_test.go new file mode 100644 index 0000000..11cb7ce --- /dev/null +++ b/pkg/utils/json_test.go @@ -0,0 +1,56 @@ +package utils + +import ( + "testing" +) + +func TestMustMarshal(t *testing.T) { + tests := []struct { + name string + v any + want string + }{ + { + name: "nil", + v: nil, + want: "null", + }, + { + name: "string", + v: "foo", + want: `"foo"`, + }, + { + name: "int", + v: 1, + want: "1", + }, + { + name: "struct", + v: struct{ Foo string }{Foo: "bar"}, + want: `{"Foo":"bar"}`, + }, + { + name: "slice", + v: []string{"foo", "bar"}, + want: `["foo","bar"]`, + }, + { + name: "map", + v: map[string]string{"foo": "bar"}, + want: `{"foo":"bar"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MustMarshal(tt.v) + + if len(got) == 0 { + t.Errorf("MustMarshal() = %v, want non-empty", got) + } + if string(got) != tt.want { + t.Errorf("MustMarshal() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/utils/maps.go b/pkg/utils/maps.go new file mode 100644 index 0000000..e49e7e5 --- /dev/null +++ b/pkg/utils/maps.go @@ -0,0 +1,66 @@ +package utils + +import "errors" + +var ( + ErrNotAStringAnyMap = errors.New("sub-map is not of type map[string]any") + ErrNoPath = errors.New("at least one path element needs to be specified") + ErrMapNil = errors.New("map is nil") + ErrValueNotFound = errors.New("value not found") +) + +// SetNestedDefault traverses a map and sets a default value if no map entry exists yet under the given key. +func SetNestedDefault(m map[string]any, v any, path ...string) error { + if m == nil { + return ErrMapNil + } + if len(path) == 0 { + return ErrNoPath + } + if len(path) == 1 { + // Value set already, don't override + if _, ok := m[path[0]]; ok { + return nil + } + // Set default + m[path[0]] = v + return nil + } + + if m[path[0]] == nil { + m[path[0]] = map[string]any{} + } + + subMap, ok := m[path[0]].(map[string]any) + if !ok { + return ErrNotAStringAnyMap + } + return SetNestedDefault(subMap, v, path[1:]...) +} + +// GetNestedValue traverses a map and looks for a value under the given path. +// If the value was not found, `ErrValueNotFound` will be returned. +func GetNestedValue(m map[string]any, path ...string) (any, error) { + if m == nil { + return nil, ErrMapNil + } + if len(path) == 0 { + return nil, ErrNoPath + } + if len(path) == 1 { + // Value set, return it + if val, ok := m[path[0]]; ok { + return val, nil + } + return nil, ErrValueNotFound + } + if m[path[0]] == nil { + return nil, ErrValueNotFound + } + + subMap, ok := m[path[0]].(map[string]any) + if !ok { + return nil, ErrNotAStringAnyMap + } + return GetNestedValue(subMap, path[1:]...) +} diff --git a/pkg/utils/maps_test.go b/pkg/utils/maps_test.go new file mode 100644 index 0000000..2d04ad0 --- /dev/null +++ b/pkg/utils/maps_test.go @@ -0,0 +1,161 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_SetNested(t *testing.T) { + testCases := []struct { + desc string + m map[string]any + v any + path []string + expected map[string]any + expectedErr error + }{ + { + desc: "should fail when map is nil", + expectedErr: ErrMapNil, + }, + { + desc: "should fail when map no field path is provided", + m: map[string]any{}, + expected: map[string]any{}, + expectedErr: ErrNoPath, + }, + { + desc: "should fail when field path leads to wrong map type", + m: map[string]any{ + "item": map[string]int{ + "count": 1, + }, + }, + path: []string{"item", "count"}, + expected: map[string]any{ + "item": map[string]int{ + "count": 1, + }, + }, + expectedErr: ErrNotAStringAnyMap, + }, + { + desc: "should not override nested field", + m: map[string]any{ + "item": map[string]any{ + "count": 1, + }, + }, + path: []string{"item", "count"}, + v: 2, + expected: map[string]any{ + "item": map[string]any{ + "count": 1, + }, + }, + expectedErr: nil, + }, + { + desc: "should set nested field", + m: map[string]any{ + "item": map[string]any{}, + }, + path: []string{"item", "count"}, + v: 2, + expected: map[string]any{ + "item": map[string]any{ + "count": 2, + }, + }, + expectedErr: nil, + }, + { + desc: "should create sub-map and set nested field", + m: map[string]any{}, + path: []string{"item", "count"}, + v: 2, + expected: map[string]any{ + "item": map[string]any{ + "count": 2, + }, + }, + expectedErr: nil, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + actualErr := SetNestedDefault(tC.m, tC.v, tC.path...) + assert.Equal(t, tC.expected, tC.m) + assert.Equal(t, tC.expectedErr, actualErr) + }) + } +} + +func Test_GetNestedValue(t *testing.T) { + testCases := []struct { + desc string + m map[string]any + path []string + expected any + expectedErr error + }{ + { + desc: "should return error when map is nil", + expectedErr: ErrMapNil, + }, + { + desc: "should return error when path is empty", + m: map[string]any{}, + expectedErr: ErrNoPath, + }, + { + desc: "should return error when value was not found", + m: map[string]any{}, + path: []string{"item"}, + expectedErr: ErrValueNotFound, + }, + { + desc: "should return error when value was not found because nested map is missing", + m: map[string]any{}, + path: []string{"item", "count"}, + expectedErr: ErrValueNotFound, + }, + { + desc: "should return error when value was not found in nested map", + m: map[string]any{ + "item": map[string]any{}, + }, + path: []string{"item", "count"}, + expectedErr: ErrValueNotFound, + }, + { + desc: "should fail when field path leads to wrong map type", + m: map[string]any{ + "item": map[string]int{ + "count": 1, + }, + }, + path: []string{"item", "count"}, + expectedErr: ErrNotAStringAnyMap, + }, + { + desc: "should find value in nested map", + m: map[string]any{ + "item": map[string]any{ + "count": 1, + }, + }, + path: []string{"item", "count"}, + expected: 1, + expectedErr: nil, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + v, err := GetNestedValue(tC.m, tC.path...) + assert.Equal(t, tC.expected, v) + assert.Equal(t, tC.expectedErr, err) + }) + } +} diff --git a/pkg/utils/meta.go b/pkg/utils/meta.go new file mode 100644 index 0000000..3d85b5c --- /dev/null +++ b/pkg/utils/meta.go @@ -0,0 +1,28 @@ +package utils + +import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + labelManagedBy = "app.kubernetes.io/managed-by" + labelManagedByValue = "control-plane-operator" +) + +func SetLabel(obj v1.Object, label string, value string) { + labels := obj.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[label] = value + obj.SetLabels(labels) +} + +func SetManagedBy(obj v1.Object) { + SetLabel(obj, labelManagedBy, labelManagedByValue) +} + +func IsManaged() client.MatchingLabels { + return client.MatchingLabels{labelManagedBy: labelManagedByValue} +} diff --git a/pkg/utils/meta_test.go b/pkg/utils/meta_test.go new file mode 100644 index 0000000..cfbffe8 --- /dev/null +++ b/pkg/utils/meta_test.go @@ -0,0 +1,87 @@ +package utils + +import ( + "testing" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "gotest.tools/v3/assert" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestSetLabel(t *testing.T) { + tests := []struct { + name string + obj v1.Object + label string + value string + want map[string]string + }{ + { + name: "add new label to object", + obj: &v1beta1.ControlPlane{}, + label: "foo", + value: "bar", + want: map[string]string{"foo": "bar"}, + }, + { + name: "update existing label", + obj: &v1beta1.ControlPlane{ObjectMeta: v1.ObjectMeta{Labels: map[string]string{"foo": "bar"}}}, + label: "foo", + value: "baz", + want: map[string]string{"foo": "baz"}, + }, + { + name: "add a second label to object", + obj: &v1beta1.ControlPlane{ObjectMeta: v1.ObjectMeta{Labels: map[string]string{"foo": "bar"}}}, + label: "abc", + value: "xyz", + want: map[string]string{"foo": "bar", "abc": "xyz"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SetLabel(tt.obj, tt.label, tt.value) + assert.DeepEqual(t, tt.obj.GetLabels(), tt.want) + }) + } +} + +func TestSetManagedBy(t *testing.T) { + tests := []struct { + name string + obj v1.Object + want map[string]string + }{ + { + name: "set managed by label", + obj: &v1beta1.ControlPlane{}, + want: map[string]string{labelManagedBy: labelManagedByValue}, + }, + { + name: "update existing label", + obj: &v1beta1.ControlPlane{ + ObjectMeta: v1.ObjectMeta{ + Labels: map[string]string{"app.kubernetes.io/managed-by": "foo"}, + }, + }, + want: map[string]string{labelManagedBy: labelManagedByValue}, + }, + { + name: "add a second label to object", + obj: &v1beta1.ControlPlane{ObjectMeta: v1.ObjectMeta{Labels: map[string]string{"foo": "bar"}}}, + want: map[string]string{"foo": "bar", labelManagedBy: labelManagedByValue}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SetManagedBy(tt.obj) + assert.DeepEqual(t, tt.obj.GetLabels(), tt.want) + }) + } +} + +func TestIsManaged(t *testing.T) { + got := IsManaged() + assert.DeepEqual(t, got, client.MatchingLabels{labelManagedBy: labelManagedByValue}) +} diff --git a/pkg/utils/rcontext/rcontext.go b/pkg/utils/rcontext/rcontext.go new file mode 100644 index 0000000..1792d4e --- /dev/null +++ b/pkg/utils/rcontext/rcontext.go @@ -0,0 +1,67 @@ +package rcontext + +import ( + "context" + + "github.com/fluxcd/pkg/apis/meta" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/secretresolver" + corev1 "k8s.io/api/core/v1" +) + +type tenantNamespaceKey struct{} + +func WithTenantNamespace(ctx context.Context, namespace string) context.Context { + return context.WithValue(ctx, tenantNamespaceKey{}, namespace) +} + +func TenantNamespace(ctx context.Context) string { + return ctx.Value(tenantNamespaceKey{}).(string) +} + +// +// ----------------------- +// + +type fluxKubeconfigKey struct{} + +func WithFluxKubeconfigRef(ctx context.Context, ref *corev1.SecretReference) context.Context { + return context.WithValue(ctx, fluxKubeconfigKey{}, &meta.KubeConfigReference{ + SecretRef: meta.SecretKeyReference{ + Name: ref.Name, + Key: "kubeconfig", + }, + }) +} + +func FluxKubeconfigRef(ctx context.Context) *meta.KubeConfigReference { + return ctx.Value(fluxKubeconfigKey{}).(*meta.KubeConfigReference) +} + +// +// ----------------------- +// + +type versionResolverFnKey struct{} + +func WithVersionResolver(ctx context.Context, fn v1beta1.VersionResolverFn) context.Context { + return context.WithValue(ctx, versionResolverFnKey{}, fn) +} + +func VersionResolver(ctx context.Context) v1beta1.VersionResolverFn { + return ctx.Value(versionResolverFnKey{}).(v1beta1.VersionResolverFn) +} + +// +// ----------------------- +// + +type secretRefResolverFnKey struct{} + +func WithSecretRefResolver(ctx context.Context, fn secretresolver.ResolveFunc) context.Context { + return context.WithValue(ctx, secretRefResolverFnKey{}, fn) +} + +func SecretRefResolver(ctx context.Context) secretresolver.ResolveFunc { + return ctx.Value(secretRefResolverFnKey{}).(secretresolver.ResolveFunc) +} diff --git a/pkg/utils/rcontext/rcontext_test.go b/pkg/utils/rcontext/rcontext_test.go new file mode 100644 index 0000000..c186544 --- /dev/null +++ b/pkg/utils/rcontext/rcontext_test.go @@ -0,0 +1,49 @@ +package rcontext + +import ( + "reflect" + "testing" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/secretresolver" + "github.com/stretchr/testify/assert" + "golang.org/x/net/context" + corev1 "k8s.io/api/core/v1" +) + +func TestTenantNamespace(t *testing.T) { + val := "some-namespace" + ctx := WithTenantNamespace(context.TODO(), val) + actual := TenantNamespace(ctx) + assert.Equal(t, val, actual) +} + +func TestFluxKubeconfigRef(t *testing.T) { + val := &corev1.SecretReference{Name: "some-secret", Namespace: "some-namespace"} + ctx := WithFluxKubeconfigRef(context.TODO(), val) + actual := FluxKubeconfigRef(ctx) + assert.Equal(t, val.Name, actual.SecretRef.Name) + assert.Equal(t, "kubeconfig", actual.SecretRef.Key) +} + +func TestWithVersionResolver(t *testing.T) { + fn := func(componentName string, channelName string) (v1beta1.ComponentVersion, error) { + return v1beta1.ComponentVersion{}, nil + } + ctx := WithVersionResolver(context.TODO(), fn) + actual := VersionResolver(ctx) + if reflect.ValueOf(actual).Pointer() != reflect.ValueOf(fn).Pointer() { + t.Error("Functions are not equal") + } +} + +func TestSecretRefResolver(t *testing.T) { + fn := func(urlType secretresolver.UrlSecretType) (*corev1.LocalObjectReference, error) { + return nil, nil + } + ctx := WithSecretRefResolver(context.TODO(), fn) + actual := SecretRefResolver(ctx) + if reflect.ValueOf(actual).Pointer() != reflect.ValueOf(fn).Pointer() { + t.Error("Functions are not equal") + } +} diff --git a/test/e2e/controlplane_test.go b/test/e2e/controlplane_test.go new file mode 100644 index 0000000..4a7074a --- /dev/null +++ b/test/e2e/controlplane_test.go @@ -0,0 +1,36 @@ +//go:build e2e + +package e2e + +import ( + "context" + "testing" + + xpres "github.com/crossplane-contrib/xp-testing/pkg/resources" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" +) + +func TestControlplane(t *testing.T) { + cpName := "cp-minimal-e2e" + feature := features.New("CO-671 Install a simple Control Plane"). + Setup(SetUpControlPlaneResources("testdata/crs/controlplane-only", cpName)). + Assess( + "Check Minimal ControlPlane Created", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + if !isControlPlaneReady(cpObserved) { + t.Error("ControlPlane resource is not ready") + } + return ctx + }, + ). + Assess( + "Check Control Plane Deleted", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + xpres.AwaitResourceDeletionOrFail(ctx, t, cfg, cpObserved) + return ctx + }, + ).Feature() + + testEnv.Test(t, feature) +} diff --git a/test/e2e/cp_btpso_test.go b/test/e2e/cp_btpso_test.go new file mode 100644 index 0000000..377fd6a --- /dev/null +++ b/test/e2e/cp_btpso_test.go @@ -0,0 +1,135 @@ +//go:build e2e + +package e2e + +import ( + "context" + "testing" + + xpres "github.com/crossplane-contrib/xp-testing/pkg/resources" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" +) + +func TestBTPServiceOperator(t *testing.T) { + cpName := "cp-e2e-btpso" + feature := features.New("CO-671 Install a Control Plane with BTP Service Operator"). + Setup(SetUpControlPlaneResources("testdata/crs/cp-btpso", cpName)). + Assess( + "Check BTP Service Operator Installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + WaitForBTPSOResources(cfg, t) + + // waits for the Cert Manager and BTP Service Operator Component to be healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpName, "CertManager", "BTPServiceOperator") + return ctx + }, + ). + Assess( + "Check BTP Service Operator Updated", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + want := &v1beta1.ComponentsConfig{ + BTPServiceOperator: &v1beta1.BTPServiceOperatorConfig{ + Version: "0.6.8", // version increase for update + }, + CertManager: &v1beta1.CertManagerConfig{ + Version: "1.16.1", + }, + } + updateCP := UpdateControlPlaneSpec(cpObserved, want) + + // Update Control Plane with new spec + UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + + // check if Deployment for BTP Service Operator is updated + WaitForBTPSOResources(cfg, t) + + // waits for the Cert Manager and BTP Service Operator Component to be healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpName, "CertManager", "BTPServiceOperator") + + return ctx + }, + ). + Assess( + "Check BTP Service Operator Deleted", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + + want := &v1beta1.ComponentsConfig{ + BTPServiceOperator: nil, + CertManager: &v1beta1.CertManagerConfig{ + Version: "1.16.1", + }, + } + updateCP := UpdateControlPlaneSpec(cpObserved, want) + + // Update Control Plane with new spec + UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + + // check if Deployment for BTP Service Operator is deleted + checkBTPSODeploymentDeletedOrError(t, cfg) + + return ctx + }, + ). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + + updateCP := UpdateControlPlaneSpec(cpObserved, &v1beta1.ComponentsConfig{ + CertManager: nil, + }) + + // Update Control Plane with new spec + UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + + checkCertManagerDeploymentDeletedOrError(t, cfg) + + // Tear down control plane so that we do other tests + cpResourceDummy := newControlPlaneResource(cfg, cpName) + xpres.AwaitResourceDeletionOrFail(ctx, t, cfg, cpResourceDummy) + return ctx + }).Feature() + + testEnv.Test(t, feature) +} + +// WaitForCrossplaneResources waits for the External Secrets Operator Deployment to be ready +func WaitForBTPSOResources(cfg *envconf.Config, t *testing.T) { + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sap-btp-operator-controller-manager", + Namespace: "sap-btp-service-operator", + }, + } + // check if Deployment for BTP Service Operator is created + err := wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(dep, func(object k8s.Object) bool { + d := object.(*appsv1.Deployment) + return float64(d.Status.ReadyReplicas)/float64(*d.Spec.Replicas) >= 0.75 + }), wait.WithTimeout(timeoutDeploymentsAvailable)) + if err != nil { + t.Error(err) + } +} + +// checkBTPSODeploymentDeletedOrError checks if the BTP Service Operator Deployment is deleted +func checkBTPSODeploymentDeletedOrError(t *testing.T, cfg *envconf.Config) { + // check if Deployment for BTP Service Operator is deleted + btpsoDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sap-btp-operator-controller-manager", + Namespace: "sap-btp-service-operator", + }, + } + + // wait for the deployments to be deleted + err := wait.For(conditions.New(cfg.Client().Resources()).ResourceDeleted(btpsoDeployment), wait.WithTimeout(timeoutDeploymentDeleted)) + + if err != nil { + t.Error(err) + } +} diff --git a/test/e2e/cp_cert_manager_test.go b/test/e2e/cp_cert_manager_test.go new file mode 100644 index 0000000..00739ba --- /dev/null +++ b/test/e2e/cp_cert_manager_test.go @@ -0,0 +1,111 @@ +//go:build e2e + +package e2e + +import ( + "context" + "testing" + + xpres "github.com/crossplane-contrib/xp-testing/pkg/resources" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" +) + +var ( + certManagerDeploymentList = &appsv1.DeploymentList{ + Items: []appsv1.Deployment{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "cert-manager", + Namespace: "cert-manager", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "cert-manager-cainjector", + Namespace: "cert-manager", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "cert-manager-webhook", + Namespace: "cert-manager", + }, + }, + }, + } +) + +func TestCertManager(t *testing.T) { + cpName := "cp-e2e-cert-manager" + feature := features.New("CO-671 Install a Control Plane with Cert Manager"). + Setup(SetUpControlPlaneResources("testdata/crs/cp-cert-manager", cpName)). + Assess( + "Check Cert Manager Installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + WaitForCertManagerResources(cfg, t) + // waits for the Cert Manager Component is healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpName, "CertManager") + return ctx + }, + ). + Assess( + "Check Cert Manager Deleted", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + + want := &v1beta1.ComponentsConfig{ + CertManager: nil, + } + + updateCP := UpdateControlPlaneSpec(cpObserved, want) + + // Update Control Plane with new spec + UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + + checkCertManagerDeploymentDeletedOrError(t, cfg) + + return ctx + }, + ). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + // Tear down control plane so that we do other tests + cpResourceDummy := newControlPlaneResource(cfg, cpName) + xpres.AwaitResourceDeletionOrFail(ctx, t, cfg, cpResourceDummy) + return ctx + }).Feature() + + testEnv.Test(t, feature) +} + +// WaitForCrossplaneResources waits for the External Secrets Operator Deployment to be ready +func WaitForCertManagerResources(cfg *envconf.Config, t *testing.T) { + // check if all 3 Deployments for External Secrets Operator are created in namespace external-secrets + err := wait.For(conditions.New(cfg.Client().Resources()).ResourceListMatchN(certManagerDeploymentList, 3, func(object k8s.Object) bool { + d := object.(*appsv1.Deployment) + + // true if the image version is the same as the version we want AND the replicas are 1 AND the deployment is available + return d.Status.Replicas == 1 && + IsStatusDeploymentConditionPresentAndEqual(d.Status.Conditions, "Available", corev1.ConditionTrue) + + }), wait.WithTimeout(timeoutDeploymentsAvailable)) + if err != nil { + t.Error(err) + } +} + +// checkCertManagerDeploymentDeletedOrError checks if the Deployment for Cert Manager is deleted +func checkCertManagerDeploymentDeletedOrError(t *testing.T, cfg *envconf.Config) { + // check if Deployment for Cert Manager is deleted + err := wait.For(conditions.New(cfg.Client().Resources()).ResourceDeleted(&certManagerDeploymentList.Items[0]), wait.WithTimeout(timeoutDeploymentDeleted)) + + if err != nil { + t.Error(err) + } +} diff --git a/test/e2e/cp_crossplane_provider_admission_policy_test.go b/test/e2e/cp_crossplane_provider_admission_policy_test.go new file mode 100644 index 0000000..caefed1 --- /dev/null +++ b/test/e2e/cp_crossplane_provider_admission_policy_test.go @@ -0,0 +1,53 @@ +//go:build e2e + +package e2e + +import ( + "context" + "strings" + "testing" + + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + "k8s.io/apimachinery/pkg/api/meta" + + xpres "github.com/crossplane-contrib/xp-testing/pkg/resources" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" +) + +func TestCrossplaneProviderAdmissionPolicy(t *testing.T) { + cpWithProviderAdmissionPolicy := "cp-e2e-crossplane-provider-admission-policy" + feature := features.New("CO-671 Check if Crossplane Provider is not allowed"). + Setup(SetUpControlPlaneResources("testdata/crs/cp-crossplane-provider-admission-policy", cpWithProviderAdmissionPolicy)). + Assess("Check Crossplane Installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + WaitForCrossplaneResources(cfg, t) + + // waits for the Crossplane Component to be healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpWithProviderAdmissionPolicy, "Crossplane") + return ctx + }). + Assess("Check that Crossplane Provider installation is prevented by policy", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + // check that Crossplane Provider is not allowed to be installed and info message is shown to the user + checkIfCrossplaneProviderFailsDueToPolicy(t, cfg, cpWithProviderAdmissionPolicy) + return ctx + }). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + // Tear down control plane so that we do other tests; this will also delete the Crossplane + cpResourceDummy := newControlPlaneResource(cfg, cpWithProviderAdmissionPolicy) + xpres.AwaitResourceDeletionOrFail(ctx, t, cfg, cpResourceDummy) + return ctx + }).Feature() + + testEnv.Test(t, feature) +} + +// checkIfCrossplaneProviderFailsDueToPolicy checks if the Crossplane Provider is not allowed to be installed (due to Validation Admission Policy) +func checkIfCrossplaneProviderFailsDueToPolicy(t *testing.T, cfg *envconf.Config, cpName string) { + cp := GetControlPlaneOrError(t, cfg, cpName) + for _, c := range cp.Status.Conditions { + if c.Type == "ProviderKubernetesReady" && c.Reason == juggler.StatusInstallFailed.Name && strings.Contains(c.Message, "ValidatingAdmissionPolicy") { + return // Crossplane Provider is not allowed to be installed - test passed + } + } + t.Errorf("Crossplane Provider is allowed to be installed but should not be! Status: %+v", meta.FindStatusCondition(cp.Status.Conditions, "ProviderKubernetesReady")) +} diff --git a/test/e2e/cp_crossplane_provider_not_allowed_test.go b/test/e2e/cp_crossplane_provider_not_allowed_test.go new file mode 100644 index 0000000..783d272 --- /dev/null +++ b/test/e2e/cp_crossplane_provider_not_allowed_test.go @@ -0,0 +1,52 @@ +//go:build e2e + +package e2e + +import ( + "context" + "strings" + "testing" + + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + + xpres "github.com/crossplane-contrib/xp-testing/pkg/resources" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" +) + +func TestCrossplaneProviderNotAllowed(t *testing.T) { + cpWithProviderNotAllowed := "cp-e2e-crossplane-provider-not-allowed" + feature := features.New("CO-671 Check if Crossplane Provider is not allowed"). + Setup(SetUpControlPlaneResources("testdata/crs/cp-crossplane-provider-not-allowed", cpWithProviderNotAllowed)). + Assess("Check Crossplane Installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + WaitForCrossplaneResources(cfg, t) + + // waits for the Crossplane Component to be healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpWithProviderNotAllowed, "Crossplane") + return ctx + }). + Assess("Check that Crossplane Provider is not allowed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + // check that Crossplane Provider is not allowed to be installed and info message is shown to the user + checkIfCrossplaneProviderIsNotAllowed(t, cfg, cpWithProviderNotAllowed) + return ctx + }). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + // Tear down control plane so that we do other tests; this will also delete the Crossplane + cpResourceDummy := newControlPlaneResource(cfg, cpWithProviderNotAllowed) + xpres.AwaitResourceDeletionOrFail(ctx, t, cfg, cpResourceDummy) + return ctx + }).Feature() + + testEnv.Test(t, feature) +} + +// checkIfCrossplaneProviderIsNotAllowed checks if the Crossplane Provider is not allowed to be installed +func checkIfCrossplaneProviderIsNotAllowed(t *testing.T, cfg *envconf.Config, cpName string) { + cp := GetControlPlaneOrError(t, cfg, cpName) + for _, c := range cp.Status.Conditions { + if c.Type == "ProviderKubernetesAbcxyzReady" && c.Reason == juggler.StatusInstallFailed.Name && strings.Contains(c.Message, "ProviderKubernetesAbcxyz not installable: Component provider-kubernetes-abcxyz with version 1.1.1 not found.") { + return // Crossplane Provider is not allowed to be installed - test passed + } + } + t.Error("Crossplane Provider is allowed to be installed but should not be!") +} diff --git a/test/e2e/cp_crossplane_providers_test.go b/test/e2e/cp_crossplane_providers_test.go new file mode 100644 index 0000000..c2d200f --- /dev/null +++ b/test/e2e/cp_crossplane_providers_test.go @@ -0,0 +1,173 @@ +//go:build e2e + +package e2e + +import ( + "context" + "testing" + + xpres "github.com/crossplane-contrib/xp-testing/pkg/resources" + xcommonv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + crossplanev1 "github.com/crossplane/crossplane/apis/pkg/v1" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/controlplane/crossplane" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/e2e-framework/klient/k8s" + res "sigs.k8s.io/e2e-framework/klient/k8s/resources" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" +) + +const ( + providerVersionInManifest = "0.14.1" + providerVersionNew = "0.15.0" + providerName = "kubernetes" +) + +func TestCrossplaneProviders(t *testing.T) { + cpName := "cp-e2e-crossplane-provider" + feature := features.New("CO-671 Install a Control Plane with Crossplane and a Crossplane Provider"). + Setup(SetUpControlPlaneResources("testdata/crs/cp-crossplane-provider", cpName)). + Assess( + "Check Crossplane Installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + WaitForCrossplaneResources(cfg, t) + + // waits for the Crossplane Component to be healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpName, "Crossplane") + return ctx + }, + ). + Assess( + "Check Crossplane Provider Installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + WaitForCrossplaneProviderResources(cfg, t, providerKubernetesComponentConfig(providerVersionInManifest)) + // waits for the Crossplane and ProviderKubernetes Component to be healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpName, "Crossplane", "ProviderKubernetes") + return ctx + }, + ). + Assess( + "Check Crossplane Provider Updated", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + + want := providerKubernetesComponentConfig(providerVersionNew) + + updateCP := UpdateControlPlaneSpec(cpObserved, want) + + // Update Control Plane with new spec + UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + + // check if Deployment for Crossplane is created + WaitForCrossplaneProviderResources(cfg, t, want) + + // waits for the Crossplane and ProviderKubernetes Component to be healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpName, "Crossplane", "ProviderKubernetes") + + return ctx + }, + ). + Assess( + "Check Crossplane Provider Deleted", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + + want := &v1beta1.ComponentsConfig{ + Crossplane: &v1beta1.CrossplaneConfig{ + Version: "1.17.1", + }, + } + updateCP := UpdateControlPlaneSpec(cpObserved, want) + + // Update Control Plane with new spec + UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + + // check if Deployment for Crossplane Provider is deleted + checkCrossplaneProviderDeploymentDeletedOrError(t, cfg) + return ctx + }, + ). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + // Tear down control plane so that we do other tests; this will also delete the Crossplane + cpResourceDummy := newControlPlaneResource(cfg, cpName) + xpres.AwaitResourceDeletionOrFail(ctx, t, cfg, cpResourceDummy) + return ctx + }).Feature() + + testEnv.Test(t, feature) +} + +func providerKubernetesComponentConfig(providerVersion string) *v1beta1.ComponentsConfig { + return &v1beta1.ComponentsConfig{ + Crossplane: &v1beta1.CrossplaneConfig{ + Version: "1.17.1", + Providers: []*v1beta1.CrossplaneProviderConfig{ + { + Name: providerName, + Version: providerVersion, + }, + }, + }, + } +} + +// WaitForCrossplaneProviderResources waits for the Crossplane Provider to be ready +func WaitForCrossplaneProviderResources(cfg *envconf.Config, t *testing.T, want *v1beta1.ComponentsConfig) { + client := getResourcesWithCrossplaneSchemeOrError(cfg, t) + + provider := &crossplanev1.Provider{ + ObjectMeta: metav1.ObjectMeta{ + Name: crossplane.AddProviderPrefix(want.Crossplane.Providers[0].Name), + }, + } + + err := wait.For(conditions.New(client).ResourceMatch(provider, func(object k8s.Object) bool { + prov := object.(*crossplanev1.Provider) + // true if correct package with version and if Provider is healthy + return IsStatusConditionPresentAndEqual(prov.Status.Conditions, "Healthy", corev1.ConditionTrue) + }), wait.WithTimeout(timeoutDeploymentsAvailable)) + if err != nil { + t.Error(err) + } +} + +// getResourcesWithCrossplaneSchemeOrError returns a res.Resources with registered Crossplane scheme +func getResourcesWithCrossplaneSchemeOrError(cfg *envconf.Config, t *testing.T) *res.Resources { + client, err := cfg.NewClient() + if err != nil { + t.Fatal(err) + } + clientres := client.Resources() + _ = crossplanev1.AddToScheme(clientres.GetScheme()) + + return clientres +} + +// IsStatusConditionPresentAndEqual returns true when conditionType is present and equal to status. +func IsStatusConditionPresentAndEqual(conditions []xcommonv1.Condition, conditionType string, status corev1.ConditionStatus) bool { + for _, condition := range conditions { + if string(condition.Type) == conditionType { + return condition.Status == status + } + } + return false +} + +// checkCrossplaneProviderDeploymentDeletedOrError checks if the Crossplane Provider Deployment is deleted +func checkCrossplaneProviderDeploymentDeletedOrError(t *testing.T, cfg *envconf.Config) { + client := getResourcesWithCrossplaneSchemeOrError(cfg, t) + + // check if Crossplane Provider is deleted + provider := &crossplanev1.Provider{ + ObjectMeta: metav1.ObjectMeta{ + Name: crossplane.AddProviderPrefix(providerName), + }, + } + + // wait for the Crossplane Provider to be deleted + err := wait.For(conditions.New(client).ResourceDeleted(provider), wait.WithTimeout(timeoutDeploymentDeleted)) + + if err != nil { + t.Error(err) + } +} diff --git a/test/e2e/cp_crossplane_test.go b/test/e2e/cp_crossplane_test.go new file mode 100644 index 0000000..5e5d9d3 --- /dev/null +++ b/test/e2e/cp_crossplane_test.go @@ -0,0 +1,117 @@ +//go:build e2e + +package e2e + +import ( + "context" + "testing" + + xpres "github.com/crossplane-contrib/xp-testing/pkg/resources" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" +) + +func TestCrossplane(t *testing.T) { + cpName := "cp-e2e-crossplane" + feature := features.New("CO-671 Install a Control Plane with Crossplane"). + Setup(SetUpControlPlaneResources("testdata/crs/cp-crossplane", cpName)). + Assess( + "Check Crossplane Installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + WaitForCrossplaneResources(cfg, t) + // waits for the Crossplane Component to be healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpName, "Crossplane") + return ctx + }, + ). + Assess( + "Check Crossplane Updated", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + + want := &v1beta1.ComponentsConfig{ + Crossplane: &v1beta1.CrossplaneConfig{ + Version: "1.17.1", + }, + } + updateCP := UpdateControlPlaneSpec(cpObserved, want) + + // Update Control Plane with new spec + UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + + // check if Deployment for Crossplane is created + WaitForCrossplaneResources(cfg, t) + + // waits for the Crossplane Component to be healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpName, "Crossplane") + return ctx + }, + ). + Assess( + "Check Crossplane Deleted", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + + want := &v1beta1.ComponentsConfig{ + Crossplane: nil, + } + updateCP := UpdateControlPlaneSpec(cpObserved, want) + + // Update Control Plane with new spec + UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + + // check if Deployment for Crossplane is deleted + checkCrossplaneDeploymentDeletedOrError(t, cfg) + + return ctx + }, + ). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + // Tear down control plane so that we do other tests + cpResourceDummy := newControlPlaneResource(cfg, cpName) + xpres.AwaitResourceDeletionOrFail(ctx, t, cfg, cpResourceDummy) + return ctx + }).Feature() + + testEnv.Test(t, feature) +} + +// WaitForCrossplaneResources waits for the Crossplane Deployment to be ready +func WaitForCrossplaneResources(cfg *envconf.Config, t *testing.T) { + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "crossplane", + Namespace: "crossplane-system", + }, + } + // check if Deployment for Crossplane is created + err := wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(dep, func(object k8s.Object) bool { + d := object.(*appsv1.Deployment) + return float64(d.Status.ReadyReplicas)/float64(*d.Spec.Replicas) >= 0.75 + }), wait.WithTimeout(timeoutDeploymentsAvailable)) + if err != nil { + t.Error(err) + } +} + +// checkCrossplaneDeploymentDeletedOrError checks if the Crossplane Deployment is deleted +func checkCrossplaneDeploymentDeletedOrError(t *testing.T, cfg *envconf.Config) { + // check if Deployment for Crossplane is deleted + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "crossplane", + Namespace: "crossplane-system", + }, + } + + // wait for the deployment to be deleted + err := wait.For(conditions.New(cfg.Client().Resources()).ResourceDeleted(dep), wait.WithTimeout(timeoutDeploymentDeleted)) + + if err != nil { + t.Error(err) + } +} diff --git a/test/e2e/cp_eso_test.go b/test/e2e/cp_eso_test.go new file mode 100644 index 0000000..76d7a4b --- /dev/null +++ b/test/e2e/cp_eso_test.go @@ -0,0 +1,141 @@ +//go:build e2e + +package e2e + +import ( + "context" + "testing" + + xpres "github.com/crossplane-contrib/xp-testing/pkg/resources" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" + + "github.com/openmcp-project/control-plane-operator/api/v1beta1" +) + +var ( + esoDeploymentList = &appsv1.DeploymentList{ + Items: []appsv1.Deployment{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "external-secrets", + Namespace: "external-secrets", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "external-secrets-cert-controller", + Namespace: "external-secrets", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "external-secrets-webhook", + Namespace: "external-secrets", + }, + }, + }, + } +) + +func TestExternalSecretsOperator(t *testing.T) { + cpName := "cp-e2e-external-secrets-operator" + feature := features.New("CO-671 Install a Control Plane with External Secrets Operator"). + Setup(SetUpControlPlaneResources("testdata/crs/cp-eso", cpName)). + Assess( + "Check External Secrets Operator Installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + WaitForExternalSecretsOperatorResources(cfg, t) + + // waits for the ESO Component to be healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpName, "ExternalSecretsOperator") + return ctx + }, + ). + Assess( + "Check External Secrets Operator Updated", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + + want := &v1beta1.ComponentsConfig{ + ExternalSecretsOperator: &v1beta1.ExternalSecretsOperatorConfig{ + Version: "0.10.4", + }, + } + + updateCP := UpdateControlPlaneSpec(cpObserved, want) + + // Update Control Plane with new spec + UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + + // check if Deployment for External Secrets Operator is created + WaitForExternalSecretsOperatorResources(cfg, t) + + // waits for the ESO Component to be healthy + WaitForComponentStatusToBeHealthy(t, cfg, cpName, "ExternalSecretsOperator") + return ctx + }, + ). + Assess( + "Check External Secrets Operator Deleted", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + + want := &v1beta1.ComponentsConfig{ + ExternalSecretsOperator: nil, + } + + updateCP := UpdateControlPlaneSpec(cpObserved, want) + + // Update Control Plane with new spec + UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + + // check if Deployment for External Secrets Operator is deleted + checkExternalSecretsOperatorDeploymentDeletedOrError(t, cfg) + + return ctx + }, + ). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + // Tear down control plane so that we do other tests + cpResourceDummy := newControlPlaneResource(cfg, cpName) + xpres.AwaitResourceDeletionOrFail(ctx, t, cfg, cpResourceDummy) + return ctx + }).Feature() + + testEnv.Test(t, feature) +} + +// WaitForCrossplaneResources waits for the External Secrets Operator Deployment to be ready +func WaitForExternalSecretsOperatorResources(cfg *envconf.Config, t *testing.T) { + // check if all 3 Deployments for External Secrets Operator are created in namespace external-secrets + err := wait.For(conditions.New(cfg.Client().Resources()).ResourceListMatchN(esoDeploymentList, 3, func(object k8s.Object) bool { + d := object.(*appsv1.Deployment) + return IsStatusDeploymentConditionPresentAndEqual(d.Status.Conditions, "Available", corev1.ConditionTrue) && + d.Status.Replicas == 1 // TODO: fix this, this should be availableReplicas >= 1 + }), wait.WithTimeout(timeoutDeploymentsAvailable)) + if err != nil { + t.Error(err) + } +} + +// checkExternalSecretsOperatorDeploymentDeletedOrError checks if the Deployment for External Secrets Operator is deleted +func checkExternalSecretsOperatorDeploymentDeletedOrError(t *testing.T, cfg *envconf.Config) { + // check if Deployment for Crossplane is deleted + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "external-secrets", + Namespace: "external-secrets", + }, + } + + // wait for the deployment to be deleted + err := wait.For(conditions.New(cfg.Client().Resources()).ResourceDeleted(dep), wait.WithTimeout(timeoutDeploymentDeleted)) + + if err != nil { + t.Error(err) + } +} diff --git a/test/e2e/cp_kyverno_test.go b/test/e2e/cp_kyverno_test.go new file mode 100644 index 0000000..c535db5 --- /dev/null +++ b/test/e2e/cp_kyverno_test.go @@ -0,0 +1,133 @@ +//go:build e2e + +package e2e + +import ( + "context" + "testing" + + xpres "github.com/crossplane-contrib/xp-testing/pkg/resources" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" +) + +var ( + // from kyverno chart 3.1.4+ (hmm pi) + kyvernoDeploymentList = &appsv1.DeploymentList{ + Items: []appsv1.Deployment{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno-admission-controller", + Namespace: "kyverno-system", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno-background-controller", + Namespace: "kyverno-system", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno-reports-controller", + Namespace: "kyverno-system", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno-cleanup-controller", + Namespace: "kyverno-system", + }, + }, + }, + } +) + +func TestKyverno(t *testing.T) { + cpName := "cp-e2e-kyverno" + feature := features.New("CO-671 Install a Control Plane with Kyverno"). + Setup(SetUpControlPlaneResources("testdata/crs/cp-kyverno", cpName)). + Assess( + "Check Kyverno Installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + WaitForKyvernoResources(cfg, t) + WaitForComponentStatusToBeHealthy(t, cfg, cpName, "Kyverno") + return ctx + }, + ). + // Assess( + // "Check Kyverno Updated", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + // cpObserved := GetControlPlaneOrError(t, cfg, cpName) + + // want := &v1beta1.ComponentsConfig{ + // Kyverno: &v1beta1.KyvernoConfig{ + // Version: "latest", + // }, + // } + // updateCP := UpdateControlPlaneSpec(cpObserved, want) + // UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + // WaitForKyvernoResources(cfg, t) + // WaitForComponentStatusToBeHealthy(t, cfg, cpName, "Kyverno") + // return ctx + // }, + // ). + Assess( + "Check Kyverno Deleted", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + + want := &v1beta1.ComponentsConfig{ + Kyverno: nil, + } + updateCP := UpdateControlPlaneSpec(cpObserved, want) + UpdateControlPlaneOrError(ctx, t, cfg, updateCP) + checkKyvernoDeletedOrError(t, cfg) + return ctx + }, + ). + Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + // Tear down control plane so that we do other tests + cpResourceDummy := newControlPlaneResource(cfg, cpName) + xpres.AwaitResourceDeletionOrFail(ctx, t, cfg, cpResourceDummy) + return ctx + }).Feature() + + testEnv.Test(t, feature) +} + +// WaitForKyvernoResources waits for the Kyverno Deployment to be ready +func WaitForKyvernoResources(cfg *envconf.Config, t *testing.T) { + // check if all deployments from kyverno to be available in kyverno-system namespace + err := wait.For(conditions.New(cfg.Client().Resources()).ResourceListMatchN(kyvernoDeploymentList, 3, func(object k8s.Object) bool { + d := object.(*appsv1.Deployment) + + return IsStatusDeploymentConditionPresentAndEqual(d.Status.Conditions, "Available", corev1.ConditionTrue) && + d.Status.AvailableReplicas >= 1 + }), wait.WithTimeout(timeoutDeploymentsAvailable)) + if err != nil { + t.Error(err) + } +} + +// checkKyvernoDeletedOrError checks if one of the Kyverno Deployment is deleted +func checkKyvernoDeletedOrError(t *testing.T, cfg *envconf.Config) { + // check if kyverno admin controller is deleted, if it is, then other kyverno deployments should also be gone (as there are more than 1) + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kyverno-admission-controller", + Namespace: "kyverno-system", + }, + } + + // wait for the kyverno resources to be deleted + err := wait.For(conditions.New(cfg.Client().Resources()).ResourceDeleted(deployment), wait.WithTimeout(timeoutDeploymentDeleted)) + + if err != nil { + t.Error(err) + } +} diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go new file mode 100644 index 0000000..dab67b4 --- /dev/null +++ b/test/e2e/main_test.go @@ -0,0 +1,197 @@ +//go:build e2e + +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/crossplane-contrib/xp-testing/pkg/envvar" + "github.com/crossplane-contrib/xp-testing/pkg/logging" + "github.com/crossplane-contrib/xp-testing/pkg/xpenvfuncs" + "github.com/pkg/errors" + v1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + crLog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/env" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/envfuncs" + "sigs.k8s.io/e2e-framework/support/kind" + "sigs.k8s.io/e2e-framework/third_party/helm" +) + +var ( + UutControllerKey = "cloud-orchestration/control-plane-operator" + + testEnv env.Environment +) + +var ( + timeoutDeploymentsAvailable = time.Minute * 5 + timeoutDeploymentDeleted = time.Minute * 4 +) + +func TestMain(m *testing.M) { + var verbosity = 4 + logging.EnableVerboseLogging(&verbosity) + crLog.SetLogger(klog.NewKlogr()) + + // read environment variables + uutImages := envvar.GetOrPanic("UUT_IMAGES") + uutController := GetImagesFromJsonOrPanic(uutImages) + + pullSecretUser := envvar.GetOrPanic("PULL_SECRET_USER") + pullSecretPassword := envvar.GetOrPanic("PULL_SECRET_PASSWORD") + + kindClusterName := envvar.GetOrDefault("CLUSTER_NAME", envconf.RandomName("mcp-e2e", 10)) + + // create a new test environment and kind cluster + testEnv = env.New() + kindCluster := kind.NewCluster(kindClusterName) + + // Setup uses pre-defined funcs to create kind cluster + testEnv.Setup( + envfuncs.CreateCluster(kindCluster, kindClusterName), + envfuncs.LoadDockerImageToCluster(kindClusterName, uutController), + + func(ctx context.Context, config *envconf.Config) (context.Context, error) { + manager := helm.New(config.KubeconfigFile()) + + // install the control plane operator via Helm + err := manager.RunInstall( + helm.WithName("flux2"), + helm.WithChart("flux2"), + helm.WithNamespace("flux-system"), + helm.WithArgs("--create-namespace", "--repo", "https://fluxcd-community.github.io/helm-charts", "--version", "2.13.0"), //nolint:lll + ) + if err != nil { + _, errDump := xpenvfuncs.DumpLogs(kindClusterName, "flux-setup-err")(ctx, config) + klog.Fatal(errDump) + } + + return ctx, err + }, + func(ctx context.Context, config *envconf.Config) (context.Context, error) { + // create a new generic secret with pull secret for Flux + err := exec.Command("kubectl", "create", "secret", "generic", "artifactory-readonly-basic", "--type=kubernetes.io/basic-auth", "--from-literal=username="+pullSecretUser, "--from-literal=password="+pullSecretPassword).Run() //nolint:lll + if err != nil { + return ctx, err + } + // pull secret has to be copied to every tenant namespace in order to get consumed by Flux + err = exec.Command("kubectl", "label", "secret", "artifactory-readonly-basic", "core.orchestrate.cloud.sap/copy-to-cp-namespaces=true").Run() //nolint:lll + if err != nil { + return ctx, err + } + err = exec.Command("kubectl", "annotate", "secret", "artifactory-readonly-basic", "core.orchestrate.cloud.sap/credentials-for-url=https://common.repositories.cloud.sap/artifactory/api/helm/deploy-releases-hyperspace-helm").Run() //nolint:lll + if err != nil { + return ctx, err + } + + return ctx, nil + }, + func(ctx context.Context, config *envconf.Config) (context.Context, error) { + manager := helm.New(config.KubeconfigFile()) + + // split uutController in key and value + uutControllerSplit := strings.Split(uutController, ":") + + err := exec.Command("kubectl", "create", "namespace", "co-system").Run() //nolint:lll + if err != nil { + return ctx, err + } + + // Create new secret with ocm registry file for local overrides + err = exec.Command("kubectl", "create", "secret", "generic", "ocm-registry", "-n", "co-system", "--from-file=ocm_registry.tgz=../testdata/ocm_registry.tgz").Run() //nolint:lll + if err != nil { + fmt.Println("Error creating secret ocm-registry") + return ctx, err + } + + // install the control plane operator via Helm + err = manager.RunInstall( + helm.WithName("co-control-plane-operator"), + helm.WithChart("../../charts/control-plane-operator"), + helm.WithNamespace("co-system"), + helm.WithArgs( + "--create-namespace", + "--set", "image.repository="+uutControllerSplit[0], + "--set", "image.tag="+uutControllerSplit[1], + "--set", "image.pullPolicy=Never", + "--set", "syncPeriod=10s", // Set the sync period here + "-f", "testdata/values.yaml"), //nolint:lll + ) + + return ctx, err + }, + + waitForController("co-control-plane-operator", "co-system"), + func(ctx context.Context, config *envconf.Config) (context.Context, error) { + // Apply the releasechannel from the samples directory + err := exec.Command("kubectl", "apply", "-f", "../../config/samples/releasechannel/local.yaml").Run() + if err != nil { + fmt.Println("Error applying releasechannel") + return ctx, err + } + + return ctx, nil + }, + ) + + testEnv.AfterEachTest(DumpLogsOnFail(kindClusterName)) + testEnv.Finish(xpenvfuncs.DumpLogs(kindClusterName, "post-test")) + + testEnv.Finish(envfuncs.DestroyCluster(kindClusterName)) + + os.Exit(testEnv.Run(m)) +} + +func DumpLogsOnFail(kindClusterName string) func(context.Context, *envconf.Config, *testing.T) (context.Context, error) { + return func(ctx context.Context, c *envconf.Config, t *testing.T) (context.Context, error) { + if t.Failed() { + xpenvfuncs.DumpLogs(kindClusterName, kindClusterName+t.Name()) + } + return ctx, nil + } +} + +// GetImagesFromJsonOrPanic returns the UUT controller image from the UUT_IMAGES environment variable +func GetImagesFromJsonOrPanic(imagesJson string) string { + imageMap := map[string]string{} + + err := json.Unmarshal([]byte(imagesJson), &imageMap) + + if err != nil { + panic(errors.Wrap(err, "failed to unmarshal json from UUT_IMAGE")) + } + + uutController := imageMap[UutControllerKey] + + return uutController +} + +// waitForController waits for the controller to become available +func waitForController(name string, namespace string) env.Func { + return func(ctx context.Context, c *envconf.Config) (context.Context, error) { + dep := v1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } + // wait for the deployment to become at least 50% + err := wait.For(conditions.New(c.Client().Resources()).ResourceMatch(&dep, func(object k8s.Object) bool { + + d := object.(*v1.Deployment) + klog.Infof("Checking controller %s/%s to be available", namespace, namespace) + return float64(d.Status.ReadyReplicas)/float64(*d.Spec.Replicas) >= 0.50 + }), wait.WithTimeout(timeoutDeploymentsAvailable)) + return ctx, err + } +} diff --git a/test/e2e/testdata/crs/controlplane-only/controlplane-minimal.yaml b/test/e2e/testdata/crs/controlplane-only/controlplane-minimal.yaml new file mode 100644 index 0000000..da9b2c0 --- /dev/null +++ b/test/e2e/testdata/crs/controlplane-only/controlplane-minimal.yaml @@ -0,0 +1,13 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: cp-minimal-e2e + namespace: default +spec: + target: + serviceAccount: {} + fluxServiceAccount: + name: flux-deployer + namespace: default + overrides: + host: https://kubernetes.default.svc diff --git a/test/e2e/testdata/crs/cp-btpso/controlplane-btp-service-operator.yaml b/test/e2e/testdata/crs/cp-btpso/controlplane-btp-service-operator.yaml new file mode 100644 index 0000000..e022071 --- /dev/null +++ b/test/e2e/testdata/crs/cp-btpso/controlplane-btp-service-operator.yaml @@ -0,0 +1,16 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: cp-e2e-btpso +spec: + target: + serviceAccount: {} + fluxServiceAccount: + name: flux-deployer + namespace: default + overrides: + host: https://kubernetes.default.svc + btpServiceOperator: + version: 0.6.0 + certManager: + version: 1.16.1 diff --git a/test/e2e/testdata/crs/cp-cert-manager/controlplane-cert-manager.yaml b/test/e2e/testdata/crs/cp-cert-manager/controlplane-cert-manager.yaml new file mode 100644 index 0000000..b3e077a --- /dev/null +++ b/test/e2e/testdata/crs/cp-cert-manager/controlplane-cert-manager.yaml @@ -0,0 +1,14 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: cp-e2e-cert-manager +spec: + target: + serviceAccount: {} + fluxServiceAccount: + name: flux-deployer + namespace: default + overrides: + host: https://kubernetes.default.svc + certManager: + version: 1.16.1 diff --git a/test/e2e/testdata/crs/cp-crossplane-provider-admission-policy/controlplane-crossplane-provider.yaml b/test/e2e/testdata/crs/cp-crossplane-provider-admission-policy/controlplane-crossplane-provider.yaml new file mode 100644 index 0000000..9a27099 --- /dev/null +++ b/test/e2e/testdata/crs/cp-crossplane-provider-admission-policy/controlplane-crossplane-provider.yaml @@ -0,0 +1,32 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: cp-e2e-crossplane-provider-admission-policy +spec: + target: + serviceAccount: {} + fluxServiceAccount: + name: flux-deployer + namespace: default + overrides: + host: https://kubernetes.default.svc + crossplane: + version: 1.17.1 + providers: + - name: kubernetes + version: 0.14.1 +--- +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: CrossplanePackageRestriction +metadata: + name: default +spec: + providers: + registries: [] # nothing is allowed + packages: [] # nothing is allowed + configurations: + registries: [] + packages: [] + functions: + registries: [] + packages: [] diff --git a/test/e2e/testdata/crs/cp-crossplane-provider-not-allowed/controlplane-crossplane-provider.yaml b/test/e2e/testdata/crs/cp-crossplane-provider-not-allowed/controlplane-crossplane-provider.yaml new file mode 100644 index 0000000..b214abf --- /dev/null +++ b/test/e2e/testdata/crs/cp-crossplane-provider-not-allowed/controlplane-crossplane-provider.yaml @@ -0,0 +1,33 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: cp-e2e-crossplane-provider-not-allowed +spec: + target: + serviceAccount: {} + fluxServiceAccount: + name: flux-deployer + namespace: default + overrides: + host: https://kubernetes.default.svc + crossplane: + version: 1.17.1 + providers: + - name: kubernetes-abcxyz # provider not allowed - not part of any ReleaseChannel + version: 1.1.1 +--- +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: CrossplanePackageRestriction +metadata: + name: default +spec: + providers: + registries: + - xpkg.upbound.io + packages: [] + configurations: + registries: [] + packages: [] + functions: + registries: [] + packages: [] diff --git a/test/e2e/testdata/crs/cp-crossplane-provider/controlplane-crossplane-provider.yaml b/test/e2e/testdata/crs/cp-crossplane-provider/controlplane-crossplane-provider.yaml new file mode 100644 index 0000000..697615a --- /dev/null +++ b/test/e2e/testdata/crs/cp-crossplane-provider/controlplane-crossplane-provider.yaml @@ -0,0 +1,33 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: cp-e2e-crossplane-provider +spec: + target: + serviceAccount: {} + fluxServiceAccount: + name: flux-deployer + namespace: default + overrides: + host: https://kubernetes.default.svc + crossplane: + version: 1.17.1 + providers: + - name: kubernetes + version: 0.14.1 +--- +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: CrossplanePackageRestriction +metadata: + name: default +spec: + providers: + registries: + - xpkg.upbound.io + packages: [] + configurations: + registries: [] + packages: [] + functions: + registries: [] + packages: [] diff --git a/test/e2e/testdata/crs/cp-crossplane/controlplane-crossplane.yaml b/test/e2e/testdata/crs/cp-crossplane/controlplane-crossplane.yaml new file mode 100644 index 0000000..fdf0349 --- /dev/null +++ b/test/e2e/testdata/crs/cp-crossplane/controlplane-crossplane.yaml @@ -0,0 +1,15 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: cp-e2e-crossplane + namespace: default +spec: + target: + serviceAccount: {} + fluxServiceAccount: + name: flux-deployer + namespace: default + overrides: + host: https://kubernetes.default.svc + crossplane: + version: 1.17.0 diff --git a/test/e2e/testdata/crs/cp-eso/controlplane-external-secrets-operator.yaml b/test/e2e/testdata/crs/cp-eso/controlplane-external-secrets-operator.yaml new file mode 100644 index 0000000..7e95abc --- /dev/null +++ b/test/e2e/testdata/crs/cp-eso/controlplane-external-secrets-operator.yaml @@ -0,0 +1,14 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: cp-e2e-external-secrets-operator +spec: + target: + serviceAccount: {} + fluxServiceAccount: + name: flux-deployer + namespace: default + overrides: + host: https://kubernetes.default.svc + externalSecretsOperator: + version: 0.10.0 diff --git a/test/e2e/testdata/crs/cp-kyverno/cp-kyverno.yaml b/test/e2e/testdata/crs/cp-kyverno/cp-kyverno.yaml new file mode 100644 index 0000000..83268e4 --- /dev/null +++ b/test/e2e/testdata/crs/cp-kyverno/cp-kyverno.yaml @@ -0,0 +1,15 @@ +apiVersion: core.orchestrate.cloud.sap/v1beta1 +kind: ControlPlane +metadata: + name: cp-e2e-kyverno + namespace: default +spec: + target: + serviceAccount: {} + fluxServiceAccount: + name: flux-deployer + namespace: default + overrides: + host: https://kubernetes.default.svc + kyverno: + version: 3.2.7 diff --git a/test/e2e/testdata/values.yaml b/test/e2e/testdata/values.yaml new file mode 100644 index 0000000..699b436 --- /dev/null +++ b/test/e2e/testdata/values.yaml @@ -0,0 +1,6 @@ +rbac: + clusterRole: + rules: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["*"] diff --git a/test/e2e/utils_test.go b/test/e2e/utils_test.go new file mode 100644 index 0000000..60467ec --- /dev/null +++ b/test/e2e/utils_test.go @@ -0,0 +1,141 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "testing" + "time" + + xpres "github.com/crossplane-contrib/xp-testing/pkg/resources" + "github.com/openmcp-project/control-plane-operator/api/v1beta1" + "github.com/openmcp-project/control-plane-operator/pkg/juggler" + v1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/e2e-framework/klient/k8s" + res "sigs.k8s.io/e2e-framework/klient/k8s/resources" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/envconf" +) + +// isControlPlaneReady validates that a v1beta1.ControlPlane was created and is ready +func isControlPlaneReady(o *v1beta1.ControlPlane) bool { + return o.CreationTimestamp.Size() > 0 && meta.IsStatusConditionTrue(o.Status.Conditions, "Ready") +} + +// IsStatusDeploymentConditionPresentAndEqual checks if a DeploymentCondition is present and has the given status +func IsStatusDeploymentConditionPresentAndEqual(conditions []v1.DeploymentCondition, conditionType string, status corev1.ConditionStatus) bool { + for _, condition := range conditions { + if string(condition.Type) == conditionType { + return condition.Status == status + } + } + return false +} + +// SetUpControlPlaneResources imports the ControlPlane resources +func SetUpControlPlaneResources(path, cpName string) func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + return func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + xpres.ImportResources(ctx, t, cfg, path) + _ = getClientFor(cfg) + waitForControlPlaneResource(cfg, t, cpName) + return ctx + } +} + +// waitForControlPlaneResource waits for the ControlPlane to be created +func waitForControlPlaneResource(cfg *envconf.Config, t *testing.T, cpName string) { + client := cfg.Client() + + // create new Control Plane + cp := newControlPlaneResource(cfg, cpName) + err := wait.For(conditions.New(client.Resources()).ResourceMatch(cp, func(object k8s.Object) bool { + d := object.(*v1beta1.ControlPlane) + return d.CreationTimestamp.Size() > 0 + })) + + if err != nil { + t.Error(err) + } +} + +// newControlPlaneResource creates a new v1beta1.ControlPlane resource with a given name +func newControlPlaneResource(cfg *envconf.Config, cpName string) *v1beta1.ControlPlane { + return &v1beta1.ControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: cpName, Namespace: cfg.Namespace(), + }, + } +} + +// UpdateControlPlaneOrError updates a v1beta1.ControlPlane resource +func UpdateControlPlaneOrError(ctx context.Context, t *testing.T, cfg *envconf.Config, updateCP *v1beta1.ControlPlane) { + client := cfg.Client() + r, _ := res.New(client.RESTConfig()) + + // update the ControlPlane resource + err := r.Update(ctx, updateCP) + if err != nil { + t.Fatal(err) + } +} + +// GetControlPlaneOrError returns the v1beta1.ControlPlane under the given cpName +func GetControlPlaneOrError(t *testing.T, cfg *envconf.Config, cpName string) *v1beta1.ControlPlane { + ct := &v1beta1.ControlPlane{} + namespace := cfg.Namespace() + res := cfg.Client().Resources() + + err := res.Get(context.TODO(), cpName, namespace, ct) + if err != nil { + t.Error("failed to get ControlPlane resource. error: ", err) + } + return ct +} + +// UpdateControlPlaneSpec updates the Control Plane spec with the given v1beta1.ComponentsConfig +func UpdateControlPlaneSpec(cp *v1beta1.ControlPlane, config *v1beta1.ComponentsConfig) *v1beta1.ControlPlane { + updateCP := cp.DeepCopy() + updateCP.Spec.ComponentsConfig = *config + return updateCP +} + +// WaitForComponentStatusToBeHealthy waits for the given components to be healthy +func WaitForComponentStatusToBeHealthy(t *testing.T, cfg *envconf.Config, cpName string, componentNames ...string) { + + cpObserved := GetControlPlaneOrError(t, cfg, cpName) + err := wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(cpObserved, func(object k8s.Object) bool { + return checkControlPlaneComponentHealthiness(cpObserved, componentNames...) + }), wait.WithTimeout(time.Minute*10)) + + if err != nil { + fmt.Println("Control Plane ", cpName, " components are not healthy:", componentNames) + t.Error(err) + } +} + +// checkControlPlaneComponentHealthiness checks if the given components are healthy +func checkControlPlaneComponentHealthiness(cp *v1beta1.ControlPlane, componentNames ...string) bool { + if componentNames == nil { + return false + } + + for _, c := range cp.Status.Conditions { + for _, componentName := range componentNames { + if c.Reason == juggler.StatusUnhealthy.Name && c.Type == componentName+"Ready" { + return false + } + } + } + return true +} + +// getClientFor returns a Resources client that can be used to interact with the API. It also adds the v1beta1 scheme to the client. +func getClientFor(config *envconf.Config) *res.Resources { + _ = v1beta1.AddToScheme(config.Client().Resources().GetScheme()) + return config.Client().Resources() +} diff --git a/test/testdata/ocm_registry.tgz b/test/testdata/ocm_registry.tgz new file mode 100644 index 0000000000000000000000000000000000000000..8fc078227ebff9eac28169631b6804ef86632229 GIT binary patch literal 15269 zcmaibcR1Dm|F;#AQJJ9*sT9fHLY=fbjg@oi*JzykJIIE(wJWGGpJz9E}BZ zLDE~>mD3-Yqvt03AoCIM@g)Y2%cC*ez~c=EdQAh1_ICI2_Vsu(MDW7wB-P3{oYVlB zh95@bH3^>MlF(GM)Xt+`qj4i|kK=bMu$gDg^$pE+c!_VW2e`9a%>&a791XpPUT*le zY3Qk9S02}$bW}Az_MU4$!Rw7ZI?XH%q&mWaZUoKkWEn<<{hSX_w!Jq~TX$baI6tPmJ07=F_Q@a?1k^WDZJl z@2*Ul@<7r)RjP+bpIOg43PrL%;Z$Bo*_@s>65Qg0n4{h3sYVc!tJ?mf$)~UzphI(C^gj+GnTwty&Yef zx!F9#UsFl2xAVqde_m#=<=Yv4i~K_7f?oG$UVS5a1zK`ngnCJj$OT!G7c4ia3))6B zjD9{buV*rhS)p<6{EWP>lD=d0@jV0ibZS2nIR&HlNqKzZS7Ygd=U&!HBmTc5Fgj@=I}x^{m+~6HXm?pCF~K#^s!mR$lq*m#EWih8_26aVg~v zk-Xn~x)&oZHmT}$o6^1J_EPC@n|zMFTZ?c}9&xX8_`_8xabvpr{BI}! zQPU!&k(@he?r*iFgLCn}LnOW3J@Dq^etyOV(WzQS7RN|iW44^dTNm=gtpr`EX>Hw+&-j zp+;+QSkH}Kal6u<>nY^-r?&*!*JSeMjxCBd$Bi~uLyp6jq|=Yd!x_V0MoeZ;axpOJ zUE$V0yZEa2W-HUwnep6g#H}vv1G&DA!S^92s(({?0r*rRF zw<$%Mc+p2)Q79@+lzUEor>xWFaHjC#XcJ=PCyiI6WS37TswySk$op`{9PJCt-K9d_ z57ge}2v|%@L8{96TG8HZAN$~=*Kd*(;Z6Pgv_SZ|#q?#PZ_;_et8L^pHBFy_<`Q@5Qmw*q0sd{>snt_x>R`xro(*AKhN!C{#xRW-xwU$rmviDPg(CMkn)YHHhPF z9pmZXTv|$==vP^0(yTCL^mWJ8o$irioicgXZwL1vMYHRfbH&NF?;lz;y=M>~zjn3s zhAN*kZDzB`b5m~935lNKoc(0^BJHf>te4q$^u8XlrF6I3wz|mM-l(Pbc%;k`8^QX- zD}>GEN}v>vdd#J(_@74#sfxeDD}M=?Adv-H+#EFJt6Osua_&q@qG?Q~ITpS@Ublrc zYDPPS2_9m`ntRv4u}8BX+d^wA|Bh;#Ke>!h9^>1S%D`LCtv}0uaS@dIx_8N@H`985 z{j1C*R#)KQp6iQ}Z?4yx9@4KpiMN9FmH#Xd_&KPm`S|hJD5+_!WOc5uqtm^V+kKCe zd`m{MyLeo;jv^R7eP3l|aJ=i@KiBg_Y+vDpUbIPvRIZDcC8aj9kXGZ%;N!7$&Z@=% zkI0;(G4oj}of_}MRgr_!qvFPjhD`=9%G8+m>AF0crtXopI<9HiGFOK9c$Z}V>3(; zKYCIR5$R77Z>{2cR#y?hI!`6#T`XYI35=EB4RJBxl0uZFH+FTk0k8fpX>OVEr`1pY>4`UNVl97Xz z%apCO%H$oC#%_;Ozur2>SbxT;x07v5YamO;05dH|_gzvE z4n^F_tzqaU1fD?^UAOn~-CbNhcp!EJj{WvC(7e)M*zbEvRRoanN!t8HI0`ZHe&hDM z%)F^K(&y#Lw4~7{PR2YPZ9YB=s#4^j%g+YT^H7|nparfyZI*K{#bri%>RDg}@=S_> z`4wsUi_+Js0|D>PRllR17_9xd_26o}hx^N#1dwFUD#b>ql+7D(*Mv+$Cnpw*1bQ_| zV6)*Y(yfWbh8;qTUpZ?HNb+C4wG#+_hzx^78Wm-iNdC8IzlCIa1f8xKCCj5MYnArv=7%2NU@mI<$;i8dotk(@~A)I6v zu7+OftVd*iJC|J;ue7PKxzL*N6UaIM>e^|rJ59(xpZQQg!)T@_XuSx*?kS{I8BRJ@ zo5HF$s`_YMf~}iN2Qy2Ec>yfW9{G9gb_&$Yyfnfd{MnKA#?GP}f6AJ^ zv1dig`LIZOXRs1BS9%-0-UR#qX>4`xfWux7O`SUe?wGzRdm|+N*nWPB_$1~VbpyWs zm=^{;S;)ce`W)`G&nVBM-xaLkhmS_h2&X%k#Yq$%D_K(}f{cA}fH^zTTHeOQtPR|~ z?z#(_e{{V*&EMT#-C3NJZNZx{jSmK!V z#_K~~#5O?8S32>#jnJcRm(wP`H&{Hh`DEv8mH12o>i}UK4!YN*PPXCZX6VLh)`Xhl zn)y~w-x}es_m)siwHSCabbDHIdpoAFg0<{$@u?ACPeHS_Vvxq&)H#;5auwPU+lSGU z$|hmVwnDB5bvjkrcP|C~4u)r!BYalwr8g~k`5m6{peFEEwZTEMwogFV9*8*udoj@WXBO!e&DFaHxLh+c!@0RMlp`odM5W{R z?|v)WIA|E)=S&4B?F`yjHlgy38_GX+x1zR;Zgx?RQGE7|ZaCCZ zpq;^=v>Jum-|946KB?&3&xh_M#7jSX!s3+MA|Lz9O*P&geS6x)M6^ahm?mPYQkTk9 zeO2t{(T_8>)sthJB~HjBNT_%PxrY0F3b?V|L!MalHu!`;b`kqjOXs{UMY%_7O{=ZD zBeH7j%u-eUDN5u=luto>&n<^{SFs5`iz0J}ac`%#0>X8rGnZ(35mOE`wZZ3GRDBr- zANs4v1W!|y>R5u}m`t|VN*!cr06nZ#wYlp9M~bh*h$~w~IQMT3V-Y6OiC1ts4#M}q28%&dzbID`MRX$C(uhx!( z7<1tI`>7N{rzCHp^g(Pv1D8N@lGIgOy=YRaFM(6C&M~^W;eXZG4q#XSrMm=hg8=Ua zo;dJ(AC8wff%}-?z|~|zN+%EysE&M>VkU~bmebRbn^sc%5~9KSV~l3~lc`S)llVo( z^}KJJh5G=x1Y`-Qe;3|XaH-J^5&S*UoJ+`uCu!`;_ligA>Sqq?)s!##t#7-$zglu` z#C{P_Edo_p0-PNO#xDZNuI{6ZC{T3>)oBBA>bRJt;YASAejUoY>bkZassxl__vRYq zvH3iN3BLmj4tlVI>0i`YOBlBhuGhf?CP~}p+cL&&=_3j?Db@8^N9qtJ{tFV}Ujytl zy4C5VPL*DRp{UcwP{KCzyOTfLe;v-P1H|$r0;;c2T%Z3K<~5W^EvGW+k>YM36&3u1 zVc}UblE#WoaDi-Vxev;T%{_r0m;+gLyzAea^gJZ^w?BbLGZV$MdBay zF%qv~IJa!D+*uW0X8sK9;ci*v%&u-9?!vv|iJj^0!D)Robfs^V(cIi34W-6@?+`Oj zpVytSv*|%6EBq$RePh+5|p$co_iN8z3VQfLgbe!^^-gy1NL| zeRK#BubV&)&kwvl#lI$i7YT%;J_aHm?sb)1-K&9;@-;$8PVQ+bY(HqBI8tfy9KssB zt%eb1-?k-qNTh05nSLKG4gNEyYFZ>AIc4ll;@d&(?%4Cy0gZzk;$%%bl= zIzjImQbovY+@y!{$*)g5UW?DlZ#p526f=ZGiuLAnExr`i37usBCJ&%*3Fxeq8^*Du_Rsn09d$)Z z>*_k6a4qjtG6!THKf5I|Wx^9ky5MyPKT`mkVuvU{x>I;|8UPKeKp$I;1HR`@{f5uh z=1Gn z>qYhtV4>G8Xcs)6%44(NyU50H`J_8z&;7y~bnX)a!wj)Ji8YF%@#X}K$Sk2xjsYbm z7cYExIZv{wJ1u4R<#n3)>-+qa5EFkAKCS3(;7Wdc2EsM~>IwlQeATzGCjd9AXky`b zZpdX#BBxoiBdtCkPl^O-m?R_bZyJ*>nA~St;8`uN*5t@Y0FVukMcfiC0^q0w;&sxz z+zieXp@UY#E;Vy;GL6vmtw0h-z*q zv**WeGihX5l8HC#tbQ+~g#}l~PJxMf+{~&_XuPwjru7A8WGh!1jT`g%y^ASx&w{KC zm${S<33XT~0|p#j1<*?bkWv8_dxjKY1t9`EhAnq&B1~AFz76(Sc_4+8UQ28`{Nykz z_h>C!2CS2+If<33?i&iLF27bE-(UElwE8>k(P!cEq7;PPXr^4su=+9hBkZkY*?l zB_ikes9fiAr|{R2yGm|RWsJn8sTmb}24A^BXS=efjR;^Ky`^0=synR3@=4I{hu$Ol zXTC3th2HGepxh#1 zUNxWRJvZODxn-*k&k>e(ntx+Kd;3 z&E9UiHRR;3Vi^s)QtWNR5!!QS#Hc!+MMa+RVJ6GHB<}}cpq$vt>wtZ?t#~3ki7)#% zgr%4>?G8z`R)P(S(mJ)t^@mj}PGVX;T{|G71;7`AkB9{0Iu7`Q1SAnp&k$l7*x<)N zZ4ZnD!(eb1l3cDAqSJ;{iOd~hH8P6lkrOO|o79DqYl_1=#W5R1sIPK_A}6306u|zC zLkKDz?|hhq5Qw9itj)b~pYg5z3;s}~t|IK$^^bL=Uha?Unfg~_=~_Jj{51f%&vHYk z;BWC@5C>tI*al1(;G77CyS!2X3=j9gAKy4a4{w159b%u26+Boyb3Y?2M^J%gh_d_s zmYVpz<<_7%6k-wd_4eWAQB}p@9o}+$0JA*+e@1z>ZIrqy>ThG~-u^SHj4rd~IwDZa zg}y?;r4Lc--N9KNnR~lH!}KF2^U5B+^t91Yp)%%Zw)lPeIAnc^5U=o)Do4WFC-g?*sKkfW8R2&lkHbvKd@6 zwIk6Ac+}t~g|OYF?4c=f=Q7!-BsJCHr)V)+1RR4sNvdCLucb;S)L+c$G!by=j|+Uo zmLtmH^v2>*)x>=R?H}N#?$d&JvSTfcXwf1$tSZ)KIJpi%p=)fLAnoIPGif+(gcssU z*i`^)yN-kb1#s-C020=R4}i@sSh3NfP-p}Ra7xaT(Oqy(v-|u|_Xdf|-T8q2VIR73 zNAc&*1|wnBp4nfncI)Z?3|?l~3&_B|&Unv;^9q3cCrW?#gNV`-#z4;YQAX?t9CHGX z0T~Je&~b*=q4<=+436`o0M=EWz&m;7DEzE2Oh1ug$t2B0;47bLIbt=>ZBw`)h%x~Y zeP3f_y>JOMcLN_B>fa?l4Coi=_WayFYJMdYf0vb-n}@_$p!#{Fshf%85Bz_ z!Bq1F+WzEC7d(xL9!j8TaI&H771n(9$tMKsA!;WjjyZ*PX8_Sbqm+)KyH(}cH>tFg z6lYli6TT%DHX_e47I(k;fUs6K&AP6Z3MOrj0q?g9+Edou~lGTa3}da#UxjGaC2z+bd8V0MY_3*<}Ea&!-kBN+RBW zhak;9vl|8ly9~%B~M?b8vg}zi0Dan4-=Bs%P`BcUK^$6;&>UR;!VCw z%`k{pBMx54fTpH<4A+{?nvq+QmMBsz-HB{7Y0Ms~Lgs${!;i2P!M*if(J;$>One?} zv9kJsQ(@DS-wH!L%wpsA7yxMlZfhVl7#tq$LG$CD+blbX7P!+*Ahjjkld7Mzafjwl z^n4*Bi!{92MX4p2ge7j#0I+KNRH3V+dXD6a2R-A`3mI{a`y!>Ix0$Z<(s4xC>2}6_ z#6839`r|#9X9;jL2IN`}zX07ea4V-u&ATR-0(Fg)JuS0^-|5}4#+Y=@8I?GQluJ;q z5q0zsDiC;vCdEPA{{3cf7Ks;`MJ+?~LfH$LLZ#SV_ZXJz*>NyP<+nLC*+@?HM5<~M zJkH>fUht3ZiT{w!6_Shy*A4W!u;&~u>S7!Y1y;oZc_?H-_| z!j2vTOx>wkx_hOJsFh>XCB~x9RegEyo%^oo7p*4SME7ZsBIuKaATtqv73?&S#aOqR z-<^m!=Tl)R$RcM@G4F9+BsMBEj{Pm%oQ6&<+8s7o|-xhhJO6H zQ;8yLcT(xHA^2isrBayhyHYG`8@1pE4XWj0d598r7l&9VS zXEuJMRH4U8Em*F3--~++)Lsw@96uWcP8SEw7wsgOl(`$iVEThUJpR2?Fz*+2y2SoI zw@Y_tRl6f>+)rl+DDord)G;*mIcRq+Amm3-JYU;jZ0#|qzl(6V_TGW)-ewcI&dTJS zKH*!_CXVJbd+}#*YofH(ziPu(Ex|}B(^c@yif4K-8Ai?H{bV?-^ljI2)P)ayIwkYtaHfDod>7x@_8#Ey9`8f={1ypU}KxL%R~*AFf!zTyyMW_AtwcoA zIrh`_M&0+h?VP^Cdtz*=bQ@k+xF=z^@C1e81}NrkwO=ly{rUN@^j zj!sHf`OA4xBTJGqPdqjN^<1rqvaPD%#BJIrTN8V(ZoZp0e`jY6vl}N#Axpao#>h5u z@TmLDQE6+pHOuDctmD>$axW(Hl)gf&Ue$jTq*{Vd6gbz#MjRjNuwA0)0oKZ_fgtV( zr!%(;)OUdT29Sc55{aek$l-KkN@D|_^EyfDfZz>)Dzmka!y0ErKy-kks^U9WNza-! z2t4tyw@m%KL#ewmY|@Zf{Acq`FJs?FtxgZM?&_QF78M6tY6kQ-TDb`r$z@V-)@o!l zEIRKrk@yI<%v~ryCfjNvu_$DncO!rgj9;!@!(G?|vw6X@x;lmUy}U8Boy6ReQAPA{O2CAOg$W=&yOv`>b;SRA6dj}NU^Id`T-vsGz`xDN#FGQ zvG6z3%{f7_AnslWYt^`YCv~JU4_l&6Mq^h+p{XY;SR+n4Ve{g>2DFOcnF$z@Y-@o2 z1Zd@bdpO0=yr!|@I&aB{mCDezDqYlfPqM`m>Xxl&@DUN*MdK zCvg=YN+j?T`+#u)pptNK^<<}#WM|rK)1HA@IVwc;dMlZ8d0F#J&Fm;g@TiYqMAx08 z_!BszTOgat^NmOrVdOUDlRCL~r3R*DvC)csQT7_tQ{Q(@T?@;GS3#04okzh#ofP@U z!hBS(pIOxzazt^A3z5#>x|R8f#B^Z(C6=iCwgUPU0NXl2tM8#KlnF6=-aC5 z06o405P$BbPVpM*ZyA17A2IvL(x;h9FLI~bUr0k~I}OoYaJv(62%`G|R>RDC8+hmf(IFw+T8=cW{Jwj)ZAu@y#NVDVO$bQ~41un%?b1(u8)y&l z{A)Ap5(d6>@54RyzAl)+ZctRMUZ%)sDXko`W-n$?O+NPVX9YT!I?n^FeAti&}(p<-zt2N_W%yJ4#U~olDvzyh$EFtBtOuu@RY^Yw9UQ2>F5W# zZjZi4a6$JMnx`ILv`TKAB13n{ZDu-LNgZmqEV7TO3l~u^LP20NB76y$IZLW_6>Zc zA$WOTQ>gpL=Xe(;7Y(Xzc^Q7-_p$h`Arnz9k~?7HUR|kVOV7dVJoqpSrsgmHd5}oO zFB1l`PN7ZP`)Kt=6#6khCsgXAv;o@9F+bgl$zD6NL!&_|Zec=j1#oZUQAD*5Z!iKd1QgmEpn1mtef6NPHilt3F-dTm^koMD zweM%uVx+m5?V9+a{?YY)%6)6b{II59ZX-dH4!?{=9i}T9ULuf`f*h415W_QibPkb% zabPL^eaTyzNiESN<*pka#R>0J|_avMYIj-;FDh~H|f5OfUNzhblnNWz-M4|sT z>0j_VLUqX!wZgwqCTalSnuxECQwZei#sL4BuiFIhKR8xOh`kpd>K(viwPK#!&>M z7Zf+9W*fAEKRD%--s$|l5hY|^p0ydwyi+H}X7b;00Rjf)L zU0o#`Kt{!EX4C;}3l2gtxe4bj1inqDP>XsOLWLD)!#%?Bb;u-~?Ax=XCU34cqp(q^ zx9fDTqgGl4jG`WLF|}TG_^HFP67y0b7IoGP4xeD5a8Ux9@^nPoIg8;@$}i;_fj~1K z%SW1h;V)R~Mzr!W*ea)W z@GyUkx#Xt@bjEIX737MstJcgI;tU2aGQdhbxv9^qARqj0;c?MjCNUu-5x_-2#lH2I zs|v)oK$~ACut$TppYdfx$LBZW8XW(C@l@~|Wy|SF+j&cda8mdv08782W|vMziITP~ zvRi_llV2uOkzrh@@CS7)kJI$V-KIXv0D+C(K7pH#o1!Gwn5c-w@2g!n-+ckMr#|~n z(@s2)BCsBDrnO%8u4E8geX4a%whV?Ihzu;nGjf$LJwp~1^V5cuVCH8mguwA*0)uY` z@cDd7L~=OjD*?T#etjRB&Krq&t@IT+u;PM}9x`R$M{2>wEH7AmrEAKnL5hdA;M1b9 z?2j%-(m~R3-uHgfm&$zLlK9Ep_!rsy%D}M{<8eEJGRT$E^fE3Ae7*{Xt=DSg$ zo~+SHCR<`MrYV;4ViQ2!0XhJO3fsa%h^2W#+@-lS_+PJ0q7J=ls$ssmUFw@aR6aM~ z;gg;zN*z#^=d+O>KcD2SH!33qxt=vO8G(suDkp?7U@<0`wgrE5JB zDlpk?TS-;Wf^N*nkSDXXU_lCAm`?bx-P?w-vr;o*!MNejk*!{n*ELap1 zFyIh1j<)(7F(aG!U4n@sUsX=-q!$C`n~7D5MfoxBgE;_XcL4+?M1Zyt&=y(%if2Cq z&e0M!+F8;ef%y{g0rJ@#Ob9-v-_B2?6p<>E)$b}ab2U>!0?*VWf4gIW<1W<{HUQ(7 z*#vRer8?uoE5!?jY*hu>$ZG!leaDTlu3*nSgk&+4T}-uKvm z7Z0r~q#F1KsL^ZeQHtAhe|z}!yLeHkxVN4#KLHBk)02;fKtf=-Jj2=!=@>uW$IXCh|Nyg5?uY;<*mTijH*43RG;LXmPn_CDH-3ox6F;G ze?d6^sNr8%)+WE*s1gXSj6i?m24YU3|4`E!>0&;!FD7C!O4DC~U6bd0p`2{4beF9u zKas&J5KlxF0fjsUsYPIoNUS>Jp02wnP~LYIwaT=K>|vIuq$`~a7W%9vo;Fcrui2GE zm$Hk8ckcpIUBh}6!WawUj;)R8mUM%p!~mztpeFA*7uT6)TbYFi=C?AW!LcO{eM|sN z0Js6TfEj-cZ(l%NgkWbEWfNsQJ?{)M{&5K3!H=LXb2W9ZGnH+9b5GG0GWyuKLlm9E ziYAYS2t$9Wh8guQ>rxg8`$tOBw7<3mN+x~p^jTjp@b=nc9RsYFt9IK4mKC^iNIu~te=oF7~);=r{dAq^S`>xi17yJKIXSt zueCmNPd}Px*MGQ1l4tPtBmQ`37FVLT5R3D%d{rFf;U+m=kUacCs|6FV>GlQV>KHhleGy=U300j$FWToa?)U~C zdJ=|1eNSmv6Ws1;?j1-z9qKm)ZO9BNW#~00_kGNIM*7MA8_E;-^6Yk6XGXtZ8Jf@% zqRZMHz6`ahSHpII>U zEg&lFnrqp7dx+~l$g1pC%n}exEk7alq>BJj1ePZLk%7ijfX_^8A~EDceFSG@*%Z4{ zMYmb1QN1{taFC-%t+=WFSp=CiKR7h>20R2!ux~Fy zlpfyx&y*_y_T&at-#7g<>fna*{(&tZ{~cT*vbD>&UC9oRCE+4qC*iMmu_j4C(ZIbstiT!XAxUt%7W8j?FvaT?jC*I}MU++A2tSL1d`P{^Do4>q2z@f00@*=X7 zm2IQF!ompzVsYKEKjFByL`~54a27xVfR3Yt>Wg-32h*n%6PR<4UtS2 zEvq+#?kXqzByJ0|8stYcfi>dsAPTK;*$#mIn`}FtKr^I3jd>&CaBa_TuW@1j3*Np1fD(>s+$thCf)@EbeW9T-(A<`YoTv1Z9>zB%?VFvzi&V&R}H4+)k!-r!}jD zy(*}q>-kQyDU`y^uv9>5%KHtjwTwTSu;$9Z!4^_E2K^@U!V z zeh}mJ$_*mji=;z5oVwkUQm{(ZG9QNr7WMjTL~QaOW}GRWIo`nT>1+-uX7C#)xsT&d zSo`PcOKWh^ol~^>0m?#!Fi^b<@bbGr<&@~~D#zmN-+=F|(RjZJ{GoY5>9N`z!~R_J z3h!IE0&e1T<*25{b5-zwUxM^pjo3$eE|T*U?kA~@<;RyC>q^tr5<|7Wb%-D z`dzrTlx?1~t4o!umQ$-%<0r&QqLlSbC-$)it!1%5u)x?QE!a`4XRIAb_@b&lTCCNo zOZsq_9E3%0)ygiMWnXJ4tF{?&x)$?q{R!er6i7b9m z!1kTj9xwkhA}r1>_dtLc3bF_-SCBeR zm;e@sa9K-Y>_`j)5GU~Nb6uVU^raKHh0JICKTg3SXzd3wbYOE_2zYh__%89}(gNt* zpR(hZ$1x{>x1Z?2FM!9DS0<84hr0~R(xw;hT>0K%*+mmxW#X!6svq*SZ`3+)$yVtt z84eC1MaGa!tNCy9v|uh-qJl43#_Xd6U4G#HA?`#jwFL580i13hY7LwT1JNLz0?4c5 z&`>wRE*1|-c!QOu<&x^=AsjZzTppcg~1we-5V=zIZEV)U&*1sd>^H z&sgbV7mm?9(sUL3n3;c1c<7+3JicO4>FwT76E;aAt6f9EuaS=dGVo9OA^jmSKUT+5 z_!Mnels_o3#U3sBJ8q;77zoQXj!CE+%#lsvQf!FMNjI@kX7> zGgNauR*qA#zq{-jmDe7(px?+z@e--L-GXs?y_ZV>SCU(jm9U4I&EW+ep^!jH(PC-6 z`yV5F*ytBYV|G{U7s;kay-y}~{s|p@IJgb|!xEmj%@fN1-z{OnUqrS8M0Gb4ZC5OW zcnJD|4>7S(cm_@jPvAI?6`(8nQZL*^I)Ci-pk8)C6xz&LPsP>UP}p^lJu+U#vgEn8 z$thPRlpe(V{oB*0#6*zm8o-~Oz$1R%>WLFi);|`ukCh0yOrt|DqO<$tb7X+QY>7ZI z<;rC0pVm55COE!Gh^hWB9}iq0PPJ@+?;}76QT@Ql5aFVf)q_M_N@7PU+3+{e$2znp zzLE|fN@X%YAdQ6Cb*hdi_JIO$&H#u3Komr_i766~LsawYn^+?J`$5c*?nAe+DD5iZ zX}$<-5>Uh>)!(*x8U=u4d&G_>YL)bs_o68pxV|`rhMv>bMX36E(XQ=H+ zK~?9wM8QT>5>)y0lmih65yZEvdHgfGd3tOJF*$`@MRi?+b2ajc|A$Hjo9>=}p6t+c zTo)^*l@g{) zwS|Wgab40(YAo{VN0=$8?uEM~rm=NXm9ICi-X-;*Wn!~zJi8&Ru@yx$EHA7A0cB*`^l2UiB}R8zE-@c0HrP5N|)ILl_-zTmM(`{awf(ytM>L>fyd&AWDBL z?LMokpBy3oQ>&@ZktO$u&b!*O4;b&@-<(&K-y)yinjAx7thjqjWLR!U$L^Q6O^wM* z^=$QQ%%5WzYR))&oe=_H$GZ#J_5Yx}ODk?T3$03}oU*yrkYwkW=xMRRPd)79$UvQ+ zNNb+R-?z&?cR6DU&WN3;2QX}b#j8M;nQ)vk36!ry3>tqI5gSK(hj%Fkk0jgNV1oH* zX7znd7E*1ms1D5+tUUF=fC4AcIe!t;px;_e1FJ*AXL?pfu%DLF!Rq&3?OI;Xj$arF z)h7z=WGU2viq5xI5ct4v_YCw5yKAKh|3kHiadrcWHe zBP{d-AG_>;ZlZY;N5}1<^BUY@Y9=dAeX@CR)KSLH0VXt&!h3O8AW{vBP;X!)UQ|d_ zsHXH#vyA*aSy9?UJM-rKl*zQ-U{`s4$!QWE9Ic&s`0>58nI94EQlv6lJLy6a0)c%0d?L&x; z2-ugj1)5SK$x^TjRiwQ#Az^NkX~}flNn=h-G@oLFqdF@KtRFq}%RPa2kZrVQajU{g zq@S`pmKXTy7!Z^%Mk&G2B^6IcwF{5EzmY3KsQ&XrrkGUU>RckZY|=+eeodOEVo zPkEe4JBg;mIxge?%x+~fB0nVa$64vwbHu9KSC#6d)_8m>z2J9`0QU#H)rWBHHgNhE zgl&KRcLJz4bmmpTwZQnRj^CQ^lW@TLNFK)iYi2}cmTG=vf9} zzPeoU!(X(DIi$KEX>4Z3n(yK(&%z@AIrrIw&N22&w6%Xnt$<$83K2Gc2i?Ae-LO^Q zCy#~R6G9U4_z6h0Z7sFq_?q6Ul1=pq!9AI$_0WjCWKNIn>#D?A3h`Fg=}$Z=WDh7{ z&d}z-=NaHA=6M}XF=Q8K91+E6VqQ4jTmS-QY_1X~v0Yp8~4?G=l_|=gw8l zt`YKeltxHpGh!{yK;R>inRKh5m*y%>t%>K;rG@)tYLaV*Z{NazxTdN+2C$w;^6qh{zN8K$b@R+v5G>{;H!oQmCS_V@4rJh8=8jsxu{0Nb|* zw-S5ie1pQ?E%?*H{f_Xh_sW)8qmURbpO}M8zp3J->E;W5~c+ ze8)1`mYKW+<}Pue@R5Kx}xA%h7#Z{|yX=UDe{aM9I(5aeROET=Bh zd*c+nGovC<0jnCGCf;!&Mj#$@v(En>du0EIQ_5Jex7$D1JZUmahL2y4Xp<2K4i4V~ Us7OcvASe0pvKUE1O-}N^03uzyasU7T literal 0 HcmV?d00001 diff --git a/test/utils/ocm.go b/test/utils/ocm.go new file mode 100644 index 0000000..aeac594 --- /dev/null +++ b/test/utils/ocm.go @@ -0,0 +1,19 @@ +package utils + +import ( + "os" +) + +const localOCMRegistryTestDataPath = "../../test/testdata/ocm_registry.tgz" + +type Path string + +const ( + LocalOCMRepositoryPathValid Path = localOCMRegistryTestDataPath + RepositoryPathInvalid Path = "invalid/path" + OCMRepositoryPathKey = "LOCAL_OCM_REPOSITORY_PATH" +) + +func SetEnvironmentVariableForLocalOCMTar(path Path) error { + return os.Setenv(OCMRepositoryPathKey, string(path)) +} diff --git a/test/utils/ocm_test.go b/test/utils/ocm_test.go new file mode 100644 index 0000000..620f0a6 --- /dev/null +++ b/test/utils/ocm_test.go @@ -0,0 +1,35 @@ +package utils + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSetEnvironmentVariableForLocalOCMTar(t *testing.T) { + tests := []struct { + name string + path Path + wantErr bool + }{ + { + name: "Set valid path", + path: LocalOCMRepositoryPathValid, + wantErr: false, + }, + { + name: "Set invalid path", + path: RepositoryPathInvalid, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.NoError(t, SetEnvironmentVariableForLocalOCMTar(tt.path)) + if os.Getenv(OCMRepositoryPathKey) != string(tt.path) { + t.Error("Environment variable not set correctly") + } + }) + } +} From 0f3ebd33e0c84f1a809e17bde5787c64bbfc486f Mon Sep 17 00:00:00 2001 From: moelsayed Date: Wed, 12 Mar 2025 12:08:01 +0100 Subject: [PATCH 2/6] fix Makefile --- Makefile | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 33c0ef0..3e214f0 100644 --- a/Makefile +++ b/Makefile @@ -79,9 +79,16 @@ test: manifests generate fmt vet envtest ## Run tests. go tool cover --html=cover.out -o cover.html go tool cover -func cover.out | tail -n 1 +.PHONY: golangci-lint +golangci-lint: localbin ## Download golangci-lint locally if necessary. If wrong version is installed, it will be overwritten. + @test -s $(LINTER) && $(LINTER) --version | grep -q $(subst v,,$(LINTER_VERSION)) || \ + ( echo "Installing golangci-lint $(LINTER_VERSION) ..."; \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(LOCALBIN) $(LINTER_VERSION) ) + + .PHONY: lint -lint: ## Run golangci-lint to lint code - golangci-lint run ./... --timeout=15m +lint:golangci-lint ## Run golangci-lint to lint code + $(LOCALBIN)/golangci-lint run ./... --timeout=15m .PHONY: tidy tidy: From 5330224fa397c9e73c13b3e101fdf1158cf60f7e Mon Sep 17 00:00:00 2001 From: Maximilian Techritz Date: Wed, 12 Mar 2025 13:25:36 +0100 Subject: [PATCH 3/6] docs(contributing): update Code of Conduct --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba7fa4a..7f0bf35 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,9 @@ All members of the project community must abide by the [SAP Open Source Code of Conduct](https://github.com/SAP/.github/blob/main/CODE_OF_CONDUCT.md). Only by respecting each other we can develop a productive, collaborative community. -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting [a project maintainer](.reuse/dep5). +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [ospo@sap.com](mailto:ospo@sap.com) (SAP Open Source Program Office). All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Engaging in Our Project From 5b82b47ad04fbbec94933858932d99843c7ca20b Mon Sep 17 00:00:00 2001 From: Maximilian Techritz Date: Wed, 12 Mar 2025 14:55:34 +0100 Subject: [PATCH 4/6] docs(readme): update managed setup, remove self-hosted part --- README.md | 117 ++---------------------------------------------------- 1 file changed, 4 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 1ebff13..bb7372b 100644 --- a/README.md +++ b/README.md @@ -3,104 +3,13 @@ # Control Plane Operator ## About this project -The Control Plane Operator is a universal Kubernetes Operator which bundles all necessary functionality to install so called `Components` (like Crossplane, External Secrets Operator, etc.) in a managed fashion into a Kubernetes cluster. +The Control Plane Operator is a universal Kubernetes Operator which bundles all necessary functionality to automate the lifecycle of so called `Components` (like Crossplane, External Secrets Operator, etc.) in a managed fashion into a Kubernetes cluster. -The Control Plane Operator can be used in different flavors and scenarios: -- [Self-hosted](#self-hosted): You install the Control Plane Operator into your own Kubernetes cluster. - * You can install `Components` like Crossplane, External Secrets Operator, etc. via one central [`ControlPlane` API](./config/crd/bases/core.orchestrate.cloud.sap_controlplanes.yaml). For more information see the [Components](#components) section. - * You are responsible running and if necessary upgrading the Control Plane Operator. -- [Managed](#managed): You are using a Managed Control Plane offering, where you order a `ManagedControlPlane` either via an API or via an Onboarding system. - +The Control Plane Operator is part of the openMCP project. It exposes the [`ControlPlane` API](./config/crd/bases/core.orchestrate.cloud.sap_controlplanes.yaml), where `Components` can be configured that should be reconciled on another Kubernetes cluster. With openMCP, the `ControlPlane` is not directly exposed to an end user. Instead, the `ManagedControlPlane` API is the facade. The [mcp-operator](https://github.com/openmcp-project/mcp-operator) is interacting with the `ControlPlane` API. -## Flavors +## Using the Control Plane Operator in a Managed Setup -### Self-hosted - -**PLEASE EXPECT SOME HICK-UPS WHEN SETTING UP THE OPERATOR IN A SELF-HOSTED SCENARIO. The following instructions might not be up-to-date.** - -If you want to use the Control Plane Operator in your own Kubernetes cluster, please follow the instructions below. -Make sure you are connected to a Kubernetes cluster that should serve as ControlPlane environment. - -```shell -# Add helm repo -helm repo add control-plane-operator-repo \ - https://helm.example.com/control-plane-operator-repo \ - --force-update \ - --username - -# Create namespace where you install the Operator and Secrets -kubectl create namespace cloud-orchestration - -# Create secret to pull Helm Charts from Artifactory -kubectl create secret docker-registry orchestrator-registry \ - --namespace cloud-orchestration \ - --docker-server="docker-registry.example.com/" \ - --docker-username= \ - --docker-password= - -# Install FluxCD -kubectl apply -f https://github.com/fluxcd/flux2/releases/latest/download/install.yaml - -# Create secret to pull Crossplane Providers from Artifactory -kubectl create secret generic artifactory-readonly-basic --type=kubernetes.io/basic-auth --from-literal=username= --from-literal=password= -kubectl label secret artifactory-readonly-basic core.orchestrate.cloud.sap/copy-to-cp-namespaces=true -kubectl annotate secret artifactory-readonly-basic core.orchestrate.cloud.sap/credentials-for-url='https://helm.example.com/control-plane-operator-repo' - -# Install the Control Plane Operator into your cluster -helm upgrade --install co-control-plane-operator control-plane-operator-repo/co-control-plane-operator --namespace cloud-orchestration --set "imagePullSecrets[0].name=orchestrator-registry" --values test/e2e/testdata/values.yaml -``` - -After the installation you will see that a Pod will spin up. - -Now, you have to install two `ReleaseChannel` resources. -A `ReleaseChannel` resource defines which version you define should be latest or stable. -(FYI: The `ReleaseChannel` feature was implemented with as a requirement in a managed setup. This may not seem suitable for a self-hosted environment. There are currently discussions about it. So this could change in the future. Feedback is much appreciated here.) - -In the [`config/samples/releasechannel/`](./config/samples/releasechannel) directory you will find two sample `ReleaseChannel` resources. -You can safely apply them to your cluster with the following command -```shell -kubectl apply -f config/samples/releasechannel/ -``` - -Make sure to keep the `latest` and `stable` `ReleaseChannel` resources up-to-date to your own needs. -In the `ControlPlane` CR you can define which `ReleaseChannel` of a Component you want to use. - -```yaml -apiVersion: core.orchestrate.cloud.sap/v1beta1 -kind: ControlPlane -metadata: - name: controlplane-sample -spec: - target: - # use local cluster - serviceAccount: {} - fluxServiceAccount: - name: flux-deployer - namespace: default - overrides: - host: https://kubernetes.default.svc - crossplane: # Remove to disable Crossplane and Providers - version: latest - providers: - - name: provider-kubernetes # IMPORTANT: this name must match the name in the ReleaseChannel! - package: xpkg.upbound.io/crossplane-contrib/provider-kubernetes - version: latest # this will install version v0.13.0 (defined in ReleaseChannel "latest") - see above - btpServiceOperator: # Remove to disable the BTP Service Operator - version: stable - certManager: # Remove to disable Cert Manager - version: stable - externalSecretsOperator: # Remove to disable External Secrets Operator - version: stable - kyverno: # Remove to disable Kyverno - version: stable -``` -**Note:** Currently, it is only possible to install Crossplane Providers which are Open Source via the ControlPlane resource. We are working on the fix. In the meantime, you can go ahead and install them via the `Provider` CRD from Crossplane itself. - -If you apply the `ControlPlane` CR, the Control Plane Operator will start to install the `Components` into your cluster. - -### Managed - -With the Managed setup, we will use this Operator in our landscape to provide a new API called `ManagedControlPlane`. +With the Managed setup, we will use this Operator in our OpenMCP landscape to provide a new API called `ManagedControlPlane`. With the `ManagedControlPlane` you will also have two different cluster setups how the MCP will look like: - Dedicated Cluster Setup - A standard Kubernetes cluster @@ -121,7 +30,6 @@ C4Context Node(clusterscoped, "Cluster Scoped Resources", "") { SystemDb(controlplane, "ControlPlane Resource", "the ordered Control Plane", "") SystemDb(releasechannelStable, "ReleaseChannel Resource", "stable", "") - SystemDb(releasechannelLatest, "ReleaseChannel Resource", "latest", "") } } @@ -137,7 +45,6 @@ C4Context UpdateElementStyle(namespace, $borderColor="orange", $fontColor="red") UpdateElementStyle(clusterscoped, $borderColor="grey") Rel(cpOperator, releasechannelStable, "watches") - Rel(cpOperator, releasechannelLatest, "watches") UpdateRelStyle(cpOperator, others, $textColor="white", $lineColor="white", $offsetX="5") UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="10") ``` @@ -155,7 +62,6 @@ C4Context Node(clusterscoped, "Cluster Scoped Resources", "") { SystemDb(controlplane, "ControlPlane Resource", "the ordered Control Plane", "") SystemDb(releasechannelStable, "ReleaseChannel Resource", "stable", "") - SystemDb(releasechannelLatest, "ReleaseChannel Resource", "latest", "") } Node(namespace, "Managed ControlPlane Namespace", "") { @@ -184,26 +90,11 @@ C4Context UpdateElementStyle(clusterscoped, $borderColor="grey") Rel(cpOperator, controlplane, "watches") Rel(cpOperator, releasechannelStable, "watches") - Rel(cpOperator, releasechannelLatest, "watches") Rel(cpOperator, syncer, "reconciles") UpdateRelStyle(cpOperator, others, $textColor="white", $lineColor="white", $offsetX="5") UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="10") ``` -## Components - -You can install the following Components via the Control Plane Operator: - -| Component | Supported in Self-hosted scenario | Supported in Managed scenario | -| ------------------------------------------------------------------------------------- | :-------------------------------: | :----------------------------: | -| [Crossplane](./pkg/controlplane/components/crossplane_component.go) | ✅ | ✅ | -| [Crossplane Providers](./pkg/controlplane/components/crossplaneprovider_component.go) | ✅ (just open source providers) | ✅ (just open source providers) | -| [External Secrets Operator](./pkg/controlplane/components/eso_component.go) | ✅ | ✅ | -| [BTP Service Operator](./pkg/controlplane/components/btpso_component.go) | ✅ | ✅ | -| [Cert Manager](./pkg/controlplane/components/cert_manager_component.go) | ✅ | ✅ | -| [Kyverno](./pkg/controlplane/components/kyverno_component.go) | ✅ | ✅ | -| [Flux](./pkg/controlplane/components/flux_component.go) | ❌ | ✅ | - ## Requirements and Setup You’ll need a Kubernetes cluster to run against. You can use [KIND](https://sigs.k8s.io/kind) to get a local cluster for From 50594af670a5a41fee1b25e07b29fbd20faeea6c Mon Sep 17 00:00:00 2001 From: Maximilian Techritz Date: Wed, 12 Mar 2025 15:00:22 +0100 Subject: [PATCH 5/6] fix: remove cloud-orchestration occurrences --- Makefile | 8 ++++---- charts/control-plane-operator/values.yaml | 2 +- charts/control-plane-operator/values.yaml.tpl | 2 +- e2e.env | 2 +- test/e2e/main_test.go | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 3e214f0..f842261 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ IMG ?= $(IMG_BASE):$(IMG_VERSION) # Pick from https://storage.googleapis.com/kubebuilder-tools ENVTEST_K8S_VERSION = 1.30.0 -export UUT_IMAGES = {"cloud-orchestration/control-plane-operator":"$(IMG)"} +export UUT_IMAGES = {"openmcp-project/control-plane-operator":"$(IMG)"} SET_BASE_DIR := $(eval BASE_DIR=$(shell git rev-parse --show-toplevel)) GENERATED_DIR := ${BASE_DIR}/hack/.generated @@ -86,16 +86,16 @@ golangci-lint: localbin ## Download golangci-lint locally if necessary. If wrong curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(LOCALBIN) $(LINTER_VERSION) ) -.PHONY: lint +.PHONY: lint lint:golangci-lint ## Run golangci-lint to lint code $(LOCALBIN)/golangci-lint run ./... --timeout=15m .PHONY: tidy -tidy: +tidy: go mod tidy -e .PHONY: verify -verify: lint goimports vet +verify: lint goimports vet .PHONY: localbin localbin: diff --git a/charts/control-plane-operator/values.yaml b/charts/control-plane-operator/values.yaml index 0112975..ad48872 100644 --- a/charts/control-plane-operator/values.yaml +++ b/charts/control-plane-operator/values.yaml @@ -5,7 +5,7 @@ replicaCount: 1 image: - repository: deploy-releases-hyperspace-docker.common.repositories.cloud.sap/cloud-orchestration/control-plane-operator + repository: ghcr.io/openmcp-project/github.com/openmcp-project/control-plane-operator/images/control-plane-operator‚ pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. tag: 0.1.3 diff --git a/charts/control-plane-operator/values.yaml.tpl b/charts/control-plane-operator/values.yaml.tpl index 2f97a79..278c549 100644 --- a/charts/control-plane-operator/values.yaml.tpl +++ b/charts/control-plane-operator/values.yaml.tpl @@ -5,7 +5,7 @@ replicaCount: 1 image: - repository: deploy-releases-hyperspace-docker.common.repositories.cloud.sap/cloud-orchestration/control-plane-operator + repository: ghcr.io/openmcp-project/github.com/openmcp-project/control-plane-operator/images/control-plane-operator pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. tag: $OPERATOR_VERSION diff --git a/e2e.env b/e2e.env index 94a56b6..8933264 100644 --- a/e2e.env +++ b/e2e.env @@ -1 +1 @@ -E2E_IMAGES={"cloud-orchestration/control-plane-operator":"control-plane-operator:dev"} +E2E_IMAGES={"openmcp-project/control-plane-operator":"control-plane-operator:dev"} diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index dab67b4..2961f5c 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -31,7 +31,7 @@ import ( ) var ( - UutControllerKey = "cloud-orchestration/control-plane-operator" + UutControllerKey = "openmcp-project/control-plane-operator" testEnv env.Environment ) From 059b38b43d3b8eac8d24ee12cddfee6f395f724f Mon Sep 17 00:00:00 2001 From: Maximilian Techritz Date: Wed, 12 Mar 2025 15:01:16 +0100 Subject: [PATCH 6/6] fix: typo --- charts/control-plane-operator/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/control-plane-operator/values.yaml b/charts/control-plane-operator/values.yaml index ad48872..e542010 100644 --- a/charts/control-plane-operator/values.yaml +++ b/charts/control-plane-operator/values.yaml @@ -5,7 +5,7 @@ replicaCount: 1 image: - repository: ghcr.io/openmcp-project/github.com/openmcp-project/control-plane-operator/images/control-plane-operator‚ + repository: ghcr.io/openmcp-project/github.com/openmcp-project/control-plane-operator/images/control-plane-operator pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. tag: 0.1.3