diff --git a/pkg/operator/constants/constants.go b/pkg/operator/constants/constants.go index acbc81ad12..cb33b6bcad 100644 --- a/pkg/operator/constants/constants.go +++ b/pkg/operator/constants/constants.go @@ -123,6 +123,12 @@ const ( // for vSphere are stored. VSphereCloudCredSecretName = "vsphere-creds" + // VSphere component-specific credential secret names in kube-system + VSphereMachineAPICredSecretName = "vsphere-machine-api-creds" + VSphereStorageCredSecretName = "vsphere-storage-creds" + VSphereCloudControllerCredSecretName = "vsphere-cloud-controller-creds" + VSphereDiagnosticsCredSecretName = "vsphere-diagnostics-creds" + // KubevirtCloudCredSecretName is the name of the secret where credentials // for Kubevirt are stored. KubevirtCloudCredSecretName = "kubevirt-credentials" diff --git a/pkg/vsphere/actuator/actuator.go b/pkg/vsphere/actuator/actuator.go index a97e702041..b121538073 100644 --- a/pkg/vsphere/actuator/actuator.go +++ b/pkg/vsphere/actuator/actuator.go @@ -273,6 +273,24 @@ func (a *VSphereActuator) getLogger(cr *minterv1.CredentialsRequest) log.FieldLo }) } +// getComponentSecretName returns the component-specific secret name based on the target namespace +func (a *VSphereActuator) getComponentSecretName(cr *minterv1.CredentialsRequest) string { + targetNamespace := cr.Spec.SecretRef.Namespace + + switch targetNamespace { + case "openshift-machine-api": + return constants.VSphereMachineAPICredSecretName + case "openshift-cluster-csi-drivers": + return constants.VSphereStorageCredSecretName + case "openshift-cloud-controller-manager": + return constants.VSphereCloudControllerCredSecretName + case "openshift-config": + return constants.VSphereDiagnosticsCredSecretName + default: + return "" + } +} + func (a *VSphereActuator) syncTargetSecret(ctx context.Context, cr *minterv1.CredentialsRequest, secretData map[string][]byte, logger log.FieldLogger) error { sLog := logger.WithFields(log.Fields{ "targetSecret": fmt.Sprintf("%s/%s", cr.Spec.SecretRef.Namespace, cr.Spec.SecretRef.Name), @@ -319,6 +337,33 @@ func (a *VSphereActuator) GetCredentialsRootSecretLocation() types.NamespacedNam func (a *VSphereActuator) GetCredentialsRootSecret(ctx context.Context, cr *minterv1.CredentialsRequest) (*corev1.Secret, error) { logger := a.getLogger(cr) + + // Try to get component-specific secret first + componentSecretName := a.getComponentSecretName(cr) + if componentSecretName != "" { + componentSecret := &corev1.Secret{} + componentSecretLocation := types.NamespacedName{ + Namespace: constants.CloudCredSecretNamespace, + Name: componentSecretName, + } + + if err := a.RootCredClient.Get(ctx, componentSecretLocation, componentSecret); err == nil { + logger.WithField("componentSecret", componentSecretName).Debug("using component-specific credential") + return componentSecret, nil + } else if !errors.IsNotFound(err) { + // If error is not NotFound, return the error + msg := "error fetching component-specific credential" + logger.WithError(err).Error(msg) + return nil, &actuatoriface.ActuatorError{ + ErrReason: minterv1.CredentialsProvisionFailure, + Message: fmt.Sprintf("%v: %v", msg, err), + } + } + // If NotFound, fall through to shared credential + logger.WithField("componentSecret", componentSecretName).Debug("component-specific credential not found, falling back to shared credential") + } + + // Fall back to shared credential cloudCredSecret := &corev1.Secret{} if err := a.RootCredClient.Get(ctx, a.GetCredentialsRootSecretLocation(), cloudCredSecret); err != nil { msg := "unable to fetch root cloud cred secret" diff --git a/pkg/vsphere/actuator/actuator_component_test.go b/pkg/vsphere/actuator/actuator_component_test.go new file mode 100644 index 0000000000..5781757a5f --- /dev/null +++ b/pkg/vsphere/actuator/actuator_component_test.go @@ -0,0 +1,322 @@ +/* +Copyright 2024 The OpenShift Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package actuator + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + minterv1 "github.com/openshift/cloud-credential-operator/pkg/apis/cloudcredential/v1" + "github.com/openshift/cloud-credential-operator/pkg/operator/constants" +) + +func TestGetComponentSecretName(t *testing.T) { + tests := []struct { + name string + targetNamespace string + expectedSecretName string + }{ + { + name: "machine-api namespace", + targetNamespace: "openshift-machine-api", + expectedSecretName: constants.VSphereMachineAPICredSecretName, + }, + { + name: "storage namespace", + targetNamespace: "openshift-cluster-csi-drivers", + expectedSecretName: constants.VSphereStorageCredSecretName, + }, + { + name: "cloud-controller namespace", + targetNamespace: "openshift-cloud-controller-manager", + expectedSecretName: constants.VSphereCloudControllerCredSecretName, + }, + { + name: "diagnostics namespace", + targetNamespace: "openshift-config", + expectedSecretName: constants.VSphereDiagnosticsCredSecretName, + }, + { + name: "unknown namespace", + targetNamespace: "openshift-other", + expectedSecretName: "", + }, + } + + actuator := &VSphereActuator{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cr := &minterv1.CredentialsRequest{ + Spec: minterv1.CredentialsRequestSpec{ + SecretRef: corev1.ObjectReference{ + Namespace: tt.targetNamespace, + Name: "test-secret", + }, + }, + } + + result := actuator.getComponentSecretName(cr) + assert.Equal(t, tt.expectedSecretName, result) + }) + } +} + +func TestGetCredentialsRootSecret_ComponentSpecific(t *testing.T) { + minterv1.AddToScheme(scheme.Scheme) + + componentSecretData := map[string][]byte{ + "username": []byte("component-user"), + "password": []byte("component-pass"), + } + + sharedSecretData := map[string][]byte{ + "username": []byte("shared-user"), + "password": []byte("shared-pass"), + } + + tests := []struct { + name string + targetNamespace string + componentSecretExists bool + sharedSecretExists bool + expectedSecretData map[string][]byte + expectError bool + }{ + { + name: "component secret exists - machine-api", + targetNamespace: "openshift-machine-api", + componentSecretExists: true, + sharedSecretExists: true, + expectedSecretData: componentSecretData, + expectError: false, + }, + { + name: "component secret missing - fallback to shared", + targetNamespace: "openshift-machine-api", + componentSecretExists: false, + sharedSecretExists: true, + expectedSecretData: sharedSecretData, + expectError: false, + }, + { + name: "both secrets missing - error", + targetNamespace: "openshift-machine-api", + componentSecretExists: false, + sharedSecretExists: false, + expectedSecretData: nil, + expectError: true, + }, + { + name: "unknown namespace - uses shared secret", + targetNamespace: "openshift-other", + componentSecretExists: false, + sharedSecretExists: true, + expectedSecretData: sharedSecretData, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objects := []runtime.Object{} + + // Create component-specific secret if needed + if tt.componentSecretExists { + componentSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereMachineAPICredSecretName, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: componentSecretData, + } + objects = append(objects, componentSecret) + } + + // Create shared secret if needed + if tt.sharedSecretExists { + sharedSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereCloudCredSecretName, + Namespace: constants.CloudCredSecretNamespace, + Annotations: map[string]string{ + constants.AnnotationKey: constants.PassthroughAnnotation, + }, + }, + Data: sharedSecretData, + } + objects = append(objects, sharedSecret) + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithRuntimeObjects(objects...).Build() + + actuator := &VSphereActuator{ + Client: fakeClient, + RootCredClient: fakeClient, + } + + cr := &minterv1.CredentialsRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cr", + Namespace: "openshift-cloud-credential-operator", + }, + Spec: minterv1.CredentialsRequestSpec{ + SecretRef: corev1.ObjectReference{ + Namespace: tt.targetNamespace, + Name: "test-target-secret", + }, + }, + } + + secret, err := actuator.GetCredentialsRootSecret(context.TODO(), cr) + + if tt.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, secret) + assert.Equal(t, tt.expectedSecretData, secret.Data) + }) + } +} + +func TestGetCredentialsRootSecret_MultiComponent(t *testing.T) { + minterv1.AddToScheme(scheme.Scheme) + + // Create all component-specific secrets + machineAPISecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereMachineAPICredSecretName, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: map[string][]byte{ + "username": []byte("machine-api-user"), + "password": []byte("machine-api-pass"), + }, + } + + storageSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereStorageCredSecretName, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: map[string][]byte{ + "username": []byte("storage-user"), + "password": []byte("storage-pass"), + }, + } + + cloudControllerSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereCloudControllerCredSecretName, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: map[string][]byte{ + "username": []byte("cloud-controller-user"), + "password": []byte("cloud-controller-pass"), + }, + } + + diagnosticsSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereDiagnosticsCredSecretName, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: map[string][]byte{ + "username": []byte("diagnostics-user"), + "password": []byte("diagnostics-pass"), + }, + } + + sharedSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereCloudCredSecretName, + Namespace: constants.CloudCredSecretNamespace, + Annotations: map[string]string{ + constants.AnnotationKey: constants.PassthroughAnnotation, + }, + }, + Data: map[string][]byte{ + "username": []byte("shared-user"), + "password": []byte("shared-pass"), + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme.Scheme). + WithRuntimeObjects(machineAPISecret, storageSecret, cloudControllerSecret, diagnosticsSecret, sharedSecret). + Build() + + actuator := &VSphereActuator{ + Client: fakeClient, + RootCredClient: fakeClient, + } + + tests := []struct { + targetNamespace string + expectedUsername string + }{ + { + targetNamespace: "openshift-machine-api", + expectedUsername: "machine-api-user", + }, + { + targetNamespace: "openshift-cluster-csi-drivers", + expectedUsername: "storage-user", + }, + { + targetNamespace: "openshift-cloud-controller-manager", + expectedUsername: "cloud-controller-user", + }, + { + targetNamespace: "openshift-config", + expectedUsername: "diagnostics-user", + }, + } + + for _, tt := range tests { + t.Run(tt.targetNamespace, func(t *testing.T) { + cr := &minterv1.CredentialsRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cr", + Namespace: "openshift-cloud-credential-operator", + }, + Spec: minterv1.CredentialsRequestSpec{ + SecretRef: corev1.ObjectReference{ + Namespace: tt.targetNamespace, + Name: "test-target-secret", + }, + }, + } + + secret, err := actuator.GetCredentialsRootSecret(context.TODO(), cr) + require.NoError(t, err) + require.NotNil(t, secret) + assert.Equal(t, tt.expectedUsername, string(secret.Data["username"])) + }) + } +} diff --git a/test/e2e/vsphere_multi_account_test.go b/test/e2e/vsphere_multi_account_test.go new file mode 100644 index 0000000000..fd7aaab53b --- /dev/null +++ b/test/e2e/vsphere_multi_account_test.go @@ -0,0 +1,313 @@ +/* +Copyright 2024 The OpenShift Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package e2e + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + + minterv1 "github.com/openshift/cloud-credential-operator/pkg/apis/cloudcredential/v1" + "github.com/openshift/cloud-credential-operator/pkg/operator/constants" +) + +var _ = Describe("VSphere Multi-Account E2E", func() { + const ( + timeout = time.Minute * 5 + interval = time.Second * 10 + ) + + var ( + k8sClient *kubernetes.Clientset + ctx context.Context + ) + + BeforeEach(func() { + ctx = context.Background() + // Initialize k8s client + // k8sClient = ... // TODO: Initialize from kubeconfig + }) + + Describe("Installation with Component Credentials", func() { + It("should provision all component credentials during installation", func() { + Skip("Requires OpenShift installation test harness") + + // Verify all component secrets exist in kube-system + componentSecrets := []string{ + constants.VSphereMachineAPICredSecretName, + constants.VSphereStorageCredSecretName, + constants.VSphereCloudControllerCredSecretName, + constants.VSphereDiagnosticsCredSecretName, + } + + for _, secretName := range componentSecrets { + secret, err := k8sClient.CoreV1().Secrets(constants.CloudCredSecretNamespace).Get(ctx, secretName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(secret.Data).NotTo(BeEmpty()) + Expect(secret.Data["username"]).NotTo(BeEmpty()) + Expect(secret.Data["password"]).NotTo(BeEmpty()) + } + + // Verify credentials provisioned to operator namespaces + targetSecrets := map[string]string{ + "openshift-machine-api": "vsphere-cloud-credentials", + "openshift-cluster-csi-drivers": "vmware-vsphere-cloud-credentials", + "openshift-cloud-controller-manager": "cloud-provider-creds", + "openshift-config": "vmware-vsphere-cloud-credentials", + } + + for namespace, secretName := range targetSecrets { + secret, err := k8sClient.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(secret.Data).NotTo(BeEmpty()) + } + }) + + It("should set CredentialsRequest status to Provisioned", func() { + Skip("Requires OpenShift installation test harness") + + // Check all CredentialsRequests for vSphere + crList := &minterv1.CredentialsRequestList{} + // TODO: List all CRs + // Expect(client.List(ctx, crList)).To(Succeed()) + + for _, cr := range crList.Items { + if cr.Spec.ProviderSpec == nil { + continue + } + // Check if it's a vSphere CR + // TODO: Decode provider spec + + // Verify status + foundProvisioned := false + for _, condition := range cr.Status.Conditions { + if condition.Type == minterv1.CredentialsProvisionedConditionType { + Expect(condition.Status).To(Equal(corev1.ConditionTrue)) + foundProvisioned = true + break + } + } + Expect(foundProvisioned).To(BeTrue(), fmt.Sprintf("CR %s/%s should have Provisioned condition", cr.Namespace, cr.Name)) + } + }) + }) + + Describe("Migration from Shared to Component Credentials", func() { + It("should migrate existing cluster to component credentials without downtime", func() { + Skip("Requires live cluster with monitoring") + + // Step 1: Verify cluster is using shared credential + sharedSecret, err := k8sClient.CoreV1().Secrets(constants.CloudCredSecretNamespace).Get(ctx, constants.VSphereCloudCredSecretName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + // Step 2: Create component secrets + componentSecrets := map[string]map[string][]byte{ + constants.VSphereMachineAPICredSecretName: { + "username": []byte("machine-api-user"), + "password": []byte("machine-api-pass"), + }, + constants.VSphereStorageCredSecretName: { + "username": []byte("storage-user"), + "password": []byte("storage-pass"), + }, + constants.VSphereCloudControllerCredSecretName: { + "username": []byte("cloud-controller-user"), + "password": []byte("cloud-controller-pass"), + }, + constants.VSphereDiagnosticsCredSecretName: { + "username": []byte("diagnostics-user"), + "password": []byte("diagnostics-pass"), + }, + } + + for name, data := range componentSecrets { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: data, + } + _, err := k8sClient.CoreV1().Secrets(constants.CloudCredSecretNamespace).Create(ctx, secret, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + } + + // Step 3: Wait for CCO to reconcile + Eventually(func() bool { + // Check if target secrets have been updated + targetSecrets := []types.NamespacedName{ + {Namespace: "openshift-machine-api", Name: "vsphere-cloud-credentials"}, + {Namespace: "openshift-cluster-csi-drivers", Name: "vmware-vsphere-cloud-credentials"}, + {Namespace: "openshift-cloud-controller-manager", Name: "cloud-provider-creds"}, + {Namespace: "openshift-config", Name: "vmware-vsphere-cloud-credentials"}, + } + + for _, secretRef := range targetSecrets { + secret, err := k8sClient.CoreV1().Secrets(secretRef.Namespace).Get(ctx, secretRef.Name, metav1.GetOptions{}) + if err != nil { + return false + } + // Verify secret data has been updated (not same as shared secret) + if string(secret.Data["username"]) == string(sharedSecret.Data["username"]) { + return false + } + } + return true + }, timeout, interval).Should(BeTrue()) + + // Step 4: Verify no operator downtime + // TODO: Check operator pod restarts, check metrics + }) + + It("should allow operators to adopt new credentials gracefully", func() { + Skip("Requires operator health monitoring") + + // Monitor operator pod health during credential update + operators := []string{ + "machine-api-operator", + "vsphere-problem-detector", + "csi-driver", + "cloud-controller-manager", + } + + for _, operatorName := range operators { + // TODO: Monitor operator health + // - Check pod restarts + // - Check error logs + // - Verify operator continues to function + } + }) + }) + + Describe("Credential Rotation", func() { + It("should detect and apply rotated component credentials", func() { + Skip("Requires live cluster") + + // Step 1: Get current credential + oldSecret, err := k8sClient.CoreV1().Secrets(constants.CloudCredSecretNamespace).Get(ctx, constants.VSphereMachineAPICredSecretName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + // Step 2: Update component secret with new credentials + newSecret := oldSecret.DeepCopy() + newSecret.Data["password"] = []byte("new-password") + _, err = k8sClient.CoreV1().Secrets(constants.CloudCredSecretNamespace).Update(ctx, newSecret, metav1.UpdateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + // Step 3: Wait for CCO to reconcile + Eventually(func() bool { + targetSecret, err := k8sClient.CoreV1().Secrets("openshift-machine-api").Get(ctx, "vsphere-cloud-credentials", metav1.GetOptions{}) + if err != nil { + return false + } + return string(targetSecret.Data["password"]) == "new-password" + }, timeout, interval).Should(BeTrue()) + + // Step 4: Verify operator adopts new credential + // TODO: Check operator can still perform operations + }) + }) + + Describe("Failure Handling", func() { + It("should handle missing component secret gracefully", func() { + Skip("Requires live cluster") + + // Delete component secret + err := k8sClient.CoreV1().Secrets(constants.CloudCredSecretNamespace).Delete(ctx, constants.VSphereStorageCredSecretName, metav1.DeleteOptions{}) + Expect(err).NotTo(HaveOccurred()) + + // Verify CCO falls back to shared credential + Eventually(func() bool { + targetSecret, err := k8sClient.CoreV1().Secrets("openshift-cluster-csi-drivers").Get(ctx, "vmware-vsphere-cloud-credentials", metav1.GetOptions{}) + if err != nil { + return false + } + + sharedSecret, err := k8sClient.CoreV1().Secrets(constants.CloudCredSecretNamespace).Get(ctx, constants.VSphereCloudCredSecretName, metav1.GetOptions{}) + if err != nil { + return false + } + + // Target should now use shared credential + return string(targetSecret.Data["username"]) == string(sharedSecret.Data["username"]) + }, timeout, interval).Should(BeTrue()) + }) + + It("should report error status when credentials are invalid", func() { + Skip("Requires credential validation logic") + + // Create invalid component secret (missing required fields) + invalidSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereMachineAPICredSecretName, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: map[string][]byte{ + "username": []byte(""), // Empty username + }, + } + _, err := k8sClient.CoreV1().Secrets(constants.CloudCredSecretNamespace).Create(ctx, invalidSecret, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + // Verify CredentialsRequest status shows error + // TODO: Check CR status for error condition + }) + + It("should handle privilege validation failure", func() { + Skip("Requires vSphere privilege validation") + + // Create component secret with insufficient privileges + // TODO: Use credential with read-only access + // Verify CCO detects insufficient privileges + // Verify error is reported in CR status + }) + + It("should handle partial provisioning correctly", func() { + Skip("Requires multi-component test scenario") + + // Create only some component secrets + // Verify successful components provision correctly + // Verify missing components fall back to shared credential + // Verify individual CR statuses are correct + }) + }) + + Describe("Multi-vCenter Support", func() { + It("should support component credentials for multiple vCenters", func() { + Skip("Requires multi-vCenter test environment") + + // TODO: Test with multiple vCenter instances + // Verify each component can have credentials for different vCenters + }) + }) + + Describe("Annotation-based Privilege Validation", func() { + It("should validate privileges using CredentialsRequest annotations", func() { + Skip("Requires privilege validation implementation") + + // Create CR with privilege annotation + // TODO: Add cloudcredential.openshift.io/mode: passthrough annotation + // Verify CCO validates credentials against required privileges + }) + }) +}) diff --git a/test/integration/vsphere_component_credentials_test.go b/test/integration/vsphere_component_credentials_test.go new file mode 100644 index 0000000000..3e9e10b3b4 --- /dev/null +++ b/test/integration/vsphere_component_credentials_test.go @@ -0,0 +1,443 @@ +/* +Copyright 2024 The OpenShift Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package integration + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + minterv1 "github.com/openshift/cloud-credential-operator/pkg/apis/cloudcredential/v1" + "github.com/openshift/cloud-credential-operator/pkg/operator/constants" +) + +var _ = Describe("VSphere Component Credentials", func() { + const ( + timeout = time.Second * 30 + interval = time.Millisecond * 250 + ) + + Context("Component Secret Detection", func() { + It("should detect machine-api component secret in kube-system", func() { + ctx := context.Background() + + // Create component secret in kube-system + componentSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereMachineAPICredSecretName, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: map[string][]byte{ + "username": []byte("machine-api-user"), + "password": []byte("machine-api-pass"), + }, + } + Expect(k8sClient.Create(ctx, componentSecret)).Should(Succeed()) + + // Verify secret exists + fetchedSecret := &corev1.Secret{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: constants.VSphereMachineAPICredSecretName, + Namespace: constants.CloudCredSecretNamespace, + }, fetchedSecret) + return err == nil + }, timeout, interval).Should(BeTrue()) + + Expect(string(fetchedSecret.Data["username"])).To(Equal("machine-api-user")) + }) + + It("should detect all four component secrets", func() { + ctx := context.Background() + + componentSecrets := map[string]string{ + constants.VSphereMachineAPICredSecretName: "machine-api-user", + constants.VSphereStorageCredSecretName: "storage-user", + constants.VSphereCloudControllerCredSecretName: "cloud-controller-user", + constants.VSphereDiagnosticsCredSecretName: "diagnostics-user", + } + + for secretName, username := range componentSecrets { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: map[string][]byte{ + "username": []byte(username), + "password": []byte("test-pass"), + }, + } + Expect(k8sClient.Create(ctx, secret)).Should(Succeed()) + } + + // Verify all secrets exist + for secretName, expectedUsername := range componentSecrets { + fetchedSecret := &corev1.Secret{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: secretName, + Namespace: constants.CloudCredSecretNamespace, + }, fetchedSecret) + return err == nil + }, timeout, interval).Should(BeTrue()) + + Expect(string(fetchedSecret.Data["username"])).To(Equal(expectedUsername)) + } + }) + }) + + Context("Credential Provisioning", func() { + It("should provision machine-api credentials to openshift-machine-api namespace", func() { + ctx := context.Background() + + // Create component secret + componentSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereMachineAPICredSecretName, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: map[string][]byte{ + "username": []byte("machine-api-user"), + "password": []byte("machine-api-pass"), + }, + } + Expect(k8sClient.Create(ctx, componentSecret)).Should(Succeed()) + + // Create target namespace + targetNs := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openshift-machine-api", + }, + } + Expect(k8sClient.Create(ctx, targetNs)).Should(Succeed()) + + // Create CredentialsRequest + cr := &minterv1.CredentialsRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "machine-api-creds", + Namespace: "openshift-cloud-credential-operator", + }, + Spec: minterv1.CredentialsRequestSpec{ + SecretRef: corev1.ObjectReference{ + Name: "vsphere-machine-api-credentials", + Namespace: "openshift-machine-api", + }, + }, + } + Expect(k8sClient.Create(ctx, cr)).Should(Succeed()) + + // Verify target secret is provisioned with correct data + targetSecret := &corev1.Secret{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "vsphere-machine-api-credentials", + Namespace: "openshift-machine-api", + }, targetSecret) + return err == nil + }, timeout, interval).Should(BeTrue()) + + Expect(string(targetSecret.Data["username"])).To(Equal("machine-api-user")) + }) + + It("should provision credentials to all operator namespaces", func() { + ctx := context.Background() + + testCases := []struct { + componentSecret string + targetNamespace string + targetSecret string + username string + }{ + { + componentSecret: constants.VSphereMachineAPICredSecretName, + targetNamespace: "openshift-machine-api", + targetSecret: "machine-api-creds", + username: "machine-api-user", + }, + { + componentSecret: constants.VSphereStorageCredSecretName, + targetNamespace: "openshift-cluster-csi-drivers", + targetSecret: "storage-creds", + username: "storage-user", + }, + { + componentSecret: constants.VSphereCloudControllerCredSecretName, + targetNamespace: "openshift-cloud-controller-manager", + targetSecret: "cloud-controller-creds", + username: "cloud-controller-user", + }, + { + componentSecret: constants.VSphereDiagnosticsCredSecretName, + targetNamespace: "openshift-config", + targetSecret: "diagnostics-creds", + username: "diagnostics-user", + }, + } + + for _, tc := range testCases { + // Create component secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: tc.componentSecret, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: map[string][]byte{ + "username": []byte(tc.username), + "password": []byte("test-pass"), + }, + } + Expect(k8sClient.Create(ctx, secret)).Should(Succeed()) + + // Create target namespace + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: tc.targetNamespace, + }, + } + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + + // Create CredentialsRequest + cr := &minterv1.CredentialsRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: tc.targetSecret + "-cr", + Namespace: "openshift-cloud-credential-operator", + }, + Spec: minterv1.CredentialsRequestSpec{ + SecretRef: corev1.ObjectReference{ + Name: tc.targetSecret, + Namespace: tc.targetNamespace, + }, + }, + } + Expect(k8sClient.Create(ctx, cr)).Should(Succeed()) + + // Verify provisioning + targetSecret := &corev1.Secret{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: tc.targetSecret, + Namespace: tc.targetNamespace, + }, targetSecret) + return err == nil + }, timeout, interval).Should(BeTrue()) + + Expect(string(targetSecret.Data["username"])).To(Equal(tc.username)) + } + }) + }) + + Context("Fallback to Shared Credential", func() { + It("should use shared credential when component secret is missing", func() { + ctx := context.Background() + + // Create only shared credential + sharedSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereCloudCredSecretName, + Namespace: constants.CloudCredSecretNamespace, + Annotations: map[string]string{ + constants.AnnotationKey: constants.PassthroughAnnotation, + }, + }, + Data: map[string][]byte{ + "username": []byte("shared-user"), + "password": []byte("shared-pass"), + }, + } + Expect(k8sClient.Create(ctx, sharedSecret)).Should(Succeed()) + + // Create target namespace + targetNs := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openshift-cluster-csi-drivers", + }, + } + Expect(k8sClient.Create(ctx, targetNs)).Should(Succeed()) + + // Create CredentialsRequest for storage (component secret doesn't exist) + cr := &minterv1.CredentialsRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "storage-fallback-cr", + Namespace: "openshift-cloud-credential-operator", + }, + Spec: minterv1.CredentialsRequestSpec{ + SecretRef: corev1.ObjectReference{ + Name: "storage-fallback-creds", + Namespace: "openshift-cluster-csi-drivers", + }, + }, + } + Expect(k8sClient.Create(ctx, cr)).Should(Succeed()) + + // Verify target secret uses shared credential data + targetSecret := &corev1.Secret{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "storage-fallback-creds", + Namespace: "openshift-cluster-csi-drivers", + }, targetSecret) + return err == nil + }, timeout, interval).Should(BeTrue()) + + Expect(string(targetSecret.Data["username"])).To(Equal("shared-user")) + }) + }) + + Context("Migration Scenario", func() { + It("should auto-reconcile when component secrets are added to existing cluster", func() { + ctx := context.Background() + + // Setup: Start with shared credential + sharedSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereCloudCredSecretName, + Namespace: constants.CloudCredSecretNamespace, + Annotations: map[string]string{ + constants.AnnotationKey: constants.PassthroughAnnotation, + }, + }, + Data: map[string][]byte{ + "username": []byte("shared-user"), + "password": []byte("shared-pass"), + }, + } + Expect(k8sClient.Create(ctx, sharedSecret)).Should(Succeed()) + + targetNs := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openshift-machine-api", + }, + } + Expect(k8sClient.Create(ctx, targetNs)).Should(Succeed()) + + cr := &minterv1.CredentialsRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "migration-cr", + Namespace: "openshift-cloud-credential-operator", + }, + Spec: minterv1.CredentialsRequestSpec{ + SecretRef: corev1.ObjectReference{ + Name: "migration-target-creds", + Namespace: "openshift-machine-api", + }, + }, + } + Expect(k8sClient.Create(ctx, cr)).Should(Succeed()) + + // Verify using shared credential + targetSecret := &corev1.Secret{} + Eventually(func() bool { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "migration-target-creds", + Namespace: "openshift-machine-api", + }, targetSecret) + return err == nil && string(targetSecret.Data["username"]) == "shared-user" + }, timeout, interval).Should(BeTrue()) + + // Migration: Add component secret + componentSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereMachineAPICredSecretName, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: map[string][]byte{ + "username": []byte("machine-api-user"), + "password": []byte("machine-api-pass"), + }, + } + Expect(k8sClient.Create(ctx, componentSecret)).Should(Succeed()) + + // Trigger reconciliation by updating CR + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cr), cr)).Should(Succeed()) + cr.Annotations = map[string]string{"migration": "true"} + Expect(k8sClient.Update(ctx, cr)).Should(Succeed()) + + // Verify target secret now uses component credential + Eventually(func() string { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "migration-target-creds", + Namespace: "openshift-machine-api", + }, targetSecret) + if err != nil { + return "" + } + return string(targetSecret.Data["username"]) + }, timeout, interval).Should(Equal("machine-api-user")) + }) + }) + + Context("CredentialsRequest Status", func() { + It("should update status to reflect provisioning success", func() { + ctx := context.Background() + + componentSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: constants.VSphereMachineAPICredSecretName, + Namespace: constants.CloudCredSecretNamespace, + }, + Data: map[string][]byte{ + "username": []byte("machine-api-user"), + "password": []byte("machine-api-pass"), + }, + } + Expect(k8sClient.Create(ctx, componentSecret)).Should(Succeed()) + + targetNs := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "openshift-machine-api", + }, + } + Expect(k8sClient.Create(ctx, targetNs)).Should(Succeed()) + + cr := &minterv1.CredentialsRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "status-test-cr", + Namespace: "openshift-cloud-credential-operator", + }, + Spec: minterv1.CredentialsRequestSpec{ + SecretRef: corev1.ObjectReference{ + Name: "status-test-creds", + Namespace: "openshift-machine-api", + }, + }, + } + Expect(k8sClient.Create(ctx, cr)).Should(Succeed()) + + // Verify status is updated + Eventually(func() bool { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(cr), cr) + if err != nil { + return false + } + // Check if Provisioned condition exists + for _, condition := range cr.Status.Conditions { + if condition.Type == minterv1.CredentialsProvisionedConditionType { + return condition.Status == corev1.ConditionTrue + } + } + return false + }, timeout, interval).Should(BeTrue()) + }) + }) +})