From 4cd79a74c13a636231e391baca4ddd05abd5b6a1 Mon Sep 17 00:00:00 2001 From: Amador Pahim Date: Fri, 27 Feb 2026 14:11:45 +0000 Subject: [PATCH] GCP-430: Wire GCP WIF credentials for CNCC in HyperShift HCP mode - Read GCP_CNCC_CREDENTIALS_FILE env var (set by the Control Plane Operator via the HyperShift PR #7824) and set GOOGLE_APPLICATION_CREDENTIALS on the CNCC container so that CNCC's auto-detection (cloud-network-config-controller PR #206) picks up the WIF external_account credential. - Validate GCP_CNCC_CREDENTIALS_FILE is a plain filename to prevent path traversal. - Add --token-audience=openshift to the cloud-token minter so GCP STS can validate the projected service-account token against the OIDC provider's allowed audience. - Add unit tests for template rendering. Co-Authored-By: Claude Opus 4.6 --- .../managed/controller.yaml | 5 + pkg/network/cloud_network.go | 9 + pkg/network/cloud_network_test.go | 173 ++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 pkg/network/cloud_network_test.go diff --git a/bindata/cloud-network-config-controller/managed/controller.yaml b/bindata/cloud-network-config-controller/managed/controller.yaml index eb3f7e2a09..663653c204 100644 --- a/bindata/cloud-network-config-controller/managed/controller.yaml +++ b/bindata/cloud-network-config-controller/managed/controller.yaml @@ -123,6 +123,7 @@ spec: args: - --service-account-namespace=openshift-cloud-network-config-controller - --service-account-name=cloud-network-config-controller + - --token-audience=openshift - --token-file=/var/run/secrets/openshift/serviceaccount/token - --kubeconfig=/etc/kubernetes/kubeconfig resources: @@ -189,6 +190,10 @@ spec: {{- if not (eq .AzureManagedSecretProviderClass "")}} - name: "ARO_HCP_CLIENT_CREDENTIALS_PATH" value: "{{ .AzureManagedCredsPath}}" +{{ end }} +{{- if not (eq .GCPCredentialsPath "")}} + - name: "GOOGLE_APPLICATION_CREDENTIALS" + value: "{{ .GCPCredentialsPath}}" {{ end }} resources: requests: diff --git a/pkg/network/cloud_network.go b/pkg/network/cloud_network.go index c29576cceb..389c08aae8 100644 --- a/pkg/network/cloud_network.go +++ b/pkg/network/cloud_network.go @@ -109,6 +109,15 @@ func renderCloudNetworkConfigController(conf *operv1.NetworkSpec, bootstrapResul data.Data["AzureManagedCertDirectory"] = azureCertPath data.Data["AzureManagedCredsPath"] = filepath.Join(azureCertPath, os.Getenv("MANAGED_AZURE_HCP_CREDENTIALS_FILE_PATH")) data.Data["AzureManagedSecretProviderClass"] = os.Getenv("ARO_HCP_SECRET_PROVIDER_CLASS") + // GCP WIF credential path for HCP deployments. + gcpCredsFile := os.Getenv("GCP_CNCC_CREDENTIALS_FILE") + if gcpCredsFile == "" { + data.Data["GCPCredentialsPath"] = "" + } else if filepath.Base(gcpCredsFile) != gcpCredsFile { + return nil, errors.Errorf("invalid GCP_CNCC_CREDENTIALS_FILE %q: must be a filename", gcpCredsFile) + } else { + data.Data["GCPCredentialsPath"] = filepath.Join("/etc/secret/cloudprovider", gcpCredsFile) + } caOverride.ObjectMeta = metav1.ObjectMeta{ Namespace: hcpCfg.Namespace, Name: "cloud-network-config-controller-kube-cloud-config", diff --git a/pkg/network/cloud_network_test.go b/pkg/network/cloud_network_test.go new file mode 100644 index 0000000000..2539e14876 --- /dev/null +++ b/pkg/network/cloud_network_test.go @@ -0,0 +1,173 @@ +package network + +import ( + "testing" + + "github.com/openshift/cluster-network-operator/pkg/render" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func makeManagedControllerRenderData() render.RenderData { + data := render.MakeRenderData() + data.Data["ReleaseVersion"] = "4.18.0" + data.Data["PlatformType"] = "GCP" + data.Data["PlatformRegion"] = "us-central1" + data.Data["PlatformTypeAWS"] = "AWS" + data.Data["PlatformTypeAzure"] = "Azure" + data.Data["PlatformTypeGCP"] = "GCP" + data.Data["CloudNetworkConfigControllerImage"] = "test-image" + data.Data["KubernetesServiceURL"] = "https://localhost:6443" + data.Data["ExternalControlPlane"] = true + data.Data["PlatformAzureEnvironment"] = "" + data.Data["PlatformAWSCAPath"] = "" + data.Data["PlatformAPIURL"] = "" + data.Data["CLIImage"] = "cli-image" + data.Data["TokenMinterImage"] = "token-minter-image" + data.Data["TokenAudience"] = "https://issuer.example.com" + data.Data["ManagementClusterName"] = "test-cluster" + data.Data["HostedClusterNamespace"] = "test-ns" + data.Data["ReleaseImage"] = "release-image" + data.Data["HCPNodeSelector"] = map[string]string{} + data.Data["HCPLabels"] = map[string]string{} + data.Data["HCPTolerations"] = []string{} + data.Data["RunAsUser"] = "" + data.Data["PriorityClass"] = "" + data.Data["HTTP_PROXY"] = "" + data.Data["HTTPS_PROXY"] = "" + data.Data["NO_PROXY"] = "" + data.Data["AzureManagedCertDirectory"] = "" + data.Data["AzureManagedCredsPath"] = "" + data.Data["AzureManagedSecretProviderClass"] = "" + data.Data["GCPCredentialsPath"] = "" + return data +} + +// getEnvVar looks up an env var by name from a container map and returns its value. +func getEnvVar(t *testing.T, container map[string]interface{}, name string) (string, bool) { + t.Helper() + envSlice, found, err := uns.NestedSlice(container, "env") + if err != nil || !found { + return "", false + } + for _, e := range envSlice { + em := e.(map[string]interface{}) + n, _, _ := uns.NestedString(em, "name") + if n == name { + v, _, _ := uns.NestedString(em, "value") + return v, true + } + } + return "", false +} + +// findUnstructuredContainer finds a container by name from a deployment's unstructured object. +func findUnstructuredContainer(t *testing.T, obj map[string]interface{}, containerName string) (map[string]interface{}, bool) { + t.Helper() + containers, found, err := uns.NestedSlice(obj, "spec", "template", "spec", "containers") + if err != nil || !found { + return nil, false + } + for _, c := range containers { + cm := c.(map[string]interface{}) + name, _, _ := uns.NestedString(cm, "name") + if name == containerName { + return cm, true + } + } + return nil, false +} + +// TestGCPCredentialsPathTemplateRendering tests that the managed controller.yaml template +// correctly renders GOOGLE_APPLICATION_CREDENTIALS when GCPCredentialsPath is set. +func TestGCPCredentialsPathTemplateRendering(t *testing.T) { + tests := []struct { + name string + gcpCredentialsPath string + expectGoogleAppCredentials bool + expectedValue string + }{ + { + name: "GCPCredentialsPath set renders GOOGLE_APPLICATION_CREDENTIALS", + gcpCredentialsPath: "/etc/secret/cloudprovider/application_default_credentials.json", + expectGoogleAppCredentials: true, + expectedValue: "/etc/secret/cloudprovider/application_default_credentials.json", + }, + { + name: "GCPCredentialsPath empty omits GOOGLE_APPLICATION_CREDENTIALS", + gcpCredentialsPath: "", + expectGoogleAppCredentials: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + data := makeManagedControllerRenderData() + data.Data["GCPCredentialsPath"] = tc.gcpCredentialsPath + + objs, err := render.RenderDir("../../bindata/cloud-network-config-controller/managed", &data) + if err != nil { + t.Fatalf("failed to render managed controller: %v", err) + } + + for _, obj := range objs { + if obj.GetKind() != "Deployment" { + continue + } + + container, found := findUnstructuredContainer(t, obj.Object, "controller") + if !found { + t.Fatal("controller container not found in Deployment") + } + + val, found := getEnvVar(t, container, "GOOGLE_APPLICATION_CREDENTIALS") + if tc.expectGoogleAppCredentials && !found { + t.Errorf("expected GOOGLE_APPLICATION_CREDENTIALS in deployment, but not found") + } + if !tc.expectGoogleAppCredentials && found { + t.Errorf("expected GOOGLE_APPLICATION_CREDENTIALS to be absent, but found") + } + if tc.expectGoogleAppCredentials && val != tc.expectedValue { + t.Errorf("expected GOOGLE_APPLICATION_CREDENTIALS value %q, got %q", tc.expectedValue, val) + } + return + } + t.Fatal("Deployment object not found in rendered output") + }) + } +} + +// TestCloudTokenMinterHasTokenAudience verifies that the cloud-token minter container +// has --token-audience=openshift in its args. +func TestCloudTokenMinterHasTokenAudience(t *testing.T) { + data := makeManagedControllerRenderData() + + objs, err := render.RenderDir("../../bindata/cloud-network-config-controller/managed", &data) + if err != nil { + t.Fatalf("failed to render managed controller: %v", err) + } + + for _, obj := range objs { + if obj.GetKind() != "Deployment" { + continue + } + + container, found := findUnstructuredContainer(t, obj.Object, "cloud-token") + if !found { + t.Fatal("cloud-token container not found in Deployment") + } + + args, found, err := uns.NestedStringSlice(container, "args") + if err != nil || !found { + t.Fatal("args not found in cloud-token-minter container") + } + + for _, arg := range args { + if arg == "--token-audience=openshift" { + return + } + } + t.Error("expected cloud-token minter to have --token-audience=openshift arg") + return + } + t.Fatal("Deployment object not found in rendered output") +}