Skip to content
Open
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
42 changes: 42 additions & 0 deletions cmd/cluster/gcp/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"fmt"
"os"
"regexp"
"strings"

hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1"
"github.com/openshift/hypershift/cmd/cluster/core"
Expand All @@ -20,6 +22,11 @@ import (

var _ core.Platform = (*CreateOptions)(nil)

var (
gcpResourceLabelKeyPattern = regexp.MustCompile(`^[a-z]([_a-z0-9-]{0,61}[a-z0-9])?$`)
gcpResourceLabelValuePattern = regexp.MustCompile(`^$|^[a-z]([_a-z0-9-]{0,61}[a-z0-9])?$`)
)

const (
SATokenIssuerSecret = "sa-token-issuer-key"

Expand All @@ -43,6 +50,7 @@ const (
flagZone = "zone"
flagSubnet = "subnet"
flagBootImage = "boot-image"
flagResourceLabels = "resource-labels"
)

// RawCreateOptions contains the raw command-line options for creating a GCP cluster
Expand Down Expand Up @@ -106,6 +114,9 @@ type RawCreateOptions struct {

// BootImage is the GCP boot image for node instances. Overrides the default RHCOS image from the release payload
BootImage string

// ResourceLabels are labels applied to GCP resources created for the hosted cluster.
ResourceLabels []string
}

// BindOptions binds the GCP-specific flags to the provided flag set
Expand All @@ -130,6 +141,7 @@ func BindOptions(opts *RawCreateOptions, flags *pflag.FlagSet) {
flags.StringVar(&opts.Zone, flagZone, "", "GCP zone for node instances (e.g. us-central1-a). Required when --node-pool-replicas >= 0")
flags.StringVar(&opts.Subnet, flagSubnet, "", "Subnet name for node instances. Defaults to the PSC subnet value")
flags.StringVar(&opts.BootImage, flagBootImage, "", "GCP boot image for node instances. Overrides the default RHCOS image from the release payload")
flags.StringSliceVar(&opts.ResourceLabels, flagResourceLabels, opts.ResourceLabels, "Comma-separated GCP resource labels to apply to cluster resources (key=value). Can be specified multiple times.")
}

// ValidatedCreateOptions represents validated options for creating a GCP cluster
Expand Down Expand Up @@ -277,6 +289,11 @@ func serviceAccountTokenIssuerSecret(namespace, name string) *corev1.Secret {

// ApplyPlatformSpecifics applies GCP-specific configurations to the HostedCluster
func (o *CreateOptions) ApplyPlatformSpecifics(hostedCluster *hyperv1.HostedCluster) error {
resourceLabels, err := parseResourceLabels(o.ResourceLabels)
if err != nil {
return err
}

hostedCluster.Spec.Platform.Type = hyperv1.GCPPlatform
hostedCluster.Spec.Platform.GCP = &hyperv1.GCPPlatformSpec{
Project: o.Project,
Expand All @@ -303,6 +320,7 @@ func (o *CreateOptions) ApplyPlatformSpecifics(hostedCluster *hyperv1.HostedClus
},
},
EndpointAccess: hyperv1.GCPEndpointAccessType(o.EndpointAccess),
ResourceLabels: resourceLabels,
}

hostedCluster.Spec.IssuerURL = o.IssuerURL
Expand Down Expand Up @@ -335,6 +353,30 @@ func (o *CreateOptions) ApplyPlatformSpecifics(hostedCluster *hyperv1.HostedClus
return nil
}

func parseResourceLabels(labels []string) ([]hyperv1.GCPResourceLabel, error) {
result := make([]hyperv1.GCPResourceLabel, 0, len(labels))
seen := make(map[string]struct{}, len(labels))
for _, label := range labels {
key, value, found := strings.Cut(label, "=")
if !found || key == "" {
return nil, fmt.Errorf("invalid --%s value %q: expected key=value", flagResourceLabels, label)
}
// GCP reserves the goog prefix for its own use; goog-partner-solution is the documented exception.
if !gcpResourceLabelKeyPattern.MatchString(key) || (strings.HasPrefix(key, "goog") && key != "goog-partner-solution") {
Comment thread
gbarabasz marked this conversation as resolved.
return nil, fmt.Errorf("invalid --%s value %q: invalid label key", flagResourceLabels, label)
}
if !gcpResourceLabelValuePattern.MatchString(value) {
return nil, fmt.Errorf("invalid --%s value %q: invalid label value", flagResourceLabels, label)
}
if _, exists := seen[key]; exists {
return nil, fmt.Errorf("invalid --%s value %q: duplicate key %q", flagResourceLabels, label, key)
}
seen[key] = struct{}{}
result = append(result, hyperv1.GCPResourceLabel{Key: key, Value: &value})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return result, nil
}

// GenerateNodePools generates the NodePool resources for GCP
func (o *CreateOptions) GenerateNodePools(constructor core.DefaultNodePoolConstructor) []*hyperv1.NodePool {
nodePool := constructor(hyperv1.GCPPlatform, "")
Expand Down
73 changes: 73 additions & 0 deletions cmd/cluster/gcp/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"

. "github.com/onsi/gomega"
Expand Down Expand Up @@ -41,6 +42,7 @@ func TestCreateOptionsApplyPlatformSpecifics(t *testing.T) {
StorageServiceAccount: "storage@test-project-123.iam.gserviceaccount.com",
ImageRegistryServiceAccount: "imageregistry@test-project-123.iam.gserviceaccount.com",
NetworkServiceAccount: "network@test-project-123.iam.gserviceaccount.com",
ResourceLabels: []string{"hypershift-e2e=resource-labels"},
},
},
},
Expand All @@ -66,6 +68,77 @@ func TestCreateOptionsApplyPlatformSpecifics(t *testing.T) {
g.Expect(hostedCluster.Spec.Platform.GCP.WorkloadIdentity.ServiceAccountsEmails.Storage).To(Equal(hyperv1.GCPServiceAccountEmail("storage@test-project-123.iam.gserviceaccount.com")))
g.Expect(hostedCluster.Spec.Platform.GCP.WorkloadIdentity.ServiceAccountsEmails.ImageRegistry).To(Equal(hyperv1.GCPServiceAccountEmail("imageregistry@test-project-123.iam.gserviceaccount.com")))
g.Expect(hostedCluster.Spec.Platform.GCP.WorkloadIdentity.ServiceAccountsEmails.Network).To(Equal(hyperv1.GCPServiceAccountEmail("network@test-project-123.iam.gserviceaccount.com")))
g.Expect(hostedCluster.Spec.Platform.GCP.ResourceLabels).To(Equal([]hyperv1.GCPResourceLabel{{
Key: "hypershift-e2e", Value: func() *string { value := "resource-labels"; return &value }(),
}}))
}

func TestParseResourceLabels(t *testing.T) {
tests := map[string]struct {
labels []string
expected []hyperv1.GCPResourceLabel
expectError string
}{
"When resource labels are valid, it should convert them to GCP resource labels": {
labels: []string{"environment=ci", "cost-center=cost1234", "empty="},
expected: []hyperv1.GCPResourceLabel{
{Key: "environment", Value: func() *string { value := "ci"; return &value }()},
{Key: "cost-center", Value: func() *string { value := "cost1234"; return &value }()},
{Key: "empty", Value: func() *string { value := ""; return &value }()},
},
},
"When a resource label has no value separator, it should return an error": {
labels: []string{"environment"},
expectError: "expected key=value",
},
"When a resource label has an empty key, it should return an error": {
labels: []string{"=ci"},
expectError: "expected key=value",
},
"When resource label keys are duplicated, it should return an error": {
labels: []string{"environment=ci", "environment=test"},
expectError: "duplicate key",
},
"When a resource label key violates GCP requirements, it should return an error": {
labels: []string{"UPPER=value"},
expectError: "invalid label key",
},
"When a resource label key uses a reserved goog prefix, it should return an error": {
labels: []string{"goog-managed=value"},
expectError: "invalid label key",
},
"When a resource label value violates GCP requirements, it should return an error": {
labels: []string{"environment=value=with-equals"},
expectError: "invalid label value",
},
"When a resource label key exceeds GCP limits, it should return an error": {
labels: []string{strings.Repeat("a", 64) + "=value"},
expectError: "invalid label key",
},
"When a resource label value exceeds GCP limits, it should return an error": {
labels: []string{"environment=" + strings.Repeat("a", 64)},
expectError: "invalid label value",
},
"When a resource label uses the Google partner exception, it should be accepted": {
labels: []string{"goog-partner-solution=openshift"},
expected: []hyperv1.GCPResourceLabel{
{Key: "goog-partner-solution", Value: func() *string { value := "openshift"; return &value }()},
},
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
g := NewGomegaWithT(t)
actual, err := parseResourceLabels(tc.labels)
if tc.expectError != "" {
g.Expect(err).To(MatchError(ContainSubstring(tc.expectError)))
return
}
g.Expect(err).NotTo(HaveOccurred())
g.Expect(actual).To(Equal(tc.expected))
})
}
}

func TestValidateGCPOptions(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions test/e2e/v2/internal/env_vars.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ func init() {
"Management-cluster kubeconfig for the target role in a cross-cluster restore test.",
false,
)
RegisterEnvVar(
"SHARED_DIR",
"CI shared directory containing platform-specific test configuration such as GCP workload identity credentials.",
false,
)
RegisterEnvVar(
"E2E_SHOW_ENV_HELP",
"When set to any non-empty value, displays environment variable help and exits without running tests.",
Expand Down
Loading