Skip to content

Commit dc00b12

Browse files
committed
Add DOCKER_FLAGS support for passing extra flags to the emulator container
1 parent b73c37a commit dc00b12

9 files changed

Lines changed: 311 additions & 4 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger
150150
LocalStackHost: cfg.LocalStackHost,
151151
Containers: appConfig.Containers,
152152
Env: appConfig.Env,
153+
DockerFlags: cfg.DockerFlags,
153154
Persist: persist,
154155
Logger: logger,
155156
Telemetry: tel,

internal/config/containers.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ type ContainerConfig struct {
9494
Volume string `mapstructure:"volume"`
9595
// Env is a list of named environment references defined in the top-level [env.*] config sections.
9696
Env []string `mapstructure:"env"`
97+
// DockerFlags is a raw docker-run-style flag string (e.g. "-e FOO=bar -v /tmp:/data").
98+
// Supported flags: -e/--env, -v/--volume.
99+
DockerFlags string `mapstructure:"docker_flags"`
97100
}
98101

99102
// VolumeDir returns the host directory to mount into the container for persistence/caching.

internal/container/dockerflags.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package container
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/localstack/lstk/internal/runtime"
8+
)
9+
10+
// ParsedFlags holds the result of parsing a DOCKER_FLAGS-style string.
11+
type ParsedFlags struct {
12+
Env []string
13+
Binds []runtime.BindMount
14+
}
15+
16+
// ParseDockerFlags parses a subset of docker run flags from a raw string.
17+
// Supported: -e/--env, -v/--volume.
18+
func ParseDockerFlags(flags string) (ParsedFlags, error) {
19+
tokens, err := shellTokenize(flags)
20+
if err != nil {
21+
return ParsedFlags{}, err
22+
}
23+
24+
var result ParsedFlags
25+
for i := 0; i < len(tokens); i++ {
26+
tok := tokens[i]
27+
28+
next := func() (string, error) {
29+
if i+1 >= len(tokens) {
30+
return "", fmt.Errorf("flag %q requires a value", tok)
31+
}
32+
i++
33+
return tokens[i], nil
34+
}
35+
36+
switch {
37+
case tok == "-e" || tok == "--env":
38+
val, err := next()
39+
if err != nil {
40+
return ParsedFlags{}, err
41+
}
42+
result.Env = append(result.Env, val)
43+
case strings.HasPrefix(tok, "-e") && len(tok) > 2:
44+
result.Env = append(result.Env, tok[2:])
45+
case strings.HasPrefix(tok, "--env="):
46+
result.Env = append(result.Env, strings.TrimPrefix(tok, "--env="))
47+
48+
case tok == "-v" || tok == "--volume":
49+
val, err := next()
50+
if err != nil {
51+
return ParsedFlags{}, err
52+
}
53+
b, err := parseBindMount(val)
54+
if err != nil {
55+
return ParsedFlags{}, err
56+
}
57+
result.Binds = append(result.Binds, b)
58+
case strings.HasPrefix(tok, "-v") && len(tok) > 2:
59+
b, err := parseBindMount(tok[2:])
60+
if err != nil {
61+
return ParsedFlags{}, err
62+
}
63+
result.Binds = append(result.Binds, b)
64+
case strings.HasPrefix(tok, "--volume="):
65+
b, err := parseBindMount(strings.TrimPrefix(tok, "--volume="))
66+
if err != nil {
67+
return ParsedFlags{}, err
68+
}
69+
result.Binds = append(result.Binds, b)
70+
71+
default:
72+
return ParsedFlags{}, fmt.Errorf("unsupported docker flag: %q", tok)
73+
}
74+
}
75+
return result, nil
76+
}
77+
78+
func parseBindMount(spec string) (runtime.BindMount, error) {
79+
parts := strings.SplitN(spec, ":", 3)
80+
if len(parts) < 2 {
81+
return runtime.BindMount{}, fmt.Errorf("invalid volume spec %q: must be HOST:CONTAINER[:ro]", spec)
82+
}
83+
b := runtime.BindMount{HostPath: parts[0], ContainerPath: parts[1]}
84+
if len(parts) == 3 {
85+
b.ReadOnly = parts[2] == "ro"
86+
}
87+
return b, nil
88+
}
89+
90+
// shellTokenize splits s into tokens using shell-like whitespace splitting,
91+
// respecting single and double quotes.
92+
func shellTokenize(s string) ([]string, error) {
93+
var tokens []string
94+
var cur strings.Builder
95+
inQuote := rune(0)
96+
97+
for _, c := range s {
98+
switch {
99+
case inQuote != 0:
100+
if c == inQuote {
101+
inQuote = 0
102+
} else {
103+
cur.WriteRune(c)
104+
}
105+
case c == '"' || c == '\'':
106+
inQuote = c
107+
case c == ' ' || c == '\t':
108+
if cur.Len() > 0 {
109+
tokens = append(tokens, cur.String())
110+
cur.Reset()
111+
}
112+
default:
113+
cur.WriteRune(c)
114+
}
115+
}
116+
if inQuote != 0 {
117+
return nil, fmt.Errorf("unterminated quote in docker flags")
118+
}
119+
if cur.Len() > 0 {
120+
tokens = append(tokens, cur.String())
121+
}
122+
return tokens, nil
123+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package container
2+
3+
import (
4+
"testing"
5+
6+
"github.com/localstack/lstk/internal/runtime"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestParseDockerFlags(t *testing.T) {
12+
t.Parallel()
13+
tests := []struct {
14+
name string
15+
input string
16+
want ParsedFlags
17+
errContains string
18+
}{
19+
{name: "-e", input: "-e FOO=bar", want: ParsedFlags{Env: []string{"FOO=bar"}}},
20+
{name: "--env", input: "--env SERVICES=s3,sqs", want: ParsedFlags{Env: []string{"SERVICES=s3,sqs"}}},
21+
{name: "--env=", input: "--env=DEBUG=1", want: ParsedFlags{Env: []string{"DEBUG=1"}}},
22+
{name: "-e inline", input: "-eSERVICES=s3", want: ParsedFlags{Env: []string{"SERVICES=s3"}}},
23+
{name: "-e quoted", input: `-e "FOO=hello world"`, want: ParsedFlags{Env: []string{"FOO=hello world"}}},
24+
25+
{name: "-v", input: "-v /tmp/data:/data", want: ParsedFlags{Binds: []runtime.BindMount{{HostPath: "/tmp/data", ContainerPath: "/data"}}}},
26+
{name: "-v readonly", input: "-v /tmp/data:/data:ro", want: ParsedFlags{Binds: []runtime.BindMount{{HostPath: "/tmp/data", ContainerPath: "/data", ReadOnly: true}}}},
27+
{name: "--volume", input: "--volume /tmp/data:/data", want: ParsedFlags{Binds: []runtime.BindMount{{HostPath: "/tmp/data", ContainerPath: "/data"}}}},
28+
{name: "--volume=", input: "--volume=/tmp/data:/data", want: ParsedFlags{Binds: []runtime.BindMount{{HostPath: "/tmp/data", ContainerPath: "/data"}}}},
29+
30+
{name: "multiple flags", input: "-e SERVICES=s3,sqs -v /tmp:/data", want: ParsedFlags{
31+
Env: []string{"SERVICES=s3,sqs"},
32+
Binds: []runtime.BindMount{{HostPath: "/tmp", ContainerPath: "/data"}},
33+
}},
34+
{name: "empty", input: ""},
35+
36+
{name: "unknown flag", input: "--rm", errContains: "unsupported docker flag"},
37+
{name: "--network unsupported", input: "--network host", errContains: "unsupported docker flag"},
38+
{name: "missing value", input: "-e", errContains: "requires a value"},
39+
{name: "unterminated quote", input: `-e "FOO=bar`, errContains: "unterminated quote"},
40+
{name: "invalid volume", input: "-v /nocolon", errContains: "invalid volume spec"},
41+
}
42+
43+
for _, tc := range tests {
44+
t.Run(tc.name, func(t *testing.T) {
45+
got, err := ParseDockerFlags(tc.input)
46+
if tc.errContains != "" {
47+
require.Error(t, err)
48+
assert.Contains(t, err.Error(), tc.errContains)
49+
return
50+
}
51+
require.NoError(t, err)
52+
assert.Equal(t, tc.want, got)
53+
})
54+
}
55+
}

internal/container/start.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type StartOptions struct {
4141
LocalStackHost string
4242
Containers []config.ContainerConfig
4343
Env map[string]map[string]string
44+
DockerFlags string // from DOCKER_FLAGS env var; merged with per-container docker_flags from config
4445
Persist bool
4546
Logger log.Logger
4647
Telemetry *telemetry.Client
@@ -127,6 +128,17 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start
127128
}
128129
binds = append(binds, runtime.BindMount{HostPath: volumeDir, ContainerPath: "/var/lib/localstack"})
129130

131+
allFlags := strings.TrimSpace(opts.DockerFlags + " " + c.DockerFlags)
132+
var extra ParsedFlags
133+
if allFlags != "" {
134+
extra, err = ParseDockerFlags(allFlags)
135+
if err != nil {
136+
return fmt.Errorf("invalid docker flags: %w", err)
137+
}
138+
}
139+
env = append(env, extra.Env...)
140+
binds = append(binds, extra.Binds...)
141+
130142
containers[i] = runtime.ContainerConfig{
131143
Image: image,
132144
Name: containerName,
@@ -137,8 +149,8 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start
137149
Env: env,
138150
Tag: c.Tag,
139151
ProductName: productName,
140-
Binds: binds,
141-
ExtraPorts: servicePortRange(),
152+
Binds: binds,
153+
ExtraPorts: servicePortRange(),
142154
}
143155
}
144156

internal/env/env.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type Env struct {
1111
AuthToken string
1212
LocalStackHost string
1313
DockerHost string
14+
DockerFlags string
1415
DisableEvents bool
1516
TracesEnabled bool
1617

@@ -38,6 +39,7 @@ func Init() *Env {
3839
AuthToken: os.Getenv("LOCALSTACK_AUTH_TOKEN"),
3940
LocalStackHost: os.Getenv("LOCALSTACK_HOST"),
4041
DockerHost: os.Getenv("DOCKER_HOST"),
42+
DockerFlags: os.Getenv("DOCKER_FLAGS"),
4143
DisableEvents: os.Getenv("LOCALSTACK_DISABLE_EVENTS") == "1",
4244
TracesEnabled: viper.GetBool("otel"),
4345
APIEndpoint: viper.GetString("api_endpoint"),

internal/runtime/runtime.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ type ContainerConfig struct {
3333
Env []string // e.g., ["KEY=value", "FOO=bar"]
3434
Tag string
3535
ProductName string
36-
Binds []BindMount
37-
ExtraPorts []PortMapping
36+
Binds []BindMount
37+
ExtraPorts []PortMapping
3838
}
3939

4040
type PullProgress struct {

test/integration/env/env.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const (
2020
Persistence Key = "LOCALSTACK_PERSISTENCE"
2121
Otel Key = "LSTK_OTEL"
2222
OtelEndpoint Key = "OTEL_EXPORTER_OTLP_ENDPOINT"
23+
DockerFlags Key = "DOCKER_FLAGS"
2324
)
2425

2526
func Get(key Key) string {

test/integration/start_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,116 @@ env = ["persistence"]
506506
"lstk start should surface persistence state when LOCALSTACK_PERSISTENCE=1 is set in the config profile")
507507
}
508508

509+
func TestStartCommandDockerFlagsEnvVar(t *testing.T) {
510+
requireDocker(t)
511+
_ = env.Require(t, env.AuthToken)
512+
513+
cleanup()
514+
t.Cleanup(cleanup)
515+
516+
mockServer := createMockLicenseServer(true)
517+
defer mockServer.Close()
518+
519+
ctx := testContext(t)
520+
_, stderr, err := runLstk(t, ctx, "",
521+
env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.APIEndpoint, mockServer.URL).With(env.DockerFlags, "-e SERVICES=s3,sqs"),
522+
"start")
523+
require.NoError(t, err, "lstk start failed: %s", stderr)
524+
525+
inspect, err := dockerClient.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{})
526+
require.NoError(t, err, "failed to inspect container")
527+
envVars := containerEnvToMap(inspect.Container.Config.Env)
528+
assert.Equal(t, "s3,sqs", envVars["SERVICES"],
529+
"SERVICES env var from DOCKER_FLAGS must be passed to the container")
530+
}
531+
532+
func TestStartCommandDockerFlagsConfigToml(t *testing.T) {
533+
requireDocker(t)
534+
_ = env.Require(t, env.AuthToken)
535+
536+
cleanup()
537+
t.Cleanup(cleanup)
538+
539+
mockServer := createMockLicenseServer(true)
540+
defer mockServer.Close()
541+
542+
configContent := `[[containers]]
543+
type = "aws"
544+
port = "4566"
545+
docker_flags = "-e SERVICES=s3,sqs"
546+
`
547+
configFile := filepath.Join(t.TempDir(), "config.toml")
548+
require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644))
549+
550+
ctx := testContext(t)
551+
_, stderr, err := runLstk(t, ctx, "",
552+
env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.APIEndpoint, mockServer.URL),
553+
"--config", configFile, "start")
554+
require.NoError(t, err, "lstk start failed: %s", stderr)
555+
556+
inspect, err := dockerClient.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{})
557+
require.NoError(t, err, "failed to inspect container")
558+
envVars := containerEnvToMap(inspect.Container.Config.Env)
559+
assert.Equal(t, "s3,sqs", envVars["SERVICES"],
560+
"SERVICES env var from docker_flags in config.toml must be passed to the container")
561+
}
562+
563+
func TestStartCommandDockerFlagsVolumeMount(t *testing.T) {
564+
requireDocker(t)
565+
_ = env.Require(t, env.AuthToken)
566+
567+
cleanup()
568+
t.Cleanup(cleanup)
569+
570+
mockServer := createMockLicenseServer(true)
571+
defer mockServer.Close()
572+
573+
tmpDir := t.TempDir()
574+
ctx := testContext(t)
575+
_, stderr, err := runLstk(t, ctx, "",
576+
env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.APIEndpoint, mockServer.URL).With(env.DockerFlags, "-v "+tmpDir+":/extra-mount"),
577+
"start")
578+
require.NoError(t, err, "lstk start failed: %s", stderr)
579+
580+
inspect, err := dockerClient.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{})
581+
require.NoError(t, err, "failed to inspect container")
582+
assert.True(t, hasBindTarget(inspect.Container.HostConfig.Binds, "/extra-mount"),
583+
"volume from DOCKER_FLAGS must be mounted in the container; got: %v", inspect.Container.HostConfig.Binds)
584+
assert.True(t, hasBindSource(inspect.Container.HostConfig.Binds, tmpDir),
585+
"volume source from DOCKER_FLAGS must match; got: %v", inspect.Container.HostConfig.Binds)
586+
}
587+
588+
func TestStartCommandDockerFlagsMergeEnvAndConfig(t *testing.T) {
589+
requireDocker(t)
590+
_ = env.Require(t, env.AuthToken)
591+
592+
cleanup()
593+
t.Cleanup(cleanup)
594+
595+
mockServer := createMockLicenseServer(true)
596+
defer mockServer.Close()
597+
598+
configContent := `[[containers]]
599+
type = "aws"
600+
port = "4566"
601+
docker_flags = "-e ENFORCE_IAM=1"
602+
`
603+
configFile := filepath.Join(t.TempDir(), "config.toml")
604+
require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644))
605+
606+
ctx := testContext(t)
607+
_, stderr, err := runLstk(t, ctx, "",
608+
env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.APIEndpoint, mockServer.URL).With(env.DockerFlags, "-e SERVICES=s3"),
609+
"--config", configFile, "start")
610+
require.NoError(t, err, "lstk start failed: %s", stderr)
611+
612+
inspect, err := dockerClient.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{})
613+
require.NoError(t, err, "failed to inspect container")
614+
envVars := containerEnvToMap(inspect.Container.Config.Env)
615+
assert.Equal(t, "s3", envVars["SERVICES"], "SERVICES from DOCKER_FLAGS env var must be present")
616+
assert.Equal(t, "1", envVars["ENFORCE_IAM"], "ENFORCE_IAM from docker_flags config must be present")
617+
}
618+
509619
// hasBindTarget checks if any bind mount targets the given container path.
510620
func hasBindTarget(binds []string, containerPath string) bool {
511621
for _, b := range binds {

0 commit comments

Comments
 (0)