Skip to content

Commit fd896c8

Browse files
committed
feat: support custom container images via config
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. Refs DEVX-703
1 parent 22e2d3a commit fd896c8

5 files changed

Lines changed: 96 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ When adding a new command that depends on configuration, wire config initializat
6666

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

69+
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.
70+
6971
# Emulator Setup Commands
7072

7173
Use `lstk setup <emulator>` to set up CLI integration for an emulator type:

internal/config/containers.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,15 @@ func KnownImageReposForType(t EmulatorType) []string {
112112
}
113113

114114
type ContainerConfig struct {
115-
Type EmulatorType `mapstructure:"type"`
116-
Tag string `mapstructure:"tag"`
117-
Port string `mapstructure:"port"`
118-
Volume string `mapstructure:"volume"`
115+
Type EmulatorType `mapstructure:"type"`
116+
Tag string `mapstructure:"tag"`
117+
Port string `mapstructure:"port"`
118+
// CustomImage overrides the default Docker image for this emulator. Set it to use an
119+
// image from an internal registry or a locally loaded offline image instead of pulling
120+
// the default localstack image from Docker Hub. If it carries no tag, Tag (or "latest")
121+
// is appended.
122+
CustomImage string `mapstructure:"image"`
123+
Volume string `mapstructure:"volume"`
119124
// Env is a list of named environment references defined in the top-level [env.*] config sections.
120125
Env []string `mapstructure:"env"`
121126
}
@@ -202,17 +207,32 @@ func (c *ContainerConfig) ResolvedEnv(namedEnvs map[string]map[string]string) ([
202207
}
203208

204209
func (c *ContainerConfig) Image() (string, error) {
205-
productName, err := c.ProductName()
206-
if err != nil {
207-
return "", err
208-
}
209210
tag := c.Tag
210211
if tag == "" {
211212
tag = "latest"
212213
}
214+
if c.CustomImage != "" {
215+
if imageHasTag(c.CustomImage) {
216+
return c.CustomImage, nil
217+
}
218+
return c.CustomImage + ":" + tag, nil
219+
}
220+
productName, err := c.ProductName()
221+
if err != nil {
222+
return "", err
223+
}
213224
return fmt.Sprintf("%s/%s:%s", dockerRegistry, productName, tag), nil
214225
}
215226

227+
// imageHasTag reports whether a Docker image reference already includes a tag.
228+
// A colon only counts as a tag separator when it appears in the final path
229+
// segment, so "my-registry:5000/localstack-pro" (registry port, no tag) is
230+
// correctly treated as untagged.
231+
func imageHasTag(image string) bool {
232+
lastSegment := image[strings.LastIndex(image, "/")+1:]
233+
return strings.Contains(lastSegment, ":")
234+
}
235+
216236
// Name returns the container name: "localstack-{type}" or "localstack-{type}-{tag}" if tag != latest
217237
func (c *ContainerConfig) Name() string {
218238
tag := c.Tag

internal/config/containers_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,38 @@ func TestEmulatorTypeForImage_Azure(t *testing.T) {
140140
assert.Equal(t, EmulatorAzure, EmulatorTypeForImage("localstack/localstack-azure:latest"))
141141
}
142142

143+
func TestImage_CustomImage(t *testing.T) {
144+
tests := []struct {
145+
name string
146+
customImage string
147+
tag string
148+
want string
149+
}{
150+
{"untagged custom image gets configured tag", "my-registry.internal/localstack-pro", "2026.4", "my-registry.internal/localstack-pro:2026.4"},
151+
{"untagged custom image defaults to latest", "local-image-name", "", "local-image-name:latest"},
152+
{"tagged custom image is used as-is", "my-registry.internal/localstack-pro:custom", "latest", "my-registry.internal/localstack-pro:custom"},
153+
{"registry port is not mistaken for a tag", "my-registry:5000/localstack-pro", "2026.4", "my-registry:5000/localstack-pro:2026.4"},
154+
{"registry port with explicit tag", "my-registry:5000/localstack-pro:custom", "", "my-registry:5000/localstack-pro:custom"},
155+
{"digest-pinned image is used as-is", "localstack/localstack-pro@sha256:abc123def456", "2026.4", "localstack/localstack-pro@sha256:abc123def456"},
156+
{"registry port with digest is used as-is", "my-registry:5000/localstack-pro@sha256:abc123def456", "", "my-registry:5000/localstack-pro@sha256:abc123def456"},
157+
}
158+
for _, tt := range tests {
159+
t.Run(tt.name, func(t *testing.T) {
160+
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: tt.tag, CustomImage: tt.customImage}
161+
image, err := c.Image()
162+
require.NoError(t, err)
163+
assert.Equal(t, tt.want, image)
164+
})
165+
}
166+
}
167+
168+
func TestImage_DefaultWhenNoCustomImage(t *testing.T) {
169+
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: "latest"}
170+
image, err := c.Image()
171+
require.NoError(t, err)
172+
assert.Equal(t, "localstack/localstack-pro:latest", image)
173+
}
174+
143175
func TestSelfValidatesLicense(t *testing.T) {
144176
// Snowflake and Azure containers activate their own license against the
145177
// licensing server, so lstk skips its pre-flight platform license check.

internal/config/default_config.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
type = "aws" # Emulator type. Currently supported: "aws", "snowflake", "azure"
1010
tag = "latest" # Docker image tag, e.g. "latest", "2026.4"
1111
port = "4566" # Host port the emulator will be accessible on
12+
# image = "" # Custom image to use instead of the default Docker Hub image, e.g.
13+
# # an internal registry mirror or a locally loaded offline image.
14+
# # If it carries no tag, 'tag' above is appended.
1215
# volume = "" # Host directory for persistent state (default: OS cache dir)
1316
# env = [] # Named environment profiles to apply (see [env.*] sections below)
1417

test/integration/start_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,37 @@ func TestStartHidesHeaderUntilAuthComplete(t *testing.T) {
619619
_ = cmd.Wait()
620620
}
621621

622+
// TestStartWithCustomImageFailsClearlyWhenUnavailable verifies that a configured
623+
// custom image is honored and that, when it can be neither pulled nor found
624+
// locally, the start fails with the pull error rather than hanging. The "latest"
625+
// tag defers the license check until after the pull, so the (unreachable) license
626+
// endpoint is never contacted — the pull failure surfaces first.
627+
func TestStartWithCustomImageFailsClearlyWhenUnavailable(t *testing.T) {
628+
requireDocker(t)
629+
cleanup()
630+
t.Cleanup(cleanup)
631+
632+
configContent := `
633+
[[containers]]
634+
type = "aws"
635+
tag = "latest"
636+
port = "4566"
637+
image = "lstk-nonexistent-custom-image"
638+
`
639+
configFile := filepath.Join(t.TempDir(), "config.toml")
640+
require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644))
641+
642+
// A dummy token satisfies the up-front auth check (it is not validated here);
643+
// the flow fails when the custom image cannot be pulled or found locally.
644+
e := env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.AuthToken, "dummy-token")
645+
stdout, stderr, err := runLstk(t, testContext(t), "", e, "--config", configFile, "--non-interactive", "start")
646+
647+
require.Error(t, err, "expected start to fail when the custom image is unavailable")
648+
requireExitCode(t, 1, err)
649+
combined := stdout + stderr
650+
assert.Contains(t, combined, "Failed to pull lstk-nonexistent-custom-image:latest")
651+
}
652+
622653
func cleanup() {
623654
ctx := context.Background()
624655
// ContainerRemove with Force already SIGKILLs the container; an explicit

0 commit comments

Comments
 (0)