diff --git a/internal/api/client.go b/internal/api/client.go index 7eb00834..fa797fdc 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -98,10 +99,13 @@ func (r *LicenseResponse) PlanDisplayName() string { // LicenseError is returned when license validation fails. // Message is user-friendly; Detail contains the raw server response for debugging. +// IsUnsupportedTag is set when the server rejects the image tag format, letting +// callers that know the config context replace Message with a more specific suggestion. type LicenseError struct { - Status int - Message string - Detail string + Status int + Message string + Detail string + IsUnsupportedTag bool } func (e *LicenseError) Error() string { @@ -309,6 +313,14 @@ func (c *PlatformClient) GetLicense(ctx context.Context, licReq *LicenseRequest) switch statusCode { case http.StatusBadRequest: + if strings.Contains(detail, "licensing.license.format") { + return nil, &LicenseError{ + Status: statusCode, + Message: "image tag not accepted by the license server", + Detail: detail, + IsUnsupportedTag: true, + } + } return nil, &LicenseError{ Status: statusCode, Message: "invalid token format, missing license assignment, or missing subscription", diff --git a/internal/api/license_test.go b/internal/api/license_test.go index 61871a55..7d1defbb 100644 --- a/internal/api/license_test.go +++ b/internal/api/license_test.go @@ -1,11 +1,46 @@ package api import ( + "context" + "net/http" + "net/http/httptest" "testing" + "github.com/localstack/lstk/internal/log" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestGetLicense_BadRequest_UnsupportedTag(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": true, "message": "licensing.license.format:illegal version string adsfgt"}`)) + })) + defer srv.Close() + + client := NewPlatformClient(srv.URL, log.Nop()) + _, err := client.GetLicense(context.Background(), &LicenseRequest{}) + + require.Error(t, err) + var licErr *LicenseError + require.ErrorAs(t, err, &licErr) + assert.True(t, licErr.IsUnsupportedTag) +} + +func TestGetLicense_BadRequest_InvalidToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": true, "message": "invalid token format"}`)) + })) + defer srv.Close() + + client := NewPlatformClient(srv.URL, log.Nop()) + _, err := client.GetLicense(context.Background(), &LicenseRequest{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid token format, missing license assignment, or missing subscription") +} + func TestPlanDisplayName(t *testing.T) { tests := []struct { licenseType string diff --git a/internal/config/containers.go b/internal/config/containers.go index 6d21cbb9..c168004a 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -1,11 +1,14 @@ package config import ( + "errors" "fmt" "os" "path/filepath" + "regexp" "strconv" "strings" + "time" ) type EmulatorType string @@ -43,6 +46,7 @@ func (e EmulatorType) ShortName() string { func (e EmulatorType) DisplayName() string { return fmt.Sprintf("LocalStack %s Emulator", e.ShortName()) } + var emulatorHealthPaths = map[EmulatorType]string{ EmulatorAWS: "/_localstack/health", EmulatorSnowflake: "/_localstack/health", @@ -86,7 +90,6 @@ func KnownImageReposForType(t EmulatorType) []string { return repos } - type ContainerConfig struct { Type EmulatorType `mapstructure:"type"` Tag string `mapstructure:"tag"` @@ -110,7 +113,44 @@ func (c *ContainerConfig) VolumeDir() (string, error) { return filepath.Join(cacheDir, "lstk", "volume", c.Name()), nil } +func UnsupportedTagMessage() string { + y, m, _ := time.Now().Date() + m-- + if m == 0 { + m, y = 12, y-1 + } + return fmt.Sprintf("unsupported image tag — try a tag like %q or \"latest\" in your config file", fmt.Sprintf("%d.%d", y, int(m))) +} + +// zeroPaddedMonthTagRe matches calendar-versioned tags where the month is zero-padded +// (e.g. "2026.04", "2026.04.1-amd64"). The license API does not accept zero-padded months, +// so these tags are normalized before license validation rather than rejected. +var zeroPaddedMonthTagRe = regexp.MustCompile(`^(\d{4}\.)0([1-9].*)$`) + +// validTagRe mirrors Docker's tag format rules: alphanumerics, dots, hyphens, underscores; +// must not start with a dot or hyphen; max 128 characters. +var validTagRe = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9._-]*$`) + +// NormalizeTag strips a leading zero from the month in calendar-versioned tags so they +// are accepted by the license API (e.g. "2026.04" → "2026.4"). Other tags pass through unchanged. +func NormalizeTag(tag string) string { + return zeroPaddedMonthTagRe.ReplaceAllString(tag, "${1}${2}") +} + +func validateTag(tag string) error { + if tag == "" { + return nil + } + if len(tag) > 128 || !validTagRe.MatchString(tag) { + return errors.New(UnsupportedTagMessage()) + } + return nil +} + func (c *ContainerConfig) Validate() error { + if err := validateTag(c.Tag); err != nil { + return err + } if c.Port == "" { return fmt.Errorf("port is required for %s emulator", c.Type) } diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index 3b289470..27ae249d 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -2,6 +2,7 @@ package config import ( "sort" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -49,6 +50,67 @@ func TestResolvedEnv_EmptyWhenNoEnvRefs(t *testing.T) { assert.Empty(t, resolved) } +func TestValidate_ZeroPaddedMonthTag_IsAccepted(t *testing.T) { + for _, tag := range []string{"2026.04", "2026.04.1", "2026.04.0-amd64", "2026.01", "2026.09.2"} { + t.Run(tag, func(t *testing.T) { + c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: tag} + assert.NoError(t, c.Validate()) + }) + } +} + +func TestNormalizeTag(t *testing.T) { + for _, tc := range []struct { + input, want string + }{ + {"2026.04", "2026.4"}, + {"2026.01", "2026.1"}, + {"2026.09.2", "2026.9.2"}, + {"2026.04.1", "2026.4.1"}, + {"2026.04.0-amd64", "2026.4.0-amd64"}, + {"2026.10", "2026.10"}, + {"latest", "latest"}, + {"", ""}, + } { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.want, NormalizeTag(tc.input)) + }) + } +} + +func TestValidate_InvalidDockerTag_IsRejected(t *testing.T) { + for _, tag := range []string{ + "my tag", // space + "2026.4!", // special char + ".hidden", // starts with dot + "-beta", // starts with hyphen + "tag@sha", // @ not allowed + "foo:bar", // colon not allowed + strings.Repeat("a", 129), // too long + } { + t.Run(tag, func(t *testing.T) { + c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: tag} + err := c.Validate() + assert.ErrorContains(t, err, "unsupported") + }) + } +} + +func TestValidate_ValidTagFormats_AreAccepted(t *testing.T) { + for _, tag := range []string{ + "", "latest", "stable", + "2026.4", "2026.4.1", "2026.4.0", "2026.4.0-amd64", "2026.4.0-arm64", + "2026.5.0.dev188", + "2026.10", "2026.11.2", + "3.8.0", "3.7.4", + } { + t.Run(tag, func(t *testing.T) { + c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: tag} + assert.NoError(t, c.Validate()) + }) + } +} + func TestValidate_ValidPort(t *testing.T) { c := &ContainerConfig{Type: EmulatorAWS, Port: "4566"} assert.NoError(t, c.Validate()) diff --git a/internal/container/start.go b/internal/container/start.go index 466ebf31..83b93388 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -557,7 +557,7 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c licenseReq := &api.LicenseRequest{ Product: api.ProductInfo{ Name: containerConfig.ProductName, - Version: version, + Version: config.NormalizeTag(version), }, Credentials: api.CredentialsInfo{ Token: token, @@ -573,8 +573,13 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c if err != nil { sink.Emit(output.SpinnerStop()) var licErr *api.LicenseError - if errors.As(err, &licErr) && licErr.Detail != "" { - opts.Logger.Error("license server response (HTTP %d): %s", licErr.Status, licErr.Detail) + if errors.As(err, &licErr) { + if licErr.Detail != "" { + opts.Logger.Error("license server response (HTTP %d): %s", licErr.Status, licErr.Detail) + } + if licErr.IsUnsupportedTag { + err = errors.New(config.UnsupportedTagMessage()) + } } opts.Telemetry.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ EventType: telemetry.LifecycleStartError,