diff --git a/config/v1/types_infrastructure.go b/config/v1/types_infrastructure.go index 8140f54973e..bb1ce808379 100644 --- a/config/v1/types_infrastructure.go +++ b/config/v1/types_infrastructure.go @@ -1620,6 +1620,64 @@ type VSpherePlatformSpec struct { // +kubebuilder:validation:XValidation:rule="self.all(x, self.exists_one(y, x == y))" // +optional MachineNetworks []CIDR `json:"machineNetworks"` + + // credentialsMode determines whether components use a shared vCenter account (Passthrough) + // or per-component accounts (PerComponent). Defaults to Passthrough when unset. + // +kubebuilder:validation:Enum="";PerComponent + // +optional + CredentialsMode VSphereCredentialsMode `json:"credentialsMode,omitempty"` + + // componentCredentials holds per-component secret references used when credentialsMode is PerComponent. + // Ignored when credentialsMode is Passthrough. + // +optional + ComponentCredentials *VSphereComponentCredentials `json:"componentCredentials,omitempty"` +} + +// VSphereCredentialsMode defines the credential sharing strategy for vSphere components. +// The empty string is the zero value and is treated identically to Passthrough. +// +kubebuilder:validation:Enum="";PerComponent +type VSphereCredentialsMode string + +const ( + // VSphereCredentialsModePassthrough means all components share a single vCenter account. + // The empty-string zero value is treated as Passthrough to preserve backward compatibility. + VSphereCredentialsModePassthrough VSphereCredentialsMode = "" + + // VSphereCredentialsModePerComponent means each component uses its own dedicated vCenter account + // sourced from the secret referenced in ComponentCredentials. + VSphereCredentialsModePerComponent VSphereCredentialsMode = "PerComponent" +) + +// VSphereComponentCredentials holds optional per-component secret references for vSphere credentials. +// Each field is a pointer to a VSphereComponentSecretRef; a nil field means that component falls back +// to the shared credential. +type VSphereComponentCredentials struct { + // machineAPI references the secret containing vCenter credentials for the Machine API operator. + // +optional + MachineAPI *VSphereComponentSecretRef `json:"machineAPI,omitempty"` + + // csiDriver references the secret containing vCenter credentials for the CSI driver. + // +optional + CSIDriver *VSphereComponentSecretRef `json:"csiDriver,omitempty"` + + // cloudController references the secret containing vCenter credentials for the Cloud Controller Manager. + // +optional + CloudController *VSphereComponentSecretRef `json:"cloudController,omitempty"` + + // vsphereProblemDetector references the secret containing vCenter credentials for the vSphere Problem Detector. + // +optional + VSphereProblemDetector *VSphereComponentSecretRef `json:"vsphereProblemDetector,omitempty"` +} + +// VSphereComponentSecretRef identifies a Kubernetes secret by name and namespace. +type VSphereComponentSecretRef struct { + // name is the name of the secret. + // +kubebuilder:validation:Required + Name string `json:"name"` + + // namespace is the namespace of the secret. + // +kubebuilder:validation:Required + Namespace string `json:"namespace"` } // VSpherePlatformStatus holds the current status of the vSphere infrastructure provider. diff --git a/config/v1/vsphere_credentials_test.go b/config/v1/vsphere_credentials_test.go new file mode 100644 index 00000000000..41864a18cb2 --- /dev/null +++ b/config/v1/vsphere_credentials_test.go @@ -0,0 +1,231 @@ +package v1 + +import ( + "encoding/json" + "testing" +) + +// AC1: VSphereCredentialsMode zero-value defaults to Passthrough + +func TestVSphereCredentialsMode_ZeroValue(t *testing.T) { + var mode VSphereCredentialsMode + if mode != VSphereCredentialsModePassthrough { + t.Errorf("zero value of VSphereCredentialsMode = %q; want %q (Passthrough)", + mode, VSphereCredentialsModePassthrough) + } +} + +func TestVSpherePlatformSpec_CredentialsModeUnset_DefaultsPassthrough(t *testing.T) { + spec := VSpherePlatformSpec{} + if spec.CredentialsMode != VSphereCredentialsModePassthrough { + t.Errorf("unset CredentialsMode = %q; want Passthrough", spec.CredentialsMode) + } +} + +func TestVSpherePlatformSpec_CredentialsModePassthrough(t *testing.T) { + spec := VSpherePlatformSpec{ + CredentialsMode: VSphereCredentialsModePassthrough, + } + if spec.CredentialsMode != VSphereCredentialsModePassthrough { + t.Errorf("CredentialsMode = %q; want Passthrough", spec.CredentialsMode) + } +} + +func TestVSpherePlatformSpec_CredentialsModePerComponent(t *testing.T) { + spec := VSpherePlatformSpec{ + CredentialsMode: VSphereCredentialsModePerComponent, + } + if spec.CredentialsMode != VSphereCredentialsModePerComponent { + t.Errorf("CredentialsMode = %q; want PerComponent", spec.CredentialsMode) + } +} + +// AC2: Existing cluster without credentialsMode → Passthrough, no regression + +func TestVSpherePlatformSpec_JSONRoundTrip_NoCredentialsMode(t *testing.T) { + raw := `{"vcenters": [{"server": "vcenter.example.com", "port": 443, "datacenters": ["DC1"]}]}` + + var spec VSpherePlatformSpec + if err := json.Unmarshal([]byte(raw), &spec); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + if spec.CredentialsMode != VSphereCredentialsModePassthrough { + t.Errorf("credentialsMode after unmarshal of legacy JSON = %q; want Passthrough", spec.CredentialsMode) + } + if spec.ComponentCredentials != nil { + t.Errorf("ComponentCredentials should be nil for legacy JSON; got %+v", spec.ComponentCredentials) + } +} + +func TestVSpherePlatformSpec_JSONRoundTrip_ExplicitPassthrough(t *testing.T) { + // VSphereCredentialsModePassthrough == "" so the canonical representation is omitting the field. + // An explicitly empty credentialsMode field is equally valid and must round-trip as Passthrough. + raw := `{"credentialsMode": ""}` + + var spec VSpherePlatformSpec + if err := json.Unmarshal([]byte(raw), &spec); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + if spec.CredentialsMode != VSphereCredentialsModePassthrough { + t.Errorf("credentialsMode = %q; want Passthrough (empty string)", spec.CredentialsMode) + } +} + +// AC3: PerComponent mode → component credentials accessible + +func TestVSpherePlatformSpec_PerComponent_AllComponents(t *testing.T) { + spec := VSpherePlatformSpec{ + CredentialsMode: VSphereCredentialsModePerComponent, + ComponentCredentials: &VSphereComponentCredentials{ + MachineAPI: &VSphereComponentSecretRef{ + Name: "vsphere-machine-api-creds", + Namespace: "openshift-machine-api", + }, + CSIDriver: &VSphereComponentSecretRef{ + Name: "vsphere-storage-creds", + Namespace: "openshift-cluster-csi-drivers", + }, + CloudController: &VSphereComponentSecretRef{ + Name: "vsphere-cloud-controller-creds", + Namespace: "openshift-cloud-controller-manager", + }, + VSphereProblemDetector: &VSphereComponentSecretRef{ + Name: "vsphere-problem-detector-creds", + Namespace: "openshift-cluster-storage-operator", + }, + }, + } + + cc := spec.ComponentCredentials + if cc == nil { + t.Fatal("ComponentCredentials is nil; want non-nil") + } + if cc.MachineAPI == nil || cc.MachineAPI.Name != "vsphere-machine-api-creds" { + t.Error("MachineAPI credential missing or wrong name") + } + if cc.CSIDriver == nil || cc.CSIDriver.Name != "vsphere-storage-creds" { + t.Error("CSIDriver credential missing or wrong name") + } + if cc.CloudController == nil || cc.CloudController.Name != "vsphere-cloud-controller-creds" { + t.Error("CloudController credential missing or wrong name") + } + if cc.VSphereProblemDetector == nil || cc.VSphereProblemDetector.Name != "vsphere-problem-detector-creds" { + t.Error("VSphereProblemDetector credential missing or wrong name") + } +} + +func TestVSpherePlatformSpec_PerComponent_PartialComponents(t *testing.T) { + spec := VSpherePlatformSpec{ + CredentialsMode: VSphereCredentialsModePerComponent, + ComponentCredentials: &VSphereComponentCredentials{ + MachineAPI: &VSphereComponentSecretRef{ + Name: "vsphere-machine-api-creds", + Namespace: "openshift-machine-api", + }, + }, + } + cc := spec.ComponentCredentials + if cc.MachineAPI == nil { + t.Error("MachineAPI should be non-nil") + } + if cc.CSIDriver != nil { + t.Errorf("CSIDriver should be nil; got %+v", cc.CSIDriver) + } + if cc.CloudController != nil { + t.Errorf("CloudController should be nil; got %+v", cc.CloudController) + } + if cc.VSphereProblemDetector != nil { + t.Errorf("VSphereProblemDetector should be nil; got %+v", cc.VSphereProblemDetector) + } +} + +func TestVSpherePlatformSpec_PerComponent_JSONRoundTrip(t *testing.T) { + original := VSpherePlatformSpec{ + CredentialsMode: VSphereCredentialsModePerComponent, + ComponentCredentials: &VSphereComponentCredentials{ + MachineAPI: &VSphereComponentSecretRef{ + Name: "vsphere-machine-api-creds", + Namespace: "openshift-machine-api", + }, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var roundTripped VSpherePlatformSpec + if err := json.Unmarshal(data, &roundTripped); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if roundTripped.CredentialsMode != VSphereCredentialsModePerComponent { + t.Errorf("credentialsMode after round-trip = %q; want PerComponent", roundTripped.CredentialsMode) + } + if roundTripped.ComponentCredentials == nil || roundTripped.ComponentCredentials.MachineAPI == nil { + t.Error("MachineAPI not preserved in round-trip") + } + if roundTripped.ComponentCredentials.MachineAPI.Name != "vsphere-machine-api-creds" { + t.Errorf("Name after round-trip = %q; want vsphere-machine-api-creds", + roundTripped.ComponentCredentials.MachineAPI.Name) + } +} + +// Adversarial cases + +func TestVSpherePlatformSpec_EmptyStringCredentialsMode(t *testing.T) { + raw := `{"credentialsMode": ""}` + var spec VSpherePlatformSpec + if err := json.Unmarshal([]byte(raw), &spec); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if spec.CredentialsMode != VSphereCredentialsModePassthrough { + t.Errorf("empty credentialsMode = %q; want Passthrough", spec.CredentialsMode) + } +} + +func TestVSpherePlatformSpec_ComponentCredentials_NilPointerSafety(t *testing.T) { + spec := VSpherePlatformSpec{ + CredentialsMode: VSphereCredentialsModePassthrough, + ComponentCredentials: nil, + } + if spec.ComponentCredentials != nil { + t.Fatalf("expected nil ComponentCredentials") + } + var machineAPICreds *VSphereComponentSecretRef + if spec.ComponentCredentials != nil { + machineAPICreds = spec.ComponentCredentials.MachineAPI + } + _ = machineAPICreds +} + +func TestVSphereComponentSecretRef_EmptyNamespace(t *testing.T) { + ref := VSphereComponentSecretRef{Name: "some-secret", Namespace: ""} + if ref.Name != "some-secret" { + t.Errorf("Name = %q; want some-secret", ref.Name) + } +} + +func TestVSphereComponentSecretRef_EmptyName(t *testing.T) { + ref := VSphereComponentSecretRef{Name: "", Namespace: "openshift-machine-api"} + if ref.Namespace != "openshift-machine-api" { + t.Errorf("Namespace = %q; want openshift-machine-api", ref.Namespace) + } +} + +func TestVSpherePlatformSpec_DeepCopy_PointerIsolation(t *testing.T) { + original := &VSpherePlatformSpec{ + CredentialsMode: VSphereCredentialsModePerComponent, + ComponentCredentials: &VSphereComponentCredentials{ + MachineAPI: &VSphereComponentSecretRef{ + Name: "vsphere-machine-api-creds", + Namespace: "openshift-machine-api", + }, + }, + } + copied := original.DeepCopy() + original.ComponentCredentials.MachineAPI.Name = "mutated" + if copied.ComponentCredentials.MachineAPI.Name == "mutated" { + t.Error("DeepCopy shares pointer with original: mutation of original affected copy") + } +} diff --git a/config/v1/zz_generated.deepcopy.go b/config/v1/zz_generated.deepcopy.go index 38aa2f6f331..a0489c8a0e6 100644 --- a/config/v1/zz_generated.deepcopy.go +++ b/config/v1/zz_generated.deepcopy.go @@ -5937,6 +5937,58 @@ func (in *UsernamePrefix) DeepCopy() *UsernamePrefix { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VSphereComponentCredentials) DeepCopyInto(out *VSphereComponentCredentials) { + *out = *in + if in.MachineAPI != nil { + in, out := &in.MachineAPI, &out.MachineAPI + *out = new(VSphereComponentSecretRef) + **out = **in + } + if in.CSIDriver != nil { + in, out := &in.CSIDriver, &out.CSIDriver + *out = new(VSphereComponentSecretRef) + **out = **in + } + if in.CloudController != nil { + in, out := &in.CloudController, &out.CloudController + *out = new(VSphereComponentSecretRef) + **out = **in + } + if in.VSphereProblemDetector != nil { + in, out := &in.VSphereProblemDetector, &out.VSphereProblemDetector + *out = new(VSphereComponentSecretRef) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VSphereComponentCredentials. +func (in *VSphereComponentCredentials) DeepCopy() *VSphereComponentCredentials { + if in == nil { + return nil + } + out := new(VSphereComponentCredentials) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VSphereComponentSecretRef) DeepCopyInto(out *VSphereComponentSecretRef) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VSphereComponentSecretRef. +func (in *VSphereComponentSecretRef) DeepCopy() *VSphereComponentSecretRef { + if in == nil { + return nil + } + out := new(VSphereComponentSecretRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VSphereFailureDomainHostGroup) DeepCopyInto(out *VSphereFailureDomainHostGroup) { *out = *in @@ -6110,6 +6162,11 @@ func (in *VSpherePlatformSpec) DeepCopyInto(out *VSpherePlatformSpec) { *out = make([]CIDR, len(*in)) copy(*out, *in) } + if in.ComponentCredentials != nil { + in, out := &in.ComponentCredentials, &out.ComponentCredentials + *out = new(VSphereComponentCredentials) + (*in).DeepCopyInto(*out) + } return }