Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
18 changes: 18 additions & 0 deletions data/data/install.openshift.io_installconfigs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6715,6 +6715,24 @@ spec:
- value
type: object
type: array
workloadIdentityFederation:
description: |-
WorkloadIdentityFederation configures GCP Workload Identity Federation
for short-lived token authentication. When set, the installer generates
external_account credential manifests instead of static service account keys.
properties:
poolID:
description: |-
PoolID is the ID of an existing workload identity pool.
When both poolID and providerID are provided, the installer uses the
existing WIF resources (BYO mode). When both are omitted, the installer
creates new WIF resources.
type: string
providerID:
description: ProviderID is the ID of an existing OIDC provider
within the pool.
type: string
type: object
required:
- projectID
- region
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ data:
azure_resource_prefix: {{.CloudCreds.Azure.Base64encodeResourcePrefix}}
azure_resourcegroup: {{.CloudCreds.Azure.Base64encodeResourceGroup}}
azure_region: {{.CloudCreds.Azure.Base64encodeRegion}}
{{- else if .CloudCreds.GCPWIF}}
service_account.json: {{.CloudCreds.GCPWIF.Base64encodeExternalAccountJSON}}
{{- else if .CloudCreds.GCP}}
service_account.json: {{.CloudCreds.GCP.Base64encodeServiceAccount}}
{{- else if .CloudCreds.IBMCloud}}
Expand Down Expand Up @@ -40,6 +42,8 @@ metadata:
name: aws-creds
{{- else if .CloudCreds.Azure}}
name: azure-credentials
{{- else if .CloudCreds.GCPWIF}}
name: gcp-credentials
{{- else if .CloudCreds.GCP}}
name: gcp-credentials
{{- else if .CloudCreds.IBMCloud}}
Expand Down
15 changes: 8 additions & 7 deletions pkg/asset/cluster/gcp/gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ func Metadata(config *types.InstallConfig) *gcp.Metadata {
}

return &gcp.Metadata{
Region: config.Platform.GCP.Region,
ProjectID: config.Platform.GCP.ProjectID,
NetworkProjectID: config.Platform.GCP.NetworkProjectID,
PrivateZoneDomain: privateZoneDomain,
PrivateZoneProjectID: privateZoneProject,
Endpoint: config.Platform.GCP.Endpoint,
FirewallRulesManagement: config.GCP.FirewallRulesManagement,
Region: config.Platform.GCP.Region,
ProjectID: config.Platform.GCP.ProjectID,
NetworkProjectID: config.Platform.GCP.NetworkProjectID,
PrivateZoneDomain: privateZoneDomain,
PrivateZoneProjectID: privateZoneProject,
Endpoint: config.Platform.GCP.Endpoint,
FirewallRulesManagement: config.GCP.FirewallRulesManagement,
WorkloadIdentityFederation: config.Platform.GCP.WorkloadIdentityFederation,
}
}
24 changes: 24 additions & 0 deletions pkg/asset/installconfig/gcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
compute "google.golang.org/api/compute/v1"
dns "google.golang.org/api/dns/v1"
"google.golang.org/api/googleapi"
"google.golang.org/api/iam/v1"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
serviceusage "google.golang.org/api/serviceusage/v1beta1"
Expand Down Expand Up @@ -60,6 +61,7 @@ type API interface {
GetKeyRing(ctx context.Context, kmsKeyRef *gcptypes.KMSKeyReference) (*kmspb.KeyRing, error)
UpdateDNSPrivateZoneLabels(ctx context.Context, baseDomain, project, zoneName string, labels map[string]string) error
GetPrivateServiceConnectEndpoint(ctx context.Context, project string, endpoint *gcptypes.PSCEndpoint) (*compute.ForwardingRule, error)
GetWIFProvider(ctx context.Context, project, poolID, providerID string) (*iam.WorkloadIdentityPoolProvider, error)
}

// Client makes calls to the GCP API.
Expand Down Expand Up @@ -795,6 +797,28 @@ func (c *Client) GetPrivateServiceConnectEndpoint(ctx context.Context, project s
return GetPrivateServiceConnectEndpoint(svc, project, endpoint)
}

// GetWIFProvider retrieves a Workload Identity Pool Provider.
func (c *Client) GetWIFProvider(ctx context.Context, project, poolID, providerID string) (*iam.WorkloadIdentityPoolProvider, error) {
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()

opts := []option.ClientOption{}
if c.endpointName != "" {
opts = append(opts, CreateEndpointOption(c.endpointName, ServiceNameGCPIAM))
}
iamSvc, err := GetIAMService(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create IAM service: %w", err)
}

name := fmt.Sprintf("projects/%s/locations/global/workloadIdentityPools/%s/providers/%s", project, poolID, providerID)
provider, err := iamSvc.Projects.Locations.WorkloadIdentityPools.Providers.Get(name).Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("failed to get WIF provider %s: %w", name, err)
}
return provider, nil
}

// aiZone returns true if the GCP zone follows the AI naming convention.
// Uses the regular expression pattern as documented in GCP API docs:
// "To match zones containing ai in their name, use the filter query parameter with the regular expression name eq '.*-ai.*'."
Expand Down
16 changes: 16 additions & 0 deletions pkg/asset/installconfig/gcp/mock/gcpclient_generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions pkg/asset/installconfig/gcp/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func Validate(client API, ic *types.InstallConfig) error {
allErrs = append(allErrs, validateMarketplaceImages(client, ic)...)
allErrs = append(allErrs, validatePlatformKMSKeys(client, ic, field.NewPath("platform").Child("gcp"))...)
allErrs = append(allErrs, validateServiceEndpointOverride(client, ic, field.NewPath("platform").Child("gcp"))...)
allErrs = append(allErrs, validateWIFProvider(client, ic)...)

if err := validateUserTags(client, ic.Platform.GCP.ProjectID, ic.Platform.GCP.UserTags); err != nil {
allErrs = append(allErrs, field.Invalid(field.NewPath("platform").Child("gcp").Child("userTags"), ic.Platform.GCP.UserTags, err.Error()))
Expand Down Expand Up @@ -731,6 +732,46 @@ func ValidateCredentialMode(client API, ic *types.InstallConfig) field.ErrorList
errMsg := "Manual credentials mode needs to be enabled to use environmental authentication"
return append(allErrs, field.Forbidden(field.NewPath("credentialsMode"), errMsg))
}

if ic.GCP.IsWIFEnabled() && ic.CredentialsMode != types.ManualCredentialsMode {
errMsg := "workload identity federation requires Manual credentials mode"
return append(allErrs, field.Forbidden(field.NewPath("credentialsMode"), errMsg))
}

return allErrs
}

func validateWIFProvider(client API, ic *types.InstallConfig) field.ErrorList {
allErrs := field.ErrorList{}
if !ic.GCP.IsWIFBYO() {
return allErrs
}

fldPath := field.NewPath("platform").Child("gcp").Child("workloadIdentityFederation")
wif := ic.GCP.WorkloadIdentityFederation

provider, err := client.GetWIFProvider(context.TODO(), ic.GCP.ProjectID, wif.PoolID, wif.ProviderID)
if err != nil {
var ae *googleapi.Error
if errors.As(err, &ae) && ae.Code == 404 {
return append(allErrs, field.NotFound(fldPath.Child("providerID"), wif.ProviderID))
}
return append(allErrs, field.InternalError(fldPath.Child("providerID"), err))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if provider.State != "ACTIVE" {
allErrs = append(allErrs, field.Invalid(fldPath.Child("providerID"), wif.ProviderID,
fmt.Sprintf("WIF provider is not active (state: %s)", provider.State)))
}

if provider.Oidc == nil {
allErrs = append(allErrs, field.Invalid(fldPath.Child("providerID"), wif.ProviderID,
"WIF provider does not have an OIDC configuration"))
} else if provider.Oidc.IssuerUri == "" {
allErrs = append(allErrs, field.Invalid(fldPath.Child("providerID"), wif.ProviderID,
"WIF provider OIDC issuer URI is empty"))
}

return allErrs
}

Expand Down
93 changes: 83 additions & 10 deletions pkg/asset/manifests/openshift.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package manifests
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path"
Expand All @@ -14,6 +15,7 @@ import (
"github.com/gophercloud/utils/v2/openstack/clientconfig"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"google.golang.org/api/option"
"k8s.io/apimachinery/pkg/util/sets"
"sigs.k8s.io/yaml"

Expand Down Expand Up @@ -137,15 +139,27 @@ func (o *Openshift) Generate(ctx context.Context, dependencies asset.Parents) er
},
}
case gcptypes.Name:
session, err := gcp.GetSession(ctx)
if err != nil {
return err
}
creds := session.Credentials.JSON
cloudCreds = cloudCredsSecretData{
GCP: &GCPCredsSecretData{
Base64encodeServiceAccount: base64.StdEncoding.EncodeToString(creds),
},
if installConfig.Config.GCP.IsWIFEnabled() {
externalAccountJSON, err := generateGCPExternalAccountJSON(ctx, installConfig, clusterID.InfraID)
if err != nil {
return err
}
cloudCreds = cloudCredsSecretData{
GCPWIF: &GCPWIFCredsSecretData{
Base64encodeExternalAccountJSON: base64.StdEncoding.EncodeToString(externalAccountJSON),
},
}
} else {
session, err := gcp.GetSession(ctx)
if err != nil {
return err
}
creds := session.Credentials.JSON
cloudCreds = cloudCredsSecretData{
GCP: &GCPCredsSecretData{
Base64encodeServiceAccount: base64.StdEncoding.EncodeToString(creds),
},
}
}
case ibmcloudtypes.Name:
client, err := ibmcloud.NewClient(installConfig.Config.Platform.IBMCloud.ServiceEndpoints)
Expand Down Expand Up @@ -277,7 +291,11 @@ func (o *Openshift) Generate(ctx context.Context, dependencies asset.Parents) er

switch platform {
case awstypes.Name, openstacktypes.Name, powervctypes.Name, vspheretypes.Name, azuretypes.Name, gcptypes.Name, ibmcloudtypes.Name, ovirttypes.Name:
if installConfig.Config.CredentialsMode != types.ManualCredentialsMode {
skipCloudCreds := installConfig.Config.CredentialsMode == types.ManualCredentialsMode
if installConfig.Config.Platform.GCP != nil && installConfig.Config.GCP.IsWIFEnabled() {
skipCloudCreds = false
}
if !skipCloudCreds {
assetData["99_cloud-creds-secret.yaml"] = applyTemplateData(cloudCredsSecret.Files()[0].Data, templateData)
}
assetData["99_role-cloud-creds-secret-reader.yaml"] = applyTemplateData(roleCloudCredsSecretReader.Files()[0].Data, templateData)
Expand Down Expand Up @@ -342,3 +360,58 @@ func (o *Openshift) Load(f asset.FileFetcher) (bool, error) {
asset.SortFiles(o.FileList)
return len(o.FileList) > 0, nil
}

func generateGCPExternalAccountJSON(ctx context.Context, ic *installconfig.InstallConfig, infraID string) ([]byte, error) {
projectID := ic.Config.GCP.ProjectID
wif := ic.Config.GCP.WorkloadIdentityFederation

poolID := wif.PoolID
providerID := wif.ProviderID
if poolID == "" {
poolID = fmt.Sprintf("%s-wif-pool", infraID)
}
if providerID == "" {
providerID = fmt.Sprintf("%s-oidc-provider", infraID)
}

opts := []option.ClientOption{}
if gcptypes.ShouldUseEndpointForInstaller(ic.Config.GCP.Endpoint) {
opts = append(opts, gcp.CreateEndpointOption(ic.Config.GCP.Endpoint.Name, gcp.ServiceNameGCPCloudResource))
}
crmSvc, err := gcp.GetCloudResourceService(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create resource manager service: %w", err)
}

project, err := crmSvc.Projects.Get(fmt.Sprintf("projects/%s", projectID)).Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("failed to get project number for %s: %w", projectID, err)
}
projectNumber := ""
if len(project.Name) > 9 {
projectNumber = project.Name[9:]
}
if projectNumber == "" {
return nil, fmt.Errorf("unexpected project name format: %s", project.Name)
}

audience := fmt.Sprintf(
"//iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s/providers/%s",
projectNumber, poolID, providerID,
)

externalAccount := map[string]any{
"type": "external_account",
"audience": audience,
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"credential_source": map[string]any{
"file": "/var/run/secrets/openshift/serviceaccount/token",
"format": map[string]string{
"type": "text",
},
},
}

return json.Marshal(externalAccount)
}
6 changes: 6 additions & 0 deletions pkg/asset/manifests/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ type GCPCredsSecretData struct {
Base64encodeServiceAccount string
}

// GCPWIFCredsSecretData holds encoded WIF external_account credentials.
type GCPWIFCredsSecretData struct {
Base64encodeExternalAccountJSON string
}

// IBMCloudCredsSecretData holds encoded credentials and is used to generate cloud-creds secret
type IBMCloudCredsSecretData struct {
Base64encodeAPIKey string
Expand Down Expand Up @@ -59,6 +64,7 @@ type cloudCredsSecretData struct {
AWS *AwsCredsSecretData
Azure *AzureCredsSecretData
GCP *GCPCredsSecretData
GCPWIF *GCPWIFCredsSecretData
IBMCloud *IBMCloudCredsSecretData
OpenStack *OpenStackCredsSecretData
VSphere *[]*VSphereCredsSecretData
Expand Down
9 changes: 9 additions & 0 deletions pkg/destroy/gcp/gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ type ClusterUninstaller struct {

firewallRulesManagement gcptypes.FirewallRulesManagementPolicy

wifEnabled bool
wifBYO bool

errorTracker
requestIDTracker
pendingItemTracker
Expand All @@ -106,6 +109,10 @@ func New(logger logrus.FieldLogger, metadata *types.ClusterMetadata) (providers.
pendingItemTracker: newPendingItemTracker(),
endpoint: metadata.ClusterPlatformMetadata.GCP.Endpoint,
firewallRulesManagement: metadata.ClusterPlatformMetadata.GCP.FirewallRulesManagement,
wifEnabled: metadata.ClusterPlatformMetadata.GCP.WorkloadIdentityFederation != nil,
wifBYO: metadata.ClusterPlatformMetadata.GCP.WorkloadIdentityFederation != nil &&
metadata.ClusterPlatformMetadata.GCP.WorkloadIdentityFederation.PoolID != "" &&
metadata.ClusterPlatformMetadata.GCP.WorkloadIdentityFederation.ProviderID != "",
}, nil
}

Expand Down Expand Up @@ -203,6 +210,7 @@ func (o *ClusterUninstaller) destroyCluster() (bool, error) {
}, {
{name: "Instances", execute: o.destroyInstances},
{name: "Disks", execute: o.destroyDisks},
{name: "WIF Providers", execute: o.destroyWIFProviders},
{name: "Service accounts", execute: o.destroyServiceAccounts},
{name: "Images", execute: o.destroyImages},
{name: "DNS", execute: o.destroyDNS},
Expand All @@ -221,6 +229,7 @@ func (o *ClusterUninstaller) destroyCluster() (bool, error) {
{name: "Subnetworks", execute: o.destroySubnetworks},
{name: "Networks", execute: o.destroyNetworks},
{name: "Filestores", execute: o.destroyFilestores},
{name: "WIF Pools", execute: o.destroyWIFPools},
}}

// create the main Context, so all stages can accept and make context children
Expand Down
Loading