diff --git a/docs/dev/e2e-ote-ccm-aws.md b/docs/dev/e2e-ote-ccm-aws.md
new file mode 100644
index 000000000..da6d65a69
--- /dev/null
+++ b/docs/dev/e2e-ote-ccm-aws.md
@@ -0,0 +1,205 @@
+# OpenShift Tests Extension (OTE) for AWS Cloud Controller Manager
+
+The OpenShift Tests Extension (OTE) binary `cloud-controller-manager-aws-tests-ext`
+exposes the upstream e2e tests from `cloud-provider-aws` (implemented under `tests/e2e`)
+to the OpenShift test framework. Tests are selected/curated by the filters defined in
+`main.go`, so that only the intended subset is available in the OpenShift test pipeline.
+
+OpenShift-specific (downstream) tests live under `openshift-tests/ccm-aws-tests/e2e/aws`
+and are added to the list of tests executed by `openshift-tests`.
+
+The OTE library uses dot imports to register both upstream and downstream Ginkgo specs into the suite.
+
+```mermaid
+flowchart LR
+ A["Upstream Tests
kubernetes/cloud-provider-aws
tests/e2e"] --> C["OTE Binary
cloud-controller-manager-aws-tests-ext"]
+ B["Downstream Tests
openshift/cluster-cloud-controller-manager-operator
openshift-tests/ccm-aws-tests/e2e"] --> C
+ C --> D["openshift-tests
execution framework"]
+
+ style A fill:#e1f5ff
+ style B fill:#fff4e1
+ style C fill:#e8f5e9
+ style D fill:#f3e5f5
+```
+
+## Directory Structure
+
+```text
+openshift-tests/ccm-aws-tests/
+├── e2e/
+│ ├── aws/
+│ │ ├── loadbalancer.go # OpenShift-specific load balancer tests
+│ │ └── helper.go # AWS client helpers (ELBv2, EC2, LB lookup)
+│ └── common/
+│ └── helper.go # Shared helpers (feature gate, topology detection)
+├── main.go # Test binary entrypoint
+├── go.mod
+└── vendor/
+```
+
+## Prerequisites
+
+- Go 1.24+
+- Access to an OpenShift cluster on AWS
+- Valid `KUBECONFIG` pointing to the cluster
+- AWS credentials configured (for tests that query AWS APIs):
+ `AWS_REGION`, `AWS_SHARED_CREDENTIALS_FILE`, or default SDK credential chain
+
+## Building
+
+### Building the test binary
+
+From the root of the project:
+
+```sh
+make cloud-controller-manager-aws-tests-ext
+```
+
+The binary will be at `openshift-tests/bin/cloud-controller-manager-aws-tests-ext`.
+
+### Building the container image
+
+```sh
+podman build --authfile $PULL_SECRET_FILE -f Dockerfile.openshift -t ccm-local:devel .
+# OR
+make image
+```
+
+The binary is embedded in the image at `/usr/bin/cloud-controller-manager-aws-tests-ext.gz`.
+
+## Using the test binary
+
+### List available tests
+
+```sh
+./openshift-tests/bin/cloud-controller-manager-aws-tests-ext list tests | jq .[].name
+```
+
+### List test suites
+
+```sh
+./openshift-tests/bin/cloud-controller-manager-aws-tests-ext list suites | jq .[].name
+```
+
+### Run a specific test (standalone cluster)
+
+```sh
+export KUBECONFIG=/path/to/kubeconfig
+export AWS_REGION=us-east-1
+
+./openshift-tests/bin/cloud-controller-manager-aws-tests-ext run-test \
+ "[cloud-provider-aws-e2e] loadbalancer CLB should be reachable with default configurations [Suite:openshift/conformance/parallel]"
+```
+
+### Run multiple tests by pattern
+
+The `run-test` command only supports running **one test at a time** (a known
+OTE framework limitation). To batch-run tests matching a
+pattern, use process substitution so that each invocation gets its own stdin:
+
+```sh
+BIN=./openshift-tests/bin/cloud-controller-manager-aws-tests-ext
+
+# Run all AWSServiceLBNetworkSecurityGroup tests
+while IFS= read -r t; do
+ echo "=== Running: $t"; $BIN run-test "$t" < /dev/null
+done < <($BIN list tests | jq -r '.[].name' | grep "AWSServiceLBNetworkSecurityGroup")
+
+# Run all upstream loadbalancer tests
+while IFS= read -r t; do
+ echo "=== Running: $t"; $BIN run-test "$t" < /dev/null
+done < <($BIN list tests | jq -r '.[].name' | grep "\[cloud-provider-aws-e2e\] loadbalancer")
+
+# Run all tests (upstream + downstream)
+while IFS= read -r t; do
+ echo "=== Running: $t"; $BIN run-test "$t" < /dev/null
+done < <($BIN list tests | jq -r '.[].name')
+```
+
+> **Note:** The `< /dev/null` is required — `run-test` reads stdin for
+> additional test names, and without it the first invocation would consume
+> all remaining names from the loop's input.
+
+Results may be quite verbose, you can pipe the logs to file and query results later with a summary:
+
+```sh
+run_test(){
+ while IFS= read -r t; do
+ echo "=== Running: $t";
+ $BIN run-test "$t" < /dev/null;
+done < <($BIN list tests | jq -r '.[].name' | grep "AWSServiceLBNetworkSecurityGroup"); }
+
+run_test | tee -a e2e-ote.log
+
+grep -E "(name\"\:|\"result\")" e2e-ote.log
+```
+
+### Run a specific test (HyperShift hosted cluster)
+
+When running against a HyperShift hosted cluster, `KUBECONFIG` must point to the
+**guest** (hosted) cluster. Additionally, the AWSServiceLBNetworkSecurityGroup
+tests need access to the management cluster to validate the CCM cloud-config,
+which lives in the hosted control plane namespace.
+
+Set these environment variables before running:
+
+```sh
+# Guest cluster kubeconfig (where tests run)
+export KUBECONFIG=/path/to/hosted-cluster/kubeconfig
+
+# Management cluster kubeconfig (for cloud-config validation)
+export HYPERSHIFT_MANAGEMENT_CLUSTER_KUBECONFIG=/path/to/management-cluster/kubeconfig
+
+# HCP namespace on the management cluster (e.g., clusters-)
+export HYPERSHIFT_MANAGEMENT_CLUSTER_NAMESPACE=clusters-my-hosted-cluster
+
+# AWS credentials for the account where the hosted cluster's resources live.
+# These must have permissions for DescribeLoadBalancers (ELBv2) and
+# DescribeSecurityGroups (EC2). In CI, this is the hypershift pool account.
+export AWS_SHARED_CREDENTIALS_FILE=/path/to/aws/credentials
+export AWS_REGION=us-east-1
+```
+
+Then run the test:
+
+```sh
+./openshift-tests/bin/cloud-controller-manager-aws-tests-ext run-test \
+ "[cloud-provider-aws-e2e-openshift] loadbalancer NLB [OCPFeatureGate:AWSServiceLBNetworkSecurityGroup] should have NLBSecurityGroupMode with 'Managed value in cloud-config [Suite:openshift/conformance/parallel]"
+```
+
+The test automatically detects External topology (HyperShift) by querying the
+cluster's `Infrastructure` resource and reads the cloud-config from ConfigMap
+`aws-cloud-config` in the HCP namespace on the management cluster, instead of
+`cloud-conf` in `openshift-cloud-controller-manager` on the guest cluster.
+
+
+#### Skipping tests that require management cluster access
+
+If you are running against a HyperShift hosted cluster but do **not** have
+access to the management cluster kubeconfig, you can skip those tests by setting:
+
+```sh
+export SKIP_MANAGEMENT_CLUSTER_TESTS=true
+```
+
+This will skip any test that requires reading resources from the management
+cluster (e.g., the cloud-config validation test). All other tests will run
+normally.
+
+## CI Jobs
+
+### Self managed
+
+Any job using `openshift/conformance/parallel` suite on OpenShift self-managed on AWS must run tests provided by CCM-AWS OTE, unless explicitly skipped (See [OTE setup](https://github.com/openshift/cluster-cloud-controller-manager-operator/tree/main/openshift-tests/ccm-aws-tests) for more information).
+
+### HyperShift (Hosted Cluster)
+
+The periodic CI job that runs these tests on Hosted Cluster is:
+
+```text
+periodic-ci-openshift-hypershift-release-5.0-e2e-aws-ovn-conformance-ccm-techpreview
+```
+
+The CI step (`hypershift-conformance`) automatically sets the management cluster
+environment variables (`HYPERSHIFT_MANAGEMENT_CLUSTER_KUBECONFIG`,
+`HYPERSHIFT_MANAGEMENT_CLUSTER_NAMESPACE`) before launching `openshift-tests`.
diff --git a/docs/dev/ote-ccm-aws.md b/docs/dev/ote-ccm-aws.md
deleted file mode 100644
index 8b89736b2..000000000
--- a/docs/dev/ote-ccm-aws.md
+++ /dev/null
@@ -1,92 +0,0 @@
-# OpenShift Tests Extension (OTE) for AWS Cloud Controller Manager
-
-The OpenShift Tests Extension (OTE) binary `aws-cloud-controller-manager-tests-ext`
-exposes the upstream e2e tests from `cloud-provider-aws` (implemented under `tests/e2e`)
-to the OpenShift test framework. Tests are selected/curated by the filters defined in
-`main.go`, so that only the intended subset is available in the OpenShift test pipeline.
-
-OpenShift-specific (downstream) tests live under `cmd/cloud-controller-manager-aws-tests-ext/e2e`
-and are added to the list of tests executed by `openshift-tests`.
-
-The OTE library uses dot imports to register both upstream and downstream Ginkgo specs into the suite.
-
-```mermaid
-flowchart LR
- A["Upstream Tests
kubernetes/cloud-provider-aws
tests/e2e"] --> C["OTE Binary
aws-cloud-controller-manager-tests-ext"]
- B["Downstream Tests
openshift/cluster-cloud-controller-manager-operator
cmd/cloud-controller-manager-aws-tests-ext/e2e"] --> C
- C --> D["openshift-tests
execution framework"]
-
- style A fill:#e1f5ff
- style B fill:#fff4e1
- style C fill:#e8f5e9
- style D fill:#f3e5f5
-```
-
-## Directory Structure
-
-```
-cmd/cloud-controller-manager-aws-tests-ext/
-├── e2e/
-│ ├── loadbalancer.go # OpenShift-specific load balancer tests
-│ └── helper.go # Helper functions (AWS clients, feature gate checks, etc.)
-├── main.go # Test binary entrypoint
-└── README.md
-```
-
-## Prerequisites
-
-- Go 1.24+
-- Access to an OpenShift cluster on AWS
-- Valid `KUBECONFIG` pointing to the cluster
-- AWS credentials configured (for tests that query AWS APIs)
-
-## Building
-
-### Building the test binary
-
-To build the OTE binary from the root of the project:
-
-```sh
-make cloud-controller-manager-aws-tests-ext
-```
-
-The binary will be created at the `bin/` directory: `./bin/cloud-controller-manager-aws-tests-ext`.
-
-### Building the container image
-
-To build the container image (regular CCCMO build):
-
-```sh
-make image
-```
-
-The binary will be created in the container image path `/usr/bin/cloud-controller-manager-aws-tests-ext.gz`.
-
-## Using the test binary
-
-### List available tests
-
-```sh
-./aws-cloud-controller-manager-tests-ext list --topology=HighAvailability --platform=aws | jq .[].name
-```
-
-Example output:
-```
-"[cloud-provider-aws-e2e-openshift] loadbalancer NLB feature AWSServiceLBNetworkSecurityGroup should have NLBSecurityGroupMode = Managed in cloud-config [Suite:openshift/conformance/parallel]"
-...
-"[cloud-provider-aws-e2e] nodes should set zone-id topology label [Suite:openshift/conformance/parallel]"
-"[cloud-provider-aws-e2e] nodes should label nodes with topology network info if instance is supported [Suite:openshift/conformance/parallel]"
-```
-
-### List test suites
-
-```sh
-./bin/cloud-controller-manager-aws-tests-ext list suites | jq .[].name
-```
-
-### Run a specific test
-
-```sh
-export KUBECONFIG=/path/to/kubeconfig
-./bin/cloud-controller-manager-aws-tests-ext run-test "[cloud-provider-aws-e2e-openshift] loadbalancer NLB [OCPFeatureGate:AWSServiceLBNetworkSecurityGroup] should have security groups attached to default ingress controller NLB [Suite:openshift/conformance/parallel]"
-```
diff --git a/openshift-tests/ccm-aws-tests/e2e/aws/helper.go b/openshift-tests/ccm-aws-tests/e2e/aws/helper.go
index f08e7a7cb..fdb9ccf7a 100644
--- a/openshift-tests/ccm-aws-tests/e2e/aws/helper.go
+++ b/openshift-tests/ccm-aws-tests/e2e/aws/helper.go
@@ -3,6 +3,7 @@ package aws
import (
"context"
"fmt"
+ "regexp"
"strings"
"time"
@@ -14,17 +15,71 @@ import (
elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2"
elbv2types "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types"
"github.com/openshift/cluster-cloud-controller-manager-operator/openshift-tests/ccm-aws-tests/e2e/common"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/kubernetes/test/e2e/framework"
)
+// awsRegionPattern matches valid AWS region names across all partitions:
+// standard (aws: us-east-1), China (aws-cn: cn-northwest-1),
+// GovCloud (aws-us-gov: us-gov-west-1), European Sovereign Cloud (aws-eusc: eusc-de-east-1),
+// and ISO/ISOB (aws-iso/iso-b: us-isob-east-1).
+var awsRegionPattern = regexp.MustCompile(`^[a-z]{2,4}(?:-[a-z0-9]+)+-\d+$`)
+
// AWS helpers
+// loadAWSConfig loads the default AWS SDK configuration. If the resolved region
+// is not a valid AWS region (e.g. a CI lease UUID), it falls back to the region
+// from the cluster's Infrastructure resource.
+func loadAWSConfig(ctx context.Context) (aws.Config, error) {
+ cfg, err := config.LoadDefaultConfig(ctx)
+ if err != nil {
+ return aws.Config{}, fmt.Errorf("unable to load AWS config: %w", err)
+ }
+
+ // This validation is required to prevent landing wrong hypershift region configurations
+ // set by environment variable.
+ if !awsRegionPattern.MatchString(cfg.Region) {
+ region, err := getRegionFromInfrastructure(ctx)
+ if err != nil {
+ return aws.Config{}, fmt.Errorf("AWS region %q is not valid and failed to get region from Infrastructure: %w", cfg.Region, err)
+ }
+ framework.Logf("AWS SDK region %q is not valid, using region from Infrastructure: %s", cfg.Region, region)
+ cfg.Region = region
+ }
+
+ framework.Logf("AWS config loaded: region=%s", cfg.Region)
+ return cfg, nil
+}
+
+// getRegionFromInfrastructure reads the AWS region from the cluster's
+// Infrastructure resource (status.platformStatus.aws.region).
+func getRegionFromInfrastructure(ctx context.Context) (string, error) {
+ oc, err := common.GetOcClient(ctx)
+ if err != nil {
+ return "", fmt.Errorf("failed to create config client: %w", err)
+ }
+ infra, err := oc.Infrastructures().Get(ctx, "cluster", metav1.GetOptions{})
+ if err != nil {
+ return "", fmt.Errorf("failed to get Infrastructure: %w", err)
+ }
+ if infra.Status.PlatformStatus == nil || infra.Status.PlatformStatus.AWS == nil {
+ return "", fmt.Errorf("Infrastructure platformStatus.aws is nil")
+ }
+ region := infra.Status.PlatformStatus.AWS.Region
+ if region == "" {
+ return "", fmt.Errorf("Infrastructure platformStatus.aws.region is empty")
+ }
+ return region, nil
+}
+
// createAWSClientLoadBalancer creates an AWS ELBv2 client using default credentials configured in the environment.
+// It forces the public regional endpoint to avoid VPC private endpoint DNS
+// resolution issues when running from a management cluster (HyperShift).
func createAWSClientLoadBalancer(ctx context.Context) (*elbv2.Client, error) {
- cfg, err := config.LoadDefaultConfig(ctx)
+ cfg, err := loadAWSConfig(ctx)
if err != nil {
- return nil, fmt.Errorf("unable to load AWS config: %v", err)
+ return nil, err
}
customRetryer := retry.NewStandard(func(o *retry.StandardOptions) {
@@ -34,6 +89,11 @@ func createAWSClientLoadBalancer(ctx context.Context) (*elbv2.Client, error) {
return elbv2.NewFromConfig(cfg, func(o *elbv2.Options) {
o.Retryer = customRetryer
+ // Use regional endpoints when region is defined preventing malformed or
+ // unreachable (from test binary, which usually runs outside cluster's VPC) endpoints.
+ if cfg.Region != "" {
+ o.BaseEndpoint = aws.String(fmt.Sprintf("https://elasticloadbalancing.%s.amazonaws.com", cfg.Region))
+ }
}), nil
}
@@ -47,7 +107,7 @@ func getAWSLoadBalancerFromDNSName(ctx context.Context, elbClient *elbv2.Client,
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
- framework.Logf("transient error describing load balancers (will retry): %v", err)
+ framework.Logf("transient error describing load balancers (will retry): %w", err)
return false, nil
}
@@ -64,7 +124,7 @@ func getAWSLoadBalancerFromDNSName(ctx context.Context, elbClient *elbv2.Client,
})
if err != nil {
- return nil, fmt.Errorf("failed to find load balancer with DNS name %s: %v", lbDNSName, err)
+ return nil, fmt.Errorf("failed to find load balancer with DNS name %s: %w", lbDNSName, err)
}
if foundLB == nil {
@@ -81,7 +141,7 @@ func findAWSLoadBalancerByDNSName(ctx context.Context, elbClient *elbv2.Client,
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
- return nil, fmt.Errorf("failed to describe load balancers: %v", err)
+ return nil, fmt.Errorf("failed to describe load balancers: %w", err)
}
framework.Logf("found %d load balancers in page", len(page.LoadBalancers))
@@ -100,13 +160,21 @@ func isFeatureEnabled(ctx context.Context, featureName string) (bool, error) {
return common.IsFeatureEnabled(ctx, featureName)
}
-// getAWSClientEC2 creates an AWS EC2 client using default credentials configured in the environment.
+// createAWSClientEC2 creates an AWS EC2 client using default credentials configured in the environment.
+// It forces the public regional endpoint to avoid VPC private endpoint DNS
+// resolution issues when running from a management cluster (HyperShift).
func createAWSClientEC2(ctx context.Context) (*ec2.Client, error) {
- cfg, err := config.LoadDefaultConfig(ctx)
+ cfg, err := loadAWSConfig(ctx)
if err != nil {
- return nil, fmt.Errorf("unable to load AWS config: %v", err)
+ return nil, err
}
- return ec2.NewFromConfig(cfg), nil
+ return ec2.NewFromConfig(cfg, func(o *ec2.Options) {
+ // Use regional endpoints when region is defined preventing malformed or
+ // unreachable (from test binary, which usually runs outside cluster's VPC) endpoints.
+ if cfg.Region != "" {
+ o.BaseEndpoint = aws.String(fmt.Sprintf("https://ec2.%s.amazonaws.com", cfg.Region))
+ }
+ }), nil
}
// getAWSSecurityGroup retrieves a security group by ID using the AWS EC2 client.
@@ -118,7 +186,7 @@ func getAWSSecurityGroup(ctx context.Context, ec2Client *ec2.Client, sgID string
result, err := ec2Client.DescribeSecurityGroups(ctx, input)
if err != nil {
- return nil, fmt.Errorf("failed to describe security group %s: %v", sgID, err)
+ return nil, fmt.Errorf("failed to describe security group %s: %w", sgID, err)
}
if len(result.SecurityGroups) == 0 {
@@ -156,7 +224,7 @@ func securityGroupExists(ctx context.Context, ec2Client *ec2.Client, sgID string
framework.Logf("security group %s does not exist", sgID)
return false, nil
}
- return false, fmt.Errorf("failed to check security group %s: %v", sgID, err)
+ return false, fmt.Errorf("failed to check security group %s: %w", sgID, err)
}
framework.Logf("security group %s exists", sgID)
diff --git a/openshift-tests/ccm-aws-tests/e2e/aws/loadbalancer.go b/openshift-tests/ccm-aws-tests/e2e/aws/loadbalancer.go
index 796e2c3fc..07b1e199a 100644
--- a/openshift-tests/ccm-aws-tests/e2e/aws/loadbalancer.go
+++ b/openshift-tests/ccm-aws-tests/e2e/aws/loadbalancer.go
@@ -77,8 +77,9 @@ var _ = Describe(fmt.Sprintf("%s NLB [OCPFeatureGate:%s]", e2eTestPrefixLoadBala
// - The test must skip if the feature gate is not enabled
It("should have NLBSecurityGroupMode with 'Managed value in cloud-config", func(ctx context.Context) {
isNLBFeatureEnabled(ctx)
+ common.SkipIfManagementClusterTestsDisabled()
- By("getting cloud-config ConfigMap from openshift-cloud-controller-manager namespace")
+ By("getting cloud-config ConfigMap")
cm, err := common.GetCloudConfig(ctx, cs)
framework.ExpectNoError(err, "failed to get cloud-config ConfigMap")
@@ -167,7 +168,7 @@ var _ = Describe(fmt.Sprintf("%s NLB [OCPFeatureGate:%s]", e2eTestPrefixLoadBala
err = wait.PollUntilContextTimeout(ctx, 5*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) {
s, err := cs.CoreV1().Services(ingressNamespace).Get(ctx, ingressServiceName, metav1.GetOptions{})
if err != nil {
- framework.Logf("Failed to get service %s/%s: %v", ingressNamespace, ingressServiceName, err)
+ framework.Logf("Failed to get service %s/%s: %w", ingressNamespace, ingressServiceName, err)
return false, nil
}
svc = s
diff --git a/openshift-tests/ccm-aws-tests/e2e/common/helper.go b/openshift-tests/ccm-aws-tests/e2e/common/helper.go
index 2f285ab4c..d2eb1bb45 100644
--- a/openshift-tests/ccm-aws-tests/e2e/common/helper.go
+++ b/openshift-tests/ccm-aws-tests/e2e/common/helper.go
@@ -3,19 +3,31 @@ package common
import (
"context"
"fmt"
+ "os"
"regexp"
"strings"
+ "github.com/onsi/ginkgo/v2"
+ configv1 "github.com/openshift/api/config/v1"
configv1client "github.com/openshift/client-go/config/clientset/versioned/typed/config/v1"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientset "k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/tools/clientcmd"
"k8s.io/kubernetes/test/e2e/framework"
)
const (
cloudConfigNamespace = "openshift-cloud-controller-manager"
cloudConfigName = "cloud-conf"
+
+ // HyperShift uses different ConfigMap naming for the CCM cloud config.
+ hcpCloudConfigName = "aws-cloud-config"
+
+ // EnvSkipManagementClusterTests when set to "true" skips tests that
+ // require a kubeconfig for the management cluster (e.g. reading the
+ // CCM cloud-config from a HyperShift hosted control plane).
+ EnvSkipManagementClusterTests = "SKIP_MANAGEMENT_CLUSTER_TESTS"
)
// GetOcClient returns an OpenShift config/v1 API client (FeatureGates, Infrastructures, etc.).
@@ -100,12 +112,85 @@ func IsFeatureEnabled(ctx context.Context, featureName string) (bool, error) {
return false, nil
}
-// GetCloudConfig retrieves the CCM cloud-config ConfigMap.
+// SkipIfManagementClusterTestsDisabled skips the current test when
+// SKIP_MANAGEMENT_CLUSTER_TESTS=true. Call this at the beginning of any
+// test that requires access to the management cluster kubeconfig.
+// This is useful to provide flexibility on Hypershift jobs that don't want to always
+// runs that checks this flag, forcing to skip any matching.
+func SkipIfManagementClusterTestsDisabled() {
+ if os.Getenv(EnvSkipManagementClusterTests) == "true" {
+ ginkgo.Skip("Skipping: test requires management cluster access and SKIP_MANAGEMENT_CLUSTER_TESTS=true")
+ }
+}
+
+// IsExternalTopology checks if the cluster has an external control plane topology
+// (e.g., HyperShift hosted clusters) by querying the Infrastructure resource.
+func IsExternalTopology(ctx context.Context) (bool, error) {
+ oc, err := GetOcClient(ctx)
+ if err != nil {
+ return false, fmt.Errorf("failed to create config client: %w", err)
+ }
+
+ infra, err := oc.Infrastructures().Get(ctx, "cluster", metav1.GetOptions{})
+ if err != nil {
+ return false, fmt.Errorf("failed to get Infrastructure 'cluster': %w", err)
+ }
+
+ isExternal := infra.Status.ControlPlaneTopology == configv1.ExternalTopologyMode
+ framework.Logf("Cluster control plane topology: %s (external: %v)", infra.Status.ControlPlaneTopology, isExternal)
+ return isExternal, nil
+}
+
+// getHCPCloudConfig retrieves the CCM cloud config from a HyperShift hosted
+// control plane. It reads the management cluster kubeconfig and HCP namespace
+// from environment variables set by the CI step, then fetches the
+// aws-cloud-config ConfigMap from the HCP namespace.
+func getHCPCloudConfig(ctx context.Context) (*v1.ConfigMap, error) {
+ mgmtKubeconfig := os.Getenv("HYPERSHIFT_MANAGEMENT_CLUSTER_KUBECONFIG")
+ if len(mgmtKubeconfig) == 0 {
+ return nil, fmt.Errorf("HYPERSHIFT_MANAGEMENT_CLUSTER_KUBECONFIG must be set for HyperShift topology")
+ }
+
+ hcpNamespace := os.Getenv("HYPERSHIFT_MANAGEMENT_CLUSTER_NAMESPACE")
+ if len(hcpNamespace) == 0 {
+ return nil, fmt.Errorf("HYPERSHIFT_MANAGEMENT_CLUSTER_NAMESPACE must be set for HyperShift topology")
+ }
+
+ framework.Logf("Using management cluster kubeconfig=%s, HCP namespace=%s", mgmtKubeconfig, hcpNamespace)
+
+ restConfig, err := clientcmd.BuildConfigFromFlags("", mgmtKubeconfig)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load management cluster kubeconfig")
+ }
+
+ mgmtClient, err := clientset.NewForConfig(restConfig)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create management cluster client")
+ }
+
+ cm, err := mgmtClient.CoreV1().ConfigMaps(hcpNamespace).Get(ctx, hcpCloudConfigName, metav1.GetOptions{})
+ if err != nil {
+ return nil, fmt.Errorf("failed to get HCP cloud-config ConfigMap %s/%s", hcpNamespace, hcpCloudConfigName)
+ }
+
+ framework.Logf("Successfully retrieved HCP cloud-config from %s/%s", hcpNamespace, hcpCloudConfigName)
+ return cm, nil
+}
+
+// GetCloudConfig retrieves the CCM cloud-config ConfigMap, choosing the right
+// source based on cluster topology (HyperShift HCP vs standalone).
// When cs is nil, a clientset is created from the current kubeconfig.
// This function must not call Ginkgo control-flow helpers (Skip, Fail, etc.)
// because it is also called from main.go outside a spec context.
func GetCloudConfig(ctx context.Context, cs clientset.Interface) (*v1.ConfigMap, error) {
- var err error
+ isExternal, err := IsExternalTopology(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to detect cluster topology: %w", err)
+ }
+
+ if isExternal {
+ return getHCPCloudConfig(ctx)
+ }
if cs == nil {
cs, err = GetKubeClient(ctx)
if err != nil {
diff --git a/openshift-tests/ccm-aws-tests/go.mod b/openshift-tests/ccm-aws-tests/go.mod
index c9a49c2f0..bc14fcc74 100644
--- a/openshift-tests/ccm-aws-tests/go.mod
+++ b/openshift-tests/ccm-aws-tests/go.mod
@@ -10,6 +10,7 @@ require (
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/openshift-eng/openshift-tests-extension v0.0.0-20250916161632-d81c09058835
+ github.com/openshift/api v0.0.0-20260429122012-1180c0f5c3e9
github.com/openshift/client-go v0.0.0-20260429123927-c81f86abfa6a
github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2
@@ -68,7 +69,6 @@ require (
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
- github.com/openshift/api v0.0.0-20260429122012-1180c0f5c3e9 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
diff --git a/openshift-tests/ccm-aws-tests/main.go b/openshift-tests/ccm-aws-tests/main.go
index e817f17e8..caf442aac 100644
--- a/openshift-tests/ccm-aws-tests/main.go
+++ b/openshift-tests/ccm-aws-tests/main.go
@@ -212,7 +212,7 @@ func initFrameworkForTests() error {
func initFrameworkForTest() error {
if ad := os.Getenv("ARTIFACT_DIR"); len(strings.TrimSpace(ad)) == 0 {
if err := os.Setenv("ARTIFACT_DIR", filepath.Join(os.TempDir(), "artifacts")); err != nil {
- return fmt.Errorf("unable to set ARTIFACT_DIR: %v", err)
+ return fmt.Errorf("unable to set ARTIFACT_DIR: %w", err)
}
}