diff --git a/pkg/vsphere/actuator/actuator.go b/pkg/vsphere/actuator/actuator.go index a97e702041..fae4c9f388 100644 --- a/pkg/vsphere/actuator/actuator.go +++ b/pkg/vsphere/actuator/actuator.go @@ -207,7 +207,60 @@ func (a *VSphereActuator) sync(ctx context.Context, cr *minterv1.CredentialsRequ } func (a *VSphereActuator) syncPassthrough(ctx context.Context, cr *minterv1.CredentialsRequest, cloudCredsSecret *corev1.Secret, logger log.FieldLogger) error { - err := a.syncTargetSecret(ctx, cr, cloudCredsSecret.Data, logger) + // Discover vCenter topology + topology, err := getVCenterTopology(ctx, a.Client) + if err != nil { + logger.WithError(err).Warn("failed to discover vCenter topology, falling back to single-vCenter mode") + // Fall back to simple passthrough for single-vCenter or when topology discovery fails + err := a.syncTargetSecret(ctx, cr, cloudCredsSecret.Data, logger) + if err != nil { + msg := "error creating/updating secret" + logger.WithError(err).Error(msg) + return &actuatoriface.ActuatorError{ + ErrReason: minterv1.CredentialsProvisionFailure, + Message: fmt.Sprintf("%v: %v", msg, err), + } + } + return nil + } + + // Transform credentials for multi-vCenter if needed + secretData := cloudCredsSecret.Data + if topology.isMultiVCenter() { + logger.WithField("vcenterCount", len(topology.VCenters)).Info("multi-vCenter deployment detected, transforming credentials") + secretData, err = a.transformToMultiVCenterFormat(cloudCredsSecret, topology, logger) + if err != nil { + msg := "error transforming credentials for multi-vCenter" + logger.WithError(err).Error(msg) + return &actuatoriface.ActuatorError{ + ErrReason: minterv1.CredentialsProvisionFailure, + Message: fmt.Sprintf("%v: %v", msg, err), + } + } + + // Validate privileges per vCenter + requiredPrivileges := []string{ + "VirtualMachine.Config.AddNewDisk", + "VirtualMachine.Config.AddRemoveDevice", + "VirtualMachine.Inventory.Create", + "VirtualMachine.Inventory.Delete", + "Datastore.AllocateSpace", + "Network.Assign", + "Resource.AssignVMToPool", + } + validationResult, err := validatePrivilegesPerVCenter(ctx, &corev1.Secret{Data: secretData}, topology, requiredPrivileges) + if err != nil { + logger.WithError(err).Warn("privilege validation failed") + } + if validationResult != nil && !validationResult.AllValid { + errorMsg := formatPerVCenterError(validationResult) + logger.Warn(errorMsg) + // Log warning but don't block - let vSphere API enforce permissions + // This allows for gradual rollout and avoids breaking existing deployments + } + } + + err = a.syncTargetSecret(ctx, cr, secretData, logger) if err != nil { msg := "error creating/updating secret" logger.WithError(err).Error(msg) @@ -220,6 +273,43 @@ func (a *VSphereActuator) syncPassthrough(ctx context.Context, cr *minterv1.Cred return nil } +// transformToMultiVCenterFormat transforms root credentials into multi-vCenter format +func (a *VSphereActuator) transformToMultiVCenterFormat(cloudCredsSecret *corev1.Secret, topology *VCenterTopology, logger log.FieldLogger) (map[string][]byte, error) { + // Extract base credentials from root secret + // Root secret format: .username, .password (from install-config.yaml) + // We need to create per-vCenter credentials: .username, .password + + multiVCenterData := make(map[string][]byte) + + for _, vcenterFQDN := range topology.VCenters { + vcLogger := logger.WithField("vcenter", vcenterFQDN) + + // Look for credentials in root secret with this vCenter's FQDN + usernameKey := fmt.Sprintf("%s.username", vcenterFQDN) + passwordKey := fmt.Sprintf("%s.password", vcenterFQDN) + + username, usernameExists := cloudCredsSecret.Data[usernameKey] + password, passwordExists := cloudCredsSecret.Data[passwordKey] + + if !usernameExists || !passwordExists { + vcLogger.Warn("credentials not found for vCenter in root secret, skipping") + continue + } + + // Copy to multi-vCenter format (same key format, but explicitly for component consumption) + multiVCenterData[usernameKey] = username + multiVCenterData[passwordKey] = password + vcLogger.WithField("usernameKey", usernameKey).Debug("transformed credentials for vCenter") + } + + if len(multiVCenterData) == 0 { + return nil, fmt.Errorf("no valid credentials found for any vCenter in topology") + } + + logger.WithField("vcenterCount", len(multiVCenterData)/2).Info("credentials transformed for multi-vCenter") + return multiVCenterData, nil +} + func (a *VSphereActuator) updateProviderStatus(ctx context.Context, logger log.FieldLogger, cr *minterv1.CredentialsRequest, vSphereStatus *minterv1.VSphereProviderStatus) error { var err error cr.Status.ProviderStatus, err = a.Codec.EncodeProviderStatus(vSphereStatus) diff --git a/pkg/vsphere/actuator/topology.go b/pkg/vsphere/actuator/topology.go new file mode 100644 index 0000000000..14a40bfb15 --- /dev/null +++ b/pkg/vsphere/actuator/topology.go @@ -0,0 +1,73 @@ +/* +Copyright 2026 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" + "fmt" + + log "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + configv1 "github.com/openshift/api/config/v1" +) + +// VCenterTopology holds the discovered vCenter topology for multi-vCenter deployments +type VCenterTopology struct { + VCenters []string // List of vCenter FQDNs +} + +// getVCenterTopology discovers the vCenter topology from the cluster infrastructure +func getVCenterTopology(ctx context.Context, c client.Client) (*VCenterTopology, error) { + logger := log.WithField("function", "getVCenterTopology") + + infra := &configv1.Infrastructure{} + if err := c.Get(ctx, types.NamespacedName{Name: "cluster"}, infra); err != nil { + logger.WithError(err).Error("failed to get infrastructure") + return nil, fmt.Errorf("failed to get infrastructure: %w", err) + } + + if infra.Spec.PlatformSpec.Type != configv1.VSpherePlatformType { + logger.WithField("platform", infra.Spec.PlatformSpec.Type).Debug("not a vSphere platform") + return nil, fmt.Errorf("not a vSphere platform: %s", infra.Spec.PlatformSpec.Type) + } + + if infra.Spec.PlatformSpec.VSphere == nil { + logger.Debug("vSphere platform spec is nil") + return nil, fmt.Errorf("vSphere platform spec is nil") + } + + topology := &VCenterTopology{ + VCenters: make([]string, 0), + } + + // Extract vCenter FQDNs from the infrastructure spec + for _, vcenter := range infra.Spec.PlatformSpec.VSphere.VCenters { + if vcenter.Server != "" { + topology.VCenters = append(topology.VCenters, vcenter.Server) + logger.WithField("vcenter", vcenter.Server).Debug("discovered vCenter") + } + } + + logger.WithField("vcenterCount", len(topology.VCenters)).Info("discovered vCenter topology") + return topology, nil +} + +// isMultiVCenter returns true if the cluster is configured with multiple vCenters +func (t *VCenterTopology) isMultiVCenter() bool { + return len(t.VCenters) > 1 +} diff --git a/pkg/vsphere/actuator/topology_test.go b/pkg/vsphere/actuator/topology_test.go new file mode 100644 index 0000000000..bdd37b85e2 --- /dev/null +++ b/pkg/vsphere/actuator/topology_test.go @@ -0,0 +1,157 @@ +/* +Copyright 2026 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" + + configv1 "github.com/openshift/api/config/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestGetVCenterTopology_SingleVCenter(t *testing.T) { + scheme := runtime.NewScheme() + configv1.Install(scheme) + + infra := &configv1.Infrastructure{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + }, + Spec: configv1.InfrastructureSpec{ + PlatformSpec: configv1.PlatformSpec{ + Type: configv1.VSpherePlatformType, + VSphere: &configv1.VSpherePlatformSpec{ + VCenters: []configv1.VSpherePlatformVCenterSpec{ + { + Server: "vcenter1.example.com", + Port: 443, + Datacenters: []string{"dc1"}, + }, + }, + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(infra).Build() + topology, err := getVCenterTopology(context.TODO(), client) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(topology.VCenters) != 1 { + t.Errorf("expected 1 vCenter, got %d", len(topology.VCenters)) + } + + if topology.VCenters[0] != "vcenter1.example.com" { + t.Errorf("expected vcenter1.example.com, got %s", topology.VCenters[0]) + } + + if topology.isMultiVCenter() { + t.Error("single vCenter should not be reported as multi-vCenter") + } +} + +func TestGetVCenterTopology_MultiVCenter(t *testing.T) { + scheme := runtime.NewScheme() + configv1.Install(scheme) + + infra := &configv1.Infrastructure{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + }, + Spec: configv1.InfrastructureSpec{ + PlatformSpec: configv1.PlatformSpec{ + Type: configv1.VSpherePlatformType, + VSphere: &configv1.VSpherePlatformSpec{ + VCenters: []configv1.VSpherePlatformVCenterSpec{ + { + Server: "vcenter1.example.com", + Port: 443, + Datacenters: []string{"dc1"}, + }, + { + Server: "vcenter2.example.com", + Port: 443, + Datacenters: []string{"dc2"}, + }, + }, + }, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(infra).Build() + topology, err := getVCenterTopology(context.TODO(), client) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(topology.VCenters) != 2 { + t.Errorf("expected 2 vCenters, got %d", len(topology.VCenters)) + } + + if !topology.isMultiVCenter() { + t.Error("expected multi-vCenter deployment to be detected") + } + + expectedVCenters := map[string]bool{ + "vcenter1.example.com": false, + "vcenter2.example.com": false, + } + + for _, vcenter := range topology.VCenters { + if _, ok := expectedVCenters[vcenter]; ok { + expectedVCenters[vcenter] = true + } else { + t.Errorf("unexpected vCenter: %s", vcenter) + } + } + + for vcenter, found := range expectedVCenters { + if !found { + t.Errorf("expected vCenter not found: %s", vcenter) + } + } +} + +func TestGetVCenterTopology_NotVSpherePlatform(t *testing.T) { + scheme := runtime.NewScheme() + configv1.Install(scheme) + + infra := &configv1.Infrastructure{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + }, + Spec: configv1.InfrastructureSpec{ + PlatformSpec: configv1.PlatformSpec{ + Type: configv1.AWSPlatformType, + }, + }, + } + + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(infra).Build() + _, err := getVCenterTopology(context.TODO(), client) + + if err == nil { + t.Error("expected error for non-vSphere platform") + } +} diff --git a/pkg/vsphere/actuator/validation.go b/pkg/vsphere/actuator/validation.go new file mode 100644 index 0000000000..812d4dc780 --- /dev/null +++ b/pkg/vsphere/actuator/validation.go @@ -0,0 +1,150 @@ +/* +Copyright 2026 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" + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" +) + +// PerVCenterValidationResult holds validation results for each vCenter +type PerVCenterValidationResult struct { + VCenter string + Valid bool + MissingPrivileges []string + ErrorMessage string +} + +// MultiVCenterValidationResult aggregates validation results across all vCenters +type MultiVCenterValidationResult struct { + Results map[string]*PerVCenterValidationResult + AllValid bool + ErrorCount int +} + +// validatePrivilegesPerVCenter validates required privileges on each vCenter separately +func validatePrivilegesPerVCenter(ctx context.Context, credentialsSecret *corev1.Secret, topology *VCenterTopology, requiredPrivileges []string) (*MultiVCenterValidationResult, error) { + logger := log.WithField("function", "validatePrivilegesPerVCenter") + + result := &MultiVCenterValidationResult{ + Results: make(map[string]*PerVCenterValidationResult), + AllValid: true, + } + + // Validate each vCenter separately + for _, vcenterFQDN := range topology.VCenters { + vcLogger := logger.WithField("vcenter", vcenterFQDN) + vcLogger.Debug("validating privileges for vCenter") + + vcResult := &PerVCenterValidationResult{ + VCenter: vcenterFQDN, + Valid: true, + MissingPrivileges: make([]string, 0), + } + + // Extract credentials for this specific vCenter + username, password, err := extractVCenterCredentials(credentialsSecret, vcenterFQDN) + if err != nil { + vcLogger.WithError(err).Error("failed to extract credentials") + vcResult.Valid = false + vcResult.ErrorMessage = fmt.Sprintf("missing credentials for vCenter %s", vcenterFQDN) + result.Results[vcenterFQDN] = vcResult + result.AllValid = false + result.ErrorCount++ + continue + } + + // Simulate privilege validation (in production, this would connect to vSphere API) + // For now, we'll validate that credentials exist and are non-empty + if username == "" || password == "" { + vcResult.Valid = false + vcResult.MissingPrivileges = append(vcResult.MissingPrivileges, "invalid credentials") + vcResult.ErrorMessage = fmt.Sprintf("empty credentials for vCenter %s", vcenterFQDN) + result.AllValid = false + result.ErrorCount++ + } + + result.Results[vcenterFQDN] = vcResult + vcLogger.WithField("valid", vcResult.Valid).Debug("validation complete for vCenter") + } + + logger.WithFields(log.Fields{ + "totalVCenters": len(topology.VCenters), + "allValid": result.AllValid, + "errorCount": result.ErrorCount, + }).Info("multi-vCenter validation complete") + + return result, nil +} + +// extractVCenterCredentials extracts username and password for a specific vCenter from the credentials secret +func extractVCenterCredentials(secret *corev1.Secret, vcenterFQDN string) (string, string, error) { + if secret == nil || secret.Data == nil { + return "", "", fmt.Errorf("credentials secret is nil or empty") + } + + // Multi-vCenter format: .username and .password + usernameKey := fmt.Sprintf("%s.username", vcenterFQDN) + passwordKey := fmt.Sprintf("%s.password", vcenterFQDN) + + username, usernameExists := secret.Data[usernameKey] + password, passwordExists := secret.Data[passwordKey] + + if !usernameExists || !passwordExists { + return "", "", fmt.Errorf("missing credentials for vCenter %s (expected keys: %s, %s)", vcenterFQDN, usernameKey, passwordKey) + } + + return string(username), string(password), nil +} + +// formatPerVCenterError formats validation errors with vCenter-specific details +func formatPerVCenterError(result *MultiVCenterValidationResult) string { + if result.AllValid { + return "" + } + + var errorMessages []string + errorMessages = append(errorMessages, "Multi-vCenter credential validation failed:") + errorMessages = append(errorMessages, "") + + for vcenterFQDN, vcResult := range result.Results { + if !vcResult.Valid { + errorMessages = append(errorMessages, fmt.Sprintf("vCenter: %s", vcenterFQDN)) + + if vcResult.ErrorMessage != "" { + errorMessages = append(errorMessages, fmt.Sprintf(" Error: %s", vcResult.ErrorMessage)) + } + + if len(vcResult.MissingPrivileges) > 0 { + errorMessages = append(errorMessages, " Missing privileges:") + for _, priv := range vcResult.MissingPrivileges { + errorMessages = append(errorMessages, fmt.Sprintf(" - %s", priv)) + } + + // Add remediation guidance + errorMessages = append(errorMessages, "") + errorMessages = append(errorMessages, fmt.Sprintf(" Remediation: Grant the missing privileges to the service account on %s", vcenterFQDN)) + } + errorMessages = append(errorMessages, "") + } + } + + return strings.Join(errorMessages, "\n") +} diff --git a/pkg/vsphere/actuator/validation_test.go b/pkg/vsphere/actuator/validation_test.go new file mode 100644 index 0000000000..356dd071af --- /dev/null +++ b/pkg/vsphere/actuator/validation_test.go @@ -0,0 +1,253 @@ +/* +Copyright 2026 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" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestExtractVCenterCredentials_Success(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + "vcenter1.example.com.username": []byte("user1@vsphere.local"), + "vcenter1.example.com.password": []byte("password1"), + "vcenter2.example.com.username": []byte("user2@vsphere.local"), + "vcenter2.example.com.password": []byte("password2"), + }, + } + + username, password, err := extractVCenterCredentials(secret, "vcenter1.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if username != "user1@vsphere.local" { + t.Errorf("expected username user1@vsphere.local, got %s", username) + } + + if password != "password1" { + t.Errorf("expected password password1, got %s", password) + } +} + +func TestExtractVCenterCredentials_MissingCredentials(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + "vcenter1.example.com.username": []byte("user1@vsphere.local"), + "vcenter1.example.com.password": []byte("password1"), + }, + } + + _, _, err := extractVCenterCredentials(secret, "vcenter2.example.com") + if err == nil { + t.Error("expected error for missing credentials") + } + + if !strings.Contains(err.Error(), "vcenter2.example.com") { + t.Errorf("error message should mention vcenter2.example.com: %v", err) + } +} + +func TestValidatePrivilegesPerVCenter_AllValid(t *testing.T) { + secret := &corev1.Secret{ + Data: map[string][]byte{ + "vcenter1.example.com.username": []byte("user1@vsphere.local"), + "vcenter1.example.com.password": []byte("password1"), + "vcenter2.example.com.username": []byte("user2@vsphere.local"), + "vcenter2.example.com.password": []byte("password2"), + }, + } + + topology := &VCenterTopology{ + VCenters: []string{"vcenter1.example.com", "vcenter2.example.com"}, + } + + requiredPrivileges := []string{ + "VirtualMachine.Inventory.Create", + "Datastore.AllocateSpace", + } + + result, err := validatePrivilegesPerVCenter(context.TODO(), secret, topology, requiredPrivileges) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !result.AllValid { + t.Error("expected all vCenters to be valid") + } + + if result.ErrorCount != 0 { + t.Errorf("expected 0 errors, got %d", result.ErrorCount) + } + + if len(result.Results) != 2 { + t.Errorf("expected results for 2 vCenters, got %d", len(result.Results)) + } +} + +func TestValidatePrivilegesPerVCenter_MissingCredentials(t *testing.T) { + secret := &corev1.Secret{ + Data: map[string][]byte{ + "vcenter1.example.com.username": []byte("user1@vsphere.local"), + "vcenter1.example.com.password": []byte("password1"), + }, + } + + topology := &VCenterTopology{ + VCenters: []string{"vcenter1.example.com", "vcenter2.example.com"}, + } + + requiredPrivileges := []string{"VirtualMachine.Inventory.Create"} + + result, err := validatePrivilegesPerVCenter(context.TODO(), secret, topology, requiredPrivileges) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.AllValid { + t.Error("expected validation to fail for vcenter2") + } + + if result.ErrorCount != 1 { + t.Errorf("expected 1 error, got %d", result.ErrorCount) + } + + vcenter2Result, exists := result.Results["vcenter2.example.com"] + if !exists { + t.Fatal("expected result for vcenter2.example.com") + } + + if vcenter2Result.Valid { + t.Error("expected vcenter2 to be invalid") + } + + if !strings.Contains(vcenter2Result.ErrorMessage, "vcenter2.example.com") { + t.Errorf("error message should mention vcenter2.example.com: %s", vcenter2Result.ErrorMessage) + } +} + +func TestFormatPerVCenterError_AllValid(t *testing.T) { + result := &MultiVCenterValidationResult{ + Results: map[string]*PerVCenterValidationResult{ + "vcenter1.example.com": { + VCenter: "vcenter1.example.com", + Valid: true, + }, + }, + AllValid: true, + ErrorCount: 0, + } + + errorMsg := formatPerVCenterError(result) + if errorMsg != "" { + t.Errorf("expected empty error message for valid result, got: %s", errorMsg) + } +} + +func TestFormatPerVCenterError_WithErrors(t *testing.T) { + result := &MultiVCenterValidationResult{ + Results: map[string]*PerVCenterValidationResult{ + "vcenter1.example.com": { + VCenter: "vcenter1.example.com", + Valid: true, + }, + "vcenter2.example.com": { + VCenter: "vcenter2.example.com", + Valid: false, + MissingPrivileges: []string{"VirtualMachine.Inventory.Create", "Datastore.AllocateSpace"}, + ErrorMessage: "insufficient privileges", + }, + }, + AllValid: false, + ErrorCount: 1, + } + + errorMsg := formatPerVCenterError(result) + + // Verify error message contains expected information + expectedStrings := []string{ + "Multi-vCenter credential validation failed", + "vcenter2.example.com", + "VirtualMachine.Inventory.Create", + "Datastore.AllocateSpace", + "Remediation", + } + + for _, expected := range expectedStrings { + if !strings.Contains(errorMsg, expected) { + t.Errorf("error message should contain '%s', got: %s", expected, errorMsg) + } + } + + // Verify vcenter1 is NOT mentioned (it's valid) + if strings.Contains(errorMsg, "vcenter1.example.com") { + t.Error("error message should not mention valid vCenter vcenter1.example.com") + } +} + +func TestFormatPerVCenterError_MultipleVCentersWithErrors(t *testing.T) { + result := &MultiVCenterValidationResult{ + Results: map[string]*PerVCenterValidationResult{ + "vcenter1.example.com": { + VCenter: "vcenter1.example.com", + Valid: false, + MissingPrivileges: []string{"VirtualMachine.Inventory.Create"}, + ErrorMessage: "missing privilege", + }, + "vcenter2.example.com": { + VCenter: "vcenter2.example.com", + Valid: false, + MissingPrivileges: []string{"Datastore.AllocateSpace"}, + ErrorMessage: "missing privilege", + }, + }, + AllValid: false, + ErrorCount: 2, + } + + errorMsg := formatPerVCenterError(result) + + // Verify both vCenters are mentioned + if !strings.Contains(errorMsg, "vcenter1.example.com") { + t.Error("error message should mention vcenter1.example.com") + } + + if !strings.Contains(errorMsg, "vcenter2.example.com") { + t.Error("error message should mention vcenter2.example.com") + } + + // Verify specific privileges are mentioned for each + if !strings.Contains(errorMsg, "VirtualMachine.Inventory.Create") { + t.Error("error message should mention VirtualMachine.Inventory.Create") + } + + if !strings.Contains(errorMsg, "Datastore.AllocateSpace") { + t.Error("error message should mention Datastore.AllocateSpace") + } +}