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
20 changes: 19 additions & 1 deletion internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ type ContainerConfig struct {
// 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; if it already carries a tag, Tag is dropped.
// is appended; if it already carries a tag, Tag is dropped. A local copy is reused
// without a pull only when IsImmutableImage holds.
CustomImage string `mapstructure:"image"`
// CustomName overrides the derived container name (see Name). It is also exported into
// the container as MAIN_CONTAINER_NAME, which the emulator uses to introspect itself over
Expand Down Expand Up @@ -567,6 +568,23 @@ func imageHasTag(image string) bool {
return strings.Contains(lastSegment, ":")
}

// fullVersionTagRe matches full release versions such as "2026.10.4" or
// "2026.10.0-bigdata-arm64", the only LocalStack tags never re-pointed upstream.
// Suffixes are limited to the published ones, so an unknown tag is pulled.
var fullVersionTagRe = regexp.MustCompile(`^\d+\.\d+\.\d+(-bigdata)?(-(amd64|arm64))?$`)

// IsImmutableImage reports whether image always resolves to the same build: it is
// pinned by digest or tagged with a full release version. Any other tag ("latest",
// "stable", "dev", "2026.10", "2026", or a custom one) may move upstream.
func IsImmutableImage(image string) bool {
if strings.Contains(image, "@") {
return true
}
lastSegment := image[strings.LastIndex(image, "/")+1:]
_, tag, _ := strings.Cut(lastSegment, ":")
return fullVersionTagRe.MatchString(tag)
}

// Name returns the container name: CustomName when set, otherwise the derived defaultName.
func (c *ContainerConfig) Name() string {
if c.CustomName != "" {
Expand Down
36 changes: 36 additions & 0 deletions internal/config/containers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,42 @@ func TestImage_CustomImage(t *testing.T) {
}
}

// The localstack/localstack-pro entries cover every tag family on Docker Hub.
func TestIsImmutableImage(t *testing.T) {
immutable := []string{
"localstack/localstack-pro:2026.10.4",
"localstack/localstack-pro:2026.09.1",
"localstack/localstack-pro:4.14.0",
"localstack/localstack-pro:2026.10.0-bigdata",
"localstack/localstack-pro:2026.10.4-arm64",
"localstack/localstack-pro:2026.10.0-bigdata-amd64",
"my-registry:5000/localstack-pro:2026.10.4",
"localstack/localstack-pro@sha256:abc123def456",
"localstack/localstack-pro:dev@sha256:abc123def456",
}
moving := []string{
"localstack/localstack-pro:latest",
"localstack/localstack-pro:stable",
"localstack/localstack-pro:dev",
"localstack/localstack-pro:dev-arm64",
"localstack/localstack-pro:latest-bigdata",
"localstack/localstack-pro:dev-bigdata",
"localstack/localstack-pro:2026.10",
"localstack/localstack-pro:2026.09",
"localstack/localstack-pro:2026.10-amd64",
"localstack/localstack-pro:2026",
"localstack/localstack-pro:my-custom-build",
"localstack/localstack-pro:2026.10.4-nightly",
"my-registry:5000/localstack-pro",
}
for _, image := range immutable {
assert.True(t, IsImmutableImage(image), image)
}
for _, image := range moving {
assert.False(t, IsImmutableImage(image), image)
}
}

func TestImage_DefaultWhenNoCustomImage(t *testing.T) {
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: "latest"}
image, err := c.Image()
Expand Down
3 changes: 2 additions & 1 deletion internal/config/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

[[containers]]
type = "aws" # Emulator type. Currently supported: "aws", "snowflake", "azure"
tag = "latest" # Docker image tag, e.g. "latest", "2026.4"
tag = "latest" # Docker image tag, e.g. "latest", "2026.4". Any tag except a full
# # version like "2026.4.1" is re-pulled on start to get newer builds.
port = "4566" # Host port the emulator will be accessible on
# container_name = "" # Container name (default: "localstack-<type>", plus "-<tag>"
# # when tag is not "latest"). Set it when something outside lstk
Expand Down
26 changes: 14 additions & 12 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,9 @@ func startOnce(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts S
}

// Validate licenses before pulling. Pinned tags are validated immediately —
// unless the image is already present locally, in which case both the pull and
// the pre-flight check are skipped. "latest" tags defer to post-pull validation.
// unless the image is already present locally, in which case the pre-flight
// check is skipped (see pullImages for when the pull is skipped too). "latest"
// tags defer to post-pull validation.
postPullContainers, prePullRefreshed, err := tryPrePullLicenseValidation(ctx, rt, sink, opts, containers, token, licenseFilePath, forceLicenseValidation)
if err != nil {
return StartResult{}, err
Expand Down Expand Up @@ -531,10 +532,11 @@ func pullImages(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *
return nil, fmt.Errorf("failed to check for local image %s: %w", c.Image, err)
}

// Reuse a locally present image for pinned tags instead of re-pulling.
// Floating "latest"/empty tags always pull until pull_policy support lands.
// Reuse a locally present image only when it cannot have moved upstream.
// Moving tags such as "dev" or "2026.10" always pull until pull_policy
// support lands; a failed pull still falls back to the local copy.
// Bundled-license images never pull: they are meant to run offline.
if c.BundledLicense || (exists && c.Tag != "" && c.Tag != "latest") {
if c.BundledLicense || (exists && config.IsImmutableImage(c.Image)) {
sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: fmt.Sprintf("Using local image %s", c.Image)})
pulled[c.Name] = false
continue
Expand Down Expand Up @@ -632,8 +634,8 @@ func pullImage(ctx context.Context, rt runtime.Runtime, sink output.Sink, tel *t
}

// Validates licenses before pulling for containers with pinned tags, except those
// whose image is already present locally (not pulled, so the check is skipped too —
// unless force is set, e.g. when retrying after a startup license failure).
// whose image is already present locally (unless force is set, e.g. when
// retrying after a startup license failure).
// "latest" and empty tags are deferred to post-pull validation via image inspection.
// The bool reports whether any validation refreshed the cached license file.
func tryPrePullLicenseValidation(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts StartOptions, containers []runtime.ContainerConfig, token, licenseFilePath string, force bool) ([]runtime.ContainerConfig, bool, error) {
Expand All @@ -649,11 +651,11 @@ func tryPrePullLicenseValidation(ctx context.Context, rt runtime.Runtime, sink o
if c.BundledLicense {
continue
}
// A pinned image already present locally is not pulled (see pullImages),
// so skip the license pre-flight too: the check is redundant — and a hard
// blocker in offline/enterprise environments — when no network round-trip
// happens at all and the container validates the license at
// startup. A probe error is non-fatal here; fall through to the check.
// An image already present locally skips the license pre-flight, even when
// pullImages re-pulls its moving tag: the container validates the license at
// startup, a stale cached license is refreshed by startWithLicenseRetry, and
// offline the check would only add a timeout and a warning.
// A probe error is non-fatal here; fall through to the check.
if !force {
if exists, err := rt.ImageExists(ctx, c.Image); err == nil && exists {
continue
Expand Down
30 changes: 30 additions & 0 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,36 @@ func TestPullImages_ReusesLocalImageWhenPresent(t *testing.T) {
assert.Contains(t, out.String(), "Using local image localstack/localstack-pro:3.5.0")
}

func TestPullImages_PullsMovingTagEvenWhenPresent(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)
mockTel := telemetry.New("", true)

c := runtime.ContainerConfig{
Image: "localstack/localstack-pro:dev",
Name: "localstack-aws-dev",
EmulatorType: config.EmulatorAWS,
Tag: "dev",
}

mockRT.EXPECT().Remove(gomock.Any(), c.Name).Return(nil)
mockRT.EXPECT().ImageExists(gomock.Any(), c.Image).Return(true, nil)
mockRT.EXPECT().PullImage(gomock.Any(), c.Image, gomock.Any()).
DoAndReturn(func(_ context.Context, _ string, progress chan<- runtime.PullProgress) error {
close(progress)
return nil
})

var out bytes.Buffer
sink := output.NewPlainSink(&out)

pulled, err := pullImages(context.Background(), mockRT, sink, mockTel, []runtime.ContainerConfig{c}, false)

require.NoError(t, err)
assert.True(t, pulled[c.Name], "a local copy of a moving tag may be stale, so it must be pulled")
assert.Contains(t, out.String(), "Pulled localstack/localstack-pro:dev")
}

func TestPullImages_PullsWhenImageMissing(t *testing.T) {
ctrl := gomock.NewController(t)
mockRT := runtime.NewMockRuntime(ctrl)
Expand Down
6 changes: 3 additions & 3 deletions test/integration/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,15 +289,15 @@ func startExternalContainer(t *testing.T, ctx context.Context, imgName, name, ho
// commitNeverHealthyImage builds a local-only image whose default command stays
// running (sleep infinity) but never serves /_localstack/health. Starting it via
// lstk exercises the failure path where the emulator comes up but never reports
// healthy. The tag must stay pinned (non-latest): a pinned locally-present image
// skips both the pull and the license checks, while a "latest" tag is validated
// healthy. The tag must stay a full version: such a locally-present image skips
// both the pull and the license checks, while a "latest" tag is validated
// post-pull via GetImageVersion, which fails on this image (no
// LOCALSTACK_BUILD_VERSION) before the health wait is ever reached. Returns the
// image reference; the image and its source container are removed on test cleanup.
func commitNeverHealthyImage(t *testing.T, ctx context.Context) string {
t.Helper()

const imageRef = "lstk-never-healthy:test"
const imageRef = "lstk-never-healthy:0.0.7"
commitTestImage(t, ctx, imageRef, "lstk-never-healthy-src", `CMD ["sleep", "infinity"]`)
return imageRef
}
Expand Down
76 changes: 51 additions & 25 deletions test/integration/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,46 +52,72 @@ func TestStartCommandSucceedsWithValidToken(t *testing.T) {
}

// PRO-323: a pinned image already present locally must be reused, not re-pulled.
// Tags the lightweight test image under a pinned localstack-pro tag so the image
// is present locally; lstk should skip the pull and emit "Using local image".
// Only a full release version counts: "dev" or "2026.10" may have moved upstream,
// so lstk still pulls them, falling back to the local copy when the pull fails.
// We only assert the pull decision (emitted before the container starts) — the
// stand-in image is not a real emulator, so the subsequent start fails fast when
// it exits. A dedicated host port keeps this off the shared 4566 used by the
// other container tests.
func TestStartCommandReusesLocalImageWhenPresent(t *testing.T) {
func TestStartCommandPullsUnlessTagIsFullVersion(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)

ctx := testContext(t)

const pinnedTag = "reuse-local-test"
const pinnedImage = "localstack/localstack-pro:" + pinnedTag
// Refuses connections, so a pull fails fast. Not localhost: Docker Desktop
// resolves it to ::1, where the dial times out after 30s instead.
const repo = "127.0.0.1:1/localstack-pro"
const pinnedTag = "0.0.1"
reader, err := dockerClient.ImagePull(ctx, testImage, client.ImagePullOptions{})
require.NoError(t, err, "failed to pull test image")
_, _ = io.Copy(io.Discard, reader)
_ = reader.Close()
_, err = dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: pinnedImage})
require.NoError(t, err)
t.Cleanup(func() {
_, _ = dockerClient.ImageRemove(context.Background(), pinnedImage, client.ImageRemoveOptions{})
})

home := t.TempDir()
configFile := filepath.Join(home, "config.toml")
require.NoError(t, os.WriteFile(configFile,
[]byte(fmt.Sprintf("[[containers]]\ntype = \"aws\"\ntag = %q\nport = \"4599\"\n", pinnedTag)), 0644))
for _, tag := range []string{pinnedTag, "dev"} {
image := repo + ":" + tag
_, err = dockerClient.ImageTag(ctx, client.ImageTagOptions{Source: testImage, Target: image})
require.NoError(t, err)
t.Cleanup(func() {
_, _ = dockerClient.ImageRemove(context.Background(), image, client.ImageRemoveOptions{})
})
}

mockServer := createMockLicenseServer(true)
defer mockServer.Close()

e := append(testEnvWithHome(home, ""),
string(env.APIEndpoint)+"="+mockServer.URL,
string(env.AuthToken)+"=fake-token")
stdout, _, _ := runLstk(t, ctx, "", e, "--config", configFile, "start")

assert.Contains(t, stdout, "Using local image "+pinnedImage, "a pinned image present locally should be reused")
assert.NotContains(t, stdout, "Pulling", "lstk must not re-pull an image that is already present")
tests := []struct {
name string
configTag string
args []string
image string
wantPull bool
}{
{name: "full version is reused", configTag: pinnedTag, image: repo + ":" + pinnedTag},
{name: "dev is pulled", configTag: "dev", image: repo + ":dev", wantPull: true},
{name: "tag in --image wins over config tag", configTag: pinnedTag, args: []string{"--image", repo + ":dev"}, image: repo + ":dev", wantPull: true},
{name: "full version in --image is reused", configTag: "latest", args: []string{"--image", repo + ":" + pinnedTag}, image: repo + ":" + pinnedTag},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
home := t.TempDir()
configFile := filepath.Join(home, "config.toml")
require.NoError(t, os.WriteFile(configFile,
[]byte(fmt.Sprintf("[[containers]]\ntype = \"aws\"\nimage = %q\ntag = %q\nport = \"4599\"\n", repo, tt.configTag)), 0644))

e := append(testEnvWithHome(home, ""),
string(env.APIEndpoint)+"="+mockServer.URL,
string(env.AuthToken)+"=fake-token")
stdout, _, _ := runLstk(t, ctx, "", e, append([]string{"--config", configFile, "start"}, tt.args...)...)

if tt.wantPull {
assert.Contains(t, stdout, "Could not pull "+tt.image, "a moving tag must be pulled even when present locally")
assert.NotContains(t, stdout, "Using local image")
} else {
assert.Contains(t, stdout, "Using local image "+tt.image, "a full version present locally should be reused")
assert.NotContains(t, stdout, "Pulling", "lstk must not re-pull an image that is already present")
}
})
}
}

// A container that exits during startup must fail with a styled
Expand All @@ -105,7 +131,7 @@ func TestStartFailsWhenContainerExitsDuringStartup(t *testing.T) {

ctx := testContext(t)

const pinnedTag = "exit-during-startup-test"
const pinnedTag = "0.0.2"
const pinnedImage = "localstack/localstack-pro:" + pinnedTag
reader, err := dockerClient.ImagePull(ctx, testImage, client.ImagePullOptions{})
require.NoError(t, err, "failed to pull test image")
Expand Down Expand Up @@ -156,7 +182,7 @@ func TestStartFailsWhenContainerNeverBecomesHealthy(t *testing.T) {
ctx := testContext(t)

const standInImage = "nginx:alpine"
const pinnedTag = "never-healthy-test"
const pinnedTag = "0.0.3"
const pinnedImage = "localstack/localstack-pro:" + pinnedTag
reader, err := dockerClient.ImagePull(ctx, standInImage, client.ImagePullOptions{})
require.NoError(t, err, "failed to pull nginx test image")
Expand Down Expand Up @@ -215,7 +241,7 @@ func TestStartEmitsTelemetryWhenInterruptedDuringStartup(t *testing.T) {
// keeps running but never serves the health endpoint, so the start stays in
// the readiness wait until interrupted.
const standInImage = "nginx:alpine"
const pinnedTag = "interrupted-startup-test"
const pinnedTag = "0.0.4"
const pinnedImage = "localstack/localstack-pro:" + pinnedTag
reader, err := dockerClient.ImagePull(ctx, standInImage, client.ImagePullOptions{})
require.NoError(t, err, "failed to pull nginx test image")
Expand Down
4 changes: 2 additions & 2 deletions test/integration/stop_timeout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestStartGivesTheContainerItsShutdownTimeoutToStop(t *testing.T) {
ctx := testContext(t)

const standInImage = "nginx:alpine"
const pinnedTag = "stop-timeout-test"
const pinnedTag = "0.0.5"
const pinnedImage = "localstack/localstack-pro:" + pinnedTag
reader, err := dockerClient.ImagePull(ctx, standInImage, client.ImagePullOptions{})
require.NoError(t, err, "failed to pull nginx test image")
Expand Down Expand Up @@ -85,7 +85,7 @@ func TestStopWaitsForTheEmulatorsOwnShutdown(t *testing.T) {
ctx := testContext(t)

const imageName = "lstk-slow-shutdown"
const imageTag = "slow-shutdown-test"
const imageTag = "0.0.6"
commitSlowShutdownImage(t, ctx, imageName+":"+imageTag)

// A non-interactive startup timeout leaves the container running as localstack-aws-<tag>, which cleanup() does
Expand Down
Loading