diff --git a/providers/gce/gce.go b/providers/gce/gce.go index 4e0b2be6b8..2850a40692 100644 --- a/providers/gce/gce.go +++ b/providers/gce/gce.go @@ -21,6 +21,7 @@ package gce import ( "context" + "encoding/json" "fmt" "io" "runtime" @@ -558,9 +559,46 @@ func GenerateCloudConfig(configFile *ConfigFile) (cloudConfig *CloudConfig, err return cloudConfig, err } +// clientOptions returns GCP API client options for authentication. +// A custom TokenSource set in the config, is used directly. +// Otherwise, FindDefaultCredentials discovers credentials. +// WithCredentialsJSON is preferred when available as it uses +// self-signed JWTs, which may be necessary for custom universe domains. +func clientOptions(ts oauth2.TokenSource) ([]option.ClientOption, error) { + if ts != nil { + return []option.ClientOption{option.WithTokenSource(ts)}, nil + } + + creds, err := google.FindDefaultCredentials(context.Background(), compute.CloudPlatformScope) + if err != nil { + return nil, fmt.Errorf("failed to find default credentials: %w", err) + } + + var opts []option.ClientOption + if len(creds.JSON) > 0 { + var f struct { + Type string `json:"type"` + } + if err := json.Unmarshal(creds.JSON, &f); err != nil { + return nil, fmt.Errorf("failed to parse credentials JSON: %w", err) + } + opts = []option.ClientOption{option.WithAuthCredentialsJSON(option.CredentialsType(f.Type), creds.JSON)} + } else { + opts = []option.ClientOption{option.WithCredentials(creds)} + } + + if ud, err := creds.GetUniverseDomain(); err == nil { + opts = append(opts, option.WithUniverseDomain(ud)) + } else { + klog.Warningf("Failed to get universe domain from credentials: %v", err) + } + + return opts, nil +} + // CreateGCECloud creates a Cloud object using the specified parameters. // If no networkUrl is specified, loads networkName via rest call. -// If no tokenSource is specified, uses oauth2.DefaultTokenSource. +// If no tokenSource is specified, uses FindDefaultCredentials. // If managedZones is nil / empty all zones in the region will be managed. func CreateGCECloud(config *CloudConfig) (*Cloud, error) { // If ManagedZones was empty at startup, it means the cluster was configured @@ -580,19 +618,24 @@ func CreateGCECloud(config *CloudConfig) (*Cloud, error) { config.NetworkProjectID = config.ProjectID } - service, err := compute.NewService(context.Background(), option.WithTokenSource(config.TokenSource)) + clientOpts, err := clientOptions(config.TokenSource) + if err != nil { + return nil, err + } + + service, err := compute.NewService(context.Background(), clientOpts...) if err != nil { return nil, err } service.UserAgent = userAgent - serviceBeta, err := computebeta.NewService(context.Background(), option.WithTokenSource(config.TokenSource)) + serviceBeta, err := computebeta.NewService(context.Background(), clientOpts...) if err != nil { return nil, err } serviceBeta.UserAgent = userAgent - serviceAlpha, err := computealpha.NewService(context.Background(), option.WithTokenSource(config.TokenSource)) + serviceAlpha, err := computealpha.NewService(context.Background(), clientOpts...) if err != nil { return nil, err } @@ -610,7 +653,7 @@ func CreateGCECloud(config *CloudConfig) (*Cloud, error) { } } - containerService, err := container.NewService(context.Background(), option.WithTokenSource(config.TokenSource)) + containerService, err := container.NewService(context.Background(), clientOpts...) if err != nil { return nil, err } diff --git a/providers/gce/gce_test.go b/providers/gce/gce_test.go index 28fa17e304..72749cc87d 100644 --- a/providers/gce/gce_test.go +++ b/providers/gce/gce_test.go @@ -21,11 +21,15 @@ package gce import ( "context" + "os" + "path/filepath" "reflect" "strings" "testing" + "golang.org/x/oauth2" "golang.org/x/oauth2/google" + "google.golang.org/api/option" cloudprovider "k8s.io/cloud-provider" ) @@ -496,6 +500,78 @@ func TestGenerateCloudConfigs(t *testing.T) { } } +// optionTypeName returns the unexported type name of a ClientOption using reflection. +func optionTypeName(opt option.ClientOption) string { + t := reflect.TypeOf(opt) + if t.Kind() == reflect.Ptr { + return "*" + t.Elem().Name() + } + return t.Name() +} + +// writeFakeCredentials writes a minimal service account JSON to a temp file +// and sets GOOGLE_APPLICATION_CREDENTIALS to point at it. +func writeFakeCredentials(t *testing.T) { + t.Helper() + fakeJSON := `{ + "type": "service_account", + "project_id": "test-project", + "private_key_id": "key-id", + "private_key": "fake-key", + "client_email": "test@test-project.iam.gserviceaccount.com", + "client_id": "123456789", + "token_uri": "https://oauth2.googleapis.com/token" + }` + f := filepath.Join(t.TempDir(), "creds.json") + if err := os.WriteFile(f, []byte(fakeJSON), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", f) +} + +func TestClientOptions(t *testing.T) { + tests := []struct { + name string + ts oauth2.TokenSource + wantOptTypes []string + }{ + { + name: "custom token source uses WithTokenSource", + ts: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test"}), + wantOptTypes: []string{ + "withTokenSource", + }, + }, + { + name: "JSON credentials uses WithAuthCredentialsJSON", + wantOptTypes: []string{ + "withAuthCredentialsJSON", + "withUniverseDomain", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writeFakeCredentials(t) + + opts, err := clientOptions(tt.ts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(opts) != len(tt.wantOptTypes) { + t.Fatalf("got %d options, want %d", len(opts), len(tt.wantOptTypes)) + } + for i, wantType := range tt.wantOptTypes { + if got := optionTypeName(opts[i]); got != wantType { + t.Errorf("opts[%d] type = %s, want %s", i, got, wantType) + } + } + }) + } +} + func TestNewAlphaFeatureGate(t *testing.T) { testCases := []struct { alphaFeatures []string