diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index d62383c1a..140af6dae 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -558,8 +558,8 @@ func (b *ByocGcp) Subscribe(ctx context.Context, req *defangv1.SubscribeRequest) } // TODO: update stack (1st param) to b.PulumiStack - subscribeStream.AddJobStatusUpdate("", req.Project, req.Etag, req.Services) - subscribeStream.AddServiceStatusUpdate("", req.Project, req.Etag, req.Services) + subscribeStream.AddJobStatusUpdate(b.PulumiStack, req.Project, req.Etag, req.Services) + subscribeStream.AddServiceStatusUpdate(b.PulumiStack, req.Project, req.Etag, req.Services) subscribeStream.StartFollow(time.Now()) return subscribeStream, nil } @@ -598,14 +598,12 @@ func (b *ByocGcp) getLogStream(ctx context.Context, gcpLogsClient GcpLogsClient, if execName == "." { execName = "" } - logStream.AddJobExecutionLog(execName) // CD log when there is an execution name - // TODO: update stack (1st param) to b.PulumiStack - logStream.AddJobLog("", req.Project, etag, req.Services) // Kaniko or CD logs when there is no execution name - logStream.AddCloudBuildLog("", req.Project, etag, req.Services) // CloudBuild logs + logStream.AddJobExecutionLog(execName) // CD log when there is an execution name + logStream.AddJobLog(b.PulumiStack, req.Project, etag, req.Services) // Kaniko or CD logs when there is no execution name + logStream.AddCloudBuildLog(b.PulumiStack, req.Project, etag, req.Services) // CloudBuild logs } if logs.LogType(req.LogType).Has(logs.LogTypeRun) { - // TODO: update stack (1st param) to b.PulumiStack - logStream.AddServiceLog("", req.Project, etag, req.Services) // Service logs + logStream.AddServiceLog(b.PulumiStack, req.Project, etag, req.Services) // Service logs } logStream.AddFilter(req.Pattern) if req.Follow { @@ -704,7 +702,7 @@ func (e ConflictDelegateDomainError) Error() string { func (b *ByocGcp) PrepareDomainDelegation(ctx context.Context, req client.PrepareDomainDelegationRequest) (*client.PrepareDomainDelegationResponse, error) { term.Debugf("Preparing domain delegation for %s", req.DelegateDomain) - name := "defang-" + dns.SafeLabel(req.DelegateDomain) + name := "defang-" + gcp.SafeZoneName(req.DelegateDomain) if zone, err := b.driver.EnsureDNSZoneExists(ctx, name, req.DelegateDomain, "defang delegate domain"); err != nil { if apiErr := new(googleapi.Error); errors.As(err, &apiErr) { if strings.Contains(apiErr.Message, "Please verify ownership of") || @@ -806,17 +804,17 @@ func (b *ByocGcp) createDeploymentLogQuery(req *defangv1.DebugRequest) string { } // Logs TODO: update stack (1st param) to b.PulumiStack - query.AddJobLogQuery("", req.Project, req.Etag, req.Services) // Kaniko OR CD logs - query.AddServiceLogQuery("", req.Project, req.Etag, req.Services) // Cloudrun service logs - query.AddCloudBuildLogQuery("", req.Project, req.Etag, req.Services) // CloudBuild logs + query.AddJobLogQuery(b.PulumiStack, req.Project, req.Etag, req.Services) // Kaniko OR CD logs + query.AddServiceLogQuery(b.PulumiStack, req.Project, req.Etag, req.Services) // Cloudrun service logs + query.AddCloudBuildLogQuery(b.PulumiStack, req.Project, req.Etag, req.Services) // CloudBuild logs query.AddSince(since) query.AddUntil(until) // Service status updates TODO: update stack (1st param) to b.PulumiStack - query.AddJobStatusUpdateRequestQuery("", req.Project, req.Etag, req.Services) - query.AddJobStatusUpdateResponseQuery("", req.Project, req.Etag, req.Services) - query.AddServiceStatusRequestUpdate("", req.Project, req.Etag, req.Services) - query.AddServiceStatusReponseUpdate("", req.Project, req.Etag, req.Services) + query.AddJobStatusUpdateRequestQuery(b.PulumiStack, req.Project, req.Etag, req.Services) + query.AddJobStatusUpdateResponseQuery(b.PulumiStack, req.Project, req.Etag, req.Services) + query.AddServiceStatusRequestUpdate(b.PulumiStack, req.Project, req.Etag, req.Services) + query.AddServiceStatusReponseUpdate(b.PulumiStack, req.Project, req.Etag, req.Services) return query.GetQuery() } diff --git a/src/pkg/cli/client/byoc/gcp/query.go b/src/pkg/cli/client/byoc/gcp/query.go index c97064686..88f680425 100644 --- a/src/pkg/cli/client/byoc/gcp/query.go +++ b/src/pkg/cli/client/byoc/gcp/query.go @@ -155,7 +155,7 @@ func (q *Query) AddCloudBuildLogQuery(stack, project, etag string, services []st query := `resource.type="build"` // FIXME: Support stack - servicesRegex := `[a-zA-Z0-9-]{1,63}` + servicesRegex := `[a-zA-Z0-9-_]{1,63}` if len(services) > 0 { servicesRegex = fmt.Sprintf("(%v)", strings.Join(services, "|")) // Cloud build labels allows upper case letters } diff --git a/src/pkg/cli/client/byoc/gcp/testdata/with_cd_exec.query b/src/pkg/cli/client/byoc/gcp/testdata/with_cd_exec.query index 69be9fb9e..d5d76dc32 100644 --- a/src/pkg/cli/client/byoc/gcp/testdata/with_cd_exec.query +++ b/src/pkg/cli/client/byoc/gcp/testdata/with_cd_exec.query @@ -9,12 +9,15 @@ logName="projects/test-project/logs/docker-logs" labels."run.googleapis.com/execution_name" = "test-execution-id" ) OR ( resource.type = "cloud_run_job" + labels."defang-stack" = "beta" ) OR ( resource.type="build" - labels.build_tags =~ "__[a-zA-Z0-9-]{1,63}_" + labels.build_tags =~ "beta__[a-zA-Z0-9-_]{1,63}_" ) OR ( resource.type="cloud_run_revision" + labels."defang-stack" = "beta" ) OR ( resource.type="gce_instance" + labels."defang-stack" = "beta" ) ) \ No newline at end of file diff --git a/src/pkg/cli/client/byoc/gcp/testdata/with_etag.query b/src/pkg/cli/client/byoc/gcp/testdata/with_etag.query index 81ce0a290..b9c8ff308 100644 --- a/src/pkg/cli/client/byoc/gcp/testdata/with_etag.query +++ b/src/pkg/cli/client/byoc/gcp/testdata/with_etag.query @@ -6,15 +6,18 @@ logName="projects/test-project/logs/docker-logs" ) AND ( ( resource.type = "cloud_run_job" + labels."defang-stack" = "beta" labels."defang-etag" = "test-etag" ) OR ( resource.type="build" - labels.build_tags =~ "__[a-zA-Z0-9-]{1,63}_test-etag" + labels.build_tags =~ "beta__[a-zA-Z0-9-_]{1,63}_test-etag" ) OR ( resource.type="cloud_run_revision" + labels."defang-stack" = "beta" labels."defang-etag" = "test-etag" ) OR ( resource.type="gce_instance" + labels."defang-stack" = "beta" labels."defang-etag" = "test-etag" ) ) \ No newline at end of file diff --git a/src/pkg/cli/client/byoc/gcp/testdata/with_etag_and_since.query b/src/pkg/cli/client/byoc/gcp/testdata/with_etag_and_since.query index e47854e1c..fbd556c85 100644 --- a/src/pkg/cli/client/byoc/gcp/testdata/with_etag_and_since.query +++ b/src/pkg/cli/client/byoc/gcp/testdata/with_etag_and_since.query @@ -6,15 +6,18 @@ logName="projects/test-project/logs/docker-logs" ) AND (timestamp >= "2024-01-01T00:00:00Z") AND ( ( resource.type = "cloud_run_job" + labels."defang-stack" = "beta" labels."defang-etag" = "test-etag" ) OR ( resource.type="build" - labels.build_tags =~ "__[a-zA-Z0-9-]{1,63}_test-etag" + labels.build_tags =~ "beta__[a-zA-Z0-9-_]{1,63}_test-etag" ) OR ( resource.type="cloud_run_revision" + labels."defang-stack" = "beta" labels."defang-etag" = "test-etag" ) OR ( resource.type="gce_instance" + labels."defang-stack" = "beta" labels."defang-etag" = "test-etag" ) ) \ No newline at end of file diff --git a/src/pkg/cli/client/byoc/gcp/testdata/with_etag_equal_cd_exec.query b/src/pkg/cli/client/byoc/gcp/testdata/with_etag_equal_cd_exec.query index 60d112eab..e51397618 100644 --- a/src/pkg/cli/client/byoc/gcp/testdata/with_etag_equal_cd_exec.query +++ b/src/pkg/cli/client/byoc/gcp/testdata/with_etag_equal_cd_exec.query @@ -9,12 +9,15 @@ logName="projects/test-project/logs/docker-logs" labels."run.googleapis.com/execution_name" = "test-execution-id" ) OR ( resource.type = "cloud_run_job" + labels."defang-stack" = "beta" ) OR ( resource.type="build" - labels.build_tags =~ "__[a-zA-Z0-9-]{1,63}_" + labels.build_tags =~ "beta__[a-zA-Z0-9-_]{1,63}_" ) OR ( resource.type="cloud_run_revision" + labels."defang-stack" = "beta" ) OR ( resource.type="gce_instance" + labels."defang-stack" = "beta" ) ) \ No newline at end of file diff --git a/src/pkg/cli/client/byoc/gcp/testdata/with_everything.query b/src/pkg/cli/client/byoc/gcp/testdata/with_everything.query index efae7701a..8188deb64 100644 --- a/src/pkg/cli/client/byoc/gcp/testdata/with_everything.query +++ b/src/pkg/cli/client/byoc/gcp/testdata/with_everything.query @@ -9,17 +9,20 @@ logName="projects/test-project/logs/docker-logs" labels."run.googleapis.com/execution_name" = "test-execution-id" ) OR ( resource.type = "cloud_run_job" + labels."defang-stack" = "beta" labels."defang-project" = "test-project" labels."defang-etag" = "test-etag" ) OR ( resource.type="build" - labels.build_tags =~ "_test-project_[a-zA-Z0-9-]{1,63}_test-etag" + labels.build_tags =~ "beta_test-project_[a-zA-Z0-9-_]{1,63}_test-etag" ) OR ( resource.type="cloud_run_revision" + labels."defang-stack" = "beta" labels."defang-project" = "test-project" labels."defang-etag" = "test-etag" ) OR ( resource.type="gce_instance" + labels."defang-stack" = "beta" labels."defang-project" = "test-project" labels."defang-etag" = "test-etag" ) diff --git a/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_all.query b/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_all.query index 3a26db77d..3c0512af2 100644 --- a/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_all.query +++ b/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_all.query @@ -6,12 +6,15 @@ logName="projects/test-project/logs/docker-logs" ) AND ("error") AND ( ( resource.type = "cloud_run_job" + labels."defang-stack" = "beta" ) OR ( resource.type="build" - labels.build_tags =~ "__[a-zA-Z0-9-]{1,63}_" + labels.build_tags =~ "beta__[a-zA-Z0-9-_]{1,63}_" ) OR ( resource.type="cloud_run_revision" + labels."defang-stack" = "beta" ) OR ( resource.type="gce_instance" + labels."defang-stack" = "beta" ) ) \ No newline at end of file diff --git a/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_build.query b/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_build.query index 58a96a053..842d6e9dc 100644 --- a/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_build.query +++ b/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_build.query @@ -6,8 +6,9 @@ logName="projects/test-project/logs/docker-logs" ) AND ( ( resource.type = "cloud_run_job" + labels."defang-stack" = "beta" ) OR ( resource.type="build" - labels.build_tags =~ "__[a-zA-Z0-9-]{1,63}_" + labels.build_tags =~ "beta__[a-zA-Z0-9-_]{1,63}_" ) ) \ No newline at end of file diff --git a/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_run.query b/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_run.query index 9741ffe3e..6d591fc52 100644 --- a/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_run.query +++ b/src/pkg/cli/client/byoc/gcp/testdata/with_logtype_run.query @@ -6,7 +6,9 @@ logName="projects/test-project/logs/docker-logs" ) AND ( ( resource.type="cloud_run_revision" + labels."defang-stack" = "beta" ) OR ( resource.type="gce_instance" + labels."defang-stack" = "beta" ) ) \ No newline at end of file diff --git a/src/pkg/cli/client/byoc/gcp/testdata/with_project.query b/src/pkg/cli/client/byoc/gcp/testdata/with_project.query index e0114e071..3e455c281 100644 --- a/src/pkg/cli/client/byoc/gcp/testdata/with_project.query +++ b/src/pkg/cli/client/byoc/gcp/testdata/with_project.query @@ -6,15 +6,18 @@ logName="projects/test-project/logs/docker-logs" ) AND ( ( resource.type = "cloud_run_job" + labels."defang-stack" = "beta" labels."defang-project" = "test-project" ) OR ( resource.type="build" - labels.build_tags =~ "_test-project_[a-zA-Z0-9-]{1,63}_" + labels.build_tags =~ "beta_test-project_[a-zA-Z0-9-_]{1,63}_" ) OR ( resource.type="cloud_run_revision" + labels."defang-stack" = "beta" labels."defang-project" = "test-project" ) OR ( resource.type="gce_instance" + labels."defang-stack" = "beta" labels."defang-project" = "test-project" ) ) \ No newline at end of file diff --git a/src/pkg/cli/client/byoc/gcp/testdata/with_project_since_and_until.query b/src/pkg/cli/client/byoc/gcp/testdata/with_project_since_and_until.query index 6ba697cf1..62027cf9d 100644 --- a/src/pkg/cli/client/byoc/gcp/testdata/with_project_since_and_until.query +++ b/src/pkg/cli/client/byoc/gcp/testdata/with_project_since_and_until.query @@ -6,15 +6,18 @@ logName="projects/test-project/logs/docker-logs" ) AND (timestamp >= "2024-01-01T00:00:00Z") AND (timestamp <= "2024-01-02T00:00:00Z") AND ( ( resource.type = "cloud_run_job" + labels."defang-stack" = "beta" labels."defang-project" = "test-project" ) OR ( resource.type="build" - labels.build_tags =~ "_test-project_[a-zA-Z0-9-]{1,63}_" + labels.build_tags =~ "beta_test-project_[a-zA-Z0-9-_]{1,63}_" ) OR ( resource.type="cloud_run_revision" + labels."defang-stack" = "beta" labels."defang-project" = "test-project" ) OR ( resource.type="gce_instance" + labels."defang-stack" = "beta" labels."defang-project" = "test-project" ) ) \ No newline at end of file diff --git a/src/pkg/cli/compose/fixup.go b/src/pkg/cli/compose/fixup.go index 04a15ee82..c32ae957f 100644 --- a/src/pkg/cli/compose/fixup.go +++ b/src/pkg/cli/compose/fixup.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "slices" "strconv" "strings" @@ -29,6 +30,12 @@ func FixupServices(ctx context.Context, provider client.Provider, project *compo } } + oldName := project.Name + project.Name = NormalizeProjectName(project.Name) + if project.Name != oldName { + term.Debugf("normalized project name %q -> %q", oldName, project.Name) + } + // Preload the current config so we can detect which environment variables should be passed as "secrets" config, err := provider.ListConfig(ctx, &defangv1.ListConfigsRequest{Project: project.Name}) if err != nil { @@ -471,3 +478,9 @@ func IsRedisRepo(repo string) bool { func IsMongoRepo(repo string) bool { return strings.HasSuffix(repo, "mongo") } + +var safeProjectNameRE = regexp.MustCompile(`[^A-Za-z0-9-]+`) + +func NormalizeProjectName(name string) string { + return safeProjectNameRE.ReplaceAllString(name, "-") +} diff --git a/src/pkg/cli/composeUp.go b/src/pkg/cli/composeUp.go index 4a31c8a0d..7d5777d8a 100644 --- a/src/pkg/cli/composeUp.go +++ b/src/pkg/cli/composeUp.go @@ -89,7 +89,7 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider cliClie } req := &defangv1.GetDelegateSubdomainZoneRequest{ - Project: project.Name, + Project: fixedProject.Name, Stack: params.Stack, } delegateDomain, err := fabric.GetDelegateSubdomainZone(ctx, req) @@ -100,7 +100,7 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider cliClie deployRequest := &defangv1.DeployRequest{ Mode: mode.Value(), - Project: project.Name, + Project: fixedProject.Name, Compose: bytes, DelegateDomain: delegateDomain.Zone, } @@ -108,7 +108,7 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider cliClie delegation, err := provider.PrepareDomainDelegation(ctx, client.PrepareDomainDelegationRequest{ DelegateDomain: delegateDomain.Zone, Preview: upload == compose.UploadModePreview || upload == compose.UploadModeEstimate, - Project: project.Name, + Project: fixedProject.Name, }) if err != nil { return nil, project, err @@ -133,7 +133,7 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider cliClie if delegation != nil && len(delegation.NameServers) > 0 { req := &defangv1.DelegateSubdomainZoneRequest{ NameServerRecords: delegation.NameServers, - Project: project.Name, + Project: fixedProject.Name, Stack: params.Stack, } _, err = fabric.DelegateSubdomainZone(ctx, req) diff --git a/src/pkg/cli/subscribe.go b/src/pkg/cli/subscribe.go index 9b05106c7..b6cc1fd05 100644 --- a/src/pkg/cli/subscribe.go +++ b/src/pkg/cli/subscribe.go @@ -5,6 +5,7 @@ import ( "errors" "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/term" "github.com/DefangLabs/defang/src/pkg/types" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" @@ -29,7 +30,7 @@ func WaitServiceState( } // Assume "services" are normalized service names - subscribeRequest := defangv1.SubscribeRequest{Project: projectName, Etag: etag, Services: services} + subscribeRequest := defangv1.SubscribeRequest{Project: compose.NormalizeProjectName(projectName), Etag: etag, Services: services} serverStream, err := provider.Subscribe(ctx, &subscribeRequest) if err != nil { return nil, err diff --git a/src/pkg/cli/tail.go b/src/pkg/cli/tail.go index 54b65df15..2c88e4a21 100644 --- a/src/pkg/cli/tail.go +++ b/src/pkg/cli/tail.go @@ -14,6 +14,7 @@ import ( "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/compose" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/logs" "github.com/DefangLabs/defang/src/pkg/spinner" @@ -207,7 +208,7 @@ func streamLogs(ctx context.Context, provider client.Provider, projectName strin Etag: options.Deployment, LogType: uint32(options.LogType), Pattern: options.Filter, - Project: projectName, + Project: compose.NormalizeProjectName(projectName), // Matching the FixupServices behavior Services: options.Services, Since: sinceTs, // this is also used to continue from the last timestamp Until: untilTs, diff --git a/src/pkg/clouds/gcp/cloudbuild.go b/src/pkg/clouds/gcp/cloudbuild.go index c965c278c..9c1fa9ab6 100644 --- a/src/pkg/clouds/gcp/cloudbuild.go +++ b/src/pkg/clouds/gcp/cloudbuild.go @@ -19,7 +19,7 @@ type BuildTag struct { func (bt BuildTag) String() string { if bt.Stack == "" { - return fmt.Sprintf("%s_%s_%s", bt.Project, bt.Service, bt.Etag) + return fmt.Sprintf("%s_%s_%s", bt.Project, bt.Service, bt.Etag) // Backward compatibility } else { return fmt.Sprintf("%s_%s_%s_%s", bt.Stack, bt.Project, bt.Service, bt.Etag) } @@ -27,20 +27,20 @@ func (bt BuildTag) String() string { func (bt *BuildTag) Parse(tag string) error { parts := strings.Split(tag, "_") - if len(parts) < 3 || len(parts) > 4 { + if len(parts) < 3 { return fmt.Errorf("invalid cloudbuild build tags value: %q", tag) } - if len(parts) == 3 { // Backward compatibility + if n := len(parts); n == 3 { // Backward compatibility bt.Stack = "" bt.Project = parts[0] bt.Service = parts[1] bt.Etag = parts[2] } else { bt.Stack = parts[0] - bt.Project = parts[1] - bt.Service = parts[2] - bt.Etag = parts[3] + bt.Project = parts[1] // Project names has been normalized to not contain underscores + bt.Service = strings.Join(parts[2:n-1], "_") // Service names may contain underscores, so join all parts except last which is the etag + bt.Etag = parts[n-1] } return nil } diff --git a/src/pkg/clouds/gcp/cloudbuild_test.go b/src/pkg/clouds/gcp/cloudbuild_test.go new file mode 100644 index 000000000..c70a4c3d6 --- /dev/null +++ b/src/pkg/clouds/gcp/cloudbuild_test.go @@ -0,0 +1,47 @@ +package gcp + +import ( + "testing" +) + +func TestBuildTagString(t *testing.T) { + tests := []struct { + name string + bt BuildTag + want string + }{ + { + name: "with stack", + bt: BuildTag{Stack: "stack1", Project: "proj", Service: "svc", Etag: "123"}, + want: "stack1_proj_svc_123", + }, + { + name: "without stack", + bt: BuildTag{Project: "proj", Service: "svc", Etag: "123"}, + want: "proj_svc_123", + }, + { + name: "service name with underscores", + bt: BuildTag{Stack: "stack1", Project: "my-proj-name", Service: "svc_name", Etag: "123"}, + want: "stack1_my-proj-name_svc_name_123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tagStr := tt.bt.String() + if tagStr != tt.want { + t.Errorf("String() = %q, want %q", tagStr, tt.want) + } + var parsed BuildTag + err := parsed.Parse(tagStr) + if err != nil { + t.Fatalf("Parse() returned error: %v", err) + } + + if parsed != tt.bt { + t.Errorf("Parse() = %+v, want %+v", parsed, tt.want) + } + }) + } +} diff --git a/src/pkg/clouds/gcp/dns.go b/src/pkg/clouds/gcp/dns.go index 70730eefd..e23b52993 100644 --- a/src/pkg/clouds/gcp/dns.go +++ b/src/pkg/clouds/gcp/dns.go @@ -2,7 +2,12 @@ package gcp import ( "context" + "crypto/sha256" + "encoding/binary" "fmt" + "regexp" + "strconv" + "strings" "google.golang.org/api/dns/v1" ) @@ -87,3 +92,44 @@ func (gcp Gcp) DeleteDNSZone(ctx context.Context, name string) error { } return nil } + +// 1. Name must be lowercase letters, numbers, and hyphens +// 2. Name may be at most 63 characters +// 3. Name must start with a lowercase letter +// 4. Name must end with a lowercase letter or a number +var safeZoneRE = regexp.MustCompile(`[^a-z0-9-]+`) + +// Zone names have the same requirements as label values. +func SafeZoneName(input string) string { + input = strings.ToLower(input) // Rule 1: lowercase + safe := safeZoneRE.ReplaceAllString(input, "-") // Rule 1: only letters, numbers, and hyphen + safe = strings.Trim(safe, "-") // Rule 3, 4: trim hyphens from start and end + if len(safe) == 0 || (safe[0] < 'a' || safe[0] > 'z') { // Rule 3: must start with a letter + safe = "zone-" + safe + } + return hashTrim(safe, 63) // Rule 2: max length 63 +} + +func hashTrim(name string, maxLength int) string { + if len(name) <= maxLength { + return name + } + + const hashLength = 6 + prefix := name[:maxLength-hashLength] + suffix := name[maxLength-hashLength:] + return prefix + hashn(suffix, hashLength) +} + +func hashn(str string, length int) string { + hash := sha256.New() + hash.Write([]byte(str)) + hashInt := binary.LittleEndian.Uint64(hash.Sum(nil)[:8]) + hashBase36 := strconv.FormatUint(hashInt, 36) // base 36 string + // truncate if the hash is too long + if len(hashBase36) > length { + return hashBase36[:length] + } + // if the hash is too short, pad with leading zeros + return fmt.Sprintf("%0*s", length, hashBase36) +} diff --git a/src/pkg/clouds/gcp/dns_test.go b/src/pkg/clouds/gcp/dns_test.go new file mode 100644 index 000000000..f77d325c9 --- /dev/null +++ b/src/pkg/clouds/gcp/dns_test.go @@ -0,0 +1,88 @@ +package gcp + +import ( + "regexp" + "strings" + "testing" +) + +func TestSafeZoneName(t *testing.T) { + tests := []struct { + name string + input string + validate func(t *testing.T, output string) + }{ + { + name: "lowercases input", + input: "MyZoneNAME", + validate: func(t *testing.T, output string) { + if output != "myzonename" { + t.Fatalf("expected myzonename, got %q", output) + } + }, + }, + { + name: "replaces invalid characters with hyphens", + input: "zone@name!with#chars", + validate: func(t *testing.T, output string) { + matched, _ := regexp.MatchString(`^[a-z0-9-]+$`, output) + if !matched { + t.Fatalf("output contains invalid characters: %q", output) + } + }, + }, + { + name: "trims leading and trailing hyphens", + input: "---zone-name---", + validate: func(t *testing.T, output string) { + if output != "zone-name" { + t.Fatalf("expected zone-name, got %q", output) + } + }, + }, + { + name: "prefix with letter if name starts with non-letter", + input: "123-zone", + validate: func(t *testing.T, output string) { + if output[0] < 'a' || output[0] > 'z' { + t.Fatalf("expected output to start with lower case letters, got %q", output) + } + }, + }, + { + name: "handles input that becomes empty", + input: "!!!", + validate: func(t *testing.T, output string) { + if output == "" { + t.Fatalf("expected non-empty zone name, got %q", output) + } + }, + }, + { + name: "does not exceed 63 characters", + input: "a" + strings.Repeat("-b", 100), + validate: func(t *testing.T, output string) { + if len(output) > 63 { + t.Fatalf("expected length <= 63, got %d", len(output)) + } + }, + }, + { + name: "ends with lowercase letter or number", + input: "zone-name-", + validate: func(t *testing.T, output string) { + last := output[len(output)-1] + if !(last >= 'a' && last <= 'z') && !(last >= '0' && last <= '9') { + t.Fatalf("invalid ending character: %q", output) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := SafeZoneName(tt.input) + tt.validate(t, out) + }) + } +}