Skip to content
Merged
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
18 changes: 15 additions & 3 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/url"
"strings"
"time"

"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions internal/api/license_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
42 changes: 41 additions & 1 deletion internal/config/containers.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package config

import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)

type EmulatorType string
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -86,7 +90,6 @@ func KnownImageReposForType(t EmulatorType) []string {
return repos
}


type ContainerConfig struct {
Type EmulatorType `mapstructure:"type"`
Tag string `mapstructure:"tag"`
Expand All @@ -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)
}
Expand Down
62 changes: 62 additions & 0 deletions internal/config/containers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"sort"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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())
Expand Down
11 changes: 8 additions & 3 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading