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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ When adding a new command that depends on configuration, wire config initializat

Created automatically on first run with defaults. Supports emulator types: `aws`, `snowflake`, and `azure`.

Each `[[containers]]` block may set an optional `image` to override the default Docker Hub image (e.g. an internal registry mirror or a locally loaded offline image). `ContainerConfig.Image()` returns `image` as-is when it already carries a tag, otherwise it appends `tag` (or `latest`); the default `localstack/<product>:<tag>` is used when `image` is unset.

# Emulator Setup Commands

Use `lstk setup <emulator>` to set up CLI integration for an emulator type:
Expand Down
36 changes: 28 additions & 8 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,15 @@ func KnownImageReposForType(t EmulatorType) []string {
}

type ContainerConfig struct {
Type EmulatorType `mapstructure:"type"`
Tag string `mapstructure:"tag"`
Port string `mapstructure:"port"`
Volume string `mapstructure:"volume"`
Type EmulatorType `mapstructure:"type"`
Tag string `mapstructure:"tag"`
Port string `mapstructure:"port"`
// CustomImage overrides the default Docker image for this emulator. Set it to use an
// image from an internal registry or a locally loaded offline image instead of pulling
// the default localstack image from Docker Hub. If it carries no tag, Tag (or "latest")
// is appended.
CustomImage string `mapstructure:"image"`
Volume string `mapstructure:"volume"`
// Env is a list of named environment references defined in the top-level [env.*] config sections.
Env []string `mapstructure:"env"`
// Snapshot is an optional snapshot REF (e.g. "pod:my-baseline" or a local path)
Expand Down Expand Up @@ -205,17 +210,32 @@ func (c *ContainerConfig) ResolvedEnv(namedEnvs map[string]map[string]string) ([
}

func (c *ContainerConfig) Image() (string, error) {
productName, err := c.ProductName()
if err != nil {
return "", err
}
tag := c.Tag
if tag == "" {
tag = "latest"
}
if c.CustomImage != "" {
if imageHasTag(c.CustomImage) {
return c.CustomImage, nil
}
return c.CustomImage + ":" + tag, nil
}
productName, err := c.ProductName()
if err != nil {
return "", err
}
return fmt.Sprintf("%s/%s:%s", dockerRegistry, productName, tag), nil
}

// imageHasTag reports whether a Docker image reference already includes a tag.
// A colon only counts as a tag separator when it appears in the final path
// segment, so "my-registry:5000/localstack-pro" (registry port, no tag) is
// correctly treated as untagged.
func imageHasTag(image string) bool {
lastSegment := image[strings.LastIndex(image, "/")+1:]
return strings.Contains(lastSegment, ":")
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we use dep github.com/distribution/reference instead of implementing ourselves? It would just need to be promoted to direct dependency.

// Name returns the container name: "localstack-{type}" or "localstack-{type}-{tag}" if tag != latest
func (c *ContainerConfig) Name() string {
tag := c.Tag
Expand Down
32 changes: 32 additions & 0 deletions internal/config/containers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,38 @@ func TestEmulatorTypeForImage_Azure(t *testing.T) {
assert.Equal(t, EmulatorAzure, EmulatorTypeForImage("localstack/localstack-azure:latest"))
}

func TestImage_CustomImage(t *testing.T) {
tests := []struct {
name string
customImage string
tag string
want string
}{
{"untagged custom image gets configured tag", "my-registry.internal/localstack-pro", "2026.4", "my-registry.internal/localstack-pro:2026.4"},
{"untagged custom image defaults to latest", "local-image-name", "", "local-image-name:latest"},
{"tagged custom image is used as-is", "my-registry.internal/localstack-pro:custom", "latest", "my-registry.internal/localstack-pro:custom"},
{"registry port is not mistaken for a tag", "my-registry:5000/localstack-pro", "2026.4", "my-registry:5000/localstack-pro:2026.4"},
{"registry port with explicit tag", "my-registry:5000/localstack-pro:custom", "", "my-registry:5000/localstack-pro:custom"},
{"digest-pinned image is used as-is", "localstack/localstack-pro@sha256:abc123def456", "2026.4", "localstack/localstack-pro@sha256:abc123def456"},
{"registry port with digest is used as-is", "my-registry:5000/localstack-pro@sha256:abc123def456", "", "my-registry:5000/localstack-pro@sha256:abc123def456"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: tt.tag, CustomImage: tt.customImage}
image, err := c.Image()
require.NoError(t, err)
assert.Equal(t, tt.want, image)
})
}
}

func TestImage_DefaultWhenNoCustomImage(t *testing.T) {
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: "latest"}
image, err := c.Image()
require.NoError(t, err)
assert.Equal(t, "localstack/localstack-pro:latest", image)
}

func TestSelfValidatesLicense(t *testing.T) {
// Snowflake and Azure containers activate their own license against the
// licensing server, so lstk skips its pre-flight platform license check.
Expand Down
3 changes: 3 additions & 0 deletions internal/config/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
type = "aws" # Emulator type. Currently supported: "aws", "snowflake", "azure"
tag = "latest" # Docker image tag, e.g. "latest", "2026.4"
port = "4566" # Host port the emulator will be accessible on
# image = "" # Custom image to use instead of the default Docker Hub image, e.g.
# # an internal registry mirror or a locally loaded offline image.
# # If it carries no tag, 'tag' above is appended.
# volume = "" # Host directory for persistent state (default: OS cache dir)
# env = [] # Named environment profiles to apply (see [env.*] sections below)
# snapshot = "pod:my-baseline" # Snapshot REF auto-loaded on start (AWS only); skip once with 'lstk start --no-snapshot'
Expand Down
31 changes: 31 additions & 0 deletions test/integration/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,37 @@ func TestStartHidesHeaderUntilAuthComplete(t *testing.T) {
_ = cmd.Wait()
}

// TestStartWithCustomImageFailsClearlyWhenUnavailable verifies that a configured
// custom image is honored and that, when it can be neither pulled nor found
// locally, the start fails with the pull error rather than hanging. The "latest"
// tag defers the license check until after the pull, so the (unreachable) license
// endpoint is never contacted — the pull failure surfaces first.
func TestStartWithCustomImageFailsClearlyWhenUnavailable(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)

configContent := `
[[containers]]
type = "aws"
tag = "latest"
port = "4566"
image = "lstk-nonexistent-custom-image"
`
configFile := filepath.Join(t.TempDir(), "config.toml")
require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644))

// A dummy token satisfies the up-front auth check (it is not validated here);
// the flow fails when the custom image cannot be pulled or found locally.
e := env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.AuthToken, "dummy-token")
stdout, stderr, err := runLstk(t, testContext(t), "", e, "--config", configFile, "--non-interactive", "start")

require.Error(t, err, "expected start to fail when the custom image is unavailable")
requireExitCode(t, 1, err)
combined := stdout + stderr
assert.Contains(t, combined, "Failed to pull lstk-nonexistent-custom-image:latest")
}

func cleanup() {
ctx := context.Background()
// ContainerRemove with Force already SIGKILLs the container; an explicit
Expand Down