diff --git a/control-plane-operator/hostedclusterconfigoperator/controllers/resources/gcp/gcp.go b/control-plane-operator/hostedclusterconfigoperator/controllers/resources/gcp/gcp.go new file mode 100644 index 000000000000..8e5dbf1dee26 --- /dev/null +++ b/control-plane-operator/hostedclusterconfigoperator/controllers/resources/gcp/gcp.go @@ -0,0 +1,93 @@ +package gcp + +import ( + "context" + "fmt" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/control-plane-operator/hostedclusterconfigoperator/controllers/resources/manifests" + "github.com/openshift/hypershift/support/capabilities" + "github.com/openshift/hypershift/support/gcputil" + "github.com/openshift/hypershift/support/upsert" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// gcpCredentialConfig defines configuration for a single credential secret. +type gcpCredentialConfig struct { + manifestFunc func() *corev1.Secret + serviceAccountEmail string + capabilityChecker func(*hyperv1.Capabilities) bool + errorContext string +} + +// SetupOperandCredentials ensures that the required GCP operand credential secrets are created or updated +// for the guest cluster's components based on the HostedControlPlane configuration. +func SetupOperandCredentials( + ctx context.Context, + c client.Client, + upsertProvider upsert.CreateOrUpdateProvider, + hcp *hyperv1.HostedControlPlane, +) []error { + configs := []gcpCredentialConfig{ + { + manifestFunc: manifests.GCPImageRegistryCloudCredsSecret, + serviceAccountEmail: string(hcp.Spec.Platform.GCP.WorkloadIdentity.ServiceAccountsEmails.ImageRegistry), + capabilityChecker: capabilities.IsImageRegistryCapabilityEnabled, + errorContext: "guest cluster image-registry credential", + }, + } + return reconcileGCPCredentials(ctx, c, upsertProvider, hcp, configs) +} + +func reconcileGCPCredentials( + ctx context.Context, + c client.Client, + upsertProvider upsert.CreateOrUpdateProvider, + hcp *hyperv1.HostedControlPlane, + configs []gcpCredentialConfig, +) []error { + var errs []error + + for _, cfg := range configs { + if cfg.capabilityChecker != nil && !cfg.capabilityChecker(hcp.Spec.Capabilities) { + continue + } + + secret := cfg.manifestFunc() + + ns := &corev1.Namespace{} + if err := c.Get(ctx, client.ObjectKey{Name: secret.Namespace}, ns); err != nil { + if apierrors.IsNotFound(err) { + ctrl.LoggerFrom(ctx).Info("WARNING: cannot sync cloud credential secret because namespace does not exist", + "secret", client.ObjectKeyFromObject(secret), + "context", cfg.errorContext) + continue + } + errs = append(errs, fmt.Errorf("failed to get namespace %s for %s: %w", secret.Namespace, cfg.errorContext, err)) + continue + } + + credentialJSON, err := gcputil.BuildWorkloadIdentityCredentials(hcp.Spec.Platform.GCP.WorkloadIdentity, cfg.serviceAccountEmail) + if err != nil { + errs = append(errs, fmt.Errorf("failed to build %s: %w", cfg.errorContext, err)) + continue + } + + if _, err := upsertProvider.CreateOrUpdate(ctx, c, secret, func() error { + secret.Data = map[string][]byte{ + "service_account.json": []byte(credentialJSON), + } + secret.Type = corev1.SecretTypeOpaque + return nil + }); err != nil { + errs = append(errs, fmt.Errorf("failed to reconcile %s: %w", cfg.errorContext, err)) + } + } + + return errs +} diff --git a/control-plane-operator/hostedclusterconfigoperator/controllers/resources/gcp/gcp_test.go b/control-plane-operator/hostedclusterconfigoperator/controllers/resources/gcp/gcp_test.go new file mode 100644 index 000000000000..e3092de83158 --- /dev/null +++ b/control-plane-operator/hostedclusterconfigoperator/controllers/resources/gcp/gcp_test.go @@ -0,0 +1,121 @@ +package gcp + +import ( + "encoding/json" + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/support/api" + "github.com/openshift/hypershift/support/gcputil" + "github.com/openshift/hypershift/support/upsert" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const ( + testImageRegistryGSA = "image-registry@test-project.iam.gserviceaccount.com" + testProjectNumber = "123456789012" + testPoolID = "test-pool" + testProviderID = "test-provider" +) + +func makeHCP() *hyperv1.HostedControlPlane { + return &hyperv1.HostedControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Name: "hcp", + Namespace: "ns", + }, + Spec: hyperv1.HostedControlPlaneSpec{ + Platform: hyperv1.PlatformSpec{ + Type: hyperv1.GCPPlatform, + GCP: &hyperv1.GCPPlatformSpec{ + WorkloadIdentity: hyperv1.GCPWorkloadIdentityConfig{ + ProjectNumber: testProjectNumber, + PoolID: testPoolID, + ProviderID: testProviderID, + ServiceAccountsEmails: hyperv1.GCPServiceAccountsEmails{ + ImageRegistry: testImageRegistryGSA, + }, + }, + }, + }, + }, + } +} + +func TestSetupOperandCredentials(t *testing.T) { + t.Parallel() + tests := []struct { + name string + disableImageRegistry bool + createNamespace bool + expectImageRegistrySec bool + }{ + { + name: "When image registry capability is enabled it should create the credential secret", + createNamespace: true, + expectImageRegistrySec: true, + }, + { + name: "When image registry capability is disabled it should skip the credential secret", + disableImageRegistry: true, + createNamespace: true, + }, + { + name: "When target namespace does not exist it should skip without error", + createNamespace: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + c := fake.NewClientBuilder().WithScheme(api.Scheme).Build() + + if tc.createNamespace { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "openshift-image-registry"}} + g.Expect(c.Create(t.Context(), ns)).To(Succeed()) + } + + hcp := makeHCP() + if tc.disableImageRegistry { + hcp.Spec.Capabilities = &hyperv1.Capabilities{ + Disabled: []hyperv1.OptionalCapability{hyperv1.ImageRegistryCapability}, + } + } + + errs := SetupOperandCredentials(t.Context(), c, upsert.New(false), hcp) + g.Expect(errs).To(BeEmpty()) + + key := client.ObjectKey{Namespace: "openshift-image-registry", Name: "installer-cloud-credentials"} + var sec corev1.Secret + err := c.Get(t.Context(), key, &sec) + + if tc.expectImageRegistrySec { + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(sec.Data).To(HaveKey("service_account.json")) + g.Expect(sec.Type).To(Equal(corev1.SecretTypeOpaque)) + + // Validate the WIF credential JSON structure + var cred gcputil.ExternalAccountCredential + g.Expect(json.Unmarshal(sec.Data["service_account.json"], &cred)).To(Succeed()) + g.Expect(cred.Type).To(Equal("external_account")) + g.Expect(cred.Audience).To(ContainSubstring(testProjectNumber)) + g.Expect(cred.Audience).To(ContainSubstring(testPoolID)) + g.Expect(cred.Audience).To(ContainSubstring(testProviderID)) + g.Expect(cred.ServiceAccountImpersonationURL).To(ContainSubstring(testImageRegistryGSA)) + g.Expect(cred.CredentialSource.File).To(Equal("/var/run/secrets/openshift/serviceaccount/token")) + } else { + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), "expected image registry credentials secret to be absent") + } + }) + } +} diff --git a/control-plane-operator/hostedclusterconfigoperator/controllers/resources/manifests/creds.go b/control-plane-operator/hostedclusterconfigoperator/controllers/resources/manifests/creds.go index 1834af7b05df..1898c6a63b3a 100644 --- a/control-plane-operator/hostedclusterconfigoperator/controllers/resources/manifests/creds.go +++ b/control-plane-operator/hostedclusterconfigoperator/controllers/resources/manifests/creds.go @@ -69,3 +69,14 @@ func AzureFileCSICloudCredsSecret() *corev1.Secret { }, } } + +// GCP credential secrets for hosted cluster operators + +func GCPImageRegistryCloudCredsSecret() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "openshift-image-registry", + Name: "installer-cloud-credentials", + }, + } +} diff --git a/control-plane-operator/hostedclusterconfigoperator/controllers/resources/resources.go b/control-plane-operator/hostedclusterconfigoperator/controllers/resources/resources.go index fdc1bc2f04ed..86dce4567a00 100644 --- a/control-plane-operator/hostedclusterconfigoperator/controllers/resources/resources.go +++ b/control-plane-operator/hostedclusterconfigoperator/controllers/resources/resources.go @@ -26,6 +26,7 @@ import ( "github.com/openshift/hypershift/control-plane-operator/hostedclusterconfigoperator/controllers/resources/cco" ccm "github.com/openshift/hypershift/control-plane-operator/hostedclusterconfigoperator/controllers/resources/cloudcontrollermanager/azure" "github.com/openshift/hypershift/control-plane-operator/hostedclusterconfigoperator/controllers/resources/crd" + gcpresources "github.com/openshift/hypershift/control-plane-operator/hostedclusterconfigoperator/controllers/resources/gcp" "github.com/openshift/hypershift/control-plane-operator/hostedclusterconfigoperator/controllers/resources/ingress" "github.com/openshift/hypershift/control-plane-operator/hostedclusterconfigoperator/controllers/resources/kas" "github.com/openshift/hypershift/control-plane-operator/hostedclusterconfigoperator/controllers/resources/konnectivity" @@ -2213,6 +2214,10 @@ func (r *reconciler) reconcileCloudCredentialSecrets(ctx context.Context, hcp *h errs = append(errs, fmt.Errorf("failed to reconcile powervs image registry cloud credentials secret %w", err)) } } + case hyperv1.GCPPlatform: + if hcp.Spec.Platform.GCP != nil { + errs = append(errs, gcpresources.SetupOperandCredentials(ctx, r.client, r.CreateOrUpdateProvider, hcp)...) + } } return errs } diff --git a/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp.go b/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp.go index 6dcac87cd04e..b4ec98d376d2 100644 --- a/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp.go +++ b/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp.go @@ -17,12 +17,12 @@ package gcp import ( "context" - "encoding/json" "fmt" "os" "strings" hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" + "github.com/openshift/hypershift/support/gcputil" "github.com/openshift/hypershift/support/images" "github.com/openshift/hypershift/support/upsert" supportutil "github.com/openshift/hypershift/support/util" @@ -323,7 +323,7 @@ func (p GCP) ReconcileCredentials(ctx context.Context, c client.Client, createOr // Create credential secrets following AWS pattern var errs []error syncSecret := func(secret *corev1.Secret, serviceAccountEmail string) error { - credentials, err := buildGCPWorkloadIdentityCredentials(hcluster.Spec.Platform.GCP.WorkloadIdentity, serviceAccountEmail) + credentials, err := gcputil.BuildWorkloadIdentityCredentials(hcluster.Spec.Platform.GCP.WorkloadIdentity, serviceAccountEmail) if err != nil { return fmt.Errorf("failed to build cloud credentials secret %s/%s: %w", secret.Namespace, secret.Name, err) } @@ -440,69 +440,6 @@ func CNCCCredsSecret(controlPlaneNamespace string) *corev1.Secret { } } -// gcpCredentialSource represents the credential source configuration for GCP external account credentials. -type gcpCredentialSource struct { - File string `json:"file"` - Format gcpCredentialSourceFormat `json:"format"` -} - -// gcpCredentialSourceFormat represents the format of the credential source. -type gcpCredentialSourceFormat struct { - Type string `json:"type"` -} - -// gcpExternalAccountCredential represents the complete GCP external account credential configuration -// for Workload Identity Federation. This follows the Google Cloud credential configuration format. -type gcpExternalAccountCredential struct { - Type string `json:"type"` - Audience string `json:"audience"` - SubjectTokenType string `json:"subject_token_type"` - TokenURL string `json:"token_url"` - ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"` - CredentialSource gcpCredentialSource `json:"credential_source"` -} - -// buildGCPWorkloadIdentityCredentials creates the credential configuration for Google Cloud SDK -// to use Workload Identity Federation with a specific service account email. -func buildGCPWorkloadIdentityCredentials(wif hyperv1.GCPWorkloadIdentityConfig, serviceAccountEmail string) (string, error) { - if wif.ProjectNumber == "" { - return "", fmt.Errorf("project number cannot be empty in GCP Workload Identity Federation credentials") - } - if wif.PoolID == "" { - return "", fmt.Errorf("pool ID cannot be empty in GCP Workload Identity Federation credentials") - } - if wif.ProviderID == "" { - return "", fmt.Errorf("provider ID cannot be empty in GCP Workload Identity Federation credentials") - } - if serviceAccountEmail == "" { - return "", fmt.Errorf("service account email cannot be empty in GCP Workload Identity Federation credentials") - } - - // Create the credential configuration that tells Google Cloud SDK how to use WIF - // This follows the standard Google Cloud credential configuration format with service account impersonation - // The audience must be the full resource name of the Workload Identity Provider - credConfig := gcpExternalAccountCredential{ - Type: "external_account", - Audience: fmt.Sprintf("//iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s/providers/%s", wif.ProjectNumber, wif.PoolID, wif.ProviderID), - SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", - TokenURL: "https://sts.googleapis.com/v1/token", - ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken", serviceAccountEmail), - CredentialSource: gcpCredentialSource{ - File: "/var/run/secrets/openshift/serviceaccount/token", - Format: gcpCredentialSourceFormat{ - Type: "text", - }, - }, - } - - credentialJSON, err := json.Marshal(credConfig) - if err != nil { - return "", fmt.Errorf("failed to marshal GCP credential configuration: %w", err) - } - - return string(credentialJSON), nil -} - // ReconcileSecretEncryption is a no-op // TODO: Implement GCP KMS secret encryption integration. func (p GCP) ReconcileSecretEncryption(ctx context.Context, c client.Client, createOrUpdate upsert.CreateOrUpdateFN, diff --git a/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp_test.go b/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp_test.go index d35a8f35d521..16aa0a7467dc 100644 --- a/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp_test.go +++ b/hypershift-operator/controllers/hostedcluster/internal/platform/gcp/gcp_test.go @@ -295,105 +295,6 @@ func TestDeleteCredentials(t *testing.T) { g.Expect(err).To(BeNil()) // Minimal implementation returns nil } -func TestBuildGCPWorkloadIdentityCredentials(t *testing.T) { - g := NewWithT(t) - - wif := hyperv1.GCPWorkloadIdentityConfig{ - ProjectNumber: "123456789012", - PoolID: "test-pool", - ProviderID: "test-provider", - ServiceAccountsEmails: hyperv1.GCPServiceAccountsEmails{ - NodePool: testNodePoolGSA, - ControlPlane: testControlPlaneGSA, - CloudController: testCloudControllerGSA, - Storage: testStorageGSA, - ImageRegistry: testImageRegistryGSA, - Network: testNetworkGSA, - }, - } - - // Using NodePool GSA as an example - the function is generic and works the same - // for any service account email (NodePool, ControlPlane, CloudController, etc.) - credentials, err := buildGCPWorkloadIdentityCredentials(wif, string(wif.ServiceAccountsEmails.NodePool)) - g.Expect(err).To(BeNil()) - g.Expect(credentials).To(ContainSubstring(`"type":"external_account"`)) - g.Expect(credentials).To(ContainSubstring("123456789012")) - g.Expect(credentials).To(ContainSubstring("test-pool")) - g.Expect(credentials).To(ContainSubstring("test-provider")) - g.Expect(credentials).To(ContainSubstring("/var/run/secrets/openshift/serviceaccount/token")) -} - -func TestBuildGCPWorkloadIdentityCredentialsValidation(t *testing.T) { - g := NewWithT(t) - - // validWIF returns a baseline valid GCPWorkloadIdentityConfig. - // Callers mutate individual fields to test specific validation errors. - validWIF := func() hyperv1.GCPWorkloadIdentityConfig { - return hyperv1.GCPWorkloadIdentityConfig{ - ProjectNumber: "123456789012", - PoolID: "test-pool", - ProviderID: "test-provider", - ServiceAccountsEmails: hyperv1.GCPServiceAccountsEmails{ - NodePool: testNodePoolGSA, - ControlPlane: testControlPlaneGSA, - CloudController: testCloudControllerGSA, - Storage: testStorageGSA, - ImageRegistry: testImageRegistryGSA, - Network: testNetworkGSA, - }, - } - } - - tests := []struct { - name string - mutate func(*hyperv1.GCPWorkloadIdentityConfig) - errorMsg string - }{ - { - name: "valid configuration", - mutate: nil, - }, - { - name: "missing project number", - mutate: func(wif *hyperv1.GCPWorkloadIdentityConfig) { wif.ProjectNumber = "" }, - errorMsg: "project number cannot be empty", - }, - { - name: "missing pool ID", - mutate: func(wif *hyperv1.GCPWorkloadIdentityConfig) { wif.PoolID = "" }, - errorMsg: "pool ID cannot be empty", - }, - { - name: "missing provider ID", - mutate: func(wif *hyperv1.GCPWorkloadIdentityConfig) { wif.ProviderID = "" }, - errorMsg: "provider ID cannot be empty", - }, - { - name: "missing service account email", - mutate: func(wif *hyperv1.GCPWorkloadIdentityConfig) { wif.ServiceAccountsEmails.NodePool = "" }, - errorMsg: "service account email cannot be empty", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - wif := validWIF() - if tt.mutate != nil { - tt.mutate(&wif) - } - // Using NodePool GSA as the serviceAccountEmail parameter - the function - // is generic and works the same for any service account email - _, err := buildGCPWorkloadIdentityCredentials(wif, string(wif.ServiceAccountsEmails.NodePool)) - if tt.errorMsg != "" { - g.Expect(err).ToNot(BeNil()) - g.Expect(err.Error()).To(ContainSubstring(tt.errorMsg)) - } else { - g.Expect(err).To(BeNil()) - } - }) - } -} - func TestValidateWorkloadIdentityConfiguration(t *testing.T) { g := NewWithT(t) diff --git a/support/gcputil/gcputil.go b/support/gcputil/gcputil.go new file mode 100644 index 000000000000..bfeb647fbb8f --- /dev/null +++ b/support/gcputil/gcputil.go @@ -0,0 +1,68 @@ +package gcputil + +import ( + "encoding/json" + "fmt" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" +) + +// CredentialSource represents the credential source configuration for GCP external account credentials. +type CredentialSource struct { + File string `json:"file"` + Format CredentialSourceFormat `json:"format"` +} + +// CredentialSourceFormat represents the format of the credential source. +type CredentialSourceFormat struct { + Type string `json:"type"` +} + +// ExternalAccountCredential represents the complete GCP external account credential configuration +// for Workload Identity Federation. This follows the Google Cloud credential configuration format. +type ExternalAccountCredential struct { + Type string `json:"type"` + Audience string `json:"audience"` + SubjectTokenType string `json:"subject_token_type"` + TokenURL string `json:"token_url"` + ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"` + CredentialSource CredentialSource `json:"credential_source"` +} + +// BuildWorkloadIdentityCredentials creates the credential configuration JSON for Google Cloud SDK +// to use Workload Identity Federation with a specific service account email. +func BuildWorkloadIdentityCredentials(wif hyperv1.GCPWorkloadIdentityConfig, serviceAccountEmail string) (string, error) { + if wif.ProjectNumber == "" { + return "", fmt.Errorf("project number cannot be empty in GCP Workload Identity Federation credentials") + } + if wif.PoolID == "" { + return "", fmt.Errorf("pool ID cannot be empty in GCP Workload Identity Federation credentials") + } + if wif.ProviderID == "" { + return "", fmt.Errorf("provider ID cannot be empty in GCP Workload Identity Federation credentials") + } + if serviceAccountEmail == "" { + return "", fmt.Errorf("service account email cannot be empty in GCP Workload Identity Federation credentials") + } + + credConfig := ExternalAccountCredential{ + Type: "external_account", + Audience: fmt.Sprintf("//iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s/providers/%s", wif.ProjectNumber, wif.PoolID, wif.ProviderID), + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + TokenURL: "https://sts.googleapis.com/v1/token", + ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken", serviceAccountEmail), + CredentialSource: CredentialSource{ + File: "/var/run/secrets/openshift/serviceaccount/token", + Format: CredentialSourceFormat{ + Type: "text", + }, + }, + } + + credentialJSON, err := json.Marshal(credConfig) + if err != nil { + return "", fmt.Errorf("failed to marshal GCP credential configuration: %w", err) + } + + return string(credentialJSON), nil +} diff --git a/support/gcputil/gcputil_test.go b/support/gcputil/gcputil_test.go new file mode 100644 index 000000000000..f6fc908b8611 --- /dev/null +++ b/support/gcputil/gcputil_test.go @@ -0,0 +1,100 @@ +package gcputil + +import ( + "testing" + + . "github.com/onsi/gomega" + + hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1" +) + +const ( + testImageRegistryGSA = "image-registry@test-project.iam.gserviceaccount.com" + testProjectNumber = "123456789012" + testPoolID = "test-pool" + testProviderID = "test-provider" +) + +func TestBuildWorkloadIdentityCredentials(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + wif := hyperv1.GCPWorkloadIdentityConfig{ + ProjectNumber: testProjectNumber, + PoolID: testPoolID, + ProviderID: testProviderID, + } + + credentials, err := BuildWorkloadIdentityCredentials(wif, testImageRegistryGSA) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(credentials).To(ContainSubstring(`"type":"external_account"`)) + g.Expect(credentials).To(ContainSubstring(testProjectNumber)) + g.Expect(credentials).To(ContainSubstring(testPoolID)) + g.Expect(credentials).To(ContainSubstring(testProviderID)) + g.Expect(credentials).To(ContainSubstring(testImageRegistryGSA)) + g.Expect(credentials).To(ContainSubstring("/var/run/secrets/openshift/serviceaccount/token")) +} + +func TestBuildWorkloadIdentityCredentialsValidation(t *testing.T) { + t.Parallel() + validWIF := func() hyperv1.GCPWorkloadIdentityConfig { + return hyperv1.GCPWorkloadIdentityConfig{ + ProjectNumber: testProjectNumber, + PoolID: testPoolID, + ProviderID: testProviderID, + } + } + + tests := []struct { + name string + mutateWIF func(*hyperv1.GCPWorkloadIdentityConfig) + serviceAccountEmail string + errorMsg string + }{ + { + name: "When all fields are valid it should succeed", + serviceAccountEmail: testImageRegistryGSA, + }, + { + name: "When project number is empty it should return an error", + mutateWIF: func(wif *hyperv1.GCPWorkloadIdentityConfig) { wif.ProjectNumber = "" }, + serviceAccountEmail: testImageRegistryGSA, + errorMsg: "project number cannot be empty", + }, + { + name: "When pool ID is empty it should return an error", + mutateWIF: func(wif *hyperv1.GCPWorkloadIdentityConfig) { wif.PoolID = "" }, + serviceAccountEmail: testImageRegistryGSA, + errorMsg: "pool ID cannot be empty", + }, + { + name: "When provider ID is empty it should return an error", + mutateWIF: func(wif *hyperv1.GCPWorkloadIdentityConfig) { wif.ProviderID = "" }, + serviceAccountEmail: testImageRegistryGSA, + errorMsg: "provider ID cannot be empty", + }, + { + name: "When service account email is empty it should return an error", + serviceAccountEmail: "", + errorMsg: "service account email cannot be empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + wif := validWIF() + if tt.mutateWIF != nil { + tt.mutateWIF(&wif) + } + _, err := BuildWorkloadIdentityCredentials(wif, tt.serviceAccountEmail) + if tt.errorMsg != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.errorMsg)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + }) + } +}