From 8a2efce1f91bdc4c5f15fc79d94d684b8d72c849 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Tue, 19 May 2026 19:59:51 +0200 Subject: [PATCH 1/6] Validate image tag format in config --- internal/config/containers.go | 38 +++++++++++++++++++++ internal/config/containers_test.go | 55 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/internal/config/containers.go b/internal/config/containers.go index 6d21cbb9..94ea8c7b 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -4,8 +4,10 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strconv" "strings" + "time" ) type EmulatorType string @@ -110,7 +112,43 @@ func (c *ContainerConfig) VolumeDir() (string, error) { return filepath.Join(cacheDir, "lstk", "volume", c.Name()), nil } +// zeroPaddedMonthTagRe matches calendar-versioned tags where the month is zero-padded +// (e.g. "2026.04", "2026.04.1-amd64"), which the license API does not accept. +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._-]*$`) + +// prevMonthExample returns the previous calendar month as a tag example, e.g. "2026.4". +func prevMonthExample() string { + y, m, _ := time.Now().Date() + m-- + if m == 0 { + m, y = 12, y-1 + } + return fmt.Sprintf("%d.%d", y, int(m)) +} + +func validateTag(tag string) error { + if tag == "" { + return nil + } + if len(tag) > 128 || !validTagRe.MatchString(tag) { + return fmt.Errorf("tag %q is not supported — try a tag like %q or \"latest\" in your config file", tag, prevMonthExample()) + } + m := zeroPaddedMonthTagRe.FindStringSubmatch(tag) + if m == nil { + return nil + } + suggested := m[1] + m[2] + return fmt.Errorf("tag %q is not supported — try %q or \"latest\" in your config file", tag, suggested) +} + 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..c2c4fd35 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,60 @@ func TestResolvedEnv_EmptyWhenNoEnvRefs(t *testing.T) { assert.Empty(t, resolved) } +func TestValidate_ZeroPaddedMonthTag_IsRejected(t *testing.T) { + cases := []struct { + tag string + suggested string + }{ + {"2026.04", "2026.4"}, + {"2026.04.1", "2026.4.1"}, + {"2026.04.0-amd64", "2026.4.0-amd64"}, + {"2026.01", "2026.1"}, + {"2026.09.2", "2026.9.2"}, + } + for _, tc := range cases { + t.Run(tc.tag, func(t *testing.T) { + c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: tc.tag} + err := c.Validate() + assert.ErrorContains(t, err, "not supported") + assert.ErrorContains(t, err, tc.suggested) + }) + } +} + +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, "not supported") + }) + } +} + +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()) From 38fdf6e4f4bcd0baeff9d7a9c0d3cfa6319637d8 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 20 May 2026 12:50:00 +0200 Subject: [PATCH 2/6] Improve error messages for invalid and unsupported image tags --- internal/api/client.go | 8 ++++++++ internal/api/license_test.go | 33 ++++++++++++++++++++++++++++++ internal/config/containers.go | 9 ++------ internal/config/containers_test.go | 20 ++++-------------- 4 files changed, 47 insertions(+), 23 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 7eb00834..5f0dd6bc 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" @@ -309,6 +310,13 @@ 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: "unsupported image tag — check the tag in your config file", + Detail: detail, + } + } 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..b5e4216f 100644 --- a/internal/api/license_test.go +++ b/internal/api/license_test.go @@ -1,11 +1,44 @@ 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) + assert.Contains(t, err.Error(), "unsupported image tag") +} + +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 94ea8c7b..4c0aa21f 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -134,15 +134,10 @@ func validateTag(tag string) error { if tag == "" { return nil } - if len(tag) > 128 || !validTagRe.MatchString(tag) { + if len(tag) > 128 || !validTagRe.MatchString(tag) || zeroPaddedMonthTagRe.MatchString(tag) { return fmt.Errorf("tag %q is not supported — try a tag like %q or \"latest\" in your config file", tag, prevMonthExample()) } - m := zeroPaddedMonthTagRe.FindStringSubmatch(tag) - if m == nil { - return nil - } - suggested := m[1] + m[2] - return fmt.Errorf("tag %q is not supported — try %q or \"latest\" in your config file", tag, suggested) + return nil } func (c *ContainerConfig) Validate() error { diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index c2c4fd35..04ff3321 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -51,22 +51,10 @@ func TestResolvedEnv_EmptyWhenNoEnvRefs(t *testing.T) { } func TestValidate_ZeroPaddedMonthTag_IsRejected(t *testing.T) { - cases := []struct { - tag string - suggested string - }{ - {"2026.04", "2026.4"}, - {"2026.04.1", "2026.4.1"}, - {"2026.04.0-amd64", "2026.4.0-amd64"}, - {"2026.01", "2026.1"}, - {"2026.09.2", "2026.9.2"}, - } - for _, tc := range cases { - t.Run(tc.tag, func(t *testing.T) { - c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: tc.tag} - err := c.Validate() - assert.ErrorContains(t, err, "not supported") - assert.ErrorContains(t, err, tc.suggested) + 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.ErrorContains(t, c.Validate(), "not supported") }) } } From e1bcd994bbd68d658b18a9630939583f50027702 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 20 May 2026 13:26:46 +0200 Subject: [PATCH 3/6] Reuse message across tag validation --- internal/api/client.go | 3 ++- internal/config/containers.go | 22 +++++++++++----------- internal/config/containers_test.go | 4 ++-- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 5f0dd6bc..4e9e84fb 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -13,6 +13,7 @@ import ( "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/log" "github.com/localstack/lstk/internal/version" ) @@ -313,7 +314,7 @@ func (c *PlatformClient) GetLicense(ctx context.Context, licReq *LicenseRequest) if strings.Contains(detail, "licensing.license.format") { return nil, &LicenseError{ Status: statusCode, - Message: "unsupported image tag — check the tag in your config file", + Message: config.UnsupportedTagMessage(), Detail: detail, } } diff --git a/internal/config/containers.go b/internal/config/containers.go index 4c0aa21f..e2dbceb7 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "os" "path/filepath" @@ -112,6 +113,15 @@ 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"), which the license API does not accept. var zeroPaddedMonthTagRe = regexp.MustCompile(`^(\d{4}\.)0([1-9].*)$`) @@ -120,22 +130,12 @@ var zeroPaddedMonthTagRe = regexp.MustCompile(`^(\d{4}\.)0([1-9].*)$`) // must not start with a dot or hyphen; max 128 characters. var validTagRe = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9._-]*$`) -// prevMonthExample returns the previous calendar month as a tag example, e.g. "2026.4". -func prevMonthExample() string { - y, m, _ := time.Now().Date() - m-- - if m == 0 { - m, y = 12, y-1 - } - return fmt.Sprintf("%d.%d", y, int(m)) -} - func validateTag(tag string) error { if tag == "" { return nil } if len(tag) > 128 || !validTagRe.MatchString(tag) || zeroPaddedMonthTagRe.MatchString(tag) { - return fmt.Errorf("tag %q is not supported — try a tag like %q or \"latest\" in your config file", tag, prevMonthExample()) + return errors.New(UnsupportedTagMessage()) } return nil } diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index 04ff3321..f1bba008 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -54,7 +54,7 @@ func TestValidate_ZeroPaddedMonthTag_IsRejected(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.ErrorContains(t, c.Validate(), "not supported") + assert.ErrorContains(t, c.Validate(), "unsupported") }) } } @@ -72,7 +72,7 @@ func TestValidate_InvalidDockerTag_IsRejected(t *testing.T) { t.Run(tag, func(t *testing.T) { c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: tag} err := c.Validate() - assert.ErrorContains(t, err, "not supported") + assert.ErrorContains(t, err, "unsupported") }) } } From e56560e75be07e06f12bc6ec96717e8c5e14b701 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 20 May 2026 13:59:33 +0200 Subject: [PATCH 4/6] Fix api -> config import by moving tag message to container layer --- internal/api/client.go | 17 ++++++++++------- internal/api/license_test.go | 4 +++- internal/container/start.go | 9 +++++++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 4e9e84fb..fa797fdc 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -13,7 +13,6 @@ import ( "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" - "github.com/localstack/lstk/internal/config" "github.com/localstack/lstk/internal/log" "github.com/localstack/lstk/internal/version" ) @@ -100,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 { @@ -313,9 +315,10 @@ func (c *PlatformClient) GetLicense(ctx context.Context, licReq *LicenseRequest) case http.StatusBadRequest: if strings.Contains(detail, "licensing.license.format") { return nil, &LicenseError{ - Status: statusCode, - Message: config.UnsupportedTagMessage(), - Detail: detail, + Status: statusCode, + Message: "image tag not accepted by the license server", + Detail: detail, + IsUnsupportedTag: true, } } return nil, &LicenseError{ diff --git a/internal/api/license_test.go b/internal/api/license_test.go index b5e4216f..7d1defbb 100644 --- a/internal/api/license_test.go +++ b/internal/api/license_test.go @@ -22,7 +22,9 @@ func TestGetLicense_BadRequest_UnsupportedTag(t *testing.T) { _, err := client.GetLicense(context.Background(), &LicenseRequest{}) require.Error(t, err) - assert.Contains(t, err.Error(), "unsupported image tag") + var licErr *LicenseError + require.ErrorAs(t, err, &licErr) + assert.True(t, licErr.IsUnsupportedTag) } func TestGetLicense_BadRequest_InvalidToken(t *testing.T) { diff --git a/internal/container/start.go b/internal/container/start.go index 466ebf31..e36c31ad 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -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 { + licErr.Message = config.UnsupportedTagMessage() + } } opts.Telemetry.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{ EventType: telemetry.LifecycleStartError, From e4ffbafdc43249ab1bbc6287b22b2a19b6cbbc46 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Wed, 20 May 2026 15:54:29 +0200 Subject: [PATCH 5/6] Normalize zero-padded calendar version tags before license validation --- internal/config/containers.go | 13 ++++++++++--- internal/config/containers_test.go | 23 +++++++++++++++++++++-- internal/container/start.go | 2 +- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/internal/config/containers.go b/internal/config/containers.go index e2dbceb7..c168004a 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -46,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", @@ -89,7 +90,6 @@ func KnownImageReposForType(t EmulatorType) []string { return repos } - type ContainerConfig struct { Type EmulatorType `mapstructure:"type"` Tag string `mapstructure:"tag"` @@ -123,18 +123,25 @@ func UnsupportedTagMessage() string { } // zeroPaddedMonthTagRe matches calendar-versioned tags where the month is zero-padded -// (e.g. "2026.04", "2026.04.1-amd64"), which the license API does not accept. +// (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) || zeroPaddedMonthTagRe.MatchString(tag) { + if len(tag) > 128 || !validTagRe.MatchString(tag) { return errors.New(UnsupportedTagMessage()) } return nil diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index f1bba008..27ae249d 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -50,11 +50,30 @@ func TestResolvedEnv_EmptyWhenNoEnvRefs(t *testing.T) { assert.Empty(t, resolved) } -func TestValidate_ZeroPaddedMonthTag_IsRejected(t *testing.T) { +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.ErrorContains(t, c.Validate(), "unsupported") + 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)) }) } } diff --git a/internal/container/start.go b/internal/container/start.go index e36c31ad..29b2d456 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, From 463b35854c08f7b36f8b7a534d53f547eeef5108 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 21 May 2026 10:58:02 +0200 Subject: [PATCH 6/6] Replace error mutation with reassignment in unsupported tag path --- internal/container/start.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/container/start.go b/internal/container/start.go index 29b2d456..83b93388 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -578,7 +578,7 @@ func validateLicense(ctx context.Context, sink output.Sink, opts StartOptions, c opts.Logger.Error("license server response (HTTP %d): %s", licErr.Status, licErr.Detail) } if licErr.IsUnsupportedTag { - licErr.Message = config.UnsupportedTagMessage() + err = errors.New(config.UnsupportedTagMessage()) } } opts.Telemetry.EmitEmulatorLifecycleEvent(ctx, telemetry.LifecycleEvent{