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
127 changes: 127 additions & 0 deletions auth/gcp/gke_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
Copyright 2025 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package gcp

import (
"context"
"fmt"
"sync"

"cloud.google.com/go/compute/metadata"
)

type gkeMetadataLoader struct {
projectID string
location string
name string

mu sync.RWMutex
loaded bool
}

var gkeMetadata gkeMetadataLoader

func (g *gkeMetadataLoader) getAudience(ctx context.Context) (string, error) {
if err := g.load(ctx); err != nil {
return "", err
}
wiPool, _ := g.workloadIdentityPool(ctx)
wiProvider, _ := g.workloadIdentityProvider(ctx)
return fmt.Sprintf("identitynamespace:%s:%s", wiPool, wiProvider), nil
}

func (g *gkeMetadataLoader) workloadIdentityPool(ctx context.Context) (string, error) {
if err := g.load(ctx); err != nil {
return "", err
}
return fmt.Sprintf("%s.svc.id.goog", g.projectID), nil
}

func (g *gkeMetadataLoader) workloadIdentityProvider(ctx context.Context) (string, error) {
if err := g.load(ctx); err != nil {
return "", err
}
return fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s",
g.projectID,
g.location,
g.name), nil
}

// load loads the GKE cluster metadata from the metadata service, assuming the
// pod is running on a GKE node/pod. It will fail otherwise, and this
// is the reason why this method should be called lazily. If this code ran on any
// other cluster that is not GKE it would fail consistently and throw the pods
// in crash loop if running on startup. This method is thread-safe and will
// only load the metadata successfully once.
//
// Technically we could receive options here to use a custom HTTP client with
// a proxy, but this proxy is configured at the object level and here we are
// loading cluster-level metadata that doesn't change during the lifetime of
// the pod. So we can't use an object-level proxy here. Furthermore, this
// implementation targets specifically GKE clusters, and in such clusters the
// metadata server is usually a DaemonSet pod that serves only node-local
// traffic, so a proxy doesn't make sense here anyway.
func (g *gkeMetadataLoader) load(ctx context.Context) error {
// Bail early if the metadata was already loaded.
g.mu.RLock()
loaded := g.loaded
g.mu.RUnlock()
if loaded {
return nil
}

g.mu.Lock()
defer g.mu.Unlock()

// Check again if the metadata was loaded while we were waiting for the lock.
if g.loaded {
return nil
}

client := metadata.NewClient(nil)

projectID, err := client.GetWithContext(ctx, "project/project-id")
if err != nil {
return fmt.Errorf("failed to get GKE cluster project ID from the metadata service: %w", err)
}
if projectID == "" {
return fmt.Errorf("failed to get GKE cluster project ID from the metadata service: empty value")
}

location, err := client.GetWithContext(ctx, "instance/attributes/cluster-location")
if err != nil {
return fmt.Errorf("failed to get GKE cluster location from the metadata service: %w", err)
}
if location == "" {
return fmt.Errorf("failed to get GKE cluster location from the metadata service: empty value")
}

name, err := client.GetWithContext(ctx, "instance/attributes/cluster-name")
if err != nil {
return fmt.Errorf("failed to get GKE cluster name from the metadata service: %w", err)
}
if name == "" {
return fmt.Errorf("failed to get GKE cluster name from the metadata service: empty value")
}

g.projectID = projectID
g.location = location
g.name = name
g.loaded = true

return nil
}
40 changes: 40 additions & 0 deletions auth/gcp/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
Copyright 2025 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package gcp

import (
"fmt"
"regexp"

corev1 "k8s.io/api/core/v1"
)

const serviceAccountEmailPattern = `^[a-zA-Z0-9-]{1,100}@[a-zA-Z0-9-]{1,100}\.iam\.gserviceaccount\.com$`

var serviceAccountEmailRegex = regexp.MustCompile(serviceAccountEmailPattern)

func getServiceAccountEmail(serviceAccount corev1.ServiceAccount) (string, error) {
email := serviceAccount.Annotations["iam.gke.io/gcp-service-account"]
if email == "" {
return "", nil
}
if !serviceAccountEmailRegex.MatchString(email) {
return "", fmt.Errorf("invalid GCP service account email: '%s'. must match %s",
email, serviceAccountEmailPattern)
}
return email, nil
}
150 changes: 150 additions & 0 deletions auth/gcp/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
Copyright 2025 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package gcp

import (
"context"
"fmt"

"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/google/externalaccount"
corev1 "k8s.io/api/core/v1"

auth "github.com/fluxcd/pkg/auth"
)

// ProviderName is the name of the GCP authentication provider.
const ProviderName = "gcp"

var scopes = []string{
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
}

// Provider implements the auth.Provider interface for GCP authentication.
type Provider struct{}

// GetName implements auth.Provider.
func (Provider) GetName() string {
return ProviderName
}

// NewDefaultToken implements auth.Provider.
func (Provider) NewDefaultToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) {
var o auth.Options
o.Apply(opts...)

if hc := o.GetHTTPClient(); hc != nil {
ctx = context.WithValue(ctx, oauth2.HTTPClient, hc)
}

src, err := google.DefaultTokenSource(ctx, scopes...)
if err != nil {
return nil, err
}
token, err := src.Token()
if err != nil {
return nil, err
}

return &Token{*token}, nil
}

// GetAudience implements auth.Provider.
func (Provider) GetAudience(ctx context.Context) (string, error) {
return gkeMetadata.workloadIdentityPool(ctx)
}

// GetIdentity implements auth.Provider.
func (Provider) GetIdentity(serviceAccount corev1.ServiceAccount) (string, error) {
email, err := getServiceAccountEmail(serviceAccount)
if err != nil {
return "", err
}
return email, nil
}

// NewTokenForServiceAccount implements auth.Provider.
func (Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken string,
serviceAccount corev1.ServiceAccount, opts ...auth.Option) (auth.Token, error) {

var o auth.Options
o.Apply(opts...)

audience, err := gkeMetadata.getAudience(ctx)
if err != nil {
return nil, err
}

conf := externalaccount.Config{
UniverseDomain: "googleapis.com",
Audience: audience,
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
TokenURL: "https://sts.googleapis.com/v1/token",
SubjectTokenSupplier: tokenSupplier(oidcToken),
Scopes: scopes,
}

email, err := getServiceAccountEmail(serviceAccount)
if err != nil {
return nil, err
}

if email != "" { // impersonation
conf.ServiceAccountImpersonationURL = fmt.Sprintf(
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
email)
} else { // direct access
conf.TokenInfoURL = "https://sts.googleapis.com/v1/introspect"
}

if hc := o.GetHTTPClient(); hc != nil {
ctx = context.WithValue(ctx, oauth2.HTTPClient, hc)
}

src, err := externalaccount.NewTokenSource(ctx, conf)
if err != nil {
return nil, err
}
token, err := src.Token()
if err != nil {
return nil, err
}

return &Token{*token}, nil
}

// GetArtifactCacheKey implements auth.Provider.
func (Provider) GetArtifactCacheKey(artifactRepository string) string {
// The artifact repository is irrelevant for GCP registry credentials.
return ProviderName
}

// NewArtifactRegistryToken implements auth.Provider.
func (Provider) NewArtifactRegistryToken(ctx context.Context, artifactRepository string,
accessToken auth.Token, opts ...auth.Option) (auth.Token, error) {

t := accessToken.(*Token)

// The artifact repository is irrelevant for GCP registry credentials.
return &auth.ArtifactRegistryCredentials{
Username: "oauth2accesstoken",
Password: t.AccessToken,
ExpiresAt: t.Expiry,
}, nil
}
36 changes: 36 additions & 0 deletions auth/gcp/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
Copyright 2025 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package gcp

import (
"time"

"golang.org/x/oauth2"
)

// Token is the GCP token.
type Token struct{ oauth2.Token }

// GetDuration implements auth.Token.
func (t *Token) GetDuration() time.Duration {
return time.Until(t.Expiry)
}

// Source gets a token source for the token to use with GCP libraries.
func (t *Token) Source() oauth2.TokenSource {
return oauth2.StaticTokenSource(&t.Token)
}
Loading
Loading