diff --git a/CLAUDE.md b/CLAUDE.md index a3b828aa..650b8a7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/:` is used when `image` is unset. + # Emulator Setup Commands Use `lstk setup ` to set up CLI integration for an emulator type: diff --git a/internal/config/containers.go b/internal/config/containers.go index 45ab23d6..30bc16d4 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -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) @@ -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, ":") +} + // Name returns the container name: "localstack-{type}" or "localstack-{type}-{tag}" if tag != latest func (c *ContainerConfig) Name() string { tag := c.Tag diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index ff1bccb0..0fbca862 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -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. diff --git a/internal/config/default_config.toml b/internal/config/default_config.toml index 02b9ef20..e6818a0b 100644 --- a/internal/config/default_config.toml +++ b/internal/config/default_config.toml @@ -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' diff --git a/test/integration/start_test.go b/test/integration/start_test.go index bfc3ab4e..13e0671d 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -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