Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
cblecker marked this conversation as resolved.
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()

Comment thread
cblecker marked this conversation as resolved.
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
}
Original file line number Diff line number Diff line change
@@ -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")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)...)
}
}
Comment thread
cblecker marked this conversation as resolved.
return errs
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading