Skip to content
Draft
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
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
123 changes: 123 additions & 0 deletions internal/container/dockerflags.go
Original file line number Diff line number Diff line change
@@ -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
}
55 changes: 55 additions & 0 deletions internal/container/dockerflags_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
17 changes: 17 additions & 0 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions internal/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type Env struct {
AuthToken string
LocalStackHost string
DockerHost string
DockerFlags string
DisableEvents bool
TracesEnabled bool

Expand Down Expand Up @@ -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"),
Expand Down
4 changes: 2 additions & 2 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions test/integration/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions test/integration/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
Loading
Loading