diff --git a/data/data/install.openshift.io_installconfigs.yaml b/data/data/install.openshift.io_installconfigs.yaml index 37c2e9c4021..fdc503f2a72 100644 --- a/data/data/install.openshift.io_installconfigs.yaml +++ b/data/data/install.openshift.io_installconfigs.yaml @@ -6640,6 +6640,12 @@ spec: description: Region specifies the GCP region where the cluster will be created. type: string + universeDomain: + description: |- + UniverseDomain is the Google Cloud universe domain for the cluster. + When no value is set, GCP APIs use a default value: googleapis.com. + Universe Domain may be required to configure Sovereign Cloud environments. + type: string userLabels: description: |- userLabels has additional keys and values that the installer will add as diff --git a/pkg/asset/installconfig/gcp/services.go b/pkg/asset/installconfig/gcp/services.go index 38185f135e6..3354bf037bd 100644 --- a/pkg/asset/installconfig/gcp/services.go +++ b/pkg/asset/installconfig/gcp/services.go @@ -12,6 +12,8 @@ import ( "google.golang.org/api/iam/v1" "google.golang.org/api/option" serviceusage "google.golang.org/api/serviceusage/v1beta1" + + gcptypes "github.com/openshift/installer/pkg/types/gcp" ) // ServiceNameGCP is the name of the GCP Service. @@ -69,6 +71,17 @@ func getOptions(ctx context.Context) ([]option.ClientOption, error) { options := []option.ClientOption{ option.WithCredentials(ssn.Credentials), } + + // Explicitly set universe domain from credentials if present + // This is required for sovereign clouds which use a different universe domain + universeDomain, err := ssn.Credentials.GetUniverseDomain() + if err != nil { + return nil, fmt.Errorf("failed to get universe domain from credentials: %w", err) + } + if gcptypes.IsNonDefaultUniverseDomain(universeDomain) { + options = append(options, option.WithUniverseDomain(universeDomain)) + } + return options, nil } diff --git a/pkg/asset/installconfig/gcp/session.go b/pkg/asset/installconfig/gcp/session.go index 431e99fcd70..6aa3aa42484 100644 --- a/pkg/asset/installconfig/gcp/session.go +++ b/pkg/asset/installconfig/gcp/session.go @@ -182,7 +182,13 @@ type contentLoader struct { } func (f *contentLoader) Load(ctx context.Context) (*googleoauth.Credentials, error) { - return googleoauth.CredentialsFromJSON(ctx, []byte(f.content), compute.CloudPlatformScope) + // Use CredentialsFromJSONWithParams to ensure universe domain from credentials is applied. + // This function automatically extracts the universe domain from the credentials JSON. + // Note: CredentialsFromJSONWithTypeAndParams would be preferred but is not available + // in the current vendored version of golang.org/x/oauth2/google. + return googleoauth.CredentialsFromJSONWithParams(ctx, []byte(f.content), googleoauth.CredentialsParams{ + Scopes: []string{compute.CloudPlatformScope}, + }) } func (f *contentLoader) String() string { @@ -196,7 +202,10 @@ func (f *contentLoader) Content() string { type cliLoader struct{} func (c *cliLoader) Load(ctx context.Context) (*googleoauth.Credentials, error) { - return googleoauth.FindDefaultCredentials(ctx, compute.CloudPlatformScope) + // Use FindDefaultCredentialsWithParams to ensure universe domain from credentials is applied + return googleoauth.FindDefaultCredentialsWithParams(ctx, googleoauth.CredentialsParams{ + Scopes: []string{compute.CloudPlatformScope}, + }) } func (c *cliLoader) String() string { diff --git a/pkg/asset/installconfig/gcp/validation.go b/pkg/asset/installconfig/gcp/validation.go index 12c268b9a17..204f8968d12 100644 --- a/pkg/asset/installconfig/gcp/validation.go +++ b/pkg/asset/installconfig/gcp/validation.go @@ -201,8 +201,14 @@ func validateServiceAccountPresent(client API, ic *types.InstallConfig) field.Er return allErrs } -// DefaultInstanceTypeForArch returns the appropriate instance type based on the target architecture. -func DefaultInstanceTypeForArch(arch types.Architecture) string { +// DefaultInstanceTypeForArch returns the appropriate instance type based on project ID, region, and architecture. +// The cloud environment is automatically detected from the project ID and region. +func DefaultInstanceTypeForArch(projectID, region string, arch types.Architecture) string { + if cloudEnv := gcp.GetCloudEnvironment(projectID); cloudEnv == gcp.CloudEnvironmentSovereign { + // c3-standard-4 is the default for sovereign clouds + // Note: ARM64 is not currently supported in sovereign clouds + return "c3-standard-4" + } if arch == types.ArchitectureARM64 { return "t2a-standard-4" } @@ -232,7 +238,7 @@ func validateInstanceTypes(client API, ic *types.InstallConfig) field.ErrorList if ic.GCP.DefaultMachinePlatform.DiskType != "" { defaultDiskType = ic.GCP.DefaultMachinePlatform.DiskType } else { - defaultDiskType = gcp.DefaultDiskTypeForInstance(defaultInstanceType) + defaultDiskType = gcp.DefaultDiskTypeForInstanceAndProjectID(defaultInstanceType, ic.GCP.ProjectID, ic.GCP.Region) } if ic.GCP.DefaultMachinePlatform.OnHostMaintenance != "" { @@ -270,7 +276,7 @@ func validateInstanceTypes(client API, ic *types.InstallConfig) field.ErrorList if ic.ControlPlane != nil { arch = string(ic.ControlPlane.Architecture) if instanceType == "" { - instanceType = DefaultInstanceTypeForArch(ic.ControlPlane.Architecture) + instanceType = DefaultInstanceTypeForArch(ic.GCP.ProjectID, ic.GCP.Region, ic.ControlPlane.Architecture) } if ic.ControlPlane.Platform.GCP != nil { if ic.ControlPlane.Platform.GCP.InstanceType != "" { @@ -291,7 +297,7 @@ func validateInstanceTypes(client API, ic *types.InstallConfig) field.ErrorList fmt.Sprintf("instance type %s requires a disk type to be set", instanceType), )) } - cpDiskType = gcp.DefaultDiskTypeForInstance(instanceType) + cpDiskType = gcp.DefaultDiskTypeForInstanceAndProjectID(instanceType, ic.GCP.ProjectID, ic.GCP.Region) } if ic.ControlPlane.Platform.GCP.OnHostMaintenance != "" { cpOnHostMaintenance = ic.ControlPlane.Platform.GCP.OnHostMaintenance @@ -334,7 +340,7 @@ func validateInstanceTypes(client API, ic *types.InstallConfig) field.ErrorList onHostMaintenance := defaultOnHostMaintenance confidentialCompute := defaultConfidentialCompute if instanceType == "" { - instanceType = DefaultInstanceTypeForArch(compute.Architecture) + instanceType = DefaultInstanceTypeForArch(ic.GCP.ProjectID, ic.GCP.Region, compute.Architecture) } if diskType == "" { diskType = gcp.PDSSD @@ -365,7 +371,7 @@ func validateInstanceTypes(client API, ic *types.InstallConfig) field.ErrorList fmt.Sprintf("instance type %s requires a disk type to be set", instanceType), )) } - diskType = gcp.DefaultDiskTypeForInstance(instanceType) + diskType = gcp.DefaultDiskTypeForInstanceAndProjectID(instanceType, ic.GCP.ProjectID, ic.GCP.Region) } } diff --git a/pkg/asset/machines/clusterapi.go b/pkg/asset/machines/clusterapi.go index 4a2b0f8a1ab..3a4b04102bf 100644 --- a/pkg/asset/machines/clusterapi.go +++ b/pkg/asset/machines/clusterapi.go @@ -326,7 +326,7 @@ func (c *ClusterAPI) Generate(ctx context.Context, dependencies asset.Parents) e c.FileList = append(c.FileList, azureMachines...) case gcptypes.Name: // Generate GCP master machines using ControPlane machinepool - mpool := defaultGCPMachinePoolPlatform(pool.Architecture) + mpool := defaultGCPMachinePoolPlatform(pool.Architecture, ic.Platform.GCP.ProjectID, ic.Platform.GCP.Region) mpool.Set(ic.Platform.GCP.DefaultMachinePlatform) mpool.Set(pool.Platform.GCP) if len(mpool.Zones) == 0 { diff --git a/pkg/asset/machines/master.go b/pkg/asset/machines/master.go index 9a46ef858f3..6bb68e8f077 100644 --- a/pkg/asset/machines/master.go +++ b/pkg/asset/machines/master.go @@ -278,7 +278,7 @@ func (m *Master) Generate(ctx context.Context, dependencies asset.Parents) error } aws.ConfigMasters(machines, controlPlaneMachineSet, clusterID.InfraID, ic.Publish) case gcptypes.Name: - mpool := defaultGCPMachinePoolPlatform(pool.Architecture) + mpool := defaultGCPMachinePoolPlatform(pool.Architecture, ic.Platform.GCP.ProjectID, ic.Platform.GCP.Region) mpool.Set(ic.Platform.GCP.DefaultMachinePlatform) mpool.Set(pool.Platform.GCP) if len(mpool.Zones) == 0 { diff --git a/pkg/asset/machines/worker.go b/pkg/asset/machines/worker.go index 57b2432f39a..58701eba348 100644 --- a/pkg/asset/machines/worker.go +++ b/pkg/asset/machines/worker.go @@ -141,13 +141,13 @@ func defaultAzureMachinePoolPlatform(env azuretypes.CloudEnvironment) azuretypes } } -func defaultGCPMachinePoolPlatform(arch types.Architecture) gcptypes.MachinePool { - instanceType := icgcp.DefaultInstanceTypeForArch(arch) +func defaultGCPMachinePoolPlatform(arch types.Architecture, projectID, region string) gcptypes.MachinePool { + instanceType := icgcp.DefaultInstanceTypeForArch(projectID, region, arch) return gcptypes.MachinePool{ InstanceType: instanceType, OSDisk: gcptypes.OSDisk{ DiskSizeGB: powerOfTwoRootVolumeSize, - DiskType: gcptypes.DefaultDiskTypeForInstance(instanceType), + DiskType: gcptypes.DefaultDiskTypeForInstanceAndProjectID(instanceType, projectID, region), }, } } @@ -684,7 +684,7 @@ func (w *Worker) Generate(ctx context.Context, dependencies asset.Parents) error } } case gcptypes.Name: - mpool := defaultGCPMachinePoolPlatform(pool.Architecture) + mpool := defaultGCPMachinePoolPlatform(pool.Architecture, ic.Platform.GCP.ProjectID, ic.Platform.GCP.Region) mpool.Set(ic.Platform.GCP.DefaultMachinePlatform) mpool.Set(pool.Platform.GCP) if len(mpool.Zones) == 0 { diff --git a/pkg/clusterapi/system.go b/pkg/clusterapi/system.go index f991d0f54ab..32321ade4f5 100644 --- a/pkg/clusterapi/system.go +++ b/pkg/clusterapi/system.go @@ -307,6 +307,18 @@ func (c *system) Run(ctx context.Context) error { //nolint:gocyclo gAppCredEnvVar: session.Path, } + // Set universe domain for sovereign cloud environments + // The universe domain is extracted from the credentials and must be explicitly + // set for the CAPI provider to use the correct GCP API endpoints + universeDomain, err := session.Credentials.GetUniverseDomain() + if err != nil { + return fmt.Errorf("failed to get universe domain from GCP credentials: %w", err) + } + if gcp.IsNonDefaultUniverseDomain(universeDomain) { + capgEnvVars["GOOGLE_CLOUD_UNIVERSE_DOMAIN"] = universeDomain + logrus.Infof("setting GOOGLE_CLOUD_UNIVERSE_DOMAIN to %s for capg infrastructure controller", universeDomain) + } + if v, ok := capgEnvVars[gAppCredEnvVar]; ok { logrus.Infof("setting %q to %s for capg infrastructure controller", gAppCredEnvVar, v) } diff --git a/pkg/types/gcp/defaults/machinepool.go b/pkg/types/gcp/defaults/machinepool.go index 2cfda74ef07..58cae3712e3 100644 --- a/pkg/types/gcp/defaults/machinepool.go +++ b/pkg/types/gcp/defaults/machinepool.go @@ -27,7 +27,7 @@ func SetMachinePoolDefaults(platform *types.Platform, pool *gcp.MachinePool) { if pool.InstanceType != "" && pool.OSDisk.DiskType == "" { family := gcp.GetGCPInstanceFamily(pool.InstanceType) if _, ok := gcp.InstanceTypeToDiskTypeMap[family]; ok { - pool.OSDisk.DiskType = gcp.DefaultDiskTypeForInstance(pool.InstanceType) + pool.OSDisk.DiskType = gcp.DefaultDiskTypeForInstanceAndProjectID(pool.InstanceType, platform.GCP.ProjectID, platform.GCP.Region) } } } diff --git a/pkg/types/gcp/machinepools.go b/pkg/types/gcp/machinepools.go index e7203eac087..001094c886c 100644 --- a/pkg/types/gcp/machinepools.go +++ b/pkg/types/gcp/machinepools.go @@ -372,21 +372,43 @@ func GetGCPInstanceFamily(instanceType string) string { } // DefaultDiskTypeForInstance returns the default disk type for a GCP instance type. If instance type is not -// recognized, pd-ssd is return. +// recognized, pd-ssd is returned. For sovereign cloud instances, hyperdisk-balanced is preferred when available. func DefaultDiskTypeForInstance(instanceType string) string { + return DefaultDiskTypeForInstanceAndProjectID(instanceType, "", "") +} + +// DefaultDiskTypeForInstanceAndProjectID returns the default disk type for a GCP instance type, project ID, and region. +// The cloud environment is automatically detected from the project ID and region. +// For sovereign cloud, hyperdisk-balanced is preferred. For public GCP, pd-ssd is preferred. +func DefaultDiskTypeForInstanceAndProjectID(instanceType, projectID, region string) string { defaultDiskType := PDSSD diskTypes, ok := GetDiskTypes(instanceType) if ok { supportedDiskTypes := sets.New(diskTypes...) - switch { - case supportedDiskTypes.Has(PDSSD): - defaultDiskType = PDSSD - case supportedDiskTypes.Has(HyperDiskBalanced): - defaultDiskType = HyperDiskBalanced - default: - // this shouldn't happen because all supported instance types - // have either pd-ssd or hyperdisk balanced - defaultDiskType = diskTypes[0] + cloudEnv := GetCloudEnvironment(projectID) + + // Sovereign cloud prefers hyperdisk-balanced + if cloudEnv == CloudEnvironmentSovereign { + switch { + case supportedDiskTypes.Has(HyperDiskBalanced): + defaultDiskType = HyperDiskBalanced + case supportedDiskTypes.Has(PDSSD): + defaultDiskType = PDSSD + default: + defaultDiskType = diskTypes[0] + } + } else { + // Public GCP prefers pd-ssd + switch { + case supportedDiskTypes.Has(PDSSD): + defaultDiskType = PDSSD + case supportedDiskTypes.Has(HyperDiskBalanced): + defaultDiskType = HyperDiskBalanced + default: + // this shouldn't happen because all supported instance types + // have either pd-ssd or hyperdisk balanced + defaultDiskType = diskTypes[0] + } } } return defaultDiskType diff --git a/pkg/types/gcp/metadata.go b/pkg/types/gcp/metadata.go index dc33be89d2c..c8a61246b9b 100644 --- a/pkg/types/gcp/metadata.go +++ b/pkg/types/gcp/metadata.go @@ -9,4 +9,5 @@ type Metadata struct { PrivateZoneProjectID string `json:"privateZoneProjectID,omitempty"` Endpoint *PSCEndpoint `json:"endpoint,omitempty"` FirewallRulesManagement FirewallRulesManagementPolicy `json:"firewallRulesManagement,omitempty"` + UniverseDomain string `json:"universeDomain,omitempty"` } diff --git a/pkg/types/gcp/platform.go b/pkg/types/gcp/platform.go index 7fbb9ca53cc..7cdfa670555 100644 --- a/pkg/types/gcp/platform.go +++ b/pkg/types/gcp/platform.go @@ -2,6 +2,9 @@ package gcp import ( "fmt" + "strings" + + "k8s.io/apimachinery/pkg/util/sets" "github.com/openshift/installer/pkg/types/dns" ) @@ -17,6 +20,18 @@ const ( // UnmanagedFirewallRules indicates that the firewall rules should be managed by the user. The // firewall rules should exist prior to the installation occurs. UnmanagedFirewallRules FirewallRulesManagementPolicy = "Unmanaged" + + // CloudEnvironmentSovereign is the cloud environment identifier for GCP sovereign clouds. + CloudEnvironmentSovereign = "sovereign" +) + +var ( + // sovereignCloudProjectPrefixes contains known project ID prefixes for sovereign clouds. + // Project IDs in sovereign clouds use the format: : + // This list helps distinguish from organization-scoped public GCP projects (orgname:project-id). + sovereignCloudProjectPrefixes = []string{ + "eu0", // European sovereign cloud (Germany) + } ) // DNS contains the gcp dns zone information for the cluster. @@ -126,6 +141,12 @@ type Platform struct { // and the firewall rules before the installation. // +optional FirewallRulesManagement FirewallRulesManagementPolicy `json:"firewallRulesManagement,omitempty"` + + // UniverseDomain is the Google Cloud universe domain for the cluster. + // When no value is set, GCP APIs use a default value: googleapis.com. + // Universe Domain may be required to configure Sovereign Cloud environments. + // +optional + UniverseDomain string `json:"universeDomain,omitempty"` } // UserLabel is a label to apply to GCP resources created for the cluster. @@ -184,8 +205,29 @@ func GetConfiguredServiceAccount(platform *Platform, mpool *MachinePool) string // GetDefaultServiceAccount returns the default service account email to use based on role. // The default should be used when an existing service account is not configured. +// For sovereign cloud project IDs (e.g., eu0:project-id), the service account email format is: +// +// service-account-name@project-id.eu0.iam.gserviceaccount.com +// +// For standard project IDs, the format is: +// +// service-account-name@project-id.iam.gserviceaccount.com func GetDefaultServiceAccount(platform *Platform, clusterID string, role string) string { - return fmt.Sprintf("%s-%s@%s.iam.gserviceaccount.com", clusterID, role[0:1], platform.ProjectID) + projectID := platform.ProjectID + + // For sovereign cloud project IDs in format "prefix:project-id", swap to "project-id.prefix" + if strings.Contains(projectID, ":") { + parts := strings.SplitN(projectID, ":", 2) + if len(parts) == 2 && sets.New(sovereignCloudProjectPrefixes...).Has(parts[0]) { + // Sovereign cloud: swap to project-id.prefix format + // Example: "eu0:openshift" becomes "openshift.eu0" + projectID = fmt.Sprintf("%s.%s", parts[1], parts[0]) + } + // For organization-scoped projects (e.g., "google.com:project-id"), + // keep the full projectID unchanged and let GCP handle the format + } + + return fmt.Sprintf("%s-%s@%s.iam.gserviceaccount.com", clusterID, role[0:1], projectID) } // ShouldUseEndpointForInstaller returns true when the endpoint should be used for GCP api endpoint overrides in the @@ -193,3 +235,28 @@ func GetDefaultServiceAccount(platform *Platform, clusterID string, role string) func ShouldUseEndpointForInstaller(endpoint *PSCEndpoint) bool { return endpoint != nil && endpoint.ClusterUseOnly != nil && !(*endpoint.ClusterUseOnly) } + +// GetCloudEnvironment determines the cloud environment from the project ID format. +// Returns CloudEnvironmentSovereign for sovereign cloud environments, empty string for public GCP. +// Uses known sovereign cloud project ID prefixes to distinguish from organization-scoped +// public GCP projects (orgname:project-id). +func GetCloudEnvironment(projectID string) string { + // Check if project ID has a known sovereign cloud prefix + if strings.Contains(projectID, ":") { + parts := strings.SplitN(projectID, ":", 2) + if len(parts) == 2 && sets.New(sovereignCloudProjectPrefixes...).Has(parts[0]) { + // Known sovereign prefix is definitive - this IS a sovereign cloud project + return CloudEnvironmentSovereign + } + } + + // No known sovereign prefix found + return "" +} + +// IsNonDefaultUniverseDomain returns true if the universe domain is non-empty +// and not the default "googleapis.com" value. +// This indicates a sovereign cloud or custom universe domain configuration. +func IsNonDefaultUniverseDomain(universeDomain string) bool { + return universeDomain != "" && universeDomain != "googleapis.com" +} diff --git a/pkg/types/gcp/validation/platform.go b/pkg/types/gcp/validation/platform.go index 6ad20026b68..1ba5c8c0ffb 100644 --- a/pkg/types/gcp/validation/platform.go +++ b/pkg/types/gcp/validation/platform.go @@ -92,6 +92,8 @@ func ValidatePlatform(p *gcp.Platform, fldPath *field.Path, ic *types.InstallCon if p.Region == "" { allErrs = append(allErrs, field.Required(fldPath.Child("region"), "must provide a region")) } + cloudEnv := gcp.GetCloudEnvironment(p.ProjectID) + allErrs = append(allErrs, validateUniverseDomain(p.UniverseDomain, cloudEnv, fldPath.Child("universeDomain"))...) if p.DefaultMachinePlatform != nil { allErrs = append(allErrs, ValidateMachinePool(p, p.DefaultMachinePlatform, fldPath.Child("defaultMachinePlatform"))...) allErrs = append(allErrs, ValidateDefaultDiskType(p.DefaultMachinePlatform, fldPath.Child("defaultMachinePlatform"))...) @@ -194,3 +196,21 @@ func validateLabel(key, value string) error { } return nil } + +// validateUniverseDomain validates universe domain usage. +// Universe domain is optional - it can be set for sovereign clouds when explicitly +// provided by the user. If not provided, the GCP SDK will extract it from credentials. +// Note: Format validation is intentionally not performed here. The GCP SDK will +// validate the actual domain format when making API calls. +func validateUniverseDomain(universeDomain, cloudEnvironment string, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + // Universe domain is optional. If provided for non-sovereign cloud, warn the user + // that this is unusual but don't prevent it (they may know something we don't). + if universeDomain != "" && cloudEnvironment != gcp.CloudEnvironmentSovereign { + allErrs = append(allErrs, field.Invalid(fldPath, universeDomain, + "universe domain is typically only needed for sovereign cloud environments (project IDs with colon prefix)")) + } + + return allErrs +}