diff --git a/internal/config/containers.go b/internal/config/containers.go index 2e7b14b5..1dd702f5 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -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 @@ -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 != "" { diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index a91dd194..28526669 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -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() diff --git a/internal/config/default_config.toml b/internal/config/default_config.toml index 086aedbd..7f11cc21 100644 --- a/internal/config/default_config.toml +++ b/internal/config/default_config.toml @@ -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-", plus "-" # # when tag is not "latest"). Set it when something outside lstk diff --git a/internal/container/start.go b/internal/container/start.go index c708352f..e7cd804f 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -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 @@ -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 @@ -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) { @@ -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 diff --git a/internal/container/start_test.go b/internal/container/start_test.go index f8292b12..0a8c35e2 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -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) diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 318a5ee0..7a57073e 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -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 } diff --git a/test/integration/start_test.go b/test/integration/start_test.go index 5f6b2785..d18a593f 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -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 @@ -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") @@ -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") @@ -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") diff --git a/test/integration/stop_timeout_test.go b/test/integration/stop_timeout_test.go index e40f6e76..3d327486 100644 --- a/test/integration/stop_timeout_test.go +++ b/test/integration/stop_timeout_test.go @@ -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") @@ -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-, which cleanup() does