From 947345273155a0dbe4e87003e69d3e76a9c51ee3 Mon Sep 17 00:00:00 2001 From: Richard Vanderpool <49568690+rvanderp3@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:20:57 -0500 Subject: [PATCH] vsphere: add support for per-component vCenter credentials Adds ComponentCredentials field to VCenter type to allow specifying separate credentials for each OpenShift component (machine-api, CSI driver, cloud controller, diagnostics). This enables least-privilege security by allowing different vCenter accounts with minimal permissions for each component. The installer can now: - Load component credentials from ~/.vsphere/credentials file (INI format) - Fall back to main VCenter username/password if not specified - Generate separate credential secrets for each component - Validate credential file permissions (must be 0600) Components affected: - machine-api-operator - vSphere CSI driver - Cloud controller manager - vsphere-problem-detector --- .../install.openshift.io_installconfigs.yaml | 64 +++ ...loud-controller-creds-secret.yaml.template | 11 + ...here-csi-driver-creds-secret.yaml.template | 11 + ...ere-diagnostics-creds-secret.yaml.template | 11 + ...ere-machine-api-creds-secret.yaml.template | 11 + pkg/asset/installconfig/installconfig.go | 21 + .../installconfig/vsphere/credentials.go | 174 ++++++++ .../installconfig/vsphere/credentials_test.go | 414 ++++++++++++++++++ pkg/asset/manifests/cloudproviderconfig.go | 14 +- pkg/asset/manifests/openshift.go | 175 +++++++- pkg/asset/manifests/template.go | 29 +- .../manifests/vsphere/cloudproviderconfig.go | 10 +- .../vsphere/cloudproviderconfig_test.go | 8 +- .../vsphere-cloud-controller-creds-secret.go | 65 +++ .../vsphere-csi-driver-creds-secret.go | 65 +++ .../vsphere-diagnostics-creds-secret.go | 65 +++ .../vsphere-machine-api-creds-secret.go | 65 +++ pkg/types/vsphere/platform.go | 38 ++ pkg/types/vsphere/validation/platform.go | 55 +++ pkg/types/vsphere/zz_generated.deepcopy.go | 57 +++ 20 files changed, 1338 insertions(+), 25 deletions(-) create mode 100644 data/data/manifests/openshift/vsphere-cloud-controller-creds-secret.yaml.template create mode 100644 data/data/manifests/openshift/vsphere-csi-driver-creds-secret.yaml.template create mode 100644 data/data/manifests/openshift/vsphere-diagnostics-creds-secret.yaml.template create mode 100644 data/data/manifests/openshift/vsphere-machine-api-creds-secret.yaml.template create mode 100644 pkg/asset/installconfig/vsphere/credentials.go create mode 100644 pkg/asset/installconfig/vsphere/credentials_test.go create mode 100644 pkg/asset/templates/content/openshift/vsphere-cloud-controller-creds-secret.go create mode 100644 pkg/asset/templates/content/openshift/vsphere-csi-driver-creds-secret.go create mode 100644 pkg/asset/templates/content/openshift/vsphere-diagnostics-creds-secret.go create mode 100644 pkg/asset/templates/content/openshift/vsphere-machine-api-creds-secret.go diff --git a/data/data/install.openshift.io_installconfigs.yaml b/data/data/install.openshift.io_installconfigs.yaml index 22fc1a94d7b..6d101332e3c 100644 --- a/data/data/install.openshift.io_installconfigs.yaml +++ b/data/data/install.openshift.io_installconfigs.yaml @@ -8497,6 +8497,70 @@ spec: VCenter stores the vCenter connection fields https://github.com/kubernetes/cloud-provider-vsphere/blob/master/pkg/common/config/types_yaml.go properties: + componentCredentials: + description: |- + ComponentCredentials specifies per-component credentials for this vCenter. + If not specified, the main Username/Password is used for all components. + This enables least-privilege security by allowing separate accounts + for installer, machine-api, CSI driver, cloud controller, and diagnostics. + properties: + cloudController: + description: CloudController specifies credentials for + the cloud controller manager on this vCenter. + properties: + password: + description: Password is the password for the account. + type: string + user: + description: User is the username for the account. + type: string + required: + - password + - user + type: object + csiDriver: + description: CSIDriver specifies credentials for the + vSphere CSI driver on this vCenter. + properties: + password: + description: Password is the password for the account. + type: string + user: + description: User is the username for the account. + type: string + required: + - password + - user + type: object + diagnostics: + description: Diagnostics specifies credentials for vsphere-problem-detector + on this vCenter. + properties: + password: + description: Password is the password for the account. + type: string + user: + description: User is the username for the account. + type: string + required: + - password + - user + type: object + machineAPI: + description: MachineAPI specifies credentials for machine-api-operator + on this vCenter. + properties: + password: + description: Password is the password for the account. + type: string + user: + description: User is the username for the account. + type: string + required: + - password + - user + type: object + type: object datacenters: description: Datacenter in which VMs are located. items: diff --git a/data/data/manifests/openshift/vsphere-cloud-controller-creds-secret.yaml.template b/data/data/manifests/openshift/vsphere-cloud-controller-creds-secret.yaml.template new file mode 100644 index 00000000000..e9cb24fc9fb --- /dev/null +++ b/data/data/manifests/openshift/vsphere-cloud-controller-creds-secret.yaml.template @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vsphere-creds-cloud-controller + namespace: kube-system +type: Opaque +data: +{{- range .CloudCreds.VSphereComponents.CloudController}} + {{.VCenter}}.username: {{.Base64encodeUsername}} + {{.VCenter}}.password: {{.Base64encodePassword}} +{{- end}} diff --git a/data/data/manifests/openshift/vsphere-csi-driver-creds-secret.yaml.template b/data/data/manifests/openshift/vsphere-csi-driver-creds-secret.yaml.template new file mode 100644 index 00000000000..ed98e43581b --- /dev/null +++ b/data/data/manifests/openshift/vsphere-csi-driver-creds-secret.yaml.template @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vsphere-creds-csi-driver + namespace: kube-system +type: Opaque +data: +{{- range .CloudCreds.VSphereComponents.CSIDriver}} + {{.VCenter}}.username: {{.Base64encodeUsername}} + {{.VCenter}}.password: {{.Base64encodePassword}} +{{- end}} diff --git a/data/data/manifests/openshift/vsphere-diagnostics-creds-secret.yaml.template b/data/data/manifests/openshift/vsphere-diagnostics-creds-secret.yaml.template new file mode 100644 index 00000000000..ced5da525e4 --- /dev/null +++ b/data/data/manifests/openshift/vsphere-diagnostics-creds-secret.yaml.template @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vsphere-creds-diagnostics + namespace: kube-system +type: Opaque +data: +{{- range .CloudCreds.VSphereComponents.Diagnostics}} + {{.VCenter}}.username: {{.Base64encodeUsername}} + {{.VCenter}}.password: {{.Base64encodePassword}} +{{- end}} diff --git a/data/data/manifests/openshift/vsphere-machine-api-creds-secret.yaml.template b/data/data/manifests/openshift/vsphere-machine-api-creds-secret.yaml.template new file mode 100644 index 00000000000..4a94f5e948a --- /dev/null +++ b/data/data/manifests/openshift/vsphere-machine-api-creds-secret.yaml.template @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: vsphere-creds-machine-api + namespace: kube-system +type: Opaque +data: +{{- range .CloudCreds.VSphereComponents.MachineAPI}} + {{.VCenter}}.username: {{.Base64encodeUsername}} + {{.VCenter}}.password: {{.Base64encodePassword}} +{{- end}} diff --git a/pkg/asset/installconfig/installconfig.go b/pkg/asset/installconfig/installconfig.go index 00fd2ce00c4..7312841ccd2 100644 --- a/pkg/asset/installconfig/installconfig.go +++ b/pkg/asset/installconfig/installconfig.go @@ -207,6 +207,12 @@ func (a *InstallConfig) finish(ctx context.Context, filename string) error { a.PowerVS = icpowervs.NewMetadata(a.Config) } if a.Config.VSphere != nil { + // Merge credentials from ~/.vsphere/credentials file if present + if err := a.mergeVSphereCredentialsFromFile(); err != nil { + logrus.Warnf("Failed to load vSphere credentials from file: %v", err) + // Non-fatal - credentials in install-config take precedence anyway + } + a.VSphere = icvsphere.NewMetadata() for _, v := range a.Config.VSphere.VCenters { @@ -284,3 +290,18 @@ func (a *InstallConfig) platformValidation(ctx context.Context) error { } return field.ErrorList{}.ToAggregate() } + +// mergeVSphereCredentialsFromFile loads credentials from ~/.vsphere/credentials +// and merges them with install-config, with install-config taking precedence. +func (a *InstallConfig) mergeVSphereCredentialsFromFile() error { + fileCredentials, err := icvsphere.LoadCredentialsFile() + if err != nil { + return err + } + + if fileCredentials != nil { + icvsphere.MergeCredentials(a.Config.VSphere.VCenters, fileCredentials) + } + + return nil +} diff --git a/pkg/asset/installconfig/vsphere/credentials.go b/pkg/asset/installconfig/vsphere/credentials.go new file mode 100644 index 00000000000..88d28f62607 --- /dev/null +++ b/pkg/asset/installconfig/vsphere/credentials.go @@ -0,0 +1,174 @@ +package vsphere + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/sirupsen/logrus" + ini "gopkg.in/ini.v1" + + "github.com/openshift/installer/pkg/types/vsphere" +) + +const ( + // DefaultCredentialsDir is the default directory for vSphere credentials. + DefaultCredentialsDir = ".vsphere" + // DefaultCredentialsFile is the default filename for vSphere credentials. + DefaultCredentialsFile = "credentials" + // CredentialsFileEnvVar is the environment variable for custom credentials file location. + CredentialsFileEnvVar = "VSPHERE_CREDENTIALS_FILE" +) + +// GetCredentialsFilePath returns the path to the credentials file, checking: +// 1. VSPHERE_CREDENTIALS_FILE environment variable +// 2. ~/.vsphere/credentials default location +func GetCredentialsFilePath() (string, error) { + // Check environment variable first + if path := os.Getenv(CredentialsFileEnvVar); path != "" { + logrus.Debugf("Using credentials file from %s: %s", CredentialsFileEnvVar, path) + return path, nil + } + + // Use default location + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + path := filepath.Join(home, DefaultCredentialsDir, DefaultCredentialsFile) + return path, nil +} + +// LoadCredentialsFile loads and parses the ~/.vsphere/credentials file. +// Returns a map of vCenter server -> component credentials. +// Returns nil if the file doesn't exist (which is not an error). +func LoadCredentialsFile() (map[string]*vsphere.VCenterComponentCredentials, error) { + path, err := GetCredentialsFilePath() + if err != nil { + return nil, err + } + + // Check if file exists + if _, err := os.Stat(path); os.IsNotExist(err) { + logrus.Debugf("Credentials file does not exist at %s, skipping", path) + return nil, nil + } + + // Validate file permissions (must be 0600 or stricter) + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("failed to stat credentials file: %w", err) + } + mode := info.Mode().Perm() + // Check if group or others have any permissions + if mode&0077 != 0 { + return nil, fmt.Errorf("credentials file %s has insecure permissions %o (must be 0600 or stricter)", path, mode) + } + + // Load INI file + cfg, err := ini.Load(path) + if err != nil { + return nil, fmt.Errorf("failed to parse credentials file %s: %w", path, err) + } + + logrus.Infof("Loading vSphere credentials from %s", path) + + credentials := make(map[string]*vsphere.VCenterComponentCredentials) + + // Parse each section (vCenter server) + for _, section := range cfg.Sections() { + sectionName := section.Name() + + // Skip default section + if sectionName == ini.DefaultSection { + continue + } + + // This is a vCenter server section + vcenterCreds := &vsphere.VCenterComponentCredentials{} + + // Parse machine-api credentials + if section.HasKey("machine-api.user") && section.HasKey("machine-api.password") { + vcenterCreds.MachineAPI = &vsphere.VCenterCredential{ + User: section.Key("machine-api.user").String(), + Password: section.Key("machine-api.password").String(), + } + } + + // Parse csi-driver credentials + if section.HasKey("csi-driver.user") && section.HasKey("csi-driver.password") { + vcenterCreds.CSIDriver = &vsphere.VCenterCredential{ + User: section.Key("csi-driver.user").String(), + Password: section.Key("csi-driver.password").String(), + } + } + + // Parse cloud-controller credentials + if section.HasKey("cloud-controller.user") && section.HasKey("cloud-controller.password") { + vcenterCreds.CloudController = &vsphere.VCenterCredential{ + User: section.Key("cloud-controller.user").String(), + Password: section.Key("cloud-controller.password").String(), + } + } + + // Parse diagnostics credentials + if section.HasKey("diagnostics.user") && section.HasKey("diagnostics.password") { + vcenterCreds.Diagnostics = &vsphere.VCenterCredential{ + User: section.Key("diagnostics.user").String(), + Password: section.Key("diagnostics.password").String(), + } + } + + // Only add if at least one component credential is defined + if vcenterCreds.MachineAPI != nil || vcenterCreds.CSIDriver != nil || + vcenterCreds.CloudController != nil || vcenterCreds.Diagnostics != nil { + credentials[sectionName] = vcenterCreds + logrus.Debugf("Loaded component credentials for vCenter %s", sectionName) + } + } + + return credentials, nil +} + +// MergeCredentials merges credentials from file with install-config, with install-config taking precedence. +// This modifies the vcenters slice in-place. +func MergeCredentials(vcenters []vsphere.VCenter, fileCredentials map[string]*vsphere.VCenterComponentCredentials) { + if fileCredentials == nil { + return + } + + for i := range vcenters { + vcenter := &vcenters[i] + + // Check if we have file credentials for this vCenter + fileCreds, exists := fileCredentials[vcenter.Server] + if !exists { + continue + } + + // Only use file credentials if install-config doesn't have component credentials + if vcenter.ComponentCredentials == nil { + vcenter.ComponentCredentials = fileCreds + logrus.Infof("Using component credentials from file for vCenter %s", vcenter.Server) + } else { + // Merge individual components - install-config takes precedence + if vcenter.ComponentCredentials.MachineAPI == nil && fileCreds.MachineAPI != nil { + vcenter.ComponentCredentials.MachineAPI = fileCreds.MachineAPI + logrus.Debugf("Using machine-api credentials from file for vCenter %s", vcenter.Server) + } + if vcenter.ComponentCredentials.CSIDriver == nil && fileCreds.CSIDriver != nil { + vcenter.ComponentCredentials.CSIDriver = fileCreds.CSIDriver + logrus.Debugf("Using csi-driver credentials from file for vCenter %s", vcenter.Server) + } + if vcenter.ComponentCredentials.CloudController == nil && fileCreds.CloudController != nil { + vcenter.ComponentCredentials.CloudController = fileCreds.CloudController + logrus.Debugf("Using cloud-controller credentials from file for vCenter %s", vcenter.Server) + } + if vcenter.ComponentCredentials.Diagnostics == nil && fileCreds.Diagnostics != nil { + vcenter.ComponentCredentials.Diagnostics = fileCreds.Diagnostics + logrus.Debugf("Using diagnostics credentials from file for vCenter %s", vcenter.Server) + } + } + } +} diff --git a/pkg/asset/installconfig/vsphere/credentials_test.go b/pkg/asset/installconfig/vsphere/credentials_test.go new file mode 100644 index 00000000000..24429d675df --- /dev/null +++ b/pkg/asset/installconfig/vsphere/credentials_test.go @@ -0,0 +1,414 @@ +package vsphere + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openshift/installer/pkg/types/vsphere" +) + +func TestLoadCredentialsFile(t *testing.T) { + tests := []struct { + name string + fileContent string + fileMode os.FileMode + expectError bool + expectNil bool + expectedCreds map[string]*vsphere.VCenterComponentCredentials + }{ + { + name: "file does not exist", + expectNil: true, + expectError: false, + }, + { + name: "insecure file permissions", + fileContent: "[vcenter1.example.com]\nuser = admin\n", + fileMode: 0644, + expectError: true, + }, + { + name: "valid single vCenter with all components", + fileContent: `[vcenter1.example.com] +user = installer@vsphere.local +password = installer-pass +machine-api.user = machine-api@vsphere.local +machine-api.password = machine-api-pass +csi-driver.user = csi@vsphere.local +csi-driver.password = csi-pass +cloud-controller.user = ccm@vsphere.local +cloud-controller.password = ccm-pass +diagnostics.user = diagnostics@vsphere.local +diagnostics.password = diagnostics-pass +`, + fileMode: 0600, + expectError: false, + expectedCreds: map[string]*vsphere.VCenterComponentCredentials{ + "vcenter1.example.com": { + MachineAPI: &vsphere.VCenterCredential{ + User: "machine-api@vsphere.local", + Password: "machine-api-pass", + }, + CSIDriver: &vsphere.VCenterCredential{ + User: "csi@vsphere.local", + Password: "csi-pass", + }, + CloudController: &vsphere.VCenterCredential{ + User: "ccm@vsphere.local", + Password: "ccm-pass", + }, + Diagnostics: &vsphere.VCenterCredential{ + User: "diagnostics@vsphere.local", + Password: "diagnostics-pass", + }, + }, + }, + }, + { + name: "multiple vCenters", + fileContent: `[vcenter1.example.com] +machine-api.user = vc1-machine-api@vsphere.local +machine-api.password = vc1-machine-api-pass + +[vcenter2.example.com] +machine-api.user = vc2-machine-api@vsphere.local +machine-api.password = vc2-machine-api-pass +csi-driver.user = vc2-csi@vsphere.local +csi-driver.password = vc2-csi-pass +`, + fileMode: 0600, + expectError: false, + expectedCreds: map[string]*vsphere.VCenterComponentCredentials{ + "vcenter1.example.com": { + MachineAPI: &vsphere.VCenterCredential{ + User: "vc1-machine-api@vsphere.local", + Password: "vc1-machine-api-pass", + }, + }, + "vcenter2.example.com": { + MachineAPI: &vsphere.VCenterCredential{ + User: "vc2-machine-api@vsphere.local", + Password: "vc2-machine-api-pass", + }, + CSIDriver: &vsphere.VCenterCredential{ + User: "vc2-csi@vsphere.local", + Password: "vc2-csi-pass", + }, + }, + }, + }, + { + name: "partial component credentials", + fileContent: `[vcenter1.example.com] +machine-api.user = machine-api@vsphere.local +machine-api.password = machine-api-pass +`, + fileMode: 0600, + expectError: false, + expectedCreds: map[string]*vsphere.VCenterComponentCredentials{ + "vcenter1.example.com": { + MachineAPI: &vsphere.VCenterCredential{ + User: "machine-api@vsphere.local", + Password: "machine-api-pass", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory and file if fileContent is provided + if tt.fileContent != "" { + tmpDir := t.TempDir() + credFile := filepath.Join(tmpDir, "credentials") + + err := os.WriteFile(credFile, []byte(tt.fileContent), tt.fileMode) + require.NoError(t, err) + + // Set environment variable to use this file + t.Setenv(CredentialsFileEnvVar, credFile) + } + + // Load credentials + creds, err := LoadCredentialsFile() + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + + if tt.expectNil { + assert.Nil(t, creds) + return + } + + require.NotNil(t, creds) + assert.Equal(t, len(tt.expectedCreds), len(creds)) + + for vcenter, expectedCompCreds := range tt.expectedCreds { + actualCompCreds, exists := creds[vcenter] + require.True(t, exists, "vCenter %s not found in loaded credentials", vcenter) + + if expectedCompCreds.MachineAPI != nil { + require.NotNil(t, actualCompCreds.MachineAPI) + assert.Equal(t, expectedCompCreds.MachineAPI.User, actualCompCreds.MachineAPI.User) + assert.Equal(t, expectedCompCreds.MachineAPI.Password, actualCompCreds.MachineAPI.Password) + } else { + assert.Nil(t, actualCompCreds.MachineAPI) + } + + if expectedCompCreds.CSIDriver != nil { + require.NotNil(t, actualCompCreds.CSIDriver) + assert.Equal(t, expectedCompCreds.CSIDriver.User, actualCompCreds.CSIDriver.User) + assert.Equal(t, expectedCompCreds.CSIDriver.Password, actualCompCreds.CSIDriver.Password) + } else { + assert.Nil(t, actualCompCreds.CSIDriver) + } + + if expectedCompCreds.CloudController != nil { + require.NotNil(t, actualCompCreds.CloudController) + assert.Equal(t, expectedCompCreds.CloudController.User, actualCompCreds.CloudController.User) + assert.Equal(t, expectedCompCreds.CloudController.Password, actualCompCreds.CloudController.Password) + } else { + assert.Nil(t, actualCompCreds.CloudController) + } + + if expectedCompCreds.Diagnostics != nil { + require.NotNil(t, actualCompCreds.Diagnostics) + assert.Equal(t, expectedCompCreds.Diagnostics.User, actualCompCreds.Diagnostics.User) + assert.Equal(t, expectedCompCreds.Diagnostics.Password, actualCompCreds.Diagnostics.Password) + } else { + assert.Nil(t, actualCompCreds.Diagnostics) + } + } + }) + } +} + +func TestMergeCredentials(t *testing.T) { + tests := []struct { + name string + vcenters []vsphere.VCenter + fileCredentials map[string]*vsphere.VCenterComponentCredentials + expected []vsphere.VCenter + }{ + { + name: "install-config has no component credentials, file has credentials", + vcenters: []vsphere.VCenter{ + { + Server: "vcenter1.example.com", + Username: "admin@vsphere.local", + Password: "admin-pass", + }, + }, + fileCredentials: map[string]*vsphere.VCenterComponentCredentials{ + "vcenter1.example.com": { + MachineAPI: &vsphere.VCenterCredential{ + User: "machine-api@vsphere.local", + Password: "machine-api-pass", + }, + }, + }, + expected: []vsphere.VCenter{ + { + Server: "vcenter1.example.com", + Username: "admin@vsphere.local", + Password: "admin-pass", + ComponentCredentials: &vsphere.VCenterComponentCredentials{ + MachineAPI: &vsphere.VCenterCredential{ + User: "machine-api@vsphere.local", + Password: "machine-api-pass", + }, + }, + }, + }, + }, + { + name: "install-config has component credentials, file has credentials (install-config wins)", + vcenters: []vsphere.VCenter{ + { + Server: "vcenter1.example.com", + Username: "admin@vsphere.local", + Password: "admin-pass", + ComponentCredentials: &vsphere.VCenterComponentCredentials{ + MachineAPI: &vsphere.VCenterCredential{ + User: "config-machine-api@vsphere.local", + Password: "config-machine-api-pass", + }, + }, + }, + }, + fileCredentials: map[string]*vsphere.VCenterComponentCredentials{ + "vcenter1.example.com": { + MachineAPI: &vsphere.VCenterCredential{ + User: "file-machine-api@vsphere.local", + Password: "file-machine-api-pass", + }, + }, + }, + expected: []vsphere.VCenter{ + { + Server: "vcenter1.example.com", + Username: "admin@vsphere.local", + Password: "admin-pass", + ComponentCredentials: &vsphere.VCenterComponentCredentials{ + MachineAPI: &vsphere.VCenterCredential{ + User: "config-machine-api@vsphere.local", + Password: "config-machine-api-pass", + }, + }, + }, + }, + }, + { + name: "partial merge - install-config has some components, file has others", + vcenters: []vsphere.VCenter{ + { + Server: "vcenter1.example.com", + Username: "admin@vsphere.local", + Password: "admin-pass", + ComponentCredentials: &vsphere.VCenterComponentCredentials{ + MachineAPI: &vsphere.VCenterCredential{ + User: "config-machine-api@vsphere.local", + Password: "config-machine-api-pass", + }, + }, + }, + }, + fileCredentials: map[string]*vsphere.VCenterComponentCredentials{ + "vcenter1.example.com": { + CSIDriver: &vsphere.VCenterCredential{ + User: "file-csi@vsphere.local", + Password: "file-csi-pass", + }, + }, + }, + expected: []vsphere.VCenter{ + { + Server: "vcenter1.example.com", + Username: "admin@vsphere.local", + Password: "admin-pass", + ComponentCredentials: &vsphere.VCenterComponentCredentials{ + MachineAPI: &vsphere.VCenterCredential{ + User: "config-machine-api@vsphere.local", + Password: "config-machine-api-pass", + }, + CSIDriver: &vsphere.VCenterCredential{ + User: "file-csi@vsphere.local", + Password: "file-csi-pass", + }, + }, + }, + }, + }, + { + name: "nil file credentials", + vcenters: []vsphere.VCenter{ + { + Server: "vcenter1.example.com", + Username: "admin@vsphere.local", + Password: "admin-pass", + }, + }, + fileCredentials: nil, + expected: []vsphere.VCenter{ + { + Server: "vcenter1.example.com", + Username: "admin@vsphere.local", + Password: "admin-pass", + }, + }, + }, + { + name: "file has credentials for non-existent vCenter", + vcenters: []vsphere.VCenter{ + { + Server: "vcenter1.example.com", + Username: "admin@vsphere.local", + Password: "admin-pass", + }, + }, + fileCredentials: map[string]*vsphere.VCenterComponentCredentials{ + "vcenter2.example.com": { + MachineAPI: &vsphere.VCenterCredential{ + User: "machine-api@vsphere.local", + Password: "machine-api-pass", + }, + }, + }, + expected: []vsphere.VCenter{ + { + Server: "vcenter1.example.com", + Username: "admin@vsphere.local", + Password: "admin-pass", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Make a copy to avoid modifying the test data + vcenters := make([]vsphere.VCenter, len(tt.vcenters)) + copy(vcenters, tt.vcenters) + + // Merge credentials + MergeCredentials(vcenters, tt.fileCredentials) + + // Verify results + require.Equal(t, len(tt.expected), len(vcenters)) + + for i, expected := range tt.expected { + actual := vcenters[i] + assert.Equal(t, expected.Server, actual.Server) + assert.Equal(t, expected.Username, actual.Username) + assert.Equal(t, expected.Password, actual.Password) + + if expected.ComponentCredentials == nil { + assert.Nil(t, actual.ComponentCredentials) + } else { + require.NotNil(t, actual.ComponentCredentials) + + if expected.ComponentCredentials.MachineAPI != nil { + require.NotNil(t, actual.ComponentCredentials.MachineAPI) + assert.Equal(t, expected.ComponentCredentials.MachineAPI.User, actual.ComponentCredentials.MachineAPI.User) + assert.Equal(t, expected.ComponentCredentials.MachineAPI.Password, actual.ComponentCredentials.MachineAPI.Password) + } else { + assert.Nil(t, actual.ComponentCredentials.MachineAPI) + } + + if expected.ComponentCredentials.CSIDriver != nil { + require.NotNil(t, actual.ComponentCredentials.CSIDriver) + assert.Equal(t, expected.ComponentCredentials.CSIDriver.User, actual.ComponentCredentials.CSIDriver.User) + assert.Equal(t, expected.ComponentCredentials.CSIDriver.Password, actual.ComponentCredentials.CSIDriver.Password) + } else { + assert.Nil(t, actual.ComponentCredentials.CSIDriver) + } + + if expected.ComponentCredentials.CloudController != nil { + require.NotNil(t, actual.ComponentCredentials.CloudController) + assert.Equal(t, expected.ComponentCredentials.CloudController.User, actual.ComponentCredentials.CloudController.User) + assert.Equal(t, expected.ComponentCredentials.CloudController.Password, actual.ComponentCredentials.CloudController.Password) + } else { + assert.Nil(t, actual.ComponentCredentials.CloudController) + } + + if expected.ComponentCredentials.Diagnostics != nil { + require.NotNil(t, actual.ComponentCredentials.Diagnostics) + assert.Equal(t, expected.ComponentCredentials.Diagnostics.User, actual.ComponentCredentials.Diagnostics.User) + assert.Equal(t, expected.ComponentCredentials.Diagnostics.Password, actual.ComponentCredentials.Diagnostics.Password) + } else { + assert.Nil(t, actual.ComponentCredentials.Diagnostics) + } + } + } + }) + } +} diff --git a/pkg/asset/manifests/cloudproviderconfig.go b/pkg/asset/manifests/cloudproviderconfig.go index 0c4d027200b..4a55ddf28e5 100644 --- a/pkg/asset/manifests/cloudproviderconfig.go +++ b/pkg/asset/manifests/cloudproviderconfig.go @@ -370,7 +370,19 @@ func (cpc *CloudProviderConfig) Generate(ctx context.Context, dependencies asset } cm.Data[cloudProviderConfigDataKey] = powervsConfig case vspheretypes.Name: - vsphereConfig, err := vspheremanifests.CloudProviderConfigYaml(clusterID.InfraID, installConfig.Config.Platform.VSphere) + // Determine which secret name to use for cloud controller credentials + secretName := "vsphere-creds" // Default legacy secret name + + // Check if any vCenter has component credentials defined + for _, vCenter := range installConfig.Config.Platform.VSphere.VCenters { + if vCenter.ComponentCredentials != nil { + // Use component-specific secret when component credentials are configured + secretName = "vsphere-creds-cloud-controller" + break + } + } + + vsphereConfig, err := vspheremanifests.CloudProviderConfigYaml(clusterID.InfraID, installConfig.Config.Platform.VSphere, secretName) if err != nil { return errors.Wrap(err, "could not create cloud provider config") diff --git a/pkg/asset/manifests/openshift.go b/pkg/asset/manifests/openshift.go index bf6a28ce1f4..ed449c6d346 100644 --- a/pkg/asset/manifests/openshift.go +++ b/pkg/asset/manifests/openshift.go @@ -3,6 +3,7 @@ package manifests import ( "context" "encoding/base64" + "fmt" "os" "path" "path/filepath" @@ -34,6 +35,7 @@ import ( baremetaltypes "github.com/openshift/installer/pkg/types/baremetal" gcptypes "github.com/openshift/installer/pkg/types/gcp" ibmcloudtypes "github.com/openshift/installer/pkg/types/ibmcloud" + nutanixtypes "github.com/openshift/installer/pkg/types/nutanix" openstacktypes "github.com/openshift/installer/pkg/types/openstack" ovirttypes "github.com/openshift/installer/pkg/types/ovirt" powervctypes "github.com/openshift/installer/pkg/types/powervc" @@ -74,6 +76,10 @@ func (o *Openshift) Dependencies() []asset.Asset { &openshift.BaremetalConfig{}, new(rhcos.Image), &openshift.AzureCloudProviderSecret{}, + &openshift.VSphereMachineAPICredsSecret{}, + &openshift.VSphereCSIDriverCredsSecret{}, + &openshift.VSphereCloudControllerCredsSecret{}, + &openshift.VSphereDiagnosticsCredsSecret{}, } } @@ -207,19 +213,34 @@ func (o *Openshift) Generate(ctx context.Context, dependencies asset.Parents) er }, } case vspheretypes.Name: - vsphereCredList := make([]*VSphereCredsSecretData, 0) - + // Check if any vCenter has component credentials defined + hasComponentCredentials := false for _, vCenter := range installConfig.Config.VSphere.VCenters { - vsphereCred := VSphereCredsSecretData{ - VCenter: vCenter.Server, - Base64encodeUsername: base64.StdEncoding.EncodeToString([]byte(vCenter.Username)), - Base64encodePassword: base64.StdEncoding.EncodeToString([]byte(vCenter.Password)), + if vCenter.ComponentCredentials != nil { + hasComponentCredentials = true + break } - vsphereCredList = append(vsphereCredList, &vsphereCred) } - cloudCreds = cloudCredsSecretData{ - VSphere: &vsphereCredList, + if hasComponentCredentials { + // Generate per-component secrets + cloudCreds = generateVSphereComponentSecrets(installConfig.Config.VSphere.VCenters) + } else { + // Legacy mode: generate single shared secret + vsphereCredList := make([]*VSphereCredsSecretData, 0) + + for _, vCenter := range installConfig.Config.VSphere.VCenters { + vsphereCred := VSphereCredsSecretData{ + VCenter: vCenter.Server, + Base64encodeUsername: base64.StdEncoding.EncodeToString([]byte(vCenter.Username)), + Base64encodePassword: base64.StdEncoding.EncodeToString([]byte(vCenter.Password)), + } + vsphereCredList = append(vsphereCredList, &vsphereCred) + } + + cloudCreds = cloudCredsSecretData{ + VSphere: &vsphereCredList, + } } case ovirttypes.Name: conf, err := ovirt.NewConfig() @@ -244,6 +265,26 @@ func (o *Openshift) Generate(ctx context.Context, dependencies asset.Parents) er Base64encodeCABundle: base64.StdEncoding.EncodeToString([]byte(conf.CABundle)), }, } + case nutanixtypes.Name: + // Format credentials as JSON array according to Nutanix format + credentialsData := fmt.Sprintf(`[{ + "type": "basic_auth", + "data": { + "prismCentral": { + "username": "%s", + "password": "%s" + } + } + }]`, + installConfig.Config.Platform.Nutanix.PrismCentral.Username, + installConfig.Config.Platform.Nutanix.PrismCentral.Password, + ) + + cloudCreds = cloudCredsSecretData{ + Nutanix: &NutanixCredsSecretData{ + Base64encodeCredentials: base64.StdEncoding.EncodeToString([]byte(credentialsData)), + }, + } } templateData := &openshiftTemplateData{ @@ -256,20 +297,54 @@ func (o *Openshift) Generate(ctx context.Context, dependencies asset.Parents) er roleCloudCredsSecretReader := &openshift.RoleCloudCredsSecretReader{} baremetalConfig := &openshift.BaremetalConfig{} rhcosImage := new(rhcos.Image) + vsphereMachineAPICredsSecret := &openshift.VSphereMachineAPICredsSecret{} + vsphereCSIDriverCredsSecret := &openshift.VSphereCSIDriverCredsSecret{} + vsphereCloudControllerCredsSecret := &openshift.VSphereCloudControllerCredsSecret{} + vsphereDiagnosticsCredsSecret := &openshift.VSphereDiagnosticsCredsSecret{} dependencies.Get( cloudCredsSecret, kubeadminPasswordSecret, roleCloudCredsSecretReader, baremetalConfig, - rhcosImage) + rhcosImage, + vsphereMachineAPICredsSecret, + vsphereCSIDriverCredsSecret, + vsphereCloudControllerCredsSecret, + vsphereDiagnosticsCredsSecret) assetData := map[string][]byte{ "99_kubeadmin-password-secret.yaml": applyTemplateData(kubeadminPasswordSecret.Files()[0].Data, templateData), } switch platform { - case awstypes.Name, openstacktypes.Name, powervctypes.Name, vspheretypes.Name, azuretypes.Name, gcptypes.Name, ibmcloudtypes.Name, ovirttypes.Name: + case vspheretypes.Name: + // Check if using component credentials + hasComponentCredentials := false + for _, vCenter := range installConfig.Config.VSphere.VCenters { + if vCenter.ComponentCredentials != nil { + hasComponentCredentials = true + break + } + } + + if hasComponentCredentials { + // Render component-specific secrets when using component credentials + if installConfig.Config.CredentialsMode != types.ManualCredentialsMode { + assetData["99_vsphere-creds-machine-api.yaml"] = applyTemplateData(vsphereMachineAPICredsSecret.Files()[0].Data, templateData) + assetData["99_vsphere-creds-csi-driver.yaml"] = applyTemplateData(vsphereCSIDriverCredsSecret.Files()[0].Data, templateData) + assetData["99_vsphere-creds-cloud-controller.yaml"] = applyTemplateData(vsphereCloudControllerCredsSecret.Files()[0].Data, templateData) + assetData["99_vsphere-creds-diagnostics.yaml"] = applyTemplateData(vsphereDiagnosticsCredsSecret.Files()[0].Data, templateData) + } + // Note: Role is not needed for component-specific secrets since CCO doesn't use them + } else { + // Legacy mode: render single shared secret + if installConfig.Config.CredentialsMode != types.ManualCredentialsMode { + assetData["99_cloud-creds-secret.yaml"] = applyTemplateData(cloudCredsSecret.Files()[0].Data, templateData) + } + assetData["99_role-cloud-creds-secret-reader.yaml"] = applyTemplateData(roleCloudCredsSecretReader.Files()[0].Data, templateData) + } + case awstypes.Name, openstacktypes.Name, powervctypes.Name, azuretypes.Name, gcptypes.Name, ibmcloudtypes.Name, ovirttypes.Name: if installConfig.Config.CredentialsMode != types.ManualCredentialsMode { assetData["99_cloud-creds-secret.yaml"] = applyTemplateData(cloudCredsSecret.Files()[0].Data, templateData) } @@ -334,3 +409,81 @@ func (o *Openshift) Load(f asset.FileFetcher) (bool, error) { asset.SortFiles(o.FileList) return len(o.FileList) > 0, nil } + +// generateVSphereComponentSecrets creates per-component credential data for vSphere. +// For each component, it uses component-specific credentials if available, +// otherwise falls back to the main vCenter credentials. +func generateVSphereComponentSecrets(vcenters []vspheretypes.VCenter) cloudCredsSecretData { + // Create separate credential lists for each component + machineAPICreds := make([]*VSphereCredsSecretData, 0) + csiDriverCreds := make([]*VSphereCredsSecretData, 0) + cloudControllerCreds := make([]*VSphereCredsSecretData, 0) + diagnosticsCreds := make([]*VSphereCredsSecretData, 0) + + for _, vcenter := range vcenters { + // Fallback to main credentials if component credentials not specified + machineAPIUser := vcenter.Username + machineAPIPassword := vcenter.Password + csiDriverUser := vcenter.Username + csiDriverPassword := vcenter.Password + cloudControllerUser := vcenter.Username + cloudControllerPassword := vcenter.Password + diagnosticsUser := vcenter.Username + diagnosticsPassword := vcenter.Password + + // Override with component-specific credentials if provided + if vcenter.ComponentCredentials != nil { + if vcenter.ComponentCredentials.MachineAPI != nil { + machineAPIUser = vcenter.ComponentCredentials.MachineAPI.User + machineAPIPassword = vcenter.ComponentCredentials.MachineAPI.Password + } + if vcenter.ComponentCredentials.CSIDriver != nil { + csiDriverUser = vcenter.ComponentCredentials.CSIDriver.User + csiDriverPassword = vcenter.ComponentCredentials.CSIDriver.Password + } + if vcenter.ComponentCredentials.CloudController != nil { + cloudControllerUser = vcenter.ComponentCredentials.CloudController.User + cloudControllerPassword = vcenter.ComponentCredentials.CloudController.Password + } + if vcenter.ComponentCredentials.Diagnostics != nil { + diagnosticsUser = vcenter.ComponentCredentials.Diagnostics.User + diagnosticsPassword = vcenter.ComponentCredentials.Diagnostics.Password + } + } + + // Create credential entries for each component + machineAPICreds = append(machineAPICreds, &VSphereCredsSecretData{ + VCenter: vcenter.Server, + Base64encodeUsername: base64.StdEncoding.EncodeToString([]byte(machineAPIUser)), + Base64encodePassword: base64.StdEncoding.EncodeToString([]byte(machineAPIPassword)), + }) + + csiDriverCreds = append(csiDriverCreds, &VSphereCredsSecretData{ + VCenter: vcenter.Server, + Base64encodeUsername: base64.StdEncoding.EncodeToString([]byte(csiDriverUser)), + Base64encodePassword: base64.StdEncoding.EncodeToString([]byte(csiDriverPassword)), + }) + + cloudControllerCreds = append(cloudControllerCreds, &VSphereCredsSecretData{ + VCenter: vcenter.Server, + Base64encodeUsername: base64.StdEncoding.EncodeToString([]byte(cloudControllerUser)), + Base64encodePassword: base64.StdEncoding.EncodeToString([]byte(cloudControllerPassword)), + }) + + diagnosticsCreds = append(diagnosticsCreds, &VSphereCredsSecretData{ + VCenter: vcenter.Server, + Base64encodeUsername: base64.StdEncoding.EncodeToString([]byte(diagnosticsUser)), + Base64encodePassword: base64.StdEncoding.EncodeToString([]byte(diagnosticsPassword)), + }) + } + + // Return component-specific credentials + return cloudCredsSecretData{ + VSphereComponents: &VSphereComponentCredsSecretData{ + MachineAPI: &machineAPICreds, + CSIDriver: &csiDriverCreds, + CloudController: &cloudControllerCreds, + Diagnostics: &diagnosticsCreds, + }, + } +} diff --git a/pkg/asset/manifests/template.go b/pkg/asset/manifests/template.go index 51179f1a7c9..df6155d5896 100644 --- a/pkg/asset/manifests/template.go +++ b/pkg/asset/manifests/template.go @@ -55,14 +55,29 @@ type OvirtCredsSecretData struct { Base64encodeCABundle string } +// NutanixCredsSecretData holds encoded credentials and is used to generate cloud-creds secret +type NutanixCredsSecretData struct { + Base64encodeCredentials string +} + +// VSphereComponentCredsSecretData holds per-component credentials for vSphere +type VSphereComponentCredsSecretData struct { + MachineAPI *[]*VSphereCredsSecretData + CSIDriver *[]*VSphereCredsSecretData + CloudController *[]*VSphereCredsSecretData + Diagnostics *[]*VSphereCredsSecretData +} + type cloudCredsSecretData struct { - AWS *AwsCredsSecretData - Azure *AzureCredsSecretData - GCP *GCPCredsSecretData - IBMCloud *IBMCloudCredsSecretData - OpenStack *OpenStackCredsSecretData - VSphere *[]*VSphereCredsSecretData - Ovirt *OvirtCredsSecretData + AWS *AwsCredsSecretData + Azure *AzureCredsSecretData + GCP *GCPCredsSecretData + IBMCloud *IBMCloudCredsSecretData + OpenStack *OpenStackCredsSecretData + VSphere *[]*VSphereCredsSecretData + VSphereComponents *VSphereComponentCredsSecretData + Ovirt *OvirtCredsSecretData + Nutanix *NutanixCredsSecretData } type bootkubeTemplateData struct { diff --git a/pkg/asset/manifests/vsphere/cloudproviderconfig.go b/pkg/asset/manifests/vsphere/cloudproviderconfig.go index 21a793ee654..db1d4a286db 100644 --- a/pkg/asset/manifests/vsphere/cloudproviderconfig.go +++ b/pkg/asset/manifests/vsphere/cloudproviderconfig.go @@ -23,7 +23,8 @@ func printIfNotEmpty(buf *bytes.Buffer, k, v string) { } // CloudProviderConfigYaml generates the yaml out of tree cloud provider config for the vSphere platform. -func CloudProviderConfigYaml(infraID string, p *vspheretypes.Platform) (string, error) { +// secretName specifies which secret the cloud controller should use for credentials. +func CloudProviderConfigYaml(infraID string, p *vspheretypes.Platform, secretName string) (string, error) { vCenters := make(map[string]*cloudconfig.VirtualCenterConfigYAML) for _, vCenter := range p.VCenters { @@ -42,7 +43,7 @@ func CloudProviderConfigYaml(infraID string, p *vspheretypes.Platform) (string, cloudProviderConfig := cloudconfig.CommonConfigYAML{ Global: cloudconfig.GlobalYAML{ - SecretName: "vsphere-creds", + SecretName: secretName, SecretNamespace: "kube-system", InsecureFlag: true, }, @@ -66,11 +67,12 @@ func CloudProviderConfigYaml(infraID string, p *vspheretypes.Platform) (string, // CloudProviderConfigIni generates the multi-zone ini cloud provider config // for the vSphere platform. folderPath is the absolute path to the VM folder that will be // used for installation. p is the vSphere platform struct. -func CloudProviderConfigIni(infraID string, p *vspheretypes.Platform) (string, error) { +// secretName specifies which secret the cloud controller should use for credentials. +func CloudProviderConfigIni(infraID string, p *vspheretypes.Platform, secretName string) (string, error) { buf := new(bytes.Buffer) fmt.Fprintln(buf, "[Global]") - printIfNotEmpty(buf, "secret-name", "vsphere-creds") + printIfNotEmpty(buf, "secret-name", secretName) printIfNotEmpty(buf, "secret-namespace", "kube-system") printIfNotEmpty(buf, "insecure-flag", "1") fmt.Fprintln(buf, "") diff --git a/pkg/asset/manifests/vsphere/cloudproviderconfig_test.go b/pkg/asset/manifests/vsphere/cloudproviderconfig_test.go index 63770eb17e9..7e89d8020f4 100644 --- a/pkg/asset/manifests/vsphere/cloudproviderconfig_test.go +++ b/pkg/asset/manifests/vsphere/cloudproviderconfig_test.go @@ -139,13 +139,15 @@ func TestCloudProviderConfig(t *testing.T) { cases := []struct { name string platform *vsphere.Platform - cloudProviderFunc func(string, *vsphere.Platform) (string, error) + cloudProviderFunc func(string, *vsphere.Platform, string) (string, error) + secretName string expectedCloudConfig string }{ { name: "valid intree cloud provider config", platform: validPlatform(), cloudProviderFunc: CloudProviderConfigIni, + secretName: "vsphere-creds", expectedCloudConfig: expectedIniConfig + expectIniLabelsSection, }, { @@ -159,6 +161,7 @@ func TestCloudProviderConfig(t *testing.T) { return p }(), cloudProviderFunc: CloudProviderConfigIni, + secretName: "vsphere-creds", expectedCloudConfig: func() string { // only a single datacenter would be provided to the datacenters ini := strings.ReplaceAll(expectedIniConfig, ",test-datacenter2", "") @@ -169,6 +172,7 @@ func TestCloudProviderConfig(t *testing.T) { name: "valid out of tree yaml cloud provider config", platform: validPlatform(), cloudProviderFunc: CloudProviderConfigYaml, + secretName: "vsphere-creds", expectedCloudConfig: expectedYamlConfig, }, } @@ -177,7 +181,7 @@ func TestCloudProviderConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { var cloudConfig string var err error - cloudConfig, err = tc.cloudProviderFunc("infraID", tc.platform) + cloudConfig, err = tc.cloudProviderFunc("infraID", tc.platform, tc.secretName) assert.NoError(t, err, "failed to create cloud provider config") assert.Equal(t, tc.expectedCloudConfig, cloudConfig, "unexpected cloud provider config") }) diff --git a/pkg/asset/templates/content/openshift/vsphere-cloud-controller-creds-secret.go b/pkg/asset/templates/content/openshift/vsphere-cloud-controller-creds-secret.go new file mode 100644 index 00000000000..eca4a1b9a84 --- /dev/null +++ b/pkg/asset/templates/content/openshift/vsphere-cloud-controller-creds-secret.go @@ -0,0 +1,65 @@ +package openshift + +import ( + "context" + "os" + "path/filepath" + + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/templates/content" +) + +const ( + vsphereCloudControllerCredsSecretFileName = "vsphere-cloud-controller-creds-secret.yaml.template" +) + +var _ asset.WritableAsset = (*VSphereCloudControllerCredsSecret)(nil) + +// VSphereCloudControllerCredsSecret is the constant to represent contents of corresponding yaml file +type VSphereCloudControllerCredsSecret struct { + FileList []*asset.File +} + +// Dependencies returns all of the dependencies directly needed by the asset +func (t *VSphereCloudControllerCredsSecret) Dependencies() []asset.Asset { + return []asset.Asset{} +} + +// Name returns the human-friendly name of the asset. +func (t *VSphereCloudControllerCredsSecret) Name() string { + return "VSphereCloudControllerCredsSecret" +} + +// Generate generates the actual files by this asset +func (t *VSphereCloudControllerCredsSecret) Generate(_ context.Context, parents asset.Parents) error { + fileName := vsphereCloudControllerCredsSecretFileName + data, err := content.GetOpenshiftTemplate(fileName) + if err != nil { + return err + } + t.FileList = []*asset.File{ + { + Filename: filepath.Join(content.TemplateDir, fileName), + Data: []byte(data), + }, + } + return nil +} + +// Files returns the files generated by the asset. +func (t *VSphereCloudControllerCredsSecret) Files() []*asset.File { + return t.FileList +} + +// Load returns the asset from disk. +func (t *VSphereCloudControllerCredsSecret) Load(f asset.FileFetcher) (bool, error) { + file, err := f.FetchByName(filepath.Join(content.TemplateDir, vsphereCloudControllerCredsSecretFileName)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + t.FileList = []*asset.File{file} + return true, nil +} diff --git a/pkg/asset/templates/content/openshift/vsphere-csi-driver-creds-secret.go b/pkg/asset/templates/content/openshift/vsphere-csi-driver-creds-secret.go new file mode 100644 index 00000000000..3091dae0ca9 --- /dev/null +++ b/pkg/asset/templates/content/openshift/vsphere-csi-driver-creds-secret.go @@ -0,0 +1,65 @@ +package openshift + +import ( + "context" + "os" + "path/filepath" + + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/templates/content" +) + +const ( + vsphereCSIDriverCredsSecretFileName = "vsphere-csi-driver-creds-secret.yaml.template" +) + +var _ asset.WritableAsset = (*VSphereCSIDriverCredsSecret)(nil) + +// VSphereCSIDriverCredsSecret is the constant to represent contents of corresponding yaml file +type VSphereCSIDriverCredsSecret struct { + FileList []*asset.File +} + +// Dependencies returns all of the dependencies directly needed by the asset +func (t *VSphereCSIDriverCredsSecret) Dependencies() []asset.Asset { + return []asset.Asset{} +} + +// Name returns the human-friendly name of the asset. +func (t *VSphereCSIDriverCredsSecret) Name() string { + return "VSphereCSIDriverCredsSecret" +} + +// Generate generates the actual files by this asset +func (t *VSphereCSIDriverCredsSecret) Generate(_ context.Context, parents asset.Parents) error { + fileName := vsphereCSIDriverCredsSecretFileName + data, err := content.GetOpenshiftTemplate(fileName) + if err != nil { + return err + } + t.FileList = []*asset.File{ + { + Filename: filepath.Join(content.TemplateDir, fileName), + Data: []byte(data), + }, + } + return nil +} + +// Files returns the files generated by the asset. +func (t *VSphereCSIDriverCredsSecret) Files() []*asset.File { + return t.FileList +} + +// Load returns the asset from disk. +func (t *VSphereCSIDriverCredsSecret) Load(f asset.FileFetcher) (bool, error) { + file, err := f.FetchByName(filepath.Join(content.TemplateDir, vsphereCSIDriverCredsSecretFileName)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + t.FileList = []*asset.File{file} + return true, nil +} diff --git a/pkg/asset/templates/content/openshift/vsphere-diagnostics-creds-secret.go b/pkg/asset/templates/content/openshift/vsphere-diagnostics-creds-secret.go new file mode 100644 index 00000000000..b2fba150682 --- /dev/null +++ b/pkg/asset/templates/content/openshift/vsphere-diagnostics-creds-secret.go @@ -0,0 +1,65 @@ +package openshift + +import ( + "context" + "os" + "path/filepath" + + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/templates/content" +) + +const ( + vsphereDiagnosticsCredsSecretFileName = "vsphere-diagnostics-creds-secret.yaml.template" +) + +var _ asset.WritableAsset = (*VSphereDiagnosticsCredsSecret)(nil) + +// VSphereDiagnosticsCredsSecret is the constant to represent contents of corresponding yaml file +type VSphereDiagnosticsCredsSecret struct { + FileList []*asset.File +} + +// Dependencies returns all of the dependencies directly needed by the asset +func (t *VSphereDiagnosticsCredsSecret) Dependencies() []asset.Asset { + return []asset.Asset{} +} + +// Name returns the human-friendly name of the asset. +func (t *VSphereDiagnosticsCredsSecret) Name() string { + return "VSphereDiagnosticsCredsSecret" +} + +// Generate generates the actual files by this asset +func (t *VSphereDiagnosticsCredsSecret) Generate(_ context.Context, parents asset.Parents) error { + fileName := vsphereDiagnosticsCredsSecretFileName + data, err := content.GetOpenshiftTemplate(fileName) + if err != nil { + return err + } + t.FileList = []*asset.File{ + { + Filename: filepath.Join(content.TemplateDir, fileName), + Data: []byte(data), + }, + } + return nil +} + +// Files returns the files generated by the asset. +func (t *VSphereDiagnosticsCredsSecret) Files() []*asset.File { + return t.FileList +} + +// Load returns the asset from disk. +func (t *VSphereDiagnosticsCredsSecret) Load(f asset.FileFetcher) (bool, error) { + file, err := f.FetchByName(filepath.Join(content.TemplateDir, vsphereDiagnosticsCredsSecretFileName)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + t.FileList = []*asset.File{file} + return true, nil +} diff --git a/pkg/asset/templates/content/openshift/vsphere-machine-api-creds-secret.go b/pkg/asset/templates/content/openshift/vsphere-machine-api-creds-secret.go new file mode 100644 index 00000000000..20509ce0399 --- /dev/null +++ b/pkg/asset/templates/content/openshift/vsphere-machine-api-creds-secret.go @@ -0,0 +1,65 @@ +package openshift + +import ( + "context" + "os" + "path/filepath" + + "github.com/openshift/installer/pkg/asset" + "github.com/openshift/installer/pkg/asset/templates/content" +) + +const ( + vsphereMachineAPICredsSecretFileName = "vsphere-machine-api-creds-secret.yaml.template" +) + +var _ asset.WritableAsset = (*VSphereMachineAPICredsSecret)(nil) + +// VSphereMachineAPICredsSecret is the constant to represent contents of corresponding yaml file +type VSphereMachineAPICredsSecret struct { + FileList []*asset.File +} + +// Dependencies returns all of the dependencies directly needed by the asset +func (t *VSphereMachineAPICredsSecret) Dependencies() []asset.Asset { + return []asset.Asset{} +} + +// Name returns the human-friendly name of the asset. +func (t *VSphereMachineAPICredsSecret) Name() string { + return "VSphereMachineAPICredsSecret" +} + +// Generate generates the actual files by this asset +func (t *VSphereMachineAPICredsSecret) Generate(_ context.Context, parents asset.Parents) error { + fileName := vsphereMachineAPICredsSecretFileName + data, err := content.GetOpenshiftTemplate(fileName) + if err != nil { + return err + } + t.FileList = []*asset.File{ + { + Filename: filepath.Join(content.TemplateDir, fileName), + Data: []byte(data), + }, + } + return nil +} + +// Files returns the files generated by the asset. +func (t *VSphereMachineAPICredsSecret) Files() []*asset.File { + return t.FileList +} + +// Load returns the asset from disk. +func (t *VSphereMachineAPICredsSecret) Load(f asset.FileFetcher) (bool, error) { + file, err := f.FetchByName(filepath.Join(content.TemplateDir, vsphereMachineAPICredsSecretFileName)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + t.FileList = []*asset.File{file} + return true, nil +} diff --git a/pkg/types/vsphere/platform.go b/pkg/types/vsphere/platform.go index f3872746196..c7763fcba4d 100644 --- a/pkg/types/vsphere/platform.go +++ b/pkg/types/vsphere/platform.go @@ -311,6 +311,44 @@ type VCenter struct { // +kubebuilder:validation:Required // +kubebuilder:validation:MinItems=1 Datacenters []string `json:"datacenters"` + + // ComponentCredentials specifies per-component credentials for this vCenter. + // If not specified, the main Username/Password is used for all components. + // This enables least-privilege security by allowing separate accounts + // for installer, machine-api, CSI driver, cloud controller, and diagnostics. + // +optional + ComponentCredentials *VCenterComponentCredentials `json:"componentCredentials,omitempty"` +} + +// VCenterComponentCredentials defines per-component credentials for a single vCenter. +// This allows separate accounts for each OpenShift component to support least-privilege security. +type VCenterComponentCredentials struct { + // MachineAPI specifies credentials for machine-api-operator on this vCenter. + // +optional + MachineAPI *VCenterCredential `json:"machineAPI,omitempty"` + + // CSIDriver specifies credentials for the vSphere CSI driver on this vCenter. + // +optional + CSIDriver *VCenterCredential `json:"csiDriver,omitempty"` + + // CloudController specifies credentials for the cloud controller manager on this vCenter. + // +optional + CloudController *VCenterCredential `json:"cloudController,omitempty"` + + // Diagnostics specifies credentials for vsphere-problem-detector on this vCenter. + // +optional + Diagnostics *VCenterCredential `json:"diagnostics,omitempty"` +} + +// VCenterCredential stores username and password for a vCenter account. +type VCenterCredential struct { + // User is the username for the account. + // +kubebuilder:validation:Required + User string `json:"user"` + + // Password is the password for the account. + // +kubebuilder:validation:Required + Password string `json:"password"` } // Host defines host VMs to generate as part of the installation. diff --git a/pkg/types/vsphere/validation/platform.go b/pkg/types/vsphere/validation/platform.go index 910988e7cdd..6b150826a33 100644 --- a/pkg/types/vsphere/validation/platform.go +++ b/pkg/types/vsphere/validation/platform.go @@ -151,7 +151,62 @@ func validateVCenters(p *vsphere.Platform, fldPath *field.Path) field.ErrorList if len(vCenter.Datacenters) == 0 { allErrs = append(allErrs, field.Required(fldPath.Index(index).Child("datacenters"), "must specify at least one datacenter")) } + + // Validate component credentials if provided + if vCenter.ComponentCredentials != nil { + allErrs = append(allErrs, validateComponentCredentials( + vCenter.ComponentCredentials, + fldPath.Index(index).Child("componentCredentials"), + )...) + } + } + return allErrs +} + +// validateComponentCredentials validates per-component credential fields. +func validateComponentCredentials(creds *vsphere.VCenterComponentCredentials, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + // Validate machine-api credentials + if creds.MachineAPI != nil { + if len(creds.MachineAPI.User) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("machineAPI", "user"), "must specify the username")) + } + if len(creds.MachineAPI.Password) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("machineAPI", "password"), "must specify the password")) + } } + + // Validate csi-driver credentials + if creds.CSIDriver != nil { + if len(creds.CSIDriver.User) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("csiDriver", "user"), "must specify the username")) + } + if len(creds.CSIDriver.Password) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("csiDriver", "password"), "must specify the password")) + } + } + + // Validate cloud-controller credentials + if creds.CloudController != nil { + if len(creds.CloudController.User) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("cloudController", "user"), "must specify the username")) + } + if len(creds.CloudController.Password) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("cloudController", "password"), "must specify the password")) + } + } + + // Validate diagnostics credentials + if creds.Diagnostics != nil { + if len(creds.Diagnostics.User) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("diagnostics", "user"), "must specify the username")) + } + if len(creds.Diagnostics.Password) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("diagnostics", "password"), "must specify the password")) + } + } + return allErrs } diff --git a/pkg/types/vsphere/zz_generated.deepcopy.go b/pkg/types/vsphere/zz_generated.deepcopy.go index 7f529e47620..52c5029eb0d 100644 --- a/pkg/types/vsphere/zz_generated.deepcopy.go +++ b/pkg/types/vsphere/zz_generated.deepcopy.go @@ -253,6 +253,11 @@ func (in *VCenter) DeepCopyInto(out *VCenter) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.ComponentCredentials != nil { + in, out := &in.ComponentCredentials, &out.ComponentCredentials + *out = new(VCenterComponentCredentials) + (*in).DeepCopyInto(*out) + } return } @@ -266,6 +271,58 @@ func (in *VCenter) DeepCopy() *VCenter { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VCenterComponentCredentials) DeepCopyInto(out *VCenterComponentCredentials) { + *out = *in + if in.MachineAPI != nil { + in, out := &in.MachineAPI, &out.MachineAPI + *out = new(VCenterCredential) + **out = **in + } + if in.CSIDriver != nil { + in, out := &in.CSIDriver, &out.CSIDriver + *out = new(VCenterCredential) + **out = **in + } + if in.CloudController != nil { + in, out := &in.CloudController, &out.CloudController + *out = new(VCenterCredential) + **out = **in + } + if in.Diagnostics != nil { + in, out := &in.Diagnostics, &out.Diagnostics + *out = new(VCenterCredential) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VCenterComponentCredentials. +func (in *VCenterComponentCredentials) DeepCopy() *VCenterComponentCredentials { + if in == nil { + return nil + } + out := new(VCenterComponentCredentials) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VCenterCredential) DeepCopyInto(out *VCenterCredential) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VCenterCredential. +func (in *VCenterCredential) DeepCopy() *VCenterCredential { + if in == nil { + return nil + } + out := new(VCenterCredential) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VCenters) DeepCopyInto(out *VCenters) { *out = *in