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
92 changes: 91 additions & 1 deletion pkg/vsphere/actuator/actuator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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: <server>.username, <server>.password (from install-config.yaml)
// We need to create per-vCenter credentials: <vcenter-fqdn>.username, <vcenter-fqdn>.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)
Expand Down
73 changes: 73 additions & 0 deletions pkg/vsphere/actuator/topology.go
Original file line number Diff line number Diff line change
@@ -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
}
157 changes: 157 additions & 0 deletions pkg/vsphere/actuator/topology_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading