Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions config/v1/types_infrastructure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Comment on lines +1674 to +1680
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce non-empty secret reference fields

Name and Namespace are marked required, but empty strings are still valid without length constraints. That allows invalid secret refs to pass schema validation and fail later at runtime. Please add MinLength=1 on both fields (Line 1676, Line 1680).

Suggested diff
 type VSphereComponentSecretRef struct {
 	// name is the name of the secret.
 	// +kubebuilder:validation:Required
+	// +kubebuilder:validation:MinLength=1
 	Name string `json:"name"`

 	// namespace is the namespace of the secret.
 	// +kubebuilder:validation:Required
+	// +kubebuilder:validation:MinLength=1
 	Namespace string `json:"namespace"`
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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"`
// name is the name of the secret.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
// namespace is the namespace of the secret.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
Namespace string `json:"namespace"`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/v1/types_infrastructure.go` around lines 1674 - 1680, The Name and
Namespace fields in the secret reference struct are marked Required but lack a
minimum-length constraint, so add kubebuilder validation to enforce non-empty
values: add the annotation `// +kubebuilder:validation:MinLength=1` above both
the `Name string \`json:"name"\`` and `Namespace string \`json:"namespace"\``
declarations in types_infrastructure.go (the struct fields named Name and
Namespace) so the CRD rejects empty strings.

}

// VSpherePlatformStatus holds the current status of the vSphere infrastructure provider.
Expand Down
231 changes: 231 additions & 0 deletions config/v1/vsphere_credentials_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
57 changes: 57 additions & 0 deletions config/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.