From f5ad77613d750313b07c7889787f1aca872383cb Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 11 Feb 2026 15:11:32 -0800 Subject: [PATCH 1/4] iam: use ec2.amazonaws.com as service principal EUS partition also uses amazonaws.com suffix similar to global partition. If using amazonaws.eu, the following error occured. MalformedPolicyDocument: Invalid principal in policy: "SERVICE":"ec2.amazonaws.eu" --- pkg/infrastructure/aws/clusterapi/iam.go | 37 +++++++++++++++--------- pkg/types/aws/regions.go | 1 + 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/pkg/infrastructure/aws/clusterapi/iam.go b/pkg/infrastructure/aws/clusterapi/iam.go index 5f1fd242a56..f5b2617f03e 100644 --- a/pkg/infrastructure/aws/clusterapi/iam.go +++ b/pkg/infrastructure/aws/clusterapi/iam.go @@ -18,6 +18,7 @@ import ( "github.com/openshift/installer/pkg/asset/installconfig" awsconfig "github.com/openshift/installer/pkg/asset/installconfig/aws" + awstypes "github.com/openshift/installer/pkg/types/aws" ) const ( @@ -122,6 +123,11 @@ func createIAMRoles(ctx context.Context, infraID string, ic *installconfig.Insta }) } + ec2SvcPrincipal, err := getEC2ServicePrincipal(ic.AWS.Region) + if err != nil { + return fmt.Errorf("failed to get EC2 service principal for IAM roles: %w", err) + } + assumePolicy := &iamv1.PolicyDocument{ Version: "2012-10-17", Statement: iamv1.Statements{ @@ -129,7 +135,7 @@ func createIAMRoles(ctx context.Context, infraID string, ic *installconfig.Insta Effect: "Allow", Principal: iamv1.Principals{ iamv1.PrincipalService: []string{ - getPartitionDNSSuffix(ic.AWS.Region), + ec2SvcPrincipal, }, }, Action: iamv1.Actions{ @@ -271,28 +277,31 @@ func getOrCreateIAMRole(ctx context.Context, nodeRole, infraID, assumePolicy str return *roleName, nil } -func getPartitionDNSSuffix(region string) string { +func getEC2ServicePrincipal(region string) (string, error) { endpoint, err := ec2.NewDefaultEndpointResolver().ResolveEndpoint(region, ec2.EndpointResolverOptions{}) if err != nil { - logrus.Errorf("failed to resolve AWS ec2 endpoint: %v", err) - return "" + return "", fmt.Errorf("failed to resolve AWS ec2 endpoint: %w", err) } u, err := url.Parse(endpoint.URL) if err != nil { - logrus.Errorf("failed to parse partition ID URL: %v", err) - return "" + return "", fmt.Errorf("failed to parse partition ID URL: %w", err) } domain := "amazonaws.com" - // Extract the hostname - host := u.Hostname() - // Split the hostname by "." to get the domain parts - parts := strings.Split(host, ".") - if len(parts) > 2 { - domain = strings.Join(parts[2:], ".") + switch endpoint.PartitionID { + case awstypes.AwsEuscPartitionID: + // AWS Europe Sovereign Cloud uses ec2.amazonaws.com as service principal + default: + // Extract the hostname + host := u.Hostname() + // Split the hostname by "." to get the domain parts + parts := strings.Split(host, ".") + if len(parts) > 2 { + domain = strings.Join(parts[2:], ".") + } } - logrus.Debugf("Using domain name: %s", domain) - return fmt.Sprintf("ec2.%s", domain) + logrus.Debugf("Using domain name: %s for EC2 service principal ID", domain) + return fmt.Sprintf("ec2.%s", domain), nil } diff --git a/pkg/types/aws/regions.go b/pkg/types/aws/regions.go index 28e730e03e5..10af9da2540 100644 --- a/pkg/types/aws/regions.go +++ b/pkg/types/aws/regions.go @@ -88,6 +88,7 @@ const ( AwsUsGovPartitionID = "aws-us-gov" // AWS GovCloud (US) partition. AwsIsoPartitionID = "aws-iso" // AWS ISO (US) partition. AwsIsoBPartitionID = "aws-iso-b" // AWS ISOB (US) partition. + AwsEuscPartitionID = "aws-eusc" // AWS Europe Sovereign Cloud. ) // AWS Standard partition's regions. From db0d82dad86ae4f7e93d4106fddde2f9f996f343 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 16 Mar 2026 13:09:57 -0700 Subject: [PATCH 2/4] defaults: set default service endpoints for eu-de-east-1 Add support for AWS European Sovereign Cloud (EUSC) region eusc-de-east-1 with automatic service endpoint configuration. When users specify this region without custom service endpoints, the installer now automatically configures the required endpoints for ec2, elasticloadbalancing, s3, route53, iam, sts, and tagging services using the .amazonaws.eu domain. See: https://github.com/openshift/enhancements/blob/master/enhancements/installer/aws-eusc-partition.md --- pkg/types/aws/defaults/platform.go | 24 ++++++++++++++++ pkg/types/aws/defaults/platform_test.go | 38 +++++++++++++++++++++++++ pkg/types/aws/regions.go | 5 ++++ 3 files changed, 67 insertions(+) diff --git a/pkg/types/aws/defaults/platform.go b/pkg/types/aws/defaults/platform.go index 038d190cf66..f011c0841e1 100644 --- a/pkg/types/aws/defaults/platform.go +++ b/pkg/types/aws/defaults/platform.go @@ -32,6 +32,22 @@ var ( // - us-east-1e is a well-known limited zone, with limited offering of // instance types supported by installer. skippedZones = []string{"us-east-1e"} + + // defaultServiceEndpoints is a list of known default endpoints for specific regions that would + // otherwise require user to set the service overrides. + // Note: This is a workaround for when the AWS SDK cannot yet handle new regions, for example, EUSC regions. + defaultServiceEndpoints = map[string][]aws.ServiceEndpoint{ + // Reference: https://docs.aws.eu/general/latest/gr/endpoints.html + aws.EuscDeEast1RegionID: { + {Name: "ec2", URL: "https://ec2.eusc-de-east-1.amazonaws.eu"}, + {Name: "elasticloadbalancing", URL: "https://elasticloadbalancing.eusc-de-east-1.amazonaws.eu"}, + {Name: "s3", URL: "https://s3.eusc-de-east-1.amazonaws.eu"}, + {Name: "route53", URL: "https://route53.amazonaws.eu"}, + {Name: "iam", URL: "https://iam.eusc-de-east-1.amazonaws.eu"}, + {Name: "sts", URL: "https://sts.eusc-de-east-1.amazonaws.eu"}, + {Name: "tagging", URL: "https://tagging.eusc-de-east-1.amazonaws.eu"}, + }, + } ) // SetPlatformDefaults sets the defaults for the platform. @@ -46,6 +62,14 @@ func SetPlatformDefaults(p *aws.Platform) { p.LBType = configv1.NLB } } + + // TODO: Remove when all openshift components migrate to AWS SDK v2 + if len(p.ServiceEndpoints) == 0 { + if eps, ok := defaultServiceEndpoints[p.Region]; ok { + p.ServiceEndpoints = eps + logrus.Infof("Adding default service endpoints for region %s", p.Region) + } + } } // InstanceTypes returns a list of instance types, in decreasing priority order, which we should use for a given diff --git a/pkg/types/aws/defaults/platform_test.go b/pkg/types/aws/defaults/platform_test.go index 14154d80476..243fb22769c 100644 --- a/pkg/types/aws/defaults/platform_test.go +++ b/pkg/types/aws/defaults/platform_test.go @@ -141,6 +141,43 @@ func TestSetPlatformDefaults(t *testing.T) { LBType: "", }, }, + { + name: "EUSC region should set default service endpoints", + platform: &aws.Platform{ + Region: aws.EuscDeEast1RegionID, + }, + expected: &aws.Platform{ + Region: aws.EuscDeEast1RegionID, + IPFamily: network.IPv4, + ServiceEndpoints: defaultServiceEndpoints[aws.EuscDeEast1RegionID], + }, + }, + { + name: "non-EUSC region should not set default service endpoints", + platform: &aws.Platform{ + Region: "us-east-1", + }, + expected: &aws.Platform{ + Region: "us-east-1", + IPFamily: network.IPv4, + }, + }, + { + name: "EUSC region with existing service endpoints should not set defaults", + platform: &aws.Platform{ + Region: aws.EuscDeEast1RegionID, + ServiceEndpoints: []aws.ServiceEndpoint{ + {Name: "ec2", URL: "https://custom.ec2.endpoint"}, + }, + }, + expected: &aws.Platform{ + Region: aws.EuscDeEast1RegionID, + IPFamily: network.IPv4, + ServiceEndpoints: []aws.ServiceEndpoint{ + {Name: "ec2", URL: "https://custom.ec2.endpoint"}, + }, + }, + }, } for _, tc := range cases { @@ -148,6 +185,7 @@ func TestSetPlatformDefaults(t *testing.T) { SetPlatformDefaults(tc.platform) assert.Equal(t, tc.expected.IPFamily, tc.platform.IPFamily) assert.Equal(t, tc.expected.LBType, tc.platform.LBType) + assert.Equal(t, tc.expected.ServiceEndpoints, tc.platform.ServiceEndpoints) }) } } diff --git a/pkg/types/aws/regions.go b/pkg/types/aws/regions.go index 10af9da2540..6866f40eda5 100644 --- a/pkg/types/aws/regions.go +++ b/pkg/types/aws/regions.go @@ -151,3 +151,8 @@ const ( const ( UsIsoBEast1RegionID = "us-isob-east-1" // AWS ISOB (US) East. ) + +// AWS European Sovereign Cloud partition's regions. +const ( + EuscDeEast1RegionID = "eusc-de-east-1" // AWS European Sovereign Cloud (Germany). +) From 575dea8cf91e71fc57a6e6f2987f7449dda107cd Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 16 Mar 2026 14:00:42 -0700 Subject: [PATCH 3/4] destroy: use partition-aware regions for resourcetagging client The cluster destroy process now detects the AWS partition (aws, aws-us-gov, aws-eusc, etc.) and selects the appropriate region for the resourcetagging client. This region may be different from the install region. Background: Since Route 53 is a "global" service, API requests must be configured with a specific "default" region, which differs based on the partition. --- pkg/asset/installconfig/aws/endpoints.go | 13 +++++++ pkg/destroy/aws/aws.go | 43 +++++++++++++++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/pkg/asset/installconfig/aws/endpoints.go b/pkg/asset/installconfig/aws/endpoints.go index ba5c85621b1..700d0bee6ab 100644 --- a/pkg/asset/installconfig/aws/endpoints.go +++ b/pkg/asset/installconfig/aws/endpoints.go @@ -363,3 +363,16 @@ func GetDefaultServiceEndpoint(ctx context.Context, service string, opts Endpoin } return endpoint, nil } + +// GetPartitionIDForRegion retrieves the partition ID for a given region. +// For example, us-east-1 returns "aws" and "eusc-de-east-1" returns "aws-eusc". +func GetPartitionIDForRegion(ctx context.Context, region string) (string, error) { + // We just need to choose any services (e.g. EC2), whose version is the most up-to-date. + // If the SDK cannot resolve the endpoint for a (unknown) region, the partitionID is returned as "aws". + endpoint, err := ec2.NewDefaultEndpointResolver().ResolveEndpoint(region, ec2.EndpointResolverOptions{}) + if err != nil { + return "", fmt.Errorf("failed to resolve AWS endpoint for region %s get partition ID: %w", region, err) + } + + return endpoint.PartitionID, nil +} diff --git a/pkg/destroy/aws/aws.go b/pkg/destroy/aws/aws.go index 845817801e1..ba18c43dd0f 100644 --- a/pkg/destroy/aws/aws.go +++ b/pkg/destroy/aws/aws.go @@ -61,6 +61,7 @@ type ClusterUninstaller struct { Filters []Filter // filter(s) we will be searching for Logger logrus.FieldLogger Region string + PartitionID string ClusterID string ClusterDomain string HostedZoneRole string @@ -150,6 +151,21 @@ func New(logger logrus.FieldLogger, metadata *types.ClusterMetadata) (providers. }, nil } +// GetPartitionID returns the partition ID for the install region. +func (o *ClusterUninstaller) GetPartitionID(ctx context.Context) (string, error) { + if len(o.PartitionID) > 0 { + return o.PartitionID, nil + } + + partitionID, err := awssession.GetPartitionIDForRegion(ctx, o.Region) + if err != nil { + return "", err + } + + o.PartitionID = partitionID + return o.PartitionID, nil +} + // validate runs before the uninstall process to ensure that // all prerequisites are met for a safe destroy. func (o *ClusterUninstaller) validate(ctx context.Context) error { @@ -250,10 +266,30 @@ func (o *ClusterUninstaller) RunWithContext(ctx context.Context) ([]string, erro tagClients := []*resourcegroupstaggingapi.Client{baseTaggingClient} if o.HostedZoneRole != "" { + partitionID, err := o.GetPartitionID(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get partition ID for region %s: %w", o.Region, err) + } + + // This tagging client is specifically for finding route53 zone, so it needs to use the "global" region, depending on the partition ID + tagRegion := o.Region + switch partitionID { + case awstypes.AwsEuscPartitionID: + // For AWS EU Sovereign Cloud, use "eusc-de-east-1" + tagRegion = awstypes.EuscDeEast1RegionID + case awstypes.AwsUsGovPartitionID: + // For AWS Government Cloud, use "us-gov-west-1" + tagRegion = awstypes.UsGovWest1RegionID + case awstypes.AwsPartitionID: + // For AWS standard, use "us-east-1" + tagRegion = awstypes.UsEast1RegionID + default: + // For other partitions, use the install region + } + // Create tagging client with assumed role credentials for cross-account hosted zone access. - // This client is specifically for finding route53 zones in the us-east-1 region. assumedRoleTagClient, err := awssession.NewResourceGroupsTaggingAPIClient(ctx, awssession.EndpointOptions{ - Region: awstypes.UsEast1RegionID, + Region: tagRegion, Endpoints: o.endpoints, }, o.HostedZoneRole, resourcegroupstaggingapi.WithAPIOptions(awsmiddleware.AddUserAgentKeyValue(awssession.OpenShiftInstallerDestroyerUserAgent, version.Raw))) if err != nil { @@ -263,10 +299,9 @@ func (o *ClusterUninstaller) RunWithContext(ctx context.Context) ([]string, erro } switch o.Region { + case awstypes.EuscDeEast1RegionID: case awstypes.CnNorth1RegionID, awstypes.CnNorthwest1RegionID: - break case awstypes.UsIsoEast1RegionID, awstypes.UsIsoWest1RegionID, awstypes.UsIsoBEast1RegionID: - break case awstypes.UsGovEast1RegionID, awstypes.UsGovWest1RegionID: if o.Region != awstypes.UsGovWest1RegionID { tagClient, err := awssession.NewResourceGroupsTaggingAPIClient(ctx, awssession.EndpointOptions{ From ee1dd303b7f56da0ef68e3b256dbe420d5917933 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Fri, 13 Feb 2026 20:21:16 -0800 Subject: [PATCH 4/4] destroy: skip shared resources that AWS do not support tagging Untagging hosted zone in region "eusc-de-east-1" is not supported via resourcetagging api. If attempting to do so, the api returns the following error: UntagResources operation: Invocation of UntagResources for this resource is not supported in this region This causes the bulk untagging operation to fail and leave other resources with the shared tag on. --- pkg/destroy/aws/shared.go | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pkg/destroy/aws/shared.go b/pkg/destroy/aws/shared.go index a7968acd4f7..9d856b4b0bd 100644 --- a/pkg/destroy/aws/shared.go +++ b/pkg/destroy/aws/shared.go @@ -19,6 +19,7 @@ import ( "k8s.io/apimachinery/pkg/util/wait" awssession "github.com/openshift/installer/pkg/asset/installconfig/aws" + awstypes "github.com/openshift/installer/pkg/types/aws" "github.com/openshift/installer/pkg/version" ) @@ -110,10 +111,14 @@ func (o *ClusterUninstaller) removeSharedTag(ctx context.Context, tagClients []* continue } + // Some regions may not support untag operations for certain resources + arns = o.filterUnsupportedUntagResources(tagClient.Options().Region, arns) + if len(arns) == 0 { o.Logger.Debugf("No matches in %s for %s: shared, removing client", tagClient.Options().Region, key) continue } + // appending the tag client here but it needs to be removed if there is a InvalidParameterException when trying to // untag below since that only leads to an infinite loop error. nextTagClients = append(nextTagClients, tagClient) @@ -300,3 +305,40 @@ func deleteMatchingRecordSetInPublicZone(ctx context.Context, client *route53.Cl } return deleteRoute53RecordSet(ctx, client, zoneID, &matchingRecordSet, logger) } + +// filterUnsupportedUntagResources filters out ARNs that cannot be untagged due to AWS limitation. +// For example, hosted zones cannot be untagged in region "eusc-de-east-1". +// FIXME: remove this handler once AWS added support for untagging hosted zone via resourcetagging API for EU Sovereign Cloud. +func (o *ClusterUninstaller) filterUnsupportedUntagResources(region string, arns []string) []string { + filtered := make([]string, 0, len(arns)) + skipped := make([]string, 0) + + switch region { + case awstypes.EuscDeEast1RegionID: + for _, arnString := range arns { + parsedARN, err := arn.Parse(arnString) + if err != nil { + filtered = append(filtered, arnString) + continue + } + resourceType, _, err := splitSlash("resource", parsedARN.Resource) + if err != nil { + filtered = append(filtered, arnString) + continue + } + if parsedARN.Service == "route53" && resourceType == "hostedzone" { + skipped = append(skipped, arnString) + continue + } + filtered = append(filtered, arnString) + } + default: + filtered = arns + } + + for _, arnString := range skipped { + o.Logger.WithField("arn", arnString).Warnf("Untagging this resource via resourcetagging api is not supported by AWS in region %s. Please use the AWS Route 53 APIs, CLI, or console", region) + } + + return filtered +}