diff --git a/README.md b/README.md index 310e322..90a4f80 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,10 @@ For each CareInstruction, a dedicated Shoot controller is dynamically created an - Optionally configures RBAC on the Shoot cluster for Greenhouse access - Cleans up Greenhouse clusters when the corresponding Gardener Shoot is removed -> **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 so the updated CM data is transported to the Garden cluster on the next reconcile. Multiple CareInstructions may reference the same ConfigMap. +> **Auth ConfigMap labeling & watch**: When `authenticationConfigMapName` is set, the shoot controller: +> - Labels the referenced Greenhouse ConfigMap with `shoot-grafter.cloudoperators.dev/auth-configmap: "true"` so the CareInstruction controller can watch it. When the data changes, all CareInstructions referencing that ConfigMap are re-enqueued. +> - Creates or overwrites a ConfigMap in the Garden cluster with the Greenhouse content verbatim. That Garden CM is labeled `shoot-grafter.cloudoperators.dev/careinstruction: ` to mark ownership and to scope the CM-change watch to Shoots managed by the same CareInstruction. +> - Labels each configured Shoot with `shoot-grafter.cloudoperators.dev/careinstruction: ` so that Garden CM changes trigger reconciliation of only the relevant Shoots. ## Custom Resource: CareInstruction @@ -368,7 +371,7 @@ spec: labelSelector: matchLabels: enabled-oidc: "true" - authenticationConfigMapRef: greenhouse-oidc-config + authenticationConfigMapName: greenhouse-oidc-config propagateLabels: - metadata.greenhouse.sap/environment ``` @@ -406,7 +409,7 @@ When `spec.authenticationConfigMapName` is configured in a CareInstruction, shoo 1. **Initial Setup**: When a Shoot is first onboarded, shoot-grafter creates an AuthenticationConfiguration ConfigMap in the Garden cluster and updates the Shoot's spec to reference it. 2. **Configuration Updates**: When the Greenhouse AuthenticationConfiguration ConfigMap is updated with new OIDC settings: - - shoot-grafter merges the updated configuration with any existing Garden cluster configuration + - shoot-grafter overwrites the Garden cluster ConfigMap verbatim with the Greenhouse content — shoot-grafter is the sole owner of that ConfigMap's data - If the Shoot spec already references the correct ConfigMap (no spec change needed), shoot-grafter automatically triggers a Shoot reconciliation by annotating it with `gardener.cloud/operation: reconcile` to apply the changes immediately without waiting for the Shoot's maintenance window - See the [Gardener documentation on immediate reconciliation](https://gardener.cloud/docs/gardener/shoot-operations/shoot_operations/#immediate-reconciliation) for more details diff --git a/api/v1alpha1/careinstruction_types.go b/api/v1alpha1/careinstruction_types.go index c28b15a..0d0ce23 100644 --- a/api/v1alpha1/careinstruction_types.go +++ b/api/v1alpha1/careinstruction_types.go @@ -33,7 +33,7 @@ const ( // CommonCleanupFinalizer is the finalizer used to clean up resources when a CareInstruction is deleted. CommonCleanupFinalizer = "shoot-grafter.cloudoperators.dev/finalizer" - // CareInstructionLabel is the label used to identify resources created by this CareInstruction. + // CareInstructionLabel is the label used to identify resources owned or configured by this CareInstruction. CareInstructionLabel = "shoot-grafter.cloudoperators.dev/careinstruction" // AuthConfigMapLabel is the label used to identify AuthenticationConfiguration ConfigMaps diff --git a/controller/careinstruction/careinstruction_controller.go b/controller/careinstruction/careinstruction_controller.go index 36dc70f..8be4722 100644 --- a/controller/careinstruction/careinstruction_controller.go +++ b/controller/careinstruction/careinstruction_controller.go @@ -324,11 +324,26 @@ func (r *CareInstructionReconciler) reconcileManager(ctx context.Context, careIn return err } + // Add a field index for CareInstructionLabel so EnqueueShoots lookups scale with cache. + if err := shootControllerMgr.GetFieldIndexer().IndexField( + context.Background(), + &gardenerv1beta1.Shoot{}, + v1alpha1.CareInstructionLabel, + func(o client.Object) []string { + if v := o.GetLabels()[v1alpha1.CareInstructionLabel]; v != "" { + return []string{v} + } + return nil + }, + ); err != nil { + return fmt.Errorf("failed to add field index for CareInstructionLabel: %w", 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, + GardenClient: shootControllerMgr.GetClient(), Logger: r.WithValues("careInstruction", careInstruction.Name), Name: shoot.GenerateName(careInstruction.Name), CareInstruction: careInstruction.DeepCopy(), diff --git a/controller/shoot/auth.go b/controller/shoot/auth.go index 17de2bf..130447b 100644 --- a/controller/shoot/auth.go +++ b/controller/shoot/auth.go @@ -12,23 +12,22 @@ import ( gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - apiserverv1beta1 "k8s.io/apiserver/pkg/apis/apiserver/v1beta1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/yaml" ) -const authConfigMapKey = "config.yaml" +const ( + authConfigMapKey = "config.yaml" + authConfigMapManagedByAnnotation = "shoot-grafter.cloudoperators.dev/managed-by" +) -// configureOIDCAuthentication configures OIDC authentication for the Shoot by: +// ConfigureOIDCAuthentication configures OIDC authentication for the Shoot by: // 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 { - // 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. +// 2. Writing it verbatim to a ConfigMap in the Garden cluster (always overwrite) +// 3. Updating the Shoot spec to reference that ConfigMap +func (r *ShootController) ConfigureOIDCAuthentication(ctx context.Context, shoot *gardenerv1beta1.Shoot) error { + // Fetch the Greenhouse auth ConfigMap and ensure it carries the watch label. var greenhouseAuthConfigMap corev1.ConfigMap if err := r.GreenhouseClient.Get(ctx, client.ObjectKey{ Namespace: r.CareInstruction.Namespace, @@ -42,14 +41,8 @@ func (r *ShootController) configureOIDCAuthentication(ctx context.Context, shoot if greenhouseAuthConfigMap.Labels == nil { greenhouseAuthConfigMap.Labels = make(map[string]string) } - labelsNeedUpdate := false - 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) } @@ -60,52 +53,37 @@ func (r *ShootController) configureOIDCAuthentication(ctx context.Context, shoot r.CareInstruction.Spec.AuthenticationConfigMapName, authConfigMapKey) } - // 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) - } - - // Determine the ConfigMap name for Garden cluster - // We create one CM per CareInstruction unless Shoot already has one configured + // Determine the ConfigMap name for the Garden cluster. + // Preserve an existing reference on the Shoot; otherwise use the default name. configMapName := r.CareInstruction.Name + "-greenhouse-auth" - useExistingConfigMap := false - - // Check if Shoot already has a ConfigMap configured if shoot.Spec.Kubernetes.KubeAPIServer != nil && shoot.Spec.Kubernetes.KubeAPIServer.StructuredAuthentication != nil && shoot.Spec.Kubernetes.KubeAPIServer.StructuredAuthentication.ConfigMapName != "" { configMapName = shoot.Spec.Kubernetes.KubeAPIServer.StructuredAuthentication.ConfigMapName - useExistingConfigMap = true - r.Info("Shoot already has AuthenticationConfiguration ConfigMap", "shoot", shoot.Name, "configMap", configMapName) } - var gardenConfigMap corev1.ConfigMap - - if useExistingConfigMap { - // Fetch the existing ConfigMap from Garden cluster - if err := r.GardenClient.Get(ctx, client.ObjectKey{ - Namespace: shoot.Namespace, + // Always overwrite the garden-cluster CM with the Greenhouse content verbatim. + gardenConfigMap := corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ Name: configMapName, - }, &gardenConfigMap); err != nil { - return fmt.Errorf("failed to fetch existing AuthenticationConfiguration ConfigMap %s from Garden cluster: %w", configMapName, err) - } - } else { - // Create new ConfigMap structure - gardenConfigMap = corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: configMapName, - Namespace: shoot.Namespace, - }, - Data: map[string]string{ - authConfigMapKey: "", - }, - } + Namespace: shoot.Namespace, + }, } + authContent := greenhouseAuthConfigMap.Data[authConfigMapKey] - // Create or update the ConfigMap, merging configurations configMapResult, err := ctrl.CreateOrUpdate(ctx, r.GardenClient, &gardenConfigMap, func() error { - return r.mergeAuthenticationConfigurations(&gardenConfigMap, &greenhouseAuthConfig) + if gardenConfigMap.Labels == nil { + gardenConfigMap.Labels = make(map[string]string) + } + gardenConfigMap.Labels[v1alpha1.CareInstructionLabel] = r.CareInstruction.Name + + if gardenConfigMap.Annotations == nil { + gardenConfigMap.Annotations = make(map[string]string) + } + gardenConfigMap.Annotations[authConfigMapManagedByAnnotation] = "shoot-grafter - do not edit by hand, this is maintained by automation" + + gardenConfigMap.Data = map[string]string{authConfigMapKey: authContent} + return nil }) if err != nil { return fmt.Errorf("failed to create/update AuthenticationConfiguration ConfigMap: %w", err) @@ -118,7 +96,19 @@ func (r *ShootController) configureOIDCAuthentication(ctx context.Context, shoot r.Info("AuthenticationConfiguration ConfigMap updated", "name", configMapName, "shoot", shoot.Name) } - // Update Shoot spec to reference the ConfigMap if needed + // Ensure the Shoot carries the careinstruction label. + if shoot.Labels == nil || shoot.Labels[v1alpha1.CareInstructionLabel] != r.CareInstruction.Name { + shootBase := shoot.DeepCopy() + if shoot.Labels == nil { + shoot.Labels = make(map[string]string) + } + shoot.Labels[v1alpha1.CareInstructionLabel] = r.CareInstruction.Name + if patchErr := r.GardenClient.Patch(ctx, shoot, client.MergeFrom(shootBase)); patchErr != nil { + return fmt.Errorf("failed to patch careinstruction label on Shoot: %w", patchErr) + } + } + + // Update the Shoot spec to reference the ConfigMap if not already pointing to it. shootNeedsUpdate := false if shoot.Spec.Kubernetes.KubeAPIServer == nil { shoot.Spec.Kubernetes.KubeAPIServer = &gardenerv1beta1.KubeAPIServerConfig{} @@ -141,81 +131,16 @@ func (r *ShootController) configureOIDCAuthentication(ctx context.Context, shoot return nil // Spec change triggers reconciliation automatically } - // At this point, Shoot spec doesn't need updates (ConfigMapName reference already exists) - // Trigger Shoot reconciliation if ConfigMap content was updated + // Trigger Shoot reconciliation if ConfigMap was created or updated. // Reference: https://gardener.cloud/docs/gardener/shoot-operations/shoot_operations/#immediate-reconciliation - if configMapResult == controllerutil.OperationResultUpdated { + if configMapResult != controllerutil.OperationResultNone { if err := AnnotateShootForReconcile(ctx, r.GardenClient, shoot.Namespace, shoot.Name); err != nil { return fmt.Errorf("failed to annotate Shoot for reconciliation: %w", err) } - r.Info("Annotated Shoot for reconciliation due to ConfigMap content update", + r.Info("Annotated Shoot for reconciliation due to ConfigMap change", "shoot", shoot.Name, "configMap", configMapName) } return nil } - -// mergeAuthenticationConfigurations merges the Greenhouse AuthenticationConfiguration -// with any existing configuration in the Garden ConfigMap. It intelligently merges: -// - JWT authenticators from both configurations -// - Deduplicates issuers by URL (Greenhouse config takes precedence) -// - Preserves Garden-specific configurations that don't conflict -func (r *ShootController) mergeAuthenticationConfigurations(gardenConfigMap *corev1.ConfigMap, greenhouseAuthConfig *apiserverv1beta1.AuthenticationConfiguration) error { - var gardenAuthConfig apiserverv1beta1.AuthenticationConfiguration - - // Parse existing Garden configuration if present - if gardenConfigMap.Data != nil && gardenConfigMap.Data[authConfigMapKey] != "" { - existingConfigYAML := gardenConfigMap.Data[authConfigMapKey] - if err := yaml.Unmarshal([]byte(existingConfigYAML), &gardenAuthConfig); err != nil { - return fmt.Errorf("failed to parse existing Garden AuthenticationConfiguration: %w", err) - } - } else { - // Create new configuration structure - gardenAuthConfig = apiserverv1beta1.AuthenticationConfiguration{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "apiserver.config.k8s.io/v1beta1", - Kind: "AuthenticationConfiguration", - }, - JWT: []apiserverv1beta1.JWTAuthenticator{}, - } - } - - if gardenAuthConfig.JWT == nil { - gardenAuthConfig.JWT = []apiserverv1beta1.JWTAuthenticator{} - } - - // Create a map of existing Garden issuer URLs for quick lookup - gardenIssuerURLs := make(map[string]int) // URL -> index in gardenAuthConfig.JWT - for i, jwtAuth := range gardenAuthConfig.JWT { - gardenIssuerURLs[jwtAuth.Issuer.URL] = i - } - - // Merge JWT authenticators from Greenhouse configuration - // Greenhouse issuers take precedence over Garden issuers with the same URL - for _, greenhouseJWT := range greenhouseAuthConfig.JWT { - if existingIndex, exists := gardenIssuerURLs[greenhouseJWT.Issuer.URL]; exists { - // Update existing issuer with Greenhouse configuration - r.Info("Updating issuer from Greenhouse configuration", "url", greenhouseJWT.Issuer.URL) - gardenAuthConfig.JWT[existingIndex] = greenhouseJWT - } else { - // Add new issuer from Greenhouse configuration, no conflict since not added to gardenIssuerURLs map - r.Info("Adding new issuer from Greenhouse configuration", "url", greenhouseJWT.Issuer.URL) - gardenAuthConfig.JWT = append(gardenAuthConfig.JWT, greenhouseJWT) - } - } - - // Marshal merged configuration back to YAML - mergedConfigYAML, err := yaml.Marshal(&gardenAuthConfig) - if err != nil { - return fmt.Errorf("failed to marshal merged AuthenticationConfiguration: %w", err) - } - - // Update ConfigMap data - if gardenConfigMap.Data == nil { - gardenConfigMap.Data = make(map[string]string) - } - gardenConfigMap.Data[authConfigMapKey] = string(mergedConfigYAML) - - return nil -} diff --git a/controller/shoot/auth_test.go b/controller/shoot/auth_test.go index 8ed3d27..d8b1868 100644 --- a/controller/shoot/auth_test.go +++ b/controller/shoot/auth_test.go @@ -1,148 +1,58 @@ // SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors // SPDX-License-Identifier: Apache-2.0 -package shoot +package shoot_test import ( + "context" + "shoot-grafter/api/v1alpha1" + "shoot-grafter/controller/shoot" + gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/go-logr/logr" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" - apiserverv1beta1 "k8s.io/apiserver/pkg/apis/apiserver/v1beta1" - "sigs.k8s.io/yaml" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) -var _ = Describe("Auth", func() { - Describe("mergeAuthenticationConfigurations", func() { - var controller *ShootController +// authScheme builds a minimal runtime.Scheme for the fake clients used in auth tests. +func authScheme() *runtime.Scheme { + s := runtime.NewScheme() + if err := corev1.AddToScheme(s); err != nil { + panic(err) + } + if err := gardenerv1beta1.AddToScheme(s); err != nil { + panic(err) + } + return s +} - BeforeEach(func() { - controller = &ShootController{ - Logger: logr.Discard(), - CareInstruction: &v1alpha1.CareInstruction{ - Spec: v1alpha1.CareInstructionSpec{ - AuthenticationConfigMapName: "greenhouse-auth-config", - }, - }, - } - }) - - DescribeTable("should correctly merge authentication configurations", - func( - initialGardenConfigMap *corev1.ConfigMap, - greenhouseAuthConfig apiserverv1beta1.AuthenticationConfiguration, - expectedConfigMap *corev1.ConfigMap, - ) { - // Call the merge function - err := controller.mergeAuthenticationConfigurations(initialGardenConfigMap, &greenhouseAuthConfig) - Expect(err).NotTo(HaveOccurred()) - - // Verify ConfigMap data was updated - Expect(initialGardenConfigMap.Data).NotTo(BeNil()) - Expect(initialGardenConfigMap.Data).To(HaveKey(authConfigMapKey)) - - // Verify other data keys are preserved (keys that are not authConfigMapKey) - for key, value := range expectedConfigMap.Data { - if key != authConfigMapKey { - Expect(initialGardenConfigMap.Data).To(HaveKeyWithValue(key, value)) - } - } - - // Unmarshal the actual result - var actualConfig apiserverv1beta1.AuthenticationConfiguration - 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[authConfigMapKey]), &expectedConfig) - Expect(err).NotTo(HaveOccurred()) - - // Compare configurations - Expect(actualConfig.APIVersion).To(Equal(expectedConfig.APIVersion)) - Expect(actualConfig.Kind).To(Equal(expectedConfig.Kind)) - Expect(actualConfig.JWT).To(HaveLen(len(expectedConfig.JWT))) - - // Compare each issuer - for i := range expectedConfig.JWT { - Expect(actualConfig.JWT[i].Issuer.URL).To(Equal(expectedConfig.JWT[i].Issuer.URL)) - Expect(actualConfig.JWT[i].Issuer.Audiences).To(Equal(expectedConfig.JWT[i].Issuer.Audiences)) - Expect(actualConfig.JWT[i].ClaimMappings.Username.Claim).To(Equal(expectedConfig.JWT[i].ClaimMappings.Username.Claim)) - - if expectedConfig.JWT[i].ClaimMappings.Username.Prefix != nil { - Expect(actualConfig.JWT[i].ClaimMappings.Username.Prefix).NotTo(BeNil()) - Expect(*actualConfig.JWT[i].ClaimMappings.Username.Prefix).To(Equal(*expectedConfig.JWT[i].ClaimMappings.Username.Prefix)) - } else { - Expect(actualConfig.JWT[i].ClaimMappings.Username.Prefix).To(BeNil()) - } - } +// makeAuthController returns a ShootController wired with a Greenhouse fake client and a Garden +// fake client that already holds the provided objects. +func makeAuthController(careInstructionName string, greenhouseObjs, gardenObjs []client.Object) *shoot.ShootController { + s := authScheme() + return &shoot.ShootController{ + GreenhouseClient: fake.NewClientBuilder().WithScheme(s).WithObjects(greenhouseObjs...).Build(), + GardenClient: fake.NewClientBuilder().WithScheme(s).WithObjects(gardenObjs...).Build(), + Logger: logr.Discard(), + CareInstruction: &v1alpha1.CareInstruction{ + ObjectMeta: metav1.ObjectMeta{ + Name: careInstructionName, + Namespace: "default", }, - Entry("with empty Garden ConfigMap and one Greenhouse issuer", - &corev1.ConfigMap{ - Data: map[string]string{}, - }, - apiserverv1beta1.AuthenticationConfiguration{ - JWT: []apiserverv1beta1.JWTAuthenticator{ - { - Issuer: apiserverv1beta1.Issuer{ - URL: "https://greenhouse.example.com", - Audiences: []string{"greenhouse"}, - }, - ClaimMappings: apiserverv1beta1.ClaimMappings{ - Username: apiserverv1beta1.PrefixedClaimOrExpression{ - Claim: "sub", - Prefix: new("greenhouse:"), - }, - }, - }, - }, - }, - &corev1.ConfigMap{ - Data: map[string]string{ - authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 -kind: AuthenticationConfiguration -jwt: -- issuer: - url: https://greenhouse.example.com - audiences: - - greenhouse - claimMappings: - username: - claim: sub - prefix: 'greenhouse:' -`, - }, - }), - Entry("with Garden ConfigMap having other data keys (should preserve them)", - &corev1.ConfigMap{ - Data: map[string]string{ - "other-key": "other-value", - "another-key": "another-value", - }, - }, - apiserverv1beta1.AuthenticationConfiguration{ - JWT: []apiserverv1beta1.JWTAuthenticator{ - { - Issuer: apiserverv1beta1.Issuer{ - URL: "https://greenhouse.example.com", - Audiences: []string{"greenhouse"}, - }, - ClaimMappings: apiserverv1beta1.ClaimMappings{ - Username: apiserverv1beta1.PrefixedClaimOrExpression{ - Claim: "sub", - Prefix: new("greenhouse:"), - }, - }, - }, - }, - }, - &corev1.ConfigMap{ - Data: map[string]string{ - "other-key": "other-value", - "another-key": "another-value", - authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 + Spec: v1alpha1.CareInstructionSpec{ + AuthenticationConfigMapName: "greenhouse-auth", + }, + }, + } +} + +const authYAML = `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: @@ -153,231 +63,285 @@ jwt: username: claim: sub prefix: 'greenhouse:' -`, - }, - }), - Entry("with Garden ConfigMap having one different issuer (should add Greenhouse issuer)", - &corev1.ConfigMap{ - Data: map[string]string{ - authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 +` + +var _ = Describe("enqueueShoots", func() { + It("enqueues only shoots labeled with the CareInstruction name, not unrelated shoots", func() { + ctx := context.Background() + ciName := "my-ci" + + labeled := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "labeled-shoot", + Namespace: "default", + Labels: map[string]string{v1alpha1.CareInstructionLabel: ciName}, + }, + } + unrelated := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unrelated-shoot", + Namespace: "default", + Labels: map[string]string{v1alpha1.CareInstructionLabel: "other-ci"}, + }, + } + unlabeled := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{Name: "unlabeled-shoot", Namespace: "default"}, + } + + s := authScheme() + sc := &shoot.ShootController{ + GardenClient: fake.NewClientBuilder().WithScheme(s).WithObjects(labeled, unrelated, unlabeled). + WithIndex(&gardenerv1beta1.Shoot{}, v1alpha1.CareInstructionLabel, func(o client.Object) []string { + if v := o.GetLabels()[v1alpha1.CareInstructionLabel]; v != "" { + return []string{v} + } + return nil + }).Build(), + Logger: logr.Discard(), + CareInstruction: &v1alpha1.CareInstruction{ + ObjectMeta: metav1.ObjectMeta{Name: ciName, Namespace: "default"}, + }, + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-auth-cm", + Namespace: "default", + Labels: map[string]string{v1alpha1.CareInstructionLabel: ciName}, + }, + } + + reqs := sc.EnqueueShoots(ctx, cm) + Expect(reqs).To(HaveLen(1)) + Expect(reqs[0].Name).To(Equal("labeled-shoot")) + }) +}) + +var _ = Describe("configureOIDCAuthentication", func() { + var ( + ctx = context.Background() + greenhouseAuthCM *corev1.ConfigMap + ) + + BeforeEach(func() { + greenhouseAuthCM = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "greenhouse-auth", + Namespace: "default", + }, + Data: map[string]string{ + "config.yaml": authYAML, + }, + } + }) + + It("creates the garden CM with the Greenhouse content verbatim on first encounter", func() { + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{Name: "my-shoot", Namespace: "default"}, + } + ctrl := makeAuthController("my-ci", []client.Object{greenhouseAuthCM}, []client.Object{shoot}) + + Expect(ctrl.ConfigureOIDCAuthentication(ctx, shoot)).To(Succeed()) + + var gardenCM corev1.ConfigMap + Expect(ctrl.GardenClient.Get(ctx, client.ObjectKey{ + Namespace: "default", Name: "my-ci-greenhouse-auth", + }, &gardenCM)).To(Succeed()) + + Expect(gardenCM.Data["config.yaml"]).To(Equal(authYAML)) + Expect(gardenCM.Labels).To(HaveKeyWithValue(v1alpha1.CareInstructionLabel, "my-ci")) + }) + + It("adds CareInstructionLabel to the Shoot", func() { + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{Name: "my-shoot", Namespace: "default"}, + } + ctrl := makeAuthController("my-ci", []client.Object{greenhouseAuthCM}, []client.Object{shoot}) + + Expect(ctrl.ConfigureOIDCAuthentication(ctx, shoot)).To(Succeed()) + + var updatedShoot gardenerv1beta1.Shoot + Expect(ctrl.GardenClient.Get(ctx, client.ObjectKey{ + Namespace: "default", Name: "my-shoot", + }, &updatedShoot)).To(Succeed()) + Expect(updatedShoot.Labels).To(HaveKeyWithValue(v1alpha1.CareInstructionLabel, "my-ci")) + }) + + It("uses the default CM name (-greenhouse-auth) when Shoot has no existing reference", func() { + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{Name: "my-shoot", Namespace: "default"}, + } + ctrl := makeAuthController("ci-default", []client.Object{greenhouseAuthCM}, []client.Object{shoot}) + + Expect(ctrl.ConfigureOIDCAuthentication(ctx, shoot)).To(Succeed()) + + var gardenCM corev1.ConfigMap + Expect(ctrl.GardenClient.Get(ctx, client.ObjectKey{ + Namespace: "default", Name: "ci-default-greenhouse-auth", + }, &gardenCM)).To(Succeed()) + }) + + It("overwrites an existing garden CM that already has different content", func() { + existingCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "existing-auth", Namespace: "default"}, + Data: map[string]string{ + "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: - url: https://other.example.com + url: https://other-issuer.example.com audiences: - other claimMappings: username: - claim: sub + claim: email `, - }, - }, - apiserverv1beta1.AuthenticationConfiguration{ - JWT: []apiserverv1beta1.JWTAuthenticator{ - { - Issuer: apiserverv1beta1.Issuer{ - URL: "https://greenhouse.example.com", - Audiences: []string{"greenhouse"}, - }, - ClaimMappings: apiserverv1beta1.ClaimMappings{ - Username: apiserverv1beta1.PrefixedClaimOrExpression{ - Claim: "sub", - Prefix: new("greenhouse:"), - }, - }, + }, + } + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{Name: "my-shoot", Namespace: "default"}, + Spec: gardenerv1beta1.ShootSpec{ + Kubernetes: gardenerv1beta1.Kubernetes{ + KubeAPIServer: &gardenerv1beta1.KubeAPIServerConfig{ + StructuredAuthentication: &gardenerv1beta1.StructuredAuthentication{ + ConfigMapName: "existing-auth", }, }, }, - &corev1.ConfigMap{ - Data: map[string]string{ - authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 + }, + } + ctrl := makeAuthController("my-ci", []client.Object{greenhouseAuthCM}, []client.Object{existingCM, shoot}) + + Expect(ctrl.ConfigureOIDCAuthentication(ctx, shoot)).To(Succeed()) + + var gardenCM corev1.ConfigMap + Expect(ctrl.GardenClient.Get(ctx, client.ObjectKey{ + Namespace: "default", Name: "existing-auth", + }, &gardenCM)).To(Succeed()) + + // Content must be exactly the Greenhouse content - old issuer must be gone + Expect(gardenCM.Data["config.yaml"]).To(Equal(authYAML)) + Expect(gardenCM.Labels).To(HaveKeyWithValue(v1alpha1.CareInstructionLabel, "my-ci")) + }) + + It("updates the garden CM when Greenhouse CM content changes", func() { + updatedYAML := `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: - url: https://other.example.com + url: https://greenhouse-new.example.com audiences: - - other - claimMappings: - username: - claim: sub -- issuer: - url: https://greenhouse.example.com - audiences: - - greenhouse + - greenhouse-new claimMappings: username: claim: sub - prefix: 'greenhouse:' -`, - }, - }), - Entry("with Garden ConfigMap having the same issuer (Greenhouse should update it)", - &corev1.ConfigMap{ - Data: map[string]string{ - authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 -kind: AuthenticationConfiguration -jwt: -- issuer: - url: https://greenhouse.example.com - audiences: - - old-audience - claimMappings: - username: - claim: email -`, - }, - }, - apiserverv1beta1.AuthenticationConfiguration{ - JWT: []apiserverv1beta1.JWTAuthenticator{ - { - Issuer: apiserverv1beta1.Issuer{ - URL: "https://greenhouse.example.com", - Audiences: []string{"greenhouse"}, - }, - ClaimMappings: apiserverv1beta1.ClaimMappings{ - Username: apiserverv1beta1.PrefixedClaimOrExpression{ - Claim: "sub", - Prefix: new("greenhouse:"), - }, - }, + prefix: 'new:' +` + // Greenhouse CM already has the new content; garden CM has the old content + updatedGreenhouseCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "greenhouse-auth", Namespace: "default", + }, + Data: map[string]string{"config.yaml": updatedYAML}, + } + gardenCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ci-greenhouse-auth", + Namespace: "default", + Labels: map[string]string{v1alpha1.CareInstructionLabel: "my-ci"}, + }, + Data: map[string]string{"config.yaml": authYAML}, + } + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{Name: "my-shoot", Namespace: "default"}, + Spec: gardenerv1beta1.ShootSpec{ + Kubernetes: gardenerv1beta1.Kubernetes{ + KubeAPIServer: &gardenerv1beta1.KubeAPIServerConfig{ + StructuredAuthentication: &gardenerv1beta1.StructuredAuthentication{ + ConfigMapName: "my-ci-greenhouse-auth", }, }, }, - &corev1.ConfigMap{ - Data: map[string]string{ - authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 -kind: AuthenticationConfiguration -jwt: -- issuer: - url: https://greenhouse.example.com - audiences: - - greenhouse - claimMappings: - username: - claim: sub - prefix: 'greenhouse:' -`, - }, - }), - Entry("with multiple Greenhouse issuers and multiple Garden issuers", - &corev1.ConfigMap{ - Data: map[string]string{ - authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 + }, + } + + ctrl := makeAuthController("my-ci", []client.Object{updatedGreenhouseCM}, []client.Object{gardenCM, shoot}) + + Expect(ctrl.ConfigureOIDCAuthentication(ctx, shoot)).To(Succeed()) + + var result corev1.ConfigMap + Expect(ctrl.GardenClient.Get(ctx, client.ObjectKey{ + Namespace: "default", Name: "my-ci-greenhouse-auth", + }, &result)).To(Succeed()) + Expect(result.Data["config.yaml"]).To(Equal(updatedYAML)) + }) + + It("updates the garden CM content when the CI's AuthenticationConfigMapName is changed to a different Greenhouse CM", func() { + // Scenario: CI.Spec.AuthenticationConfigMapName was "greenhouse-auth" and is now + // changed to "greenhouse-auth-v2". The garden CM name is unchanged (-greenhouse-auth), + // but its content must reflect the new Greenhouse CM. + newGreenhouseCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "greenhouse-auth-v2", + Namespace: "default", + }, + Data: map[string]string{ + "config.yaml": `apiVersion: apiserver.config.k8s.io/v1beta1 kind: AuthenticationConfiguration jwt: - issuer: - url: https://garden-issuer1.example.com - audiences: - - garden1 - claimMappings: - username: - claim: sub -- issuer: - url: https://greenhouse.example.com - audiences: - - old-audience - claimMappings: - username: - claim: email -- issuer: - url: https://garden-issuer2.example.com + url: https://greenhouse-v2.example.com audiences: - - garden2 + - greenhouse-v2 claimMappings: username: claim: sub + prefix: 'v2:' `, - }, - }, - apiserverv1beta1.AuthenticationConfiguration{ - JWT: []apiserverv1beta1.JWTAuthenticator{ - { - Issuer: apiserverv1beta1.Issuer{ - URL: "https://greenhouse.example.com", - Audiences: []string{"greenhouse"}, - }, - ClaimMappings: apiserverv1beta1.ClaimMappings{ - Username: apiserverv1beta1.PrefixedClaimOrExpression{ - Claim: "sub", - Prefix: new("greenhouse:"), - }, - }, - }, - { - Issuer: apiserverv1beta1.Issuer{ - URL: "https://another-greenhouse.example.com", - Audiences: []string{"another"}, - }, - ClaimMappings: apiserverv1beta1.ClaimMappings{ - Username: apiserverv1beta1.PrefixedClaimOrExpression{ - Claim: "email", - Prefix: new("other:"), - }, - }, + }, + } + gardenCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ci-greenhouse-auth", + Namespace: "default", + Labels: map[string]string{v1alpha1.CareInstructionLabel: "my-ci"}, + }, + Data: map[string]string{"config.yaml": authYAML}, + } + shootObj := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{Name: "my-shoot", Namespace: "default"}, + Spec: gardenerv1beta1.ShootSpec{ + Kubernetes: gardenerv1beta1.Kubernetes{ + KubeAPIServer: &gardenerv1beta1.KubeAPIServerConfig{ + StructuredAuthentication: &gardenerv1beta1.StructuredAuthentication{ + ConfigMapName: "my-ci-greenhouse-auth", }, }, }, - &corev1.ConfigMap{ - Data: map[string]string{ - authConfigMapKey: `apiVersion: apiserver.config.k8s.io/v1beta1 -kind: AuthenticationConfiguration -jwt: -- issuer: - url: https://garden-issuer1.example.com - audiences: - - garden1 - claimMappings: - username: - claim: sub -- issuer: - url: https://greenhouse.example.com - audiences: - - greenhouse - claimMappings: - username: - claim: sub - prefix: 'greenhouse:' -- issuer: - url: https://garden-issuer2.example.com - audiences: - - garden2 - claimMappings: - username: - claim: sub -- issuer: - url: https://another-greenhouse.example.com - audiences: - - another - claimMappings: - username: - claim: email - prefix: 'other:' -`, - }, - }), - ) + }, + } - It("should return error for invalid YAML in Garden ConfigMap", func() { - configMap := &corev1.ConfigMap{ - Data: map[string]string{ - authConfigMapKey: "invalid: yaml: content: [", + s := authScheme() + ctrl := &shoot.ShootController{ + GreenhouseClient: fake.NewClientBuilder().WithScheme(s).WithObjects(newGreenhouseCM).Build(), + GardenClient: fake.NewClientBuilder().WithScheme(s).WithObjects(gardenCM, shootObj).Build(), + Logger: logr.Discard(), + CareInstruction: &v1alpha1.CareInstruction{ + ObjectMeta: metav1.ObjectMeta{Name: "my-ci", Namespace: "default"}, + Spec: v1alpha1.CareInstructionSpec{ + // CI now references the new Greenhouse CM + AuthenticationConfigMapName: "greenhouse-auth-v2", }, - } - - greenhouseAuthConfig := apiserverv1beta1.AuthenticationConfiguration{ - JWT: []apiserverv1beta1.JWTAuthenticator{ - { - Issuer: apiserverv1beta1.Issuer{ - URL: "https://greenhouse.example.com", - Audiences: []string{"greenhouse"}, - }, - }, - }, - } + }, + } + + Expect(ctrl.ConfigureOIDCAuthentication(ctx, shootObj)).To(Succeed()) - err := controller.mergeAuthenticationConfigurations(configMap, &greenhouseAuthConfig) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to parse existing Garden AuthenticationConfiguration")) - }) + var result corev1.ConfigMap + Expect(ctrl.GardenClient.Get(ctx, client.ObjectKey{ + Namespace: "default", Name: "my-ci-greenhouse-auth", + }, &result)).To(Succeed()) + // Garden CM name is unchanged; content reflects the new Greenhouse CM + Expect(result.Data["config.yaml"]).To(Equal(newGreenhouseCM.Data["config.yaml"])) }) }) diff --git a/controller/shoot/shoot_controller.go b/controller/shoot/shoot_controller.go index 8b58612..0775e44 100644 --- a/controller/shoot/shoot_controller.go +++ b/controller/shoot/shoot_controller.go @@ -12,6 +12,7 @@ import ( "strings" "shoot-grafter/api/v1alpha1" + "shoot-grafter/internal/clientutil" greenhouseapis "github.com/cloudoperators/greenhouse/api" greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" @@ -27,6 +28,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" ) @@ -84,6 +86,14 @@ func (r *ShootController) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). Named(r.Name). For(&gardenerv1beta1.Shoot{}, builder.WithPredicates(predicates...)). + Watches( + &corev1.ConfigMap{}, + handler.EnqueueRequestsFromMapFunc(r.EnqueueShoots), + builder.WithPredicates( + clientutil.PredicateHasLabel(v1alpha1.CareInstructionLabel), + clientutil.PredicateConfigMapDataChanged(), + ), + ). Complete(r) } @@ -107,6 +117,24 @@ func (r *ShootController) matchesCEL(shoot *gardenerv1beta1.Shoot) bool { return matches } +// EnqueueShoots maps a ConfigMap change to reconcile requests for Shoots that were configured by the same CareInstruction. +func (r *ShootController) EnqueueShoots(ctx context.Context, obj client.Object) []ctrl.Request { + ciName := obj.GetLabels()[v1alpha1.CareInstructionLabel] + var shoots gardenerv1beta1.ShootList + if err := r.GardenClient.List(ctx, &shoots, + client.InNamespace(obj.GetNamespace()), + client.MatchingFields{v1alpha1.CareInstructionLabel: ciName}, + ); err != nil { + r.Error(err, "failed to list Shoots for ConfigMap watch") + return nil + } + reqs := make([]ctrl.Request, 0, len(shoots.Items)) + for _, s := range shoots.Items { + reqs = append(reqs, ctrl.Request{NamespacedName: client.ObjectKey{Name: s.Name, Namespace: s.Namespace}}) + } + return reqs +} + func (r *ShootController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { r.Info("Reconciling Shoot", "name", req.Name, "namespace", req.Namespace) @@ -311,7 +339,7 @@ func (r *ShootController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl // Do this before RBAC setup so RBAC errors don't prevent OIDC configuration if r.CareInstruction.Spec.AuthenticationConfigMapName != "" { r.Info("Found OIDC auth config, configuring on Shoot", "name", shoot.Name) - if err := r.configureOIDCAuthentication(ctx, &shoot); err != nil { + if err := r.ConfigureOIDCAuthentication(ctx, &shoot); err != nil { r.Info("failed to configure OIDC authentication for Shoot", "name", shoot.Name, "error", err) r.emitEvent(r.CareInstruction, corev1.EventTypeWarning, "OIDCConfigurationFailed", fmt.Sprintf("Failed to configure OIDC authentication for shoot %s/%s: %v", shoot.Namespace, shoot.Name, err)) diff --git a/controller/shoot/shoot_controller_test.go b/controller/shoot/shoot_controller_test.go index b168db3..50f4f4d 100644 --- a/controller/shoot/shoot_controller_test.go +++ b/controller/shoot/shoot_controller_test.go @@ -1591,6 +1591,9 @@ jwt: g.Expect(authConfig.JWT[0].ClaimMappings.Username.Prefix).NotTo(BeNil()) g.Expect(*authConfig.JWT[0].ClaimMappings.Username.Prefix).To(Equal("greenhouse:")) + // Verify CM carries the ownership label + g.Expect(authConfigMap.Labels).To(HaveKeyWithValue(v1alpha1.CareInstructionLabel, "test-careinstruction-oidc")) + return true }).Should(BeTrue(), "should eventually create OIDC AuthenticationConfiguration ConfigMap") @@ -1609,6 +1612,9 @@ jwt: g.Expect(updatedShoot.Spec.Kubernetes.KubeAPIServer.StructuredAuthentication).NotTo(BeNil()) g.Expect(updatedShoot.Spec.Kubernetes.KubeAPIServer.StructuredAuthentication.ConfigMapName).To(Equal("test-careinstruction-oidc-greenhouse-auth")) + // Verify Shoot carries the careinstruction label + g.Expect(updatedShoot.Labels).To(HaveKeyWithValue(v1alpha1.CareInstructionLabel, "test-careinstruction-oidc")) + return true }).Should(BeTrue(), "should eventually update shoot spec with ConfigMap reference") }) @@ -1679,7 +1685,7 @@ jwt: } Expect(test.GardenK8sClient.Create(test.Ctx, cm)).To(Succeed(), "should create CA ConfigMap resource") - // Eventually verify the auth ConfigMap was updated with correct greenhouse config + // Eventually verify the auth ConfigMap was overwritten with Greenhouse content Eventually(func(g Gomega) bool { authConfigMap := &corev1.ConfigMap{} err := test.GardenK8sClient.Get(test.Ctx, client.ObjectKey{ @@ -1695,7 +1701,7 @@ jwt: err = yaml.Unmarshal([]byte(authConfigMap.Data["config.yaml"]), &authConfig) g.Expect(err).NotTo(HaveOccurred()) - // Should have one issuer (greenhouse, updated) + // Old issuer must be gone; only the Greenhouse issuer remains g.Expect(authConfig.JWT).To(HaveLen(1)) g.Expect(authConfig.JWT[0].Issuer.URL).To(Equal("https://greenhouse.test.example.com")) g.Expect(authConfig.JWT[0].Issuer.Audiences).To(ConsistOf("greenhouse")) @@ -1704,11 +1710,12 @@ jwt: g.Expect(*authConfig.JWT[0].ClaimMappings.Username.Prefix).To(Equal("greenhouse:")) return true - }).Should(BeTrue(), "should eventually update existing OIDC configuration") + }).Should(BeTrue(), "should eventually overwrite existing OIDC configuration with Greenhouse content") }) - It("should preserve other issuers when adding greenhouse issuer", func() { - // Create a shoot with existing OIDC config containing other issuers + It("should overwrite existing garden CM content - other issuers are not preserved", func() { + // shoot-grafter is the sole owner of the garden CM; it overwrites content verbatim + // from the Greenhouse CM. Any existing issuers in the garden CM are replaced. shoot := &gardenerv1beta1.Shoot{ ObjectMeta: metav1.ObjectMeta{ Name: "test-shoot-oidc-preserve", @@ -1739,7 +1746,7 @@ jwt: } Expect(test.GardenK8sClient.Status().Update(test.Ctx, shoot)).To(Succeed(), "should update Shoot status") - // Create existing auth ConfigMap with other issuers + // Create existing auth ConfigMap with other issuers - these must be overwritten, not preserved existingAuthCM := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-shoot-oidc-preserve-auth", @@ -1780,7 +1787,7 @@ jwt: } Expect(test.GardenK8sClient.Create(test.Ctx, cm)).To(Succeed(), "should create CA ConfigMap resource") - // Eventually verify the auth ConfigMap was updated with greenhouse issuer added + // Eventually verify the garden CM was overwritten with only the Greenhouse issuer Eventually(func(g Gomega) bool { authConfigMap := &corev1.ConfigMap{} err := test.GardenK8sClient.Get(test.Ctx, client.ObjectKey{ @@ -1791,50 +1798,21 @@ jwt: return false } - // Parse and verify the configuration var authConfig apiserverv1beta1.AuthenticationConfiguration err = yaml.Unmarshal([]byte(authConfigMap.Data["config.yaml"]), &authConfig) g.Expect(err).NotTo(HaveOccurred()) - // Should have three issuers now (two original + greenhouse) - g.Expect(authConfig.JWT).To(HaveLen(3)) - - // Verify other issuers are preserved - foundIssuer1 := false - foundIssuer2 := false - foundGreenhouse := false - - for _, issuer := range authConfig.JWT { - switch issuer.Issuer.URL { - case "https://other-issuer1.example.com": - foundIssuer1 = true - g.Expect(issuer.Issuer.Audiences).To(ConsistOf("issuer1")) - case "https://other-issuer2.example.com": - foundIssuer2 = true - g.Expect(issuer.Issuer.Audiences).To(ConsistOf("issuer2")) - case "https://greenhouse.test.example.com": - foundGreenhouse = true - g.Expect(issuer.Issuer.Audiences).To(ConsistOf("greenhouse")) - g.Expect(issuer.ClaimMappings.Username.Claim).To(Equal("sub")) - g.Expect(issuer.ClaimMappings.Username.Prefix).NotTo(BeNil()) - g.Expect(*issuer.ClaimMappings.Username.Prefix).To(Equal("greenhouse:")) - } - } - - g.Expect(foundIssuer1).To(BeTrue(), "should preserve issuer1") - g.Expect(foundIssuer2).To(BeTrue(), "should preserve issuer2") - g.Expect(foundGreenhouse).To(BeTrue(), "should add greenhouse issuer") + // Only the Greenhouse issuer must remain - the old issuers are gone + g.Expect(authConfig.JWT).To(HaveLen(1), "only Greenhouse issuer should remain after overwrite") + g.Expect(authConfig.JWT[0].Issuer.URL).To(Equal("https://greenhouse.test.example.com")) return true - }).Should(BeTrue(), "should eventually add greenhouse issuer while preserving others") + }).Should(BeTrue(), "should eventually overwrite garden CM with only Greenhouse content") }) - It("should preserve all user issuers and add greenhouse issuer in real-world complex ConfigMap scenario", func() { - // This test reproduces a real-world scenario where: - // - User creates a ConfigMap with 3 issuers (no greenhouse issuer) - // - shoot-grafter should ADD the greenhouse issuer - // - All 3 original user issuers must be preserved - // - Final result: 4 issuers total (3 original + 1 greenhouse) + It("should overwrite all existing content in a complex pre-existing garden CM", func() { + // shoot-grafter is the sole owner - even a CM with many user-managed issuers is + // replaced entirely with the Greenhouse content. shoot := &gardenerv1beta1.Shoot{ ObjectMeta: metav1.ObjectMeta{ Name: "test-shoot-complex", @@ -1865,7 +1843,6 @@ jwt: } Expect(test.GardenK8sClient.Status().Update(test.Ctx, shoot)).To(Succeed(), "should update Shoot status") - // Create existing auth ConfigMap with only 3 user-defined issuers (NO greenhouse issuer yet) existingAuthCM := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "authentication-config-complex", @@ -1913,7 +1890,6 @@ jwt: } Expect(test.GardenK8sClient.Create(test.Ctx, existingAuthCM)).To(Succeed(), "should create existing complex auth ConfigMap with 3 user issuers") - // Create CA ConfigMap cm := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-shoot-complex.ca-cluster", @@ -1925,7 +1901,7 @@ jwt: } Expect(test.GardenK8sClient.Create(test.Ctx, cm)).To(Succeed(), "should create CA ConfigMap resource") - // Eventually verify: 3 original user issuers + 1 greenhouse issuer = 4 total + // After reconciliation only the Greenhouse issuer must remain - all 3 user issuers are gone Eventually(func(g Gomega) bool { authConfigMap := &corev1.ConfigMap{} err := test.GardenK8sClient.Get(test.Ctx, client.ObjectKey{ @@ -1936,44 +1912,16 @@ jwt: return false } - // Parse and verify the configuration var authConfig apiserverv1beta1.AuthenticationConfiguration err = yaml.Unmarshal([]byte(authConfigMap.Data["config.yaml"]), &authConfig) g.Expect(err).NotTo(HaveOccurred()) - // Should have 4 issuers total: 3 original user issuers + 1 greenhouse issuer added by shoot-grafter - g.Expect(authConfig.JWT).To(HaveLen(4), "should have 4 issuers: 3 original + 1 greenhouse") - - // Track which issuers we found - foundIssuerURLs := make(map[string]bool) - for _, jwt := range authConfig.JWT { - foundIssuerURLs[jwt.Issuer.URL] = true - } - - // Verify all 3 original user issuers are preserved - g.Expect(foundIssuerURLs).To(HaveKey("https://issuer1.example.com"), "should preserve issuer1") - g.Expect(foundIssuerURLs).To(HaveKey("https://issuer2.example.com/v1/identity/oidc"), "should preserve issuer2") - g.Expect(foundIssuerURLs).To(HaveKey("https://issuer3.example.com"), "should preserve issuer3") - - // Verify greenhouse issuer was added by shoot-grafter - g.Expect(foundIssuerURLs).To(HaveKey("https://greenhouse.test.example.com"), "should add greenhouse issuer from greenhouse-auth-config") - - // Verify greenhouse issuer has correct configuration - var greenhouseIssuer *apiserverv1beta1.JWTAuthenticator - for i := range authConfig.JWT { - if authConfig.JWT[i].Issuer.URL == "https://greenhouse.test.example.com" { - greenhouseIssuer = &authConfig.JWT[i] - break - } - } - g.Expect(greenhouseIssuer).NotTo(BeNil(), "should find greenhouse issuer") - g.Expect(greenhouseIssuer.Issuer.Audiences).To(ConsistOf("greenhouse"), "greenhouse issuer should have correct audience") - g.Expect(greenhouseIssuer.ClaimMappings.Username.Claim).To(Equal("sub"), "greenhouse issuer should have correct username claim") - g.Expect(greenhouseIssuer.ClaimMappings.Username.Prefix).NotTo(BeNil(), "greenhouse issuer should have username prefix") - g.Expect(*greenhouseIssuer.ClaimMappings.Username.Prefix).To(Equal("greenhouse:"), "greenhouse issuer should have correct prefix") + g.Expect(authConfig.JWT).To(HaveLen(1), "only Greenhouse issuer should remain after overwrite") + g.Expect(authConfig.JWT[0].Issuer.URL).To(Equal("https://greenhouse.test.example.com")) + g.Expect(authConfig.JWT[0].Issuer.Audiences).To(ConsistOf("greenhouse")) return true - }).Should(BeTrue(), "should eventually have 4 issuers: 3 original user issuers preserved + 1 greenhouse issuer added") + }).Should(BeTrue(), "should eventually overwrite complex garden CM with only Greenhouse content") // Verify shoot spec references the correct ConfigMap Eventually(func(g Gomega) bool {