Skip to content

Commit bda6687

Browse files
feat: support offline mode and custom images for enterprise setups
Enterprise environments that cannot pull from Docker Hub (internal registries, offline/air-gapped, proxy/cert issues) now have two complementary knobs, following Carole's proposal: - A per-container `image` config field overrides the default Docker Hub image (e.g. an internal-registry mirror or a locally loaded image). If it carries no tag, the configured `tag` (or "latest") is appended. - `lstk --offline` (or `LSTK_OFFLINE=1`) runs from a locally available image without pulling, skips the license-server check (the license ships inside enterprise images), makes the auth token optional, and disables telemetry and update checks. In offline mode `container.Start` uses `useLocalImages`, which verifies the image is present locally via the new `runtime.ImageExists` and emits an actionable error if it is missing, instead of pulling and validating. Generated with [Linear](https://linear.app/localstack/issue/DEVX-703/support-enterprise-environments-that-cannot-pull-from-docker-hub#agent-session-77e3f9fc) Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
1 parent 2fe34fc commit bda6687

16 files changed

Lines changed: 367 additions & 55 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ 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+
71+
# Offline Mode
72+
73+
For enterprise environments that cannot pull from Docker Hub, `lstk --offline` (or `LSTK_OFFLINE=1`) runs the emulator from a locally available image without pulling, skips the license-server check (the license ships inside enterprise images), and disables telemetry/update checks. The flag is a persistent root flag honored by `start` and `restart`; `cmd.resolveOfflineFlag` folds it into `env.Env.Offline`, which flows into `container.StartOptions.Offline`. In offline mode `container.Start` calls `useLocalImages` (verifies the image exists locally via `runtime.ImageExists`, no pull, no license validation) instead of the pull/validate path, and an auth token becomes optional. Pair it with a custom `image` in the config to point at a locally loaded or internal-registry image.
74+
6975
# Emulator Setup Commands
7076

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

cmd/restart.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ func newRestartCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobr
3737
return err
3838
}
3939

40+
if err := resolveOfflineFlag(cmd, cfg); err != nil {
41+
return err
42+
}
43+
if cfg.Offline {
44+
tel.Disable()
45+
}
46+
4047
stopOpts := container.StopOptions{
4148
Telemetry: tel,
4249
}

cmd/root.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
5050
if err != nil {
5151
return err
5252
}
53+
if err := resolveOfflineFlag(cmd, cfg); err != nil {
54+
return err
55+
}
5356
return startEmulator(cmd.Context(), rt, cfg, tel, logger, persist, firstRun)
5457
},
5558
}
@@ -60,6 +63,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C
6063

6164
root.PersistentFlags().String("config", "", "Path to config file")
6265
root.PersistentFlags().BoolVar(&cfg.NonInteractive, "non-interactive", false, "Disable interactive mode")
66+
root.PersistentFlags().Bool("offline", false, "Run from a locally available image without pulling or contacting the license server")
6367
root.Flags().Bool("persist", false, "Persist emulator state across restarts")
6468

6569
configureHelp(root)
@@ -157,6 +161,17 @@ func Execute(ctx context.Context) error {
157161
return nil
158162
}
159163

164+
// resolveOfflineFlag folds the --offline flag into cfg.Offline (which may already
165+
// be set via LSTK_OFFLINE) so both the env var and the flag enable offline mode.
166+
func resolveOfflineFlag(cmd *cobra.Command, cfg *env.Env) error {
167+
offline, err := cmd.Flags().GetBool("offline")
168+
if err != nil {
169+
return err
170+
}
171+
cfg.Offline = cfg.Offline || offline
172+
return nil
173+
}
174+
160175
func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger, tel *telemetry.Client, persist bool) container.StartOptions {
161176
return container.StartOptions{
162177
PlatformClient: api.NewPlatformClient(cfg.APIEndpoint, logger),
@@ -169,6 +184,7 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger
169184
Persist: persist,
170185
Logger: logger,
171186
Telemetry: tel,
187+
Offline: cfg.Offline,
172188
}
173189
}
174190

@@ -178,6 +194,12 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
178194
return fmt.Errorf("failed to get config: %w", err)
179195
}
180196

197+
// Offline environments may not be able to reach the analytics endpoint, so
198+
// disable telemetry entirely rather than queue events that can never flush.
199+
if cfg.Offline {
200+
tel.Disable()
201+
}
202+
181203
opts := buildStartOptions(cfg, appConfig, logger, tel, persist)
182204

183205
notifyOpts := update.NotifyOptions{
@@ -212,7 +234,9 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
212234
Text: fmt.Sprintf("Configured with default emulator %s.", emName),
213235
})
214236
}
215-
update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken})
237+
if !cfg.Offline {
238+
update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken})
239+
}
216240
if _, err = container.Start(ctx, rt, sink, opts, false); err != nil {
217241
return err
218242
}

cmd/start.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ Host environment variables prefixed with LOCALSTACK_ are forwarded to the emulat
2626
if err != nil {
2727
return err
2828
}
29+
if err := resolveOfflineFlag(c, cfg); err != nil {
30+
return err
31+
}
2932
return startEmulator(c.Context(), rt, cfg, tel, logger, persist, firstRun)
3033
},
3134
}

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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,36 @@ 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+
}
156+
for _, tt := range tests {
157+
t.Run(tt.name, func(t *testing.T) {
158+
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: tt.tag, CustomImage: tt.customImage}
159+
image, err := c.Image()
160+
require.NoError(t, err)
161+
assert.Equal(t, tt.want, image)
162+
})
163+
}
164+
}
165+
166+
func TestImage_DefaultWhenNoCustomImage(t *testing.T) {
167+
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Tag: "latest"}
168+
image, err := c.Image()
169+
require.NoError(t, err)
170+
assert.Equal(t, "localstack/localstack-pro:latest", image)
171+
}
172+
143173
func TestSelfValidatesLicense(t *testing.T) {
144174
// Snowflake and Azure containers activate their own license against the
145175
// licensing server, so lstk skips its pre-flight platform license check.

internal/config/default_config.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,16 @@
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

18+
# Run 'lstk --offline' (or set LSTK_OFFLINE=1) in environments that cannot pull
19+
# from Docker Hub. Offline mode uses the local image without pulling, skips the
20+
# license check (the license ships inside enterprise images), and disables telemetry.
21+
1522
# Environment profiles let you group environment variables and reference
1623
# them by name in one or more containers via the 'env' field above.
1724
#

0 commit comments

Comments
 (0)