diff --git a/.github/renovate.json b/.github/renovate.json index c804c1a..27da5d0 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -3,8 +3,8 @@ "extends": [ "config:recommended", "default:pinDigestsDisabled", - "mergeConfidence:all-badges", - "docker:disable" + "docker:pinDigests", + "mergeConfidence:all-badges" ], "assignees": [ "@cloudoperators/greenhouse-backend" @@ -58,6 +58,15 @@ ], "dependencyDashboardApproval": true }, + { + "matchFileNames": [ + ".github/workflows/checks.yaml", + ".github/workflows/ci.yaml", + ".github/workflows/codeql.yaml", + ".github/workflows/container-registry-ghcr.yaml" + ], + "enabled": false + }, { "matchPackageNames": [ "/^k8s.io\\//" diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index b9bed01..fb316c1 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -24,16 +24,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: check-latest: true - go-version: 1.26.3 + go-version: 1.26.4 - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9 with: - version: latest + version: v2.12.2 - name: Delete pre-installed shellcheck run: sudo rm -f "$(which shellcheck)" - name: Run shellcheck @@ -41,16 +43,10 @@ jobs: - name: Dependency Licenses Review run: make check-dependency-licenses - name: Check for spelling errors - uses: crate-ci/typos@v1 + uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # v1 env: CLICOLOR: "1" - - name: Delete typos binary - run: rm -f typos - name: Check if source code files have license header run: make check-addlicense - name: REUSE Compliance Check - uses: fsfe/reuse-action@v6 - - name: Install govulncheck - run: go install golang.org/x/vuln/cmd/govulncheck@latest - - name: Run govulncheck - run: govulncheck -format text ./... + uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6059666..9665322 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,12 +27,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: check-latest: true - go-version: 1.26.3 + go-version: 1.26.4 - name: Build all binaries run: make build-all code_coverage: @@ -43,12 +45,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false - name: Post coverage report - uses: fgrosse/go-coverage-report@v1.3.0 + uses: fgrosse/go-coverage-report@cbeb2ab2e32591d690337146ba02a911cc566f3f # v1.3.0 with: coverage-artifact-name: code-coverage coverage-file-name: cover.out + root-package: shoot-grafter permissions: actions: read contents: read @@ -60,16 +65,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: check-latest: true - go-version: 1.26.3 + go-version: 1.26.4 - name: Run tests and generate coverage report run: make test-with-envtest - name: Archive code coverage results - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: code-coverage path: build/cover.out diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 0ea1911..8db0f82 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -27,18 +27,20 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: check-latest: true - go-version: 1.26.3 + go-version: 1.26.4 - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: go queries: security-extended - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 diff --git a/.github/workflows/container-registry-ghcr.yaml b/.github/workflows/container-registry-ghcr.yaml index bc5f92a..eedacb7 100644 --- a/.github/workflows/container-registry-ghcr.yaml +++ b/.github/workflows/container-registry-ghcr.yaml @@ -23,16 +23,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false - name: Log in to the Container registry - uses: docker/login-action@v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: password: ${{ secrets.GITHUB_TOKEN }} registry: ghcr.io username: ${{ github.actor }} - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 with: images: ghcr.io/${{ github.repository }} tags: | @@ -45,11 +47,11 @@ jobs: # https://github.com/docker/metadata-action#typesha type=sha,format=long - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Build and push Docker image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 with: context: . labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index efa593e..8fa739d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vscode +.claude /bin/ /build/ diff --git a/Makefile b/Makefile index 6694588..55383b7 100644 --- a/Makefile +++ b/Makefile @@ -61,10 +61,10 @@ prepare-static-check: FORCE install-goimports install-golangci-lint install-shel # To add additional flags or values (before the default ones), specify the variable in the environment, e.g. `GO_BUILDFLAGS='-tags experimental' make`. # To override the default flags or values, specify the variable on the command line, e.g. `make GO_BUILDFLAGS='-tags experimental'`. GO_BUILDFLAGS += -GO_LDFLAGS += -GO_TESTFLAGS += -GO_TESTENV += -GO_BUILDENV += +GO_LDFLAGS += +GO_TESTFLAGS += +GO_TESTENV += +GO_BUILDENV += build-all: build/shoot-grafter diff --git a/Makefile.maker.yaml b/Makefile.maker.yaml index 2d904ad..0009e4d 100644 --- a/Makefile.maker.yaml +++ b/Makefile.maker.yaml @@ -6,7 +6,6 @@ # NOTE: After running go-makefile-maker, manually apply these changes: # 1. Add 'branches: [main]' to container-registry-ghcr.yaml for main branch builds # 2. Change 'make build/cover.out' to 'make test-with-envtest' in ci.yaml for envtest support -# 3. Change 'rm typos' to 'rm -f typos' in checks.yaml metadata: url: https://github.com/cloudoperators/shoot-grafter diff --git a/README.md b/README.md index d32fd2a..c87ccf3 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,8 @@ For each CareInstruction, a dedicated Shoot controller is dynamically created an - Optionally configures OIDC authentication on Shoot clusters for Greenhouse access. Also see respective [Greenhouse docs](https://cloudoperators.github.io/greenhouse/docs/user-guides/cluster/oidc_connectivity/) and [Gardener docs](https://gardener.cloud/docs/guides/administer-shoots/oidc-login/#configure-the-shoot-cluster) - Optionally configures RBAC on the Shoot cluster for Greenhouse access +> **Auth ConfigMap labeling & watch**: When `authenticationConfigMapName` is set, the shoot controller labels the referenced Greenhouse ConfigMap with `shoot-grafter.cloudoperators.dev/auth-configmap: "true"` on first interaction. The CareInstruction controller watches these labeled ConfigMaps; when the data changes, all CareInstructions referencing that ConfigMap are re-enqueued. Each stamps `shoot-grafter.cloudoperators.dev/auth-cm-revision` on its matching Shoots, triggering the ShootController to re-run OIDC configuration with the updated data. Multiple CareInstructions may reference the same ConfigMap. + ## Custom Resource: CareInstruction A `CareInstruction` defines the configuration for onboarding Shoots from a specific Garden cluster. @@ -208,7 +210,7 @@ spec: | `shootSelector.expression` | string | No | CEL expression for filtering shoots by status or other fields (max 1024 chars). The shoot object is available as `object` | | `propagateLabels` | []string | No | List of label keys to copy from Shoot to Greenhouse Cluster | | `additionalLabels` | map[string]string | No | Additional labels to add to all created Greenhouse Clusters | -| `authenticationConfigMapName` | string | No | Name of ConfigMap in Greenhouse cluster containing AuthenticationConfiguration [(config.yaml with apiserver.config.k8s.io/v1beta1 content)](https://gardener.cloud/docs/guides/administer-shoots/oidc-login/#configure-the-shoot-cluster)| +| `authenticationConfigMapName` | string | No | Name of ConfigMap in Greenhouse cluster containing AuthenticationConfiguration [(config.yaml with apiserver.config.k8s.io/v1beta1 content)](https://gardener.cloud/docs/guides/administer-shoots/oidc-login/#configure-the-shoot-cluster). Multiple CareInstructions may share the same ConfigMap. | | `enableRBAC` | bool | No | When false, skips automatic RBAC setup on Shoot clusters (default: true‚) | *Note: Either `gardenClusterName` or `gardenClusterKubeConfigSecretName` must be provided (priority: kubeconfig secret > cluster name) diff --git a/REUSE.toml b/REUSE.toml index 5318a17..43797a7 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -11,6 +11,7 @@ path = [ ".gitignore", ".license-scan-overrides.jsonl", ".license-scan-rules.json", + "build/**/*", ] SPDX-FileCopyrightText = "SAP SE or an SAP affiliate company" SPDX-License-Identifier = "Apache-2.0" @@ -20,6 +21,7 @@ path = [ "go.mod", "go.sum", "Makefile.maker.yaml", + "vendor/modules.txt", ] SPDX-FileCopyrightText = "SAP SE or an SAP affiliate company and Greenhouse contributors" SPDX-License-Identifier = "Apache-2.0" diff --git a/api/v1alpha1/careinstruction_types.go b/api/v1alpha1/careinstruction_types.go index 4d4eae4..c7052b5 100644 --- a/api/v1alpha1/careinstruction_types.go +++ b/api/v1alpha1/careinstruction_types.go @@ -29,6 +29,10 @@ const ( // ShootsReconciledCondition indicates that the shoots targeted by this CareInstruction have been reconciled. ShootsReconciledCondition greenhousemetav1alpha1.ConditionType = "ShootsReconciled" + // AuthCMFoundCondition indicates that the auth ConfigMap referenced by this CareInstruction was found and is readable. + // Only set when authenticationConfigMapName is configured. + AuthCMFoundCondition greenhousemetav1alpha1.ConditionType = "AuthCMFound" + // CommonCleanupFinalizer is the finalizer used to clean up resources when a CareInstruction is deleted. CommonCleanupFinalizer = "shoot-grafter.cloudoperators.dev/finalizer" @@ -36,7 +40,10 @@ const ( CareInstructionLabel = "shoot-grafter.cloudoperators.dev/careinstruction" // AuthConfigMapLabel is the label used to identify AuthenticationConfiguration ConfigMaps - AuthConfigMapLabel = "shoot-grafter.cloudoperators/authconfigmap" + AuthConfigMapLabel = "shoot-grafter.cloudoperators.dev/auth-configmap" + + // AuthCMRevisionAnnotation is set on Shoots to trigger re-reconciliation when the Greenhouse auth ConfigMap changes. + AuthCMRevisionAnnotation = "shoot-grafter.cloudoperators.dev/auth-cm-revision" // ShootStatusOnboarded indicates the shoot has been onboarded as a Greenhouse Cluster. ShootStatusOnboarded = "Onboarded" diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 47a3a35..8d10cac 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -15,6 +15,7 @@ rules: verbs: - get - list + - patch - update - watch - apiGroups: diff --git a/controller/careinstruction/careinstruction_controller.go b/controller/careinstruction/careinstruction_controller.go index 2b4c967..e3aeb8d 100644 --- a/controller/careinstruction/careinstruction_controller.go +++ b/controller/careinstruction/careinstruction_controller.go @@ -6,6 +6,7 @@ package careinstruction import ( "context" "errors" + "fmt" "reflect" "sync" @@ -29,6 +30,7 @@ import ( "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" ) @@ -46,12 +48,13 @@ type CareInstructionReconciler struct { } type garden struct { - mgr ctrl.Manager // The manager for the garden cluster - gardenConfig *rest.Config // The REST config for the garden cluster - gardenClient *client.Client // The client for the garden cluster - careInstructionSpec *v1alpha1.CareInstructionSpec // The CareInstruction object for the garden cluster - cancelFunc context.CancelFunc // Cancel function to stop the manager - stopChan chan bool // Channel to know if the manager is stopped + mgr ctrl.Manager // The manager for the garden cluster + gardenConfig *rest.Config // The REST config for the garden cluster + gardenClient *client.Client // The client for the garden cluster + careInstructionSpec *v1alpha1.CareInstructionSpec // The CareInstruction object for the garden cluster + cancelFunc context.CancelFunc // Cancel function to stop the manager + stopChan chan bool // Channel to know if the manager is stopped + authConfigMapRevision string // Last-seen auth ConfigMap resourceVersion; used to detect data changes } type careInstructionContextKey struct{} @@ -60,7 +63,7 @@ type careInstructionContextKey struct{} // +kubebuilder:rbac:groups=shoot-grafter.cloudoperators.dev,resources=careinstructions/status,verbs=get;update;patch //+kubebuilder:rbac:groups=greenhouse.sap,resources=clusters,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;update +//+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch;delete func (r *CareInstructionReconciler) SetupWithManager(mgr ctrl.Manager) error { @@ -69,6 +72,13 @@ func (r *CareInstructionReconciler) SetupWithManager(mgr ctrl.Manager) error { For(&v1alpha1.CareInstruction{}). Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.enqueueCareInstructionForGardenCluster), builder.WithPredicates(clientutil.PredicateFilterBySecretTypes(greenhouseapis.SecretTypeKubeConfig, greenhouseapis.SecretTypeOIDCConfig))). Watches(&greenhousev1alpha1.Cluster{}, handler.EnqueueRequestsFromMapFunc(r.enqueueCareInstructionForCreatedClusters), builder.WithPredicates(clientutil.PredicateHasLabel(v1alpha1.CareInstructionLabel))). + // Watch auth ConfigMaps; on data change, re-enqueue referencing CareInstructions to annotate their Shoots. + Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.enqueueCareInstructionForAuthConfigMap), + builder.WithPredicates( + clientutil.PredicateHasLabel(v1alpha1.AuthConfigMapLabel), + clientutil.PredicateConfigMapDataChanged(), + ), + ). Complete(r) } @@ -167,11 +177,12 @@ func (r *CareInstructionReconciler) reconcileManager(ctx context.Context, careIn } if _, exists := r.gardens[gardenKey]; !exists { r.gardens[gardenKey] = &garden{ - mgr: nil, - gardenConfig: nil, - gardenClient: nil, - careInstructionSpec: &careInstruction.Spec, - cancelFunc: nil, + mgr: nil, + gardenConfig: nil, + gardenClient: nil, + careInstructionSpec: &careInstruction.Spec, + cancelFunc: nil, + authConfigMapRevision: "", } } r.gardensMu.Unlock() @@ -197,6 +208,9 @@ func (r *CareInstructionReconciler) reconcileManager(ctx context.Context, careIn ), ) + // Fetch auth ConfigMap revision once; set condition and capture revision for change detection below. + currentAuthCMRevision := r.fetchAuthConfigMapRevision(ctx, &careInstruction) + // Now we check the following to see if we need to recreate and restart the manager (with read lock): r.gardensMu.RLock() garden := r.gardens[gardenKey] @@ -208,7 +222,9 @@ func (r *CareInstructionReconciler) reconcileManager(ctx context.Context, careIn gardenConfigChanged := !reflect.DeepEqual(garden.gardenConfig, &gardenClientConfig) // 4. If the CareInstruction.Spec has changed, we need to recreate the client and manager careInstructionSpecChanged := !reflect.DeepEqual(*garden.careInstructionSpec, careInstruction.Spec) - // 5. This is a safeguard: if the stop channel is nil or closed, we need to recreate the manager + // 5. If the auth ConfigMap data has changed, annotate matching Shoots to trigger re-reconciliation + authConfigMapRevisionChanged := garden.authConfigMapRevision != currentAuthCMRevision + // 6. This is a safeguard: if the stop channel is nil or closed, we need to recreate the manager channelExists := garden.stopChan != nil channelOpen := true if channelExists { @@ -224,116 +240,136 @@ func (r *CareInstructionReconciler) reconcileManager(ctx context.Context, careIn r.gardensMu.RUnlock() if mgrExists && shootControllerStarted && !gardenConfigChanged && !careInstructionSpecChanged && channelExists && channelOpen { - r.Info("Manager is running, garden cluster config & careInstruction.Spec is unchanged, skipping client and manager recreation", "careInstruction", careInstruction.Name) - return nil - } - var reason string - switch { - case !mgrExists: - reason = "no manager exists" - case gardenConfigChanged: - reason = "garden cluster config has changed" - case careInstructionSpecChanged: - reason = "careInstruction.Spec has changed" - case !shootControllerStarted: - reason = "shoot controller not started" - case !channelExists: - reason = "stop channel is missing" - case !channelOpen: - reason = "manager stop channel is closed" - default: - reason = "unknown reason" - } - r.Info("Recreating client and manager for garden cluster because "+reason, "careInstruction", careInstruction.Name) - - // Stop the existing manager if it exists (with read lock for cancelFunc access) - if mgrExists { - r.gardensMu.RLock() - cancelFunc := r.gardens[gardenKey].cancelFunc - r.gardensMu.RUnlock() - - if cancelFunc != nil { - cancelFunc() + r.Info("Manager is running, config unchanged, skipping recreation", "careInstruction", careInstruction.Name) + } else { + var reason string + switch { + case !mgrExists: + reason = "no manager exists" + case gardenConfigChanged: + reason = "garden cluster config has changed" + case careInstructionSpecChanged: + reason = "careInstruction.Spec has changed" + case !shootControllerStarted: + reason = "shoot controller not started" + case !channelExists: + reason = "stop channel is missing" + case !channelOpen: + reason = "manager stop channel is closed" + default: + reason = "unknown reason" } - r.Info("Stopped existing garden manager for shootController", "shootControllerName", shoot.GenerateName(careInstruction.Name), "careInstruction", careInstruction.Name) - } + r.Info("Recreating client and manager for garden cluster because "+reason, "careInstruction", careInstruction.Name) - // Create a new client for the garden cluster - gardenClient, err := client.New(&gardenClientConfig, client.Options{Scheme: scheme}) - if err != nil { - return err - } - r.Info("Successfully created client for garden cluster", "name", careInstruction.Spec.GardenClusterName) + // Stop the existing manager if it exists (with read lock for cancelFunc access) + if mgrExists { + r.gardensMu.RLock() + cancelFunc := r.gardens[gardenKey].cancelFunc + r.gardensMu.RUnlock() + + if cancelFunc != nil { + cancelFunc() + } + r.Info("Stopped existing garden manager for shootController", "shootControllerName", shoot.GenerateName(careInstruction.Name), "careInstruction", careInstruction.Name) + } - skipNameValidation := true - shootControllerMgr, err := ctrl.NewManager(&gardenClientConfig, ctrl.Options{ - Scheme: scheme, - Cache: cache.Options{ - // Only watch the namespace specified in the CareInstruction - DefaultNamespaces: map[string]cache.Config{ - careInstruction.Spec.GardenNamespace: {}, + // Create a new client for the garden cluster + gardenClient, err := client.New(&gardenClientConfig, client.Options{Scheme: scheme}) + if err != nil { + return err + } + r.Info("Successfully created client for garden cluster", "name", careInstruction.Spec.GardenClusterName) + + skipNameValidation := true + shootControllerMgr, err := ctrl.NewManager(&gardenClientConfig, ctrl.Options{ + Scheme: scheme, + Cache: cache.Options{ + // Only watch the namespace specified in the CareInstruction + DefaultNamespaces: map[string]cache.Config{ + careInstruction.Spec.GardenNamespace: {}, + }, }, - }, - Metrics: server.Options{ - BindAddress: "0", // Disable metrics for the shoot controller manager - }, - BaseContext: r.GardenMgrContextFunc, // Use the context factory function to get a long-running context - Controller: config.Controller{ - SkipNameValidation: &skipNameValidation, // Skip name validation for the controller - }, - }) + Metrics: server.Options{ + BindAddress: "0", // Disable metrics for the shoot controller manager + }, + BaseContext: r.GardenMgrContextFunc, // Use the context factory function to get a long-running context + Controller: config.Controller{ + SkipNameValidation: &skipNameValidation, // Skip name validation for the controller + }, + }) - if err != nil { - return err - } + if err != nil { + return err + } - // Register the ShootController with the garden manager - // Note: EventRecorder is obtained from the Greenhouse manager to emit events on the Greenhouse cluster - sc := &shoot.ShootController{ - GreenhouseClient: r.Client, - GardenClient: gardenClient, - Logger: r.WithValues("careInstruction", careInstruction.Name), - Name: shoot.GenerateName(careInstruction.Name), - CareInstruction: careInstruction.DeepCopy(), - EventRecorder: r.GetEventRecorder(shoot.GenerateName(careInstruction.Name)), - } - if err := sc.SetupWithManager(shootControllerMgr); err != nil { - return err - } - r.Info("Successfully created shoot controller manager for garden cluster with shoot controller", "shootControllerName", sc.Name, "careInstruction", careInstruction.Name) + // Register the ShootController with the garden manager. + // Note: EventRecorder is obtained from the Greenhouse manager to emit events on the Greenhouse cluster. + sc := &shoot.ShootController{ + GreenhouseClient: r.Client, + GardenClient: gardenClient, + Logger: r.WithValues("careInstruction", careInstruction.Name), + Name: shoot.GenerateName(careInstruction.Name), + CareInstruction: careInstruction.DeepCopy(), + EventRecorder: r.GetEventRecorder(shoot.GenerateName(careInstruction.Name)), + } + if err := sc.SetupWithManager(shootControllerMgr); err != nil { + return err + } + r.Info("Successfully created shoot controller manager for garden cluster with shoot controller", "shootControllerName", sc.Name, "careInstruction", careInstruction.Name) + + // Create a new context for the garden manager from the main context with cancel function + gardenMgrContext, cancel := context.WithCancel(r.GardenMgrContextFunc()) + + // Update garden state (with write lock) + r.gardensMu.Lock() + r.gardens[gardenKey].gardenConfig = &gardenClientConfig + r.gardens[gardenKey].gardenClient = &gardenClient + r.gardens[gardenKey].mgr = shootControllerMgr + r.gardens[gardenKey].cancelFunc = cancel + r.gardens[gardenKey].stopChan = make(chan bool) + r.gardens[gardenKey].careInstructionSpec = &careInstruction.Spec + stopChan := r.gardens[gardenKey].stopChan + r.gardensMu.Unlock() - // Create a new context for the garden manager from the main context with cancel function - gardenMgrContext, cancel := context.WithCancel(r.GardenMgrContextFunc()) + // Start the garden manager in a goroutine + go func() { + defer close(stopChan) + log.FromContext(gardenMgrContext).Info("Starting garden manager", "shootControllerName", sc.Name, "careInstruction", careInstruction.Name) + if err := shootControllerMgr.Start(gardenMgrContext); err != nil { + log.FromContext(gardenMgrContext).Error(err, "Failed to start garden manager", "shootControllerName", sc.Name, "careInstruction", careInstruction.Name) + return + } + log.FromContext(gardenMgrContext).Info("Shut down garden manager", "shootControllerName", sc.Name, "careInstruction", careInstruction.Name) + }() - // Update garden state (with write lock) - r.gardensMu.Lock() - r.gardens[gardenKey].gardenConfig = &gardenClientConfig - r.gardens[gardenKey].gardenClient = &gardenClient - r.gardens[gardenKey].mgr = shootControllerMgr - r.gardens[gardenKey].cancelFunc = cancel - r.gardens[gardenKey].stopChan = make(chan bool) - r.gardens[gardenKey].careInstructionSpec = &careInstruction.Spec - stopChan := r.gardens[gardenKey].stopChan - r.gardensMu.Unlock() + careInstruction.Status.SetConditions( + greenhousemetav1alpha1.TrueCondition( + v1alpha1.ShootControllerStartedCondition, + "Started", + "", + ), + ) + } // end manager-restart else block - // Start the garden manager in a goroutine - go func() { - defer close(stopChan) - log.FromContext(gardenMgrContext).Info("Starting garden manager", "shootControllerName", sc.Name, "careInstruction", careInstruction.Name) - if err := shootControllerMgr.Start(gardenMgrContext); err != nil { - log.FromContext(gardenMgrContext).Error(err, "Failed to start garden manager", "shootControllerName", sc.Name, "careInstruction", careInstruction.Name) - return - } - log.FromContext(gardenMgrContext).Info("Shut down garden manager", "shootControllerName", sc.Name, "careInstruction", careInstruction.Name) - }() + // On auth CM data change, annotate matching Shoots so the ShootController re-reconciles them. + // Skip on first reconcile (prevRevision == "") — the initial cache sync already queues every Shoot. + if authConfigMapRevisionChanged && currentAuthCMRevision != "" { + r.gardensMu.RLock() + prevRevision := r.gardens[gardenKey].authConfigMapRevision + gardenClientPtr := r.gardens[gardenKey].gardenClient + r.gardensMu.RUnlock() - careInstruction.Status.SetConditions( - greenhousemetav1alpha1.TrueCondition( - v1alpha1.ShootControllerStartedCondition, - "Started", - "", - ), - ) + if prevRevision != "" { + if err := r.annotateMatchingShootsForReconcile(ctx, &careInstruction, *gardenClientPtr, currentAuthCMRevision); err != nil { + // Keep old revision so next reconcile retries. + r.Error(err, "failed to annotate shoots after auth CM change", "careInstruction", careInstruction.Name) + return nil + } + } + r.gardensMu.Lock() + r.gardens[gardenKey].authConfigMapRevision = currentAuthCMRevision + r.gardensMu.Unlock() + } return nil } @@ -587,3 +623,99 @@ func (r *CareInstructionReconciler) enqueueCareInstructionForCreatedClusters(_ c }, } } + +// enqueueCareInstructionForAuthConfigMap enqueues all CareInstructions in the same namespace that reference +// the changed auth ConfigMap via spec.authenticationConfigMapName. +func (r *CareInstructionReconciler) enqueueCareInstructionForAuthConfigMap(ctx context.Context, obj client.Object) []ctrl.Request { + cm, ok := obj.(*corev1.ConfigMap) + if !ok { + return nil + } + + var ciList v1alpha1.CareInstructionList + if err := r.List(ctx, &ciList, client.InNamespace(cm.Namespace)); err != nil { + r.Error(err, "failed to list CareInstructions for auth ConfigMap change", "configMap", cm.Name, "namespace", cm.Namespace) + return nil + } + + var requests []ctrl.Request + for _, ci := range ciList.Items { + if ci.Spec.AuthenticationConfigMapName == cm.Name { + requests = append(requests, ctrl.Request{NamespacedName: client.ObjectKey{Name: ci.Name, Namespace: ci.Namespace}}) + } + } + if len(requests) > 0 { + r.Info("Enqueuing CareInstructions for auth ConfigMap change", "configMap", cm.Name, "namespace", cm.Namespace, "count", len(requests)) + } + return requests +} + +// annotateMatchingShootsForReconcile stamps AuthCMRevisionAnnotation on each Shoot matching the CI's +// label selector, triggering the ShootController to re-run configureOIDCAuthentication. +func (r *CareInstructionReconciler) annotateMatchingShootsForReconcile( + ctx context.Context, + careInstruction *v1alpha1.CareInstruction, + gardenClient client.Client, + revision string, +) error { + + listOpts := []client.ListOption{ + client.InNamespace(careInstruction.Spec.GardenNamespace), + } + if sel := careInstruction.Spec.ShootSelector; sel != nil && sel.LabelSelector != nil { + ls, err := metav1.LabelSelectorAsSelector(sel.LabelSelector) + if err != nil { + return fmt.Errorf("invalid label selector: %w", err) + } + listOpts = append(listOpts, client.MatchingLabelsSelector{Selector: ls}) + } + + var shoots gardenerv1beta1.ShootList + if err := gardenClient.List(ctx, &shoots, listOpts...); err != nil { + return fmt.Errorf("list shoots for auth CM fan-out: %w", err) + } + + var errs []error + for i := range shoots.Items { + s := &shoots.Items[i] + if s.Annotations[v1alpha1.AuthCMRevisionAnnotation] == revision { + continue // already at this revision, no-op + } + base := s.DeepCopy() + if s.Annotations == nil { + s.Annotations = map[string]string{} + } + s.Annotations[v1alpha1.AuthCMRevisionAnnotation] = revision + if err := gardenClient.Patch(ctx, s, client.MergeFrom(base)); err != nil { + errs = append(errs, fmt.Errorf("annotate shoot %s: %w", s.Name, err)) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + r.Info("annotated shoots for reconcile due to auth ConfigMap change", + "careInstruction", careInstruction.Name, + "namespace", careInstruction.Spec.GardenNamespace, + "revision", revision, + "count", len(shoots.Items)) + return nil +} + +// fetchAuthConfigMapRevision fetches the auth ConfigMap referenced by the CareInstruction, sets the AuthCMFound +// condition, and returns the ConfigMap's ResourceVersion. Returns "" when no auth ConfigMap is configured. +func (r *CareInstructionReconciler) fetchAuthConfigMapRevision(ctx context.Context, careInstruction *v1alpha1.CareInstruction) string { + if careInstruction.Spec.AuthenticationConfigMapName == "" { + return "" + } + var cm corev1.ConfigMap + if err := r.Get(ctx, client.ObjectKey{ + Namespace: careInstruction.Namespace, + Name: careInstruction.Spec.AuthenticationConfigMapName, + }, &cm); err != nil { + r.Info("auth ConfigMap unavailable", "error", err) + careInstruction.Status.SetConditions(greenhousemetav1alpha1.FalseCondition(v1alpha1.AuthCMFoundCondition, "AuthCMNotFound", err.Error())) + return "" + } + careInstruction.Status.SetConditions(greenhousemetav1alpha1.TrueCondition(v1alpha1.AuthCMFoundCondition, "AuthCMFound", "")) + return cm.ResourceVersion +} diff --git a/controller/careinstruction/careinstruction_controller_test.go b/controller/careinstruction/careinstruction_controller_test.go index b3f88dc..19501fc 100644 --- a/controller/careinstruction/careinstruction_controller_test.go +++ b/controller/careinstruction/careinstruction_controller_test.go @@ -1267,4 +1267,166 @@ var _ = Describe("CareInstruction Controller", func() { }) }) + Context("When multiple CareInstructions reference the same auth ConfigMap", func() { + var ( + sharedAuthCM *corev1.ConfigMap + careInstruction1 *v1alpha1.CareInstruction + careInstruction2 *v1alpha1.CareInstruction + ) + + BeforeEach(func() { + sharedAuthCM = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shared-auth-config", + Namespace: "default", + Labels: map[string]string{ + v1alpha1.AuthConfigMapLabel: "true", + }, + }, + Data: map[string]string{ + "config.yaml": "initial: data", + }, + } + Expect(test.K8sClient.Create(test.Ctx, sharedAuthCM)).To(Succeed(), "should create shared auth ConfigMap") + + careInstruction1 = &v1alpha1.CareInstruction{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shared-auth-ci-1", + Namespace: "default", + }, + Spec: v1alpha1.CareInstructionSpec{ + GardenClusterName: test.GardenClusterName, + GardenNamespace: "default", + AuthenticationConfigMapName: "shared-auth-config", + ShootSelector: &v1alpha1.ShootSelector{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"shared-auth": "true"}, + }, + }, + }, + } + Expect(test.K8sClient.Create(test.Ctx, careInstruction1)).To(Succeed(), "should create first CareInstruction") + + careInstruction2 = &v1alpha1.CareInstruction{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shared-auth-ci-2", + Namespace: "default", + }, + Spec: v1alpha1.CareInstructionSpec{ + GardenClusterName: test.GardenClusterName, + GardenNamespace: "default", + AuthenticationConfigMapName: "shared-auth-config", + ShootSelector: &v1alpha1.ShootSelector{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"shared-auth": "true"}, + }, + }, + }, + } + Expect(test.K8sClient.Create(test.Ctx, careInstruction2)).To(Succeed(), "should create second CareInstruction") + }) + + AfterEach(func() { + Expect(client.IgnoreNotFound(test.K8sClient.Delete(test.Ctx, sharedAuthCM))).To(Succeed()) + }) + + It("should annotate matching Shoots when the shared auth ConfigMap data changes", func() { + By("Creating a Shoot on the Garden cluster matching the CI label selector") + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shared-auth-shoot", + Namespace: "default", + Labels: map[string]string{"shared-auth": "true"}, + }, + } + Expect(test.GardenK8sClient.Create(test.Ctx, shoot)).To(Succeed(), "should create Shoot on garden cluster") + defer func() { + Expect(client.IgnoreNotFound(test.GardenK8sClient.Delete(test.Ctx, shoot))).To(Succeed()) + }() + + By("Waiting for both CareInstructions to start their ShootController managers") + Eventually(func(g Gomega) { + g.Expect(test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(careInstruction1), careInstruction1)).To(Succeed()) + cond := careInstruction1.Status.GetConditionByType(v1alpha1.ShootControllerStartedCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.IsTrue()).To(BeTrue()) + + g.Expect(test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(careInstruction2), careInstruction2)).To(Succeed()) + cond2 := careInstruction2.Status.GetConditionByType(v1alpha1.ShootControllerStartedCondition) + g.Expect(cond2).ToNot(BeNil()) + g.Expect(cond2.IsTrue()).To(BeTrue()) + }).Should(Succeed(), "both CareInstructions should have their ShootController managers started") + + By("Updating the shared auth ConfigMap data to trigger a watch event") + Expect(test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(sharedAuthCM), sharedAuthCM)).To(Succeed()) + base := sharedAuthCM.DeepCopy() + sharedAuthCM.Data["config.yaml"] = "updated: data" + Expect(test.K8sClient.Patch(test.Ctx, sharedAuthCM, client.MergeFrom(base))).To(Succeed()) + updatedRevision := sharedAuthCM.ResourceVersion + + By("Expecting the matching Shoot to receive the auth-cm-revision annotation") + Eventually(func(g Gomega) { + var updatedShoot gardenerv1beta1.Shoot + g.Expect(test.GardenK8sClient.Get(test.Ctx, client.ObjectKeyFromObject(shoot), &updatedShoot)).To(Succeed()) + g.Expect(updatedShoot.Annotations).To(HaveKeyWithValue(v1alpha1.AuthCMRevisionAnnotation, updatedRevision), + "CareInstruction controller should stamp auth-cm-revision on the Shoot after CM data change") + }).Should(Succeed(), "Shoot should receive auth-cm-revision annotation after CM change") + }) + + It("should not annotate Shoots for an auth ConfigMap in a different namespace", func() { + By("Creating a Shoot on the Garden cluster with a pre-stamped sentinel revision") + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shared-auth-shoot-ns", + Namespace: "default", + Labels: map[string]string{"shared-auth": "true"}, + Annotations: map[string]string{ + v1alpha1.AuthCMRevisionAnnotation: "sentinel", + }, + }, + } + Expect(test.GardenK8sClient.Create(test.Ctx, shoot)).To(Succeed(), "should create Shoot on garden cluster") + defer func() { + Expect(client.IgnoreNotFound(test.GardenK8sClient.Delete(test.Ctx, shoot))).To(Succeed()) + }() + + By("Creating an auth ConfigMap with the same name in a different namespace") + otherNS := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "other-ns"}, + } + Expect(client.IgnoreAlreadyExists(test.K8sClient.Create(test.Ctx, otherNS))).To(Succeed()) + + otherCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shared-auth-config", + Namespace: "other-ns", + Labels: map[string]string{ + v1alpha1.AuthConfigMapLabel: "true", + }, + }, + Data: map[string]string{"config.yaml": "other: data"}, + } + Expect(test.K8sClient.Create(test.Ctx, otherCM)).To(Succeed()) + defer func() { + Expect(client.IgnoreNotFound(test.K8sClient.Delete(test.Ctx, otherCM))).To(Succeed()) + }() + + By("Updating the CM in the other namespace") + Expect(test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(otherCM), otherCM)).To(Succeed()) + base := otherCM.DeepCopy() + otherCM.Data["config.yaml"] = "other: updated" + Expect(test.K8sClient.Patch(test.Ctx, otherCM, client.MergeFrom(base))).To(Succeed()) + + By("Confirming the Shoot's auth-cm-revision annotation stays at the sentinel value") + // The mapper lists CIs in cm.Namespace ("other-ns") only; neither CI is there, so + // annotateMatchingShootsForReconcile is never called and the sentinel must stay unchanged. + Consistently(func(g Gomega) { + var updatedShoot gardenerv1beta1.Shoot + g.Expect(test.GardenK8sClient.Get(test.Ctx, client.ObjectKeyFromObject(shoot), &updatedShoot)).To(Succeed()) + g.Expect(updatedShoot.Annotations).To(HaveKeyWithValue(v1alpha1.AuthCMRevisionAnnotation, "sentinel"), + "Shoot annotation must not be changed by a CM event from another namespace") + }, "3s", "500ms").Should(Succeed(), "Shoot annotation should not be updated by a CM in another namespace") + }) + }) + }) diff --git a/controller/careinstruction/metrics.go b/controller/careinstruction/metrics.go index cb1caf3..c43e742 100644 --- a/controller/careinstruction/metrics.go +++ b/controller/careinstruction/metrics.go @@ -10,10 +10,12 @@ import ( "shoot-grafter/api/v1alpha1" ) -const metricLabelCareInstruction = "care_instruction" -const metricLabelNamespace = "namespace" -const metricLabelGardenNamespace = "garden_namespace" -const metricLabelShootName = "shoot_name" +const ( + labelCareInstruction = "care_instruction" + labelNamespace = "namespace" + labelGardenNamespace = "garden_namespace" + labelShootName = "shoot_name" +) var ( TotalTargetShootsGauge = prometheus.NewGaugeVec( @@ -21,28 +23,28 @@ var ( Name: "shoot_grafter_total_target_shoots", Help: "Total number of shoots matching the CareInstruction label selector", }, - []string{metricLabelCareInstruction, metricLabelNamespace, metricLabelGardenNamespace}, + []string{labelCareInstruction, labelNamespace, labelGardenNamespace}, ) CreatedClustersGauge = prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "shoot_grafter_created_clusters", Help: "Number of clusters created by the CareInstruction", }, - []string{metricLabelCareInstruction, metricLabelNamespace, metricLabelGardenNamespace}, + []string{labelCareInstruction, labelNamespace, labelGardenNamespace}, ) FailedClustersGauge = prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "shoot_grafter_failed_clusters", Help: "Number of clusters failed to be created by the CareInstruction", }, - []string{metricLabelCareInstruction, metricLabelNamespace, metricLabelGardenNamespace}, + []string{labelCareInstruction, labelNamespace, labelGardenNamespace}, ) ShootOnboardedGauge = prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "shoot_grafter_shoot_onboarded", Help: "Is shoot onboarded by the CareInstruction", }, - []string{metricLabelCareInstruction, metricLabelNamespace, metricLabelGardenNamespace, metricLabelShootName}, + []string{labelCareInstruction, labelNamespace, labelGardenNamespace, labelShootName}, ) ) @@ -64,9 +66,9 @@ func UpdateCareInstructionMetrics(careInstruction *v1alpha1.CareInstruction) { func updateTotalTargetShootsMetric(careInstruction *v1alpha1.CareInstruction) { metricLabels := prometheus.Labels{ - metricLabelCareInstruction: careInstruction.Name, - metricLabelNamespace: careInstruction.Namespace, - metricLabelGardenNamespace: careInstruction.Spec.GardenNamespace, + labelCareInstruction: careInstruction.Name, + labelNamespace: careInstruction.Namespace, + labelGardenNamespace: careInstruction.Spec.GardenNamespace, } totalTargetShoots := careInstruction.Status.TotalTargetShoots TotalTargetShootsGauge.With(metricLabels).Set(float64(totalTargetShoots)) @@ -74,9 +76,9 @@ func updateTotalTargetShootsMetric(careInstruction *v1alpha1.CareInstruction) { func updateCreatedClustersMetric(careInstruction *v1alpha1.CareInstruction) { metricLabels := prometheus.Labels{ - metricLabelCareInstruction: careInstruction.Name, - metricLabelNamespace: careInstruction.Namespace, - metricLabelGardenNamespace: careInstruction.Spec.GardenNamespace, + labelCareInstruction: careInstruction.Name, + labelNamespace: careInstruction.Namespace, + labelGardenNamespace: careInstruction.Spec.GardenNamespace, } createdCount := careInstruction.Status.CreatedClusters CreatedClustersGauge.With(metricLabels).Set(float64(createdCount)) @@ -84,9 +86,9 @@ func updateCreatedClustersMetric(careInstruction *v1alpha1.CareInstruction) { func updateFailedClustersMetric(careInstruction *v1alpha1.CareInstruction) { metricLabels := prometheus.Labels{ - metricLabelCareInstruction: careInstruction.Name, - metricLabelNamespace: careInstruction.Namespace, - metricLabelGardenNamespace: careInstruction.Spec.GardenNamespace, + labelCareInstruction: careInstruction.Name, + labelNamespace: careInstruction.Namespace, + labelGardenNamespace: careInstruction.Spec.GardenNamespace, } failedCount := careInstruction.Status.FailedClusters FailedClustersGauge.With(metricLabels).Set(float64(failedCount)) @@ -95,10 +97,10 @@ func updateFailedClustersMetric(careInstruction *v1alpha1.CareInstruction) { func updateOnboardedShootsMetrics(careInstruction *v1alpha1.CareInstruction) { for _, ss := range careInstruction.Status.Shoots { metricLabels := prometheus.Labels{ - metricLabelCareInstruction: careInstruction.Name, - metricLabelNamespace: careInstruction.Namespace, - metricLabelGardenNamespace: careInstruction.Spec.GardenNamespace, - metricLabelShootName: ss.Name, + labelCareInstruction: careInstruction.Name, + labelNamespace: careInstruction.Namespace, + labelGardenNamespace: careInstruction.Spec.GardenNamespace, + labelShootName: ss.Name, } if ss.Status == v1alpha1.ShootStatusOnboarded { ShootOnboardedGauge.With(metricLabels).Set(float64(1)) diff --git a/controller/shoot/auth.go b/controller/shoot/auth.go index 0e79c2a..077d257 100644 --- a/controller/shoot/auth.go +++ b/controller/shoot/auth.go @@ -11,6 +11,7 @@ import ( gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" apiserverv1beta1 "k8s.io/apiserver/pkg/apis/apiserver/v1beta1" ctrl "sigs.k8s.io/controller-runtime" @@ -22,39 +23,47 @@ import ( const authConfigMapKey = "config.yaml" // configureOIDCAuthentication configures OIDC authentication for the Shoot by: -// 1. Fetching the AuthenticationConfiguration ConfigMap from Greenhouse cluster +// 1. Reading the AuthenticationConfiguration from the Greenhouse auth ConfigMap // 2. Merging it with any existing configuration on the Garden cluster // 3. Updating the Shoot spec to reference the merged configuration func (r *ShootController) configureOIDCAuthentication(ctx context.Context, shoot *gardenerv1beta1.Shoot) error { - // Fetch the AuthenticationConfiguration ConfigMap from Greenhouse cluster + // Label the Greenhouse auth ConfigMap so the CareInstruction controller's watch predicate can + // identify it and associate it with this CareInstruction. + // We fetch the live CM to get the current metadata, then patch only the labels. var greenhouseAuthConfigMap corev1.ConfigMap if err := r.GreenhouseClient.Get(ctx, client.ObjectKey{ Namespace: r.CareInstruction.Namespace, Name: r.CareInstruction.Spec.AuthenticationConfigMapName, }, &greenhouseAuthConfigMap); err != nil { - return fmt.Errorf("failed to fetch AuthenticationConfiguration ConfigMap %s from Greenhouse cluster: %w", - r.CareInstruction.Spec.AuthenticationConfigMapName, err) - } + if !errors.IsNotFound(err) { + r.Info("failed to fetch auth ConfigMap for labeling; skipping label patch", + "configMap", r.CareInstruction.Spec.AuthenticationConfigMapName, "error", err) + } + } else { + base := greenhouseAuthConfigMap.DeepCopy() + if greenhouseAuthConfigMap.Labels == nil { + greenhouseAuthConfigMap.Labels = make(map[string]string) + } + labelsNeedUpdate := false - // Add the auth ConfigMap label if it doesn't exist - if greenhouseAuthConfigMap.Labels == nil { - greenhouseAuthConfigMap.Labels = make(map[string]string) - } - if _, hasLabel := greenhouseAuthConfigMap.Labels[v1alpha1.AuthConfigMapLabel]; !hasLabel { - greenhouseAuthConfigMap.Labels[v1alpha1.AuthConfigMapLabel] = "true" - if err := r.GreenhouseClient.Update(ctx, &greenhouseAuthConfigMap); err != nil { - r.Info("failed to add auth ConfigMap label", "configMap", greenhouseAuthConfigMap.Name, "error", err) - // Don't fail the reconciliation for this, just log it + if _, hasAuthLabel := greenhouseAuthConfigMap.Labels[v1alpha1.AuthConfigMapLabel]; !hasAuthLabel { + greenhouseAuthConfigMap.Labels[v1alpha1.AuthConfigMapLabel] = "true" + labelsNeedUpdate = true + } + + if labelsNeedUpdate { + if patchErr := r.GreenhouseClient.Patch(ctx, &greenhouseAuthConfigMap, client.MergeFrom(base)); patchErr != nil { + r.Info("failed to patch labels on auth ConfigMap", "configMap", greenhouseAuthConfigMap.Name, "error", patchErr) + } } } - // Verify the ConfigMap contains config.yaml if greenhouseAuthConfigMap.Data == nil || greenhouseAuthConfigMap.Data[authConfigMapKey] == "" { - return fmt.Errorf("AuthenticationConfiguration ConfigMap %s does not contain config.yaml", - r.CareInstruction.Spec.AuthenticationConfigMapName) + r.Info("auth ConfigMap has no data, skipping OIDC configuration", + "configMap", r.CareInstruction.Spec.AuthenticationConfigMapName) + return nil } - // Parse the Greenhouse authentication configuration var greenhouseAuthConfig apiserverv1beta1.AuthenticationConfiguration if err := yaml.Unmarshal([]byte(greenhouseAuthConfigMap.Data[authConfigMapKey]), &greenhouseAuthConfig); err != nil { return fmt.Errorf("failed to parse Greenhouse AuthenticationConfiguration: %w", err) diff --git a/controller/shoot/auth_test.go b/controller/shoot/auth_test.go index 9f9f8ce..8ed3d27 100644 --- a/controller/shoot/auth_test.go +++ b/controller/shoot/auth_test.go @@ -41,23 +41,23 @@ var _ = Describe("Auth", func() { // Verify ConfigMap data was updated Expect(initialGardenConfigMap.Data).NotTo(BeNil()) - Expect(initialGardenConfigMap.Data).To(HaveKey("config.yaml")) + Expect(initialGardenConfigMap.Data).To(HaveKey(authConfigMapKey)) - // Verify other data keys are preserved (keys that are not "config.yaml") + // Verify other data keys are preserved (keys that are not authConfigMapKey) for key, value := range expectedConfigMap.Data { - if key != "config.yaml" { + if key != authConfigMapKey { Expect(initialGardenConfigMap.Data).To(HaveKeyWithValue(key, value)) } } // Unmarshal the actual result var actualConfig apiserverv1beta1.AuthenticationConfiguration - err = yaml.Unmarshal([]byte(initialGardenConfigMap.Data["config.yaml"]), &actualConfig) + err = yaml.Unmarshal([]byte(initialGardenConfigMap.Data[authConfigMapKey]), &actualConfig) Expect(err).NotTo(HaveOccurred()) // Unmarshal the expected configuration var expectedConfig apiserverv1beta1.AuthenticationConfiguration - err = yaml.Unmarshal([]byte(expectedConfigMap.Data["config.yaml"]), &expectedConfig) + err = yaml.Unmarshal([]byte(expectedConfigMap.Data[authConfigMapKey]), &expectedConfig) Expect(err).NotTo(HaveOccurred()) // Compare configurations @@ -101,7 +101,7 @@ var _ = Describe("Auth", func() { }, &corev1.ConfigMap{ Data: map[string]string{ - "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 + authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: @@ -142,7 +142,7 @@ jwt: Data: map[string]string{ "other-key": "other-value", "another-key": "another-value", - "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 + authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: @@ -159,7 +159,7 @@ jwt: Entry("with Garden ConfigMap having one different issuer (should add Greenhouse issuer)", &corev1.ConfigMap{ Data: map[string]string{ - "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 + authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: @@ -190,7 +190,7 @@ jwt: }, &corev1.ConfigMap{ Data: map[string]string{ - "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 + authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: @@ -214,7 +214,7 @@ jwt: Entry("with Garden ConfigMap having the same issuer (Greenhouse should update it)", &corev1.ConfigMap{ Data: map[string]string{ - "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 + authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: @@ -245,7 +245,7 @@ jwt: }, &corev1.ConfigMap{ Data: map[string]string{ - "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 + authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: @@ -262,7 +262,7 @@ jwt: Entry("with multiple Greenhouse issuers and multiple Garden issuers", &corev1.ConfigMap{ Data: map[string]string{ - "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 + authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: @@ -319,7 +319,7 @@ jwt: }, &corev1.ConfigMap{ Data: map[string]string{ - "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 + authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: @@ -360,7 +360,7 @@ jwt: It("should return error for invalid YAML in Garden ConfigMap", func() { configMap := &corev1.ConfigMap{ Data: map[string]string{ - "config.yaml": "invalid: yaml: content: [", + authConfigMapKey: "invalid: yaml: content: [", }, } diff --git a/controller/shoot/shoot_controller_test.go b/controller/shoot/shoot_controller_test.go index 872c569..660c631 100644 --- a/controller/shoot/shoot_controller_test.go +++ b/controller/shoot/shoot_controller_test.go @@ -34,6 +34,7 @@ var ( mgrCtx context.Context mgrCancel context.CancelFunc ) + var _ = Describe("Shoot Controller", func() { JustBeforeEach(func() { // register controllers in JustBeforeEach, as they depend on the CareInstruction. @@ -63,18 +64,18 @@ var _ = Describe("Shoot Controller", func() { }) Expect(err).NotTo(HaveOccurred(), "there must be no error creating the garden manager") - // Create a manager for the Greenhouse cluster (where events should be emitted) + // Build an event recorder backed by the Greenhouse cluster so events are emitted there. + // The garden manager's recorder would emit to the Garden cluster, but tests verify events + // on the Greenhouse cluster (test.K8sClient). greenhouseMgr, err := ctrl.NewManager(test.Cfg, ctrl.Options{ Scheme: scheme.Scheme, Metrics: server.Options{ - BindAddress: "0", // Disable metrics + BindAddress: "0", }, LeaderElection: false, }) - Expect(err).NotTo(HaveOccurred(), "there must be no error creating the greenhouse manager") + Expect(err).NotTo(HaveOccurred(), "there must be no error creating the greenhouse manager for event recording") - // Create ShootController with EventRecorder from Greenhouse manager - Expect(err).NotTo(HaveOccurred(), "there must be no error creating the manager") Expect((&shoot.ShootController{ GreenhouseClient: test.K8sClient, GardenClient: test.GardenK8sClient, @@ -88,7 +89,12 @@ var _ = Describe("Shoot Controller", func() { Expect(careInstructionWebhook.SetupWebhookWithManager(mgr)).To(Succeed(), "there must be no error setting up the webhook with the manager") mgrCtx, mgrCancel = context.WithCancel(test.Ctx) - // start the manager + + // start both managers + go func() { + defer GinkgoRecover() + Expect(greenhouseMgr.Start(mgrCtx)).To(Succeed()) + }() go func() { defer GinkgoRecover() Expect(mgr.Start(mgrCtx)).To(Succeed(), "there must be no error starting the manager") @@ -157,24 +163,20 @@ var _ = Describe("Shoot Controller", func() { return len(configMaps.Items) == 0 // Only the garden cluster ConfigMap should remain }).Should(BeTrue(), "should eventually not find ConfigMap resources") - // Clean up auth ConfigMaps in Greenhouse cluster using label selector - greenhouseAuthConfigMaps := &corev1.ConfigMapList{} - Expect(test.K8sClient.List(test.Ctx, greenhouseAuthConfigMaps, client.MatchingLabels{ - v1alpha1.AuthConfigMapLabel: "true", - })).To(Succeed(), "should list auth ConfigMaps in Greenhouse cluster") - for _, configMap := range greenhouseAuthConfigMaps.Items { - Expect(client.IgnoreNotFound(test.K8sClient.Delete(test.Ctx, &configMap))).To(Succeed(), "should delete auth ConfigMap resource") + // Clean up all ConfigMaps created during tests in the Greenhouse cluster + greenhouseConfigMaps := &corev1.ConfigMapList{} + Expect(test.K8sClient.List(test.Ctx, greenhouseConfigMaps, client.InNamespace("default"))).To(Succeed(), "should list ConfigMaps in Greenhouse cluster") + for _, configMap := range greenhouseConfigMaps.Items { + Expect(client.IgnoreNotFound(test.K8sClient.Delete(test.Ctx, &configMap))).To(Succeed(), "should delete ConfigMap resource") } Eventually(func(g Gomega) bool { - greenhouseAuthConfigMaps := &corev1.ConfigMapList{} - err := test.K8sClient.List(test.Ctx, greenhouseAuthConfigMaps, client.MatchingLabels{ - v1alpha1.AuthConfigMapLabel: "true", - }) + greenhouseConfigMaps := &corev1.ConfigMapList{} + err := test.K8sClient.List(test.Ctx, greenhouseConfigMaps, client.InNamespace("default")) if err != nil { return false } - return len(greenhouseAuthConfigMaps.Items) == 0 - }).Should(BeTrue(), "should eventually not find auth ConfigMap resources in Greenhouse cluster") + return len(greenhouseConfigMaps.Items) == 0 + }).Should(BeTrue(), "should eventually not find ConfigMap resources in Greenhouse cluster") // Clean up any Events created during the tests events := &corev1.EventList{} @@ -191,7 +193,6 @@ var _ = Describe("Shoot Controller", func() { return len(events.Items) == 0 }).Should(BeTrue(), "should eventually not find Event resources") - // stop the manager mgrCancel() }) @@ -1729,7 +1730,7 @@ jwt: } Expect(test.GardenK8sClient.Create(test.Ctx, cm)).To(Succeed(), "should create CA ConfigMap") - // Eventually verify the label was added by the controller + // Eventually verify both labels were added by the controller Eventually(func(g Gomega) bool { var updatedConfigMap corev1.ConfigMap err := test.K8sClient.Get(test.Ctx, client.ObjectKey{ @@ -1739,10 +1740,11 @@ jwt: if err != nil { return false } - // Verify the label was added - g.Expect(updatedConfigMap.Labels).To(HaveKeyWithValue(v1alpha1.AuthConfigMapLabel, "true")) + // Verify AuthConfigMapLabel was added to enable the watch predicate + g.Expect(updatedConfigMap.Labels).To(HaveKeyWithValue(v1alpha1.AuthConfigMapLabel, "true"), + "controller should add auth-configmap label so the watch predicate can match") return true - }).Should(BeTrue(), "controller should add auth ConfigMap label when not initially present") + }).Should(BeTrue(), "controller should add the auth-configmap label when not initially present") }) }) @@ -1936,10 +1938,9 @@ jwt: AuthenticationConfigMapName: "greenhouse-oidc-config", }, } - }) - It("should NOT annotate Shoot when setting up OIDC for the first time", func() { - // Create Greenhouse auth ConfigMap + // Create Greenhouse auth ConfigMap in BeforeEach so JustBeforeEach can fetch its data + // for AuthConfigMapData (which is passed in-memory to the ShootController). greenhouseAuthCM := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "greenhouse-oidc-config", @@ -1961,7 +1962,9 @@ jwt: }, } Expect(test.K8sClient.Create(test.Ctx, greenhouseAuthCM)).To(Succeed(), "should create Greenhouse auth ConfigMap") + }) + It("should NOT annotate Shoot when setting up OIDC for the first time", func() { // Create a shoot without any OIDC configuration shoot := &gardenerv1beta1.Shoot{ ObjectMeta: metav1.ObjectMeta{ @@ -2024,37 +2027,17 @@ jwt: }) It("should annotate Shoot with gardener.cloud/operation=reconcile when ConfigMap content changes", func() { - // Create Greenhouse auth ConfigMap - greenhouseAuthCM := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "greenhouse-oidc-config", - Namespace: "default", - }, - Data: map[string]string{ - "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 -kind: AuthenticationConfiguration -jwt: -- issuer: - url: https://greenhouse.example.com - audiences: - - greenhouse - claimMappings: - username: - claim: sub - prefix: 'greenhouse:' -`, - }, - } - Expect(test.K8sClient.Create(test.Ctx, greenhouseAuthCM)).To(Succeed(), "should create Greenhouse auth ConfigMap") + // Pre-populate the Garden OIDC ConfigMap with old content. The Greenhouse auth ConfigMap + // (created in BeforeEach) has audience "greenhouse". On reconcile the ShootController + // fetches the Greenhouse CM directly, detects the content difference, updates the Garden CM, + // and adds the reconcile annotation. - // Create a shoot that ALREADY has the OIDC ConfigMap reference + // Create a Shoot that already references the Garden OIDC ConfigMap shoot := &gardenerv1beta1.Shoot{ ObjectMeta: metav1.ObjectMeta{ Name: "test-shoot-oidc-update", Namespace: "default", - Labels: map[string]string{ - "oidc-test": "true", - }, + Labels: map[string]string{"oidc-test": "true"}, }, Spec: gardenerv1beta1.ShootSpec{ Kubernetes: gardenerv1beta1.Kubernetes{ @@ -2067,35 +2050,23 @@ jwt: }, } Expect(test.GardenK8sClient.Create(test.Ctx, shoot)).To(Succeed(), "should create Shoot resource") - shoot.Status = gardenerv1beta1.ShootStatus{ AdvertisedAddresses: []gardenerv1beta1.ShootAdvertisedAddress{ - { - Name: "external", - URL: "https://api-server.test-shoot-oidc-update.example.com", - }, + {Name: "external", URL: "https://api-server.test-shoot-oidc-update.example.com"}, }, } Expect(test.GardenK8sClient.Status().Update(test.Ctx, shoot)).To(Succeed(), "should update Shoot status") // Create CA ConfigMap caCM := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-shoot-oidc-update.ca-cluster", - Namespace: "default", - }, - Data: map[string]string{ - "ca.crt": "test-ca-data", - }, + ObjectMeta: metav1.ObjectMeta{Name: "test-shoot-oidc-update.ca-cluster", Namespace: "default"}, + Data: map[string]string{"ca.crt": "test-ca-data"}, } Expect(test.GardenK8sClient.Create(test.Ctx, caCM)).To(Succeed(), "should create CA ConfigMap") - // Create the OIDC ConfigMap in Garden cluster with initial content + // Pre-populate the Garden OIDC ConfigMap with old content (old audience). gardenOIDCCM := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-careinstruction-oidc-greenhouse-auth", - Namespace: "default", - }, + ObjectMeta: metav1.ObjectMeta{Name: "test-careinstruction-oidc-greenhouse-auth", Namespace: "default"}, Data: map[string]string{ "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration @@ -2110,29 +2081,62 @@ jwt: `, }, } - Expect(test.GardenK8sClient.Create(test.Ctx, gardenOIDCCM)).To(Succeed(), "should create Garden OIDC ConfigMap") + Expect(test.GardenK8sClient.Create(test.Ctx, gardenOIDCCM)).To(Succeed(), "should create Garden OIDC ConfigMap with old content") - // Wait a moment for initial reconciliation - Eventually(func(g Gomega) bool { - secret := &corev1.Secret{} - err := test.K8sClient.Get(test.Ctx, client.ObjectKey{ - Name: "test-shoot-oidc-update", + // Reconcile fires automatically; wait for the Shoot to be annotated + Eventually(func(g Gomega) { + updatedShoot := &gardenerv1beta1.Shoot{} + g.Expect(test.GardenK8sClient.Get(test.Ctx, client.ObjectKey{ + Name: "test-shoot-oidc-update", Namespace: "default", + }, updatedShoot)).To(Succeed()) + g.Expect(updatedShoot.Annotations).ToNot(BeNil(), "should have annotations") + g.Expect(updatedShoot.Annotations["gardener.cloud/operation"]).To(Equal("reconcile"), + "should have reconcile annotation because Garden CM content was updated") + }).Should(Succeed(), "should eventually add reconcile annotation to Shoot") + }) + + It("should re-run OIDC configuration when auth-cm-revision annotation is stamped on the Shoot", func() { + // This test validates the annotation fan-out path: the CareInstruction controller stamps + // AuthCMRevisionAnnotation on matching Shoots when the Greenhouse auth CM data changes. + // The ShootController picks this up as a normal Update event and re-runs configureOIDCAuthentication, + // which detects that the Garden-side CM content differs and sets gardener.cloud/operation=reconcile. + + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-shoot-annotation-fanout", Namespace: "default", - }, secret) - return err == nil - }).Should(BeTrue(), "should eventually create secret") + Labels: map[string]string{"oidc-test": "true"}, + }, + } + Expect(test.GardenK8sClient.Create(test.Ctx, shoot)).To(Succeed(), "should create Shoot resource") + shoot.Status = gardenerv1beta1.ShootStatus{ + AdvertisedAddresses: []gardenerv1beta1.ShootAdvertisedAddress{ + {Name: "external", URL: "https://api-server.test-shoot-annotation-fanout.example.com"}, + }, + } + Expect(test.GardenK8sClient.Status().Update(test.Ctx, shoot)).To(Succeed(), "should update Shoot status") - // Now update the Greenhouse ConfigMap (simulating content change) - Eventually(func(g Gomega) error { + caCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-shoot-annotation-fanout.ca-cluster", Namespace: "default"}, + Data: map[string]string{"ca.crt": "test-ca-data"}, + } + Expect(test.GardenK8sClient.Create(test.Ctx, caCM)).To(Succeed(), "should create CA ConfigMap") + + // Wait for the initial Garden OIDC ConfigMap to be created (first reconcile completed). + Eventually(func(g Gomega) { cm := &corev1.ConfigMap{} - err := test.K8sClient.Get(test.Ctx, client.ObjectKey{ - Name: "greenhouse-oidc-config", - Namespace: "default", - }, cm) - if err != nil { - return err - } - cm.Data["config.yaml"] = `apiVersion: apiserver.config.k8s.io/v1beta1 + g.Expect(test.GardenK8sClient.Get(test.Ctx, client.ObjectKey{ + Name: "test-careinstruction-oidc-greenhouse-auth", Namespace: "default", + }, cm)).To(Succeed()) + }).Should(Succeed(), "should eventually create Garden OIDC ConfigMap") + + // Update the Greenhouse auth CM data (new audience) to create a content difference. + greenhouseAuthCM := &corev1.ConfigMap{} + Expect(test.K8sClient.Get(test.Ctx, client.ObjectKey{ + Name: "greenhouse-oidc-config", Namespace: "default", + }, greenhouseAuthCM)).To(Succeed()) + base := greenhouseAuthCM.DeepCopy() + greenhouseAuthCM.Data["config.yaml"] = `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: @@ -2142,47 +2146,33 @@ jwt: claimMappings: username: claim: sub - prefix: 'greenhouse-updated:' + prefix: 'greenhouse:' ` - return test.K8sClient.Update(test.Ctx, cm) - }).Should(Succeed(), "should update Greenhouse auth ConfigMap") - - // Trigger reconciliation by updating shoot label - Eventually(func(g Gomega) error { - s := &gardenerv1beta1.Shoot{} - err := test.GardenK8sClient.Get(test.Ctx, client.ObjectKey{ - Name: "test-shoot-oidc-update", - Namespace: "default", - }, s) - if err != nil { - return err - } - if s.Labels == nil { - s.Labels = make(map[string]string) - } - s.Labels["trigger-reconcile"] = "true" - return test.GardenK8sClient.Update(test.Ctx, s) - }).Should(Succeed(), "should update Shoot to trigger reconciliation") - - // Eventually verify the Shoot has the reconcile annotation - Eventually(func(g Gomega) bool { - updatedShoot := &gardenerv1beta1.Shoot{} - err := test.GardenK8sClient.Get(test.Ctx, client.ObjectKey{ - Name: "test-shoot-oidc-update", - Namespace: "default", - }, updatedShoot) - g.Expect(err).NotTo(HaveOccurred(), "should get updated Shoot") - - // Verify reconcile annotation was added - if updatedShoot.Annotations == nil { - return false - } - reconcileOp, hasReconcileAnnotation := updatedShoot.Annotations["gardener.cloud/operation"] - g.Expect(hasReconcileAnnotation).To(BeTrue(), "should have reconcile annotation for ConfigMap content change") - g.Expect(reconcileOp).To(Equal("reconcile"), "should have correct reconcile annotation value") - - return true - }).Should(BeTrue(), "should eventually add reconcile annotation to Shoot") + Expect(test.K8sClient.Patch(test.Ctx, greenhouseAuthCM, client.MergeFrom(base))).To(Succeed(), "should patch Greenhouse auth CM") + + // Simulate the CareInstruction controller fan-out: stamp auth-cm-revision on the Shoot. + updatedShoot := &gardenerv1beta1.Shoot{} + Expect(test.GardenK8sClient.Get(test.Ctx, client.ObjectKey{ + Name: "test-shoot-annotation-fanout", Namespace: "default", + }, updatedShoot)).To(Succeed()) + shootBase := updatedShoot.DeepCopy() + if updatedShoot.Annotations == nil { + updatedShoot.Annotations = map[string]string{} + } + updatedShoot.Annotations[v1alpha1.AuthCMRevisionAnnotation] = greenhouseAuthCM.ResourceVersion + Expect(test.GardenK8sClient.Patch(test.Ctx, updatedShoot, client.MergeFrom(shootBase))).To(Succeed(), "should stamp auth-cm-revision annotation") + + // The ShootController picks up the annotation Update event, re-runs configureOIDCAuthentication, + // detects the changed content, updates the Garden CM, and sets gardener.cloud/operation=reconcile. + Eventually(func(g Gomega) { + finalShoot := &gardenerv1beta1.Shoot{} + g.Expect(test.GardenK8sClient.Get(test.Ctx, client.ObjectKey{ + Name: "test-shoot-annotation-fanout", Namespace: "default", + }, finalShoot)).To(Succeed()) + g.Expect(finalShoot.Annotations).ToNot(BeNil()) + g.Expect(finalShoot.Annotations["gardener.cloud/operation"]).To(Equal("reconcile"), + "ShootController should set gardener.cloud/operation=reconcile after re-running OIDC config via annotation fan-out") + }).Should(Succeed(), "should eventually set gardener.cloud/operation=reconcile on Shoot") }) }) }) diff --git a/go.mod b/go.mod index 518b559..27d65be 100644 --- a/go.mod +++ b/go.mod @@ -98,12 +98,12 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/term v0.42.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.44.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index fe51987..77ce451 100644 --- a/go.sum +++ b/go.sum @@ -363,8 +363,8 @@ go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfP golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -375,8 +375,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn 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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -387,14 +387,14 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/clientutil/predicates.go b/internal/clientutil/predicates.go index c54c2c4..63e2b39 100644 --- a/internal/clientutil/predicates.go +++ b/internal/clientutil/predicates.go @@ -4,10 +4,12 @@ package clientutil import ( + "maps" "slices" corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/predicate" ) @@ -28,3 +30,19 @@ func PredicateHasLabel(key string) predicate.Predicate { return exists }) } + +// PredicateConfigMapDataChanged fires on Create and on Update only when the ConfigMap Data changes. +func PredicateConfigMapDataChanged() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(_ event.CreateEvent) bool { return true }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldCM, ok1 := e.ObjectOld.(*corev1.ConfigMap) + newCM, ok2 := e.ObjectNew.(*corev1.ConfigMap) + if !ok1 || !ok2 { + return false + } + return !maps.Equal(oldCM.Data, newCM.Data) + }, + DeleteFunc: func(_ event.DeleteEvent) bool { return false }, + } +}