diff --git a/cmd/root.go b/cmd/root.go index 909203e4..79dc9bb1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -150,6 +150,7 @@ func buildStartOptions(cfg *env.Env, appConfig *config.Config, logger log.Logger LocalStackHost: cfg.LocalStackHost, Containers: appConfig.Containers, Env: appConfig.Env, + DockerFlags: cfg.DockerFlags, Persist: persist, Logger: logger, Telemetry: tel, diff --git a/internal/config/containers.go b/internal/config/containers.go index 6d21cbb9..d1411ee5 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -94,6 +94,9 @@ type ContainerConfig struct { Volume string `mapstructure:"volume"` // Env is a list of named environment references defined in the top-level [env.*] config sections. Env []string `mapstructure:"env"` + // DockerFlags is a raw docker-run-style flag string (e.g. "-e FOO=bar -v /tmp:/data"). + // Supported flags: -e/--env, -v/--volume. + DockerFlags string `mapstructure:"docker_flags"` } // VolumeDir returns the host directory to mount into the container for persistence/caching. diff --git a/internal/container/dockerflags.go b/internal/container/dockerflags.go new file mode 100644 index 00000000..69034009 --- /dev/null +++ b/internal/container/dockerflags.go @@ -0,0 +1,123 @@ +package container + +import ( + "fmt" + "strings" + + "github.com/localstack/lstk/internal/runtime" +) + +// ParsedFlags holds the result of parsing a DOCKER_FLAGS-style string. +type ParsedFlags struct { + Env []string + Binds []runtime.BindMount +} + +// ParseDockerFlags parses a subset of docker run flags from a raw string. +// Supported: -e/--env, -v/--volume. +func ParseDockerFlags(flags string) (ParsedFlags, error) { + tokens, err := shellTokenize(flags) + if err != nil { + return ParsedFlags{}, err + } + + var result ParsedFlags + for i := 0; i < len(tokens); i++ { + tok := tokens[i] + + next := func() (string, error) { + if i+1 >= len(tokens) { + return "", fmt.Errorf("flag %q requires a value", tok) + } + i++ + return tokens[i], nil + } + + switch { + case tok == "-e" || tok == "--env": + val, err := next() + if err != nil { + return ParsedFlags{}, err + } + result.Env = append(result.Env, val) + case strings.HasPrefix(tok, "-e") && len(tok) > 2: + result.Env = append(result.Env, tok[2:]) + case strings.HasPrefix(tok, "--env="): + result.Env = append(result.Env, strings.TrimPrefix(tok, "--env=")) + + case tok == "-v" || tok == "--volume": + val, err := next() + if err != nil { + return ParsedFlags{}, err + } + b, err := parseBindMount(val) + if err != nil { + return ParsedFlags{}, err + } + result.Binds = append(result.Binds, b) + case strings.HasPrefix(tok, "-v") && len(tok) > 2: + b, err := parseBindMount(tok[2:]) + if err != nil { + return ParsedFlags{}, err + } + result.Binds = append(result.Binds, b) + case strings.HasPrefix(tok, "--volume="): + b, err := parseBindMount(strings.TrimPrefix(tok, "--volume=")) + if err != nil { + return ParsedFlags{}, err + } + result.Binds = append(result.Binds, b) + + default: + return ParsedFlags{}, fmt.Errorf("unsupported docker flag: %q", tok) + } + } + return result, nil +} + +func parseBindMount(spec string) (runtime.BindMount, error) { + parts := strings.SplitN(spec, ":", 3) + if len(parts) < 2 { + return runtime.BindMount{}, fmt.Errorf("invalid volume spec %q: must be HOST:CONTAINER[:ro]", spec) + } + b := runtime.BindMount{HostPath: parts[0], ContainerPath: parts[1]} + if len(parts) == 3 { + b.ReadOnly = parts[2] == "ro" + } + return b, nil +} + +// shellTokenize splits s into tokens using shell-like whitespace splitting, +// respecting single and double quotes. +func shellTokenize(s string) ([]string, error) { + var tokens []string + var cur strings.Builder + inQuote := rune(0) + + for _, c := range s { + switch { + case inQuote != 0: + if c == inQuote { + inQuote = 0 + } else { + cur.WriteRune(c) + } + case c == '"' || c == '\'': + inQuote = c + case c == ' ' || c == '\t': + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + cur.Reset() + } + default: + cur.WriteRune(c) + } + } + if inQuote != 0 { + return nil, fmt.Errorf("unterminated quote in docker flags") + } + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + } + return tokens, nil +} diff --git a/internal/container/dockerflags_test.go b/internal/container/dockerflags_test.go new file mode 100644 index 00000000..0de31e8e --- /dev/null +++ b/internal/container/dockerflags_test.go @@ -0,0 +1,55 @@ +package container + +import ( + "testing" + + "github.com/localstack/lstk/internal/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDockerFlags(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + want ParsedFlags + errContains string + }{ + {name: "-e", input: "-e FOO=bar", want: ParsedFlags{Env: []string{"FOO=bar"}}}, + {name: "--env", input: "--env SERVICES=s3,sqs", want: ParsedFlags{Env: []string{"SERVICES=s3,sqs"}}}, + {name: "--env=", input: "--env=DEBUG=1", want: ParsedFlags{Env: []string{"DEBUG=1"}}}, + {name: "-e inline", input: "-eSERVICES=s3", want: ParsedFlags{Env: []string{"SERVICES=s3"}}}, + {name: "-e quoted", input: `-e "FOO=hello world"`, want: ParsedFlags{Env: []string{"FOO=hello world"}}}, + + {name: "-v", input: "-v /tmp/data:/data", want: ParsedFlags{Binds: []runtime.BindMount{{HostPath: "/tmp/data", ContainerPath: "/data"}}}}, + {name: "-v readonly", input: "-v /tmp/data:/data:ro", want: ParsedFlags{Binds: []runtime.BindMount{{HostPath: "/tmp/data", ContainerPath: "/data", ReadOnly: true}}}}, + {name: "--volume", input: "--volume /tmp/data:/data", want: ParsedFlags{Binds: []runtime.BindMount{{HostPath: "/tmp/data", ContainerPath: "/data"}}}}, + {name: "--volume=", input: "--volume=/tmp/data:/data", want: ParsedFlags{Binds: []runtime.BindMount{{HostPath: "/tmp/data", ContainerPath: "/data"}}}}, + + {name: "multiple flags", input: "-e SERVICES=s3,sqs -v /tmp:/data", want: ParsedFlags{ + Env: []string{"SERVICES=s3,sqs"}, + Binds: []runtime.BindMount{{HostPath: "/tmp", ContainerPath: "/data"}}, + }}, + {name: "empty", input: ""}, + + {name: "unknown flag", input: "--rm", errContains: "unsupported docker flag"}, + {name: "--network unsupported", input: "--network host", errContains: "unsupported docker flag"}, + {name: "missing value", input: "-e", errContains: "requires a value"}, + {name: "unterminated quote", input: `-e "FOO=bar`, errContains: "unterminated quote"}, + {name: "invalid volume", input: "-v /nocolon", errContains: "invalid volume spec"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseDockerFlags(tc.input) + if tc.errContains != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errContains) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/container/start.go b/internal/container/start.go index e7062c6e..0bf681de 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -41,6 +41,7 @@ type StartOptions struct { LocalStackHost string Containers []config.ContainerConfig Env map[string]map[string]string + DockerFlags string // from DOCKER_FLAGS env var; merged with per-container docker_flags from config Persist bool Logger log.Logger Telemetry *telemetry.Client @@ -127,6 +128,22 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start } binds = append(binds, runtime.BindMount{HostPath: volumeDir, ContainerPath: "/var/lib/localstack"}) + allFlags := strings.TrimSpace(opts.DockerFlags + " " + c.DockerFlags) + var extra ParsedFlags + if allFlags != "" { + extra, err = ParseDockerFlags(allFlags) + if err != nil { + return fmt.Errorf("invalid docker flags: %w", err) + } + } + env = append(env, extra.Env...) + //for _, b := range extra.Binds { + // if b.ContainerPath == "/var/lib/localstack" { + // return fmt.Errorf("docker_flags: /var/lib/localstack is managed by lstk; use the volume config option instead") + // } + //} + //binds = append(binds, extra.Binds...) + containers[i] = runtime.ContainerConfig{ Image: image, Name: containerName, diff --git a/internal/env/env.go b/internal/env/env.go index 1c9c6e02..00789ed1 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -11,6 +11,7 @@ type Env struct { AuthToken string LocalStackHost string DockerHost string + DockerFlags string DisableEvents bool TracesEnabled bool @@ -38,6 +39,7 @@ func Init() *Env { AuthToken: os.Getenv("LOCALSTACK_AUTH_TOKEN"), LocalStackHost: os.Getenv("LOCALSTACK_HOST"), DockerHost: os.Getenv("DOCKER_HOST"), + DockerFlags: os.Getenv("DOCKER_FLAGS"), DisableEvents: os.Getenv("LOCALSTACK_DISABLE_EVENTS") == "1", TracesEnabled: viper.GetBool("otel"), APIEndpoint: viper.GetString("api_endpoint"), diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index c8bfe37b..b5f93867 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -33,8 +33,8 @@ type ContainerConfig struct { Env []string // e.g., ["KEY=value", "FOO=bar"] Tag string ProductName string - Binds []BindMount - ExtraPorts []PortMapping + Binds []BindMount + ExtraPorts []PortMapping } type PullProgress struct { diff --git a/test/integration/env/env.go b/test/integration/env/env.go index c9b93149..5684456c 100644 --- a/test/integration/env/env.go +++ b/test/integration/env/env.go @@ -20,6 +20,7 @@ const ( Persistence Key = "LOCALSTACK_PERSISTENCE" Otel Key = "LSTK_OTEL" OtelEndpoint Key = "OTEL_EXPORTER_OTLP_ENDPOINT" + DockerFlags Key = "DOCKER_FLAGS" ) func Get(key Key) string { diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 84ac5985..822207e6 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -325,6 +325,24 @@ func requireExitCode(t *testing.T, expected int, err error) { require.Equal(t, expected, exitErr.ExitCode()) } +// tempHomeDir returns a temp dir and registers a pre-removal chmod walk so that +// root-owned files written by Docker containers into the bind-mounted volume +// directory don't cause "permission denied" on cleanup. +func tempHomeDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Cleanup(func() { + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + _ = os.Chmod(path, 0755) + return nil + }) + }) + return dir +} + func createMockLicenseServer(success bool) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" && r.URL.Path == "/v1/license/request" { diff --git a/test/integration/start_test.go b/test/integration/start_test.go index f384c710..f1257d04 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -506,6 +506,135 @@ env = ["persistence"] "lstk start should surface persistence state when LOCALSTACK_PERSISTENCE=1 is set in the config profile") } +func TestStartCommandDockerFlagsEnvVar(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + ctx := testContext(t) + _, stderr, err := runLstk(t, ctx, "", + env.Environ(testEnvWithHome(tempHomeDir(t), "")).With(env.APIEndpoint, mockServer.URL).With(env.DockerFlags, "-e SERVICES=s3,sqs"), + "start") + require.NoError(t, err, "lstk start failed: %s", stderr) + + inspect, err := dockerClient.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{}) + require.NoError(t, err, "failed to inspect container") + envVars := containerEnvToMap(inspect.Container.Config.Env) + assert.Equal(t, "s3,sqs", envVars["SERVICES"], + "SERVICES env var from DOCKER_FLAGS must be passed to the container") +} + +func TestStartCommandDockerFlagsConfigToml(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + configContent := `[[containers]] +type = "aws" +port = "4566" +docker_flags = "-e SERVICES=s3,sqs" +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + ctx := testContext(t) + _, stderr, err := runLstk(t, ctx, "", + env.Environ(testEnvWithHome(tempHomeDir(t), "")).With(env.APIEndpoint, mockServer.URL), + "--config", configFile, "start") + require.NoError(t, err, "lstk start failed: %s", stderr) + + inspect, err := dockerClient.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{}) + require.NoError(t, err, "failed to inspect container") + envVars := containerEnvToMap(inspect.Container.Config.Env) + assert.Equal(t, "s3,sqs", envVars["SERVICES"], + "SERVICES env var from docker_flags in config.toml must be passed to the container") +} + +func TestStartCommandDockerFlagsVolumeMount(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + tmpDir := t.TempDir() + ctx := testContext(t) + _, stderr, err := runLstk(t, ctx, "", + env.Environ(testEnvWithHome(tempHomeDir(t), "")).With(env.APIEndpoint, mockServer.URL).With(env.DockerFlags, "-v "+tmpDir+":/extra-mount"), + "start") + require.NoError(t, err, "lstk start failed: %s", stderr) + + inspect, err := dockerClient.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{}) + require.NoError(t, err, "failed to inspect container") + assert.True(t, hasBindTarget(inspect.Container.HostConfig.Binds, "/extra-mount"), + "volume from DOCKER_FLAGS must be mounted in the container; got: %v", inspect.Container.HostConfig.Binds) + assert.True(t, hasBindSource(inspect.Container.HostConfig.Binds, tmpDir), + "volume source from DOCKER_FLAGS must match; got: %v", inspect.Container.HostConfig.Binds) +} + +func TestStartCommandDockerFlagsMergeEnvAndConfig(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + configContent := `[[containers]] +type = "aws" +port = "4566" +docker_flags = "-e ENFORCE_IAM=1" +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + ctx := testContext(t) + _, stderr, err := runLstk(t, ctx, "", + env.Environ(testEnvWithHome(tempHomeDir(t), "")).With(env.APIEndpoint, mockServer.URL).With(env.DockerFlags, "-e SERVICES=s3"), + "--config", configFile, "start") + require.NoError(t, err, "lstk start failed: %s", stderr) + + inspect, err := dockerClient.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{}) + require.NoError(t, err, "failed to inspect container") + envVars := containerEnvToMap(inspect.Container.Config.Env) + assert.Equal(t, "s3", envVars["SERVICES"], "SERVICES from DOCKER_FLAGS env var must be present") + assert.Equal(t, "1", envVars["ENFORCE_IAM"], "ENFORCE_IAM from docker_flags config must be present") +} + +func TestStartCommandDockerFlagsRejectsVarLibLocalstack(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + ctx := testContext(t) + _, stderr, err := runLstk(t, ctx, "", + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.APIEndpoint, mockServer.URL).With(env.DockerFlags, "-v /tmp:/var/lib/localstack"), + "start") + require.Error(t, err, "lstk start should fail when DOCKER_FLAGS mounts /var/lib/localstack") + assert.Contains(t, stderr, "/var/lib/localstack", + "error message should mention /var/lib/localstack; got: %s", stderr) +} + // hasBindTarget checks if any bind mount targets the given container path. func hasBindTarget(binds []string, containerPath string) bool { for _, b := range binds {