From 10506e9b2e550e315e4c538c33bd1fcde8d4ac30 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 25 Jun 2026 12:35:17 +0200 Subject: [PATCH 1/4] Support mounting volumes and init hooks via config Add a per-container `volumes` list of Docker-style "host:container[:ro]" bind specs, enabling arbitrary mounts such as Snowflake init hooks (e.g. /etc/localstack/init/ready.d/). The persistence mount to /var/lib/localstack is folded into this list; the legacy singular `volume` field still works for backward compatibility. Relative host sources resolve against the config file's directory and a leading ~/ is expanded, since the Docker SDK treats a non-absolute source as a named volume. Extra mounts must already exist (init-hook entries are files), unlike the persistence dir which is created. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 + internal/config/containers.go | 170 ++++++++++++++++++++++++++-- internal/config/containers_test.go | 140 ++++++++++++++++++++++- internal/config/default_config.toml | 8 ++ internal/container/start.go | 14 +++ test/integration/start_test.go | 69 +++++++++++ test/integration/volume_test.go | 40 +++++++ 7 files changed, 434 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 506569ad..adeb8037 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,12 @@ When adding a new command that depends on configuration, wire config initializat Created automatically on first run with defaults. Supports emulator types: `aws`, `snowflake`, and `azure`. +## Volume Mounts + +Each `[[containers]]` block accepts a `volumes` list of Docker-style `"host:container[:ro]"` bind specs (e.g. for Snowflake init hooks mounted into `/etc/localstack/init/{boot,start,ready,shutdown}.d`). The persistence/cache mount to `/var/lib/localstack` is folded into this list: the entry whose container target is `/var/lib/localstack` (`persistenceTarget` in `internal/config/containers.go`) defines the host dir backing it, and that path is what `VolumeDir()`, `lstk volume path`, and `lstk volume clear` resolve. Resolution precedence in `VolumeDir()`: a `volumes` entry targeting `/var/lib/localstack` → the legacy singular `volume = "..."` field (still honored for backward compatibility) → the default OS cache dir. Setting the persistence dir via both `volume` and a `volumes` entry with differing sources is a validation error. + +Parsing/resolution lives in `parseVolume`/`ExtraVolumes` in `internal/config/containers.go`. Relative host sources resolve against the **config file's directory** and a leading `~/` is expanded — this is required because the Docker SDK treats a non-absolute source as a *named volume* rather than a bind mount. `start.go` mounts the persistence dir (creating it via `MkdirAll`) and appends `ExtraVolumes()`; extra sources are not created (`os.Stat` + error if missing) since init-hook entries are files, not dirs. + # Emulator Setup Commands Use `lstk setup ` to set up CLI integration for an emulator type: diff --git a/internal/config/containers.go b/internal/config/containers.go index 89a05069..b9c04ab7 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -112,18 +112,147 @@ func KnownImageReposForType(t EmulatorType) []string { } type ContainerConfig struct { - Type EmulatorType `mapstructure:"type"` - Tag string `mapstructure:"tag"` - Port string `mapstructure:"port"` - Volume string `mapstructure:"volume"` + Type EmulatorType `mapstructure:"type"` + Tag string `mapstructure:"tag"` + Port string `mapstructure:"port"` + // Volume is the legacy single-host-directory knob for the persistence mount + // (target /var/lib/localstack). It is still honored; new configs can express the + // same mount as a Volumes entry targeting persistenceTarget instead. + Volume string `mapstructure:"volume"` + // Volumes is the umbrella list of "host:container[:ro]" bind specs. It covers + // arbitrary mounts (e.g. Snowflake init hooks) and may also contain the persistence + // mount (the entry targeting /var/lib/localstack). + Volumes []string `mapstructure:"volumes"` // Env is a list of named environment references defined in the top-level [env.*] config sections. Env []string `mapstructure:"env"` } -// VolumeDir returns the host directory to mount into the container for persistence/caching. -// If Volume is set in the config, it is returned as-is. Otherwise, a default is computed -// from os.UserCacheDir()/lstk/volume/. +// persistenceTarget is the container path of the managed persistence/cache mount. +// The entry in Volumes targeting this path (or the legacy Volume field) defines the +// host directory backing it; lstk creates it and `lstk volume clear`/`volume path` act on it. +const persistenceTarget = "/var/lib/localstack" + +// VolumeMount is a parsed bind specification with the host source resolved to an absolute path. +type VolumeMount struct { + Source string + Target string + ReadOnly bool +} + +// parseVolume parses a "host:container[:opts]" spec. The host source is resolved to an +// absolute path: a leading "~/" is expanded to the user's home directory, and a relative +// path is joined with configDir (the directory of the config file that declared it). This +// is required because the Docker SDK treats a non-absolute source as a named volume rather +// than a bind mount. opts is a comma-separated list; only "ro" is honored. +// +// Note: a Windows drive-letter source (e.g. "C:\\data") splits on ":" ambiguously — the same +// limitation as the upstream LocalStack volume parser. +func parseVolume(spec, configDir string) (VolumeMount, error) { + parts := strings.Split(spec, ":") + if len(parts) < 2 || len(parts) > 3 { + return VolumeMount{}, fmt.Errorf("invalid volume %q: expected \"host:container\" or \"host:container:ro\"", spec) + } + + source, target := parts[0], parts[1] + if source == "" { + return VolumeMount{}, fmt.Errorf("invalid volume %q: host source is empty", spec) + } + if target == "" { + return VolumeMount{}, fmt.Errorf("invalid volume %q: container target is empty", spec) + } + if !filepath.IsAbs(target) { + return VolumeMount{}, fmt.Errorf("invalid volume %q: container target %q must be an absolute path", spec, target) + } + + resolved, err := resolveHostPath(source, configDir) + if err != nil { + return VolumeMount{}, fmt.Errorf("invalid volume %q: %w", spec, err) + } + + var readOnly bool + if len(parts) == 3 { + for _, opt := range strings.Split(parts[2], ",") { + if opt == "ro" { + readOnly = true + } + } + } + + return VolumeMount{Source: resolved, Target: target, ReadOnly: readOnly}, nil +} + +// resolveHostPath expands a leading "~/" and makes a relative path absolute against configDir. +func resolveHostPath(path, configDir string) (string, error) { + if path == "~" || strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to expand ~: %w", err) + } + path = filepath.Join(home, strings.TrimPrefix(path, "~")) + } + if filepath.IsAbs(path) { + return path, nil + } + return filepath.Join(configDir, path), nil +} + +// configDirForRelativePaths returns the directory used to resolve relative volume sources: +// the directory of the resolved config file. It falls back to the current working directory +// when no config file is in use (e.g. in-memory defaults). +func configDirForRelativePaths() string { + path, err := ConfigFilePath() + if err != nil || path == "" { + return "." + } + return filepath.Dir(path) +} + +// parsedVolumes parses every entry in Volumes, resolving sources against the config dir. +func (c *ContainerConfig) parsedVolumes() ([]VolumeMount, error) { + configDir := configDirForRelativePaths() + mounts := make([]VolumeMount, 0, len(c.Volumes)) + for _, spec := range c.Volumes { + m, err := parseVolume(spec, configDir) + if err != nil { + return nil, err + } + mounts = append(mounts, m) + } + return mounts, nil +} + +// ExtraVolumes returns the parsed bind mounts EXCLUDING the persistence entry +// (target /var/lib/localstack), which start.go mounts separately via VolumeDir. +func (c *ContainerConfig) ExtraVolumes() ([]VolumeMount, error) { + mounts, err := c.parsedVolumes() + if err != nil { + return nil, err + } + extras := make([]VolumeMount, 0, len(mounts)) + for _, m := range mounts { + if m.Target == persistenceTarget { + continue + } + extras = append(extras, m) + } + return extras, nil +} + +// VolumeDir returns the host directory to mount into the container for persistence/caching +// (the mount targeting /var/lib/localstack). Resolution precedence: +// 1. A Volumes entry targeting persistenceTarget — its resolved host source. +// 2. The legacy Volume field, if set — returned as-is. +// 3. The default os.UserCacheDir()/lstk/volume/. func (c *ContainerConfig) VolumeDir() (string, error) { + mounts, err := c.parsedVolumes() + if err != nil { + return "", err + } + for _, m := range mounts { + if m.Target == persistenceTarget { + return m.Source, nil + } + } if c.Volume != "" { return c.Volume, nil } @@ -182,6 +311,33 @@ func (c *ContainerConfig) Validate() error { if port < 1 || port > 65535 { return fmt.Errorf("port %d is out of range (must be 1–65535)", port) } + return c.validateVolumes() +} + +// validateVolumes checks each Volumes entry is structurally parseable and guards against +// declaring the persistence directory twice with conflicting sources. It does not touch the +// filesystem (existence of sources is checked at start time). +func (c *ContainerConfig) validateVolumes() error { + configDir := configDirForRelativePaths() + var persistenceSource string + for _, spec := range c.Volumes { + m, err := parseVolume(spec, configDir) + if err != nil { + return err + } + if m.Target == persistenceTarget { + persistenceSource = m.Source + } + } + if c.Volume != "" && persistenceSource != "" { + resolved, err := resolveHostPath(c.Volume, configDir) + if err != nil { + return err + } + if resolved != persistenceSource { + return fmt.Errorf("persistence directory set both via 'volume' and a 'volumes' entry targeting %s; use one or the other", persistenceTarget) + } + } return nil } diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index ff1bccb0..a08ba9c7 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -1,6 +1,8 @@ package config import ( + "os" + "path/filepath" "sort" "strings" "testing" @@ -80,12 +82,12 @@ func TestNormalizeTag(t *testing.T) { func TestValidate_InvalidDockerTag_IsRejected(t *testing.T) { for _, tag := range []string{ - "my tag", // space - "2026.4!", // special char - ".hidden", // starts with dot - "-beta", // starts with hyphen - "tag@sha", // @ not allowed - "foo:bar", // colon not allowed + "my tag", // space + "2026.4!", // special char + ".hidden", // starts with dot + "-beta", // starts with hyphen + "tag@sha", // @ not allowed + "foo:bar", // colon not allowed strings.Repeat("a", 129), // too long } { t.Run(tag, func(t *testing.T) { @@ -185,3 +187,129 @@ func TestValidate_NegativePort(t *testing.T) { err := c.Validate() assert.ErrorContains(t, err, "out of range") } + +func TestParseVolume_TwoParts(t *testing.T) { + m, err := parseVolume("/host/data:/var/lib/localstack", "/cfg") + require.NoError(t, err) + assert.Equal(t, VolumeMount{Source: "/host/data", Target: "/var/lib/localstack", ReadOnly: false}, m) +} + +func TestParseVolume_ReadOnly(t *testing.T) { + m, err := parseVolume("/host/seed:/seed:ro", "/cfg") + require.NoError(t, err) + assert.Equal(t, VolumeMount{Source: "/host/seed", Target: "/seed", ReadOnly: true}, m) +} + +func TestParseVolume_ReadOnlyAmongOptions(t *testing.T) { + m, err := parseVolume("/host/seed:/seed:z,ro", "/cfg") + require.NoError(t, err) + assert.True(t, m.ReadOnly) +} + +func TestParseVolume_RelativeSourceResolvedAgainstConfigDir(t *testing.T) { + m, err := parseVolume("./init.sf.sql:/etc/localstack/init/ready.d/init.sf.sql", "/cfg/project") + require.NoError(t, err) + assert.Equal(t, "/cfg/project/init.sf.sql", m.Source) +} + +func TestParseVolume_TildeExpanded(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + m, err := parseVolume("~/scripts/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql", "/cfg") + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, "scripts/x.sf.sql"), m.Source) +} + +func TestParseVolume_AbsoluteSourceUnchanged(t *testing.T) { + m, err := parseVolume("/abs/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql", "/cfg") + require.NoError(t, err) + assert.Equal(t, "/abs/x.sf.sql", m.Source) +} + +func TestParseVolume_Errors(t *testing.T) { + cases := map[string]string{ + "one part": "/host/only", + "four parts": "/h:/c:ro:extra", + "empty source": ":/c", + "empty target": "/h:", + "relative target": "/h:relative/target", + } + for name, spec := range cases { + t.Run(name, func(t *testing.T) { + _, err := parseVolume(spec, "/cfg") + assert.Error(t, err) + }) + } +} + +func TestVolumeDir_VolumesEntryTargetingPersistenceWins(t *testing.T) { + c := &ContainerConfig{ + Type: EmulatorAWS, + Volume: "", // not set + Volumes: []string{"/persist/dir:/var/lib/localstack", "/abs/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql"}, + } + dir, err := c.VolumeDir() + require.NoError(t, err) + assert.Equal(t, "/persist/dir", dir) +} + +func TestVolumeDir_LegacyVolumeUsedWhenNoPersistenceEntry(t *testing.T) { + c := &ContainerConfig{ + Type: EmulatorAWS, + Volume: "/legacy/persist", + Volumes: []string{"/abs/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql"}, + } + dir, err := c.VolumeDir() + require.NoError(t, err) + assert.Equal(t, "/legacy/persist", dir) +} + +func TestVolumeDir_DefaultsToCacheDirWhenNeitherSet(t *testing.T) { + cacheDir, err := os.UserCacheDir() + require.NoError(t, err) + c := &ContainerConfig{Type: EmulatorAWS} + dir, err := c.VolumeDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(cacheDir, "lstk", "volume", c.Name()), dir) +} + +func TestExtraVolumes_ExcludesPersistenceEntry(t *testing.T) { + c := &ContainerConfig{ + Type: EmulatorAWS, + Volumes: []string{ + "/persist/dir:/var/lib/localstack", + "/abs/a.sf.sql:/etc/localstack/init/ready.d/a.sf.sql", + "/abs/b.sf.sql:/etc/localstack/init/ready.d/b.sf.sql:ro", + }, + } + extras, err := c.ExtraVolumes() + require.NoError(t, err) + require.Len(t, extras, 2) + assert.Equal(t, VolumeMount{Source: "/abs/a.sf.sql", Target: "/etc/localstack/init/ready.d/a.sf.sql"}, extras[0]) + assert.Equal(t, VolumeMount{Source: "/abs/b.sf.sql", Target: "/etc/localstack/init/ready.d/b.sf.sql", ReadOnly: true}, extras[1]) +} + +func TestValidate_RejectsMalformedVolume(t *testing.T) { + c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Volumes: []string{"/host/only"}} + assert.ErrorContains(t, c.Validate(), "invalid volume") +} + +func TestValidate_RejectsConflictingPersistenceSources(t *testing.T) { + c := &ContainerConfig{ + Type: EmulatorAWS, + Port: "4566", + Volume: "/persist/a", + Volumes: []string{"/persist/b:/var/lib/localstack"}, + } + assert.ErrorContains(t, c.Validate(), "persistence directory set both") +} + +func TestValidate_AllowsMatchingPersistenceSources(t *testing.T) { + c := &ContainerConfig{ + Type: EmulatorAWS, + Port: "4566", + Volume: "/persist/same", + Volumes: []string{"/persist/same:/var/lib/localstack"}, + } + assert.NoError(t, c.Validate()) +} diff --git a/internal/config/default_config.toml b/internal/config/default_config.toml index ccdcb36d..dd0f9279 100644 --- a/internal/config/default_config.toml +++ b/internal/config/default_config.toml @@ -11,6 +11,14 @@ tag = "latest" # Docker image tag, e.g. "latest", "2026.4" port = "4566" # Host port the emulator will be accessible on # volume = "" # Host directory for persistent state (default: OS cache dir) # env = [] # Named environment profiles to apply (see [env.*] sections below) +# volumes = [] # Extra bind mounts, each "host:container[:ro]". Relative host paths +# # resolve against this config file's directory; a leading ~/ is expanded. +# # A "volumes" entry targeting /var/lib/localstack sets the persistent +# # state directory (equivalent to "volume" above). +# # +# # Mount Snowflake init hooks (scripts run on startup) — see +# # https://docs.localstack.cloud/snowflake/capabilities/init-hooks/ +# # volumes = ["./test.sf.sql:/etc/localstack/init/ready.d/test.sf.sql"] # Environment profiles let you group environment variables and reference # them by name in one or more containers via the 'env' field above. diff --git a/internal/container/start.go b/internal/container/start.go index 84fe145c..0d124894 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -139,6 +139,20 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start } binds = append(binds, runtime.BindMount{HostPath: volumeDir, ContainerPath: "/var/lib/localstack"}) + // Extra user-defined mounts (e.g. Snowflake init hooks). Unlike the persistence + // directory, these are not created — init-hook entries are files, so the source + // must already exist; creating it would produce a wrong empty directory. + extraVolumes, err := c.ExtraVolumes() + if err != nil { + return "", err + } + for _, m := range extraVolumes { + if _, err := os.Stat(m.Source); err != nil { + return "", fmt.Errorf("volume source %q does not exist: %w", m.Source, err) + } + binds = append(binds, runtime.BindMount{HostPath: m.Source, ContainerPath: m.Target, ReadOnly: m.ReadOnly}) + } + containers[i] = runtime.ContainerConfig{ Image: image, Name: containerName, diff --git a/test/integration/start_test.go b/test/integration/start_test.go index bfc3ab4e..3fe36c03 100644 --- a/test/integration/start_test.go +++ b/test/integration/start_test.go @@ -456,6 +456,75 @@ func TestStartCommandSetsUpContainerCorrectly(t *testing.T) { }) } +func TestStartCommandMountsExtraVolumes(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + // A real init-hook script that lstk mounts as a file (it must already exist). + scriptDir := t.TempDir() + scriptPath := filepath.Join(scriptDir, "init.sf.sql") + require.NoError(t, os.WriteFile(scriptPath, []byte("SHOW DATABASES;\n"), 0644)) + + configContent := ` +[[containers]] +type = "aws" +tag = "latest" +port = "4566" +volumes = ["` + escapeTomlPath(scriptPath) + `:/etc/localstack/init/ready.d/init.sf.sql:ro"] +` + 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.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") + require.True(t, inspect.Container.State.Running) + + binds := inspect.Container.HostConfig.Binds + assert.True(t, hasBindTarget(binds, "/var/lib/localstack"), + "persistence mount must still be present, got: %v", binds) + assert.True(t, hasBindTarget(binds, "/etc/localstack/init/ready.d/init.sf.sql"), + "expected init-hook mount target, got: %v", binds) + assert.True(t, hasBindSource(binds, scriptPath), + "expected init-hook mount source %s, got: %v", scriptPath, binds) +} + +func TestStartCommandFailsOnMissingVolumeSource(t *testing.T) { + requireDocker(t) + _ = env.Require(t, env.AuthToken) + + cleanup() + t.Cleanup(cleanup) + + mockServer := createMockLicenseServer(true) + defer mockServer.Close() + + missing := filepath.Join(t.TempDir(), "does-not-exist.sf.sql") + configContent := ` +[[containers]] +type = "aws" +tag = "latest" +port = "4566" +volumes = ["` + escapeTomlPath(missing) + `:/etc/localstack/init/ready.d/x.sf.sql"] +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + _, stderr, err := runLstk(t, testContext(t), "", env.With(env.APIEndpoint, mockServer.URL), "--config", configFile, "start") + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stderr, "does not exist") +} + func TestStartCommandPassesCIAndLocalStackEnvVars(t *testing.T) { requireDocker(t) _ = env.Require(t, env.AuthToken) diff --git a/test/integration/volume_test.go b/test/integration/volume_test.go index 8812836a..9edf247b 100644 --- a/test/integration/volume_test.go +++ b/test/integration/volume_test.go @@ -54,6 +54,46 @@ volume = "` + escapeTomlPath(customVolume) + `" assertSamePath(t, customVolume, stdout) }) + t.Run("follows volumes entry targeting the persistence path", func(t *testing.T) { + t.Parallel() + persistDir := filepath.Join(t.TempDir(), "persist") + configContent := ` +[[containers]] +type = "aws" +tag = "latest" +port = "4566" +volumes = ["` + escapeTomlPath(persistDir) + `:/var/lib/localstack", "/abs/init.sf.sql:/etc/localstack/init/ready.d/init.sf.sql"] +` + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), testEnvWithHome(t.TempDir(), ""), "--config", configFile, "volume", "path") + require.NoError(t, err, stderr) + requireExitCode(t, 0, err) + + assertSamePath(t, persistDir, stdout) + }) + + t.Run("resolves a relative persistence source against the config directory", func(t *testing.T) { + t.Parallel() + configDir := t.TempDir() + configContent := ` +[[containers]] +type = "aws" +tag = "latest" +port = "4566" +volumes = ["./persist:/var/lib/localstack"] +` + configFile := filepath.Join(configDir, "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644)) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), testEnvWithHome(t.TempDir(), ""), "--config", configFile, "volume", "path") + require.NoError(t, err, stderr) + requireExitCode(t, 0, err) + + assertSamePath(t, filepath.Join(configDir, "persist"), stdout) + }) + t.Run("emits telemetry", func(t *testing.T) { t.Parallel() tmpHome := t.TempDir() From 145923aad7c86408f2f4940db6cfbafa314acd02 Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 25 Jun 2026 12:51:08 +0200 Subject: [PATCH 2/4] Fix volume parsing on Windows Two Windows-only bugs broke `volumes` parsing: - The container target was validated with filepath.IsAbs, which rejects "/var/lib/localstack" on Windows (no drive). The target is always a Unix path inside the container, so validate it with path.IsAbs (slash semantics). - A Windows host source has a drive letter ("C:\\data"), whose ':' was mistaken for the host:container separator. Add a drive-letter-aware splitter, guarded to Windows so a single-letter relative host dir ("a:/data") stays valid elsewhere, matching Docker's behavior. Make the volume unit tests OS-portable by using filepath-based absolute sources instead of hardcoded Unix paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/config/containers.go | 50 ++++++++++++---- internal/config/containers_test.go | 96 ++++++++++++++++++++++++------ 2 files changed, 116 insertions(+), 30 deletions(-) diff --git a/internal/config/containers.go b/internal/config/containers.go index b9c04ab7..bf299013 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -4,8 +4,10 @@ import ( "errors" "fmt" "os" + "path" "path/filepath" "regexp" + "runtime" "strconv" "strings" "time" @@ -145,22 +147,22 @@ type VolumeMount struct { // is required because the Docker SDK treats a non-absolute source as a named volume rather // than a bind mount. opts is a comma-separated list; only "ro" is honored. // -// Note: a Windows drive-letter source (e.g. "C:\\data") splits on ":" ambiguously — the same -// limitation as the upstream LocalStack volume parser. +// A Windows drive-letter source (e.g. "C:\\data") is handled: its drive ':' is not mistaken +// for a field separator. The container target is always a Unix (slash) absolute path. func parseVolume(spec, configDir string) (VolumeMount, error) { - parts := strings.Split(spec, ":") - if len(parts) < 2 || len(parts) > 3 { - return VolumeMount{}, fmt.Errorf("invalid volume %q: expected \"host:container\" or \"host:container:ro\"", spec) + source, target, opts, err := splitVolumeSpec(spec, runtime.GOOS == "windows") + if err != nil { + return VolumeMount{}, fmt.Errorf("invalid volume %q: %w", spec, err) } - - source, target := parts[0], parts[1] if source == "" { return VolumeMount{}, fmt.Errorf("invalid volume %q: host source is empty", spec) } if target == "" { return VolumeMount{}, fmt.Errorf("invalid volume %q: container target is empty", spec) } - if !filepath.IsAbs(target) { + // The target is a path inside the (Linux) container, so it is validated with slash + // semantics rather than the host OS's filepath rules. + if !path.IsAbs(target) { return VolumeMount{}, fmt.Errorf("invalid volume %q: container target %q must be an absolute path", spec, target) } @@ -170,17 +172,39 @@ func parseVolume(spec, configDir string) (VolumeMount, error) { } var readOnly bool - if len(parts) == 3 { - for _, opt := range strings.Split(parts[2], ",") { - if opt == "ro" { - readOnly = true - } + for _, opt := range strings.Split(opts, ",") { + if opt == "ro" { + readOnly = true } } return VolumeMount{Source: resolved, Target: target, ReadOnly: readOnly}, nil } +// splitVolumeSpec splits a "host:container[:opts]" spec into its three components. When +// windows is true, a leading drive letter on the host (e.g. "C:\\data") is rejoined so its +// ':' is not treated as a field separator — Docker applies the same rule only on Windows, so +// that a single-letter relative host dir (e.g. "a:/data") stays valid elsewhere. +func splitVolumeSpec(spec string, windows bool) (source, target, opts string, err error) { + parts := strings.Split(spec, ":") + if windows && len(parts) >= 2 && len(parts[0]) == 1 && isDriveLetter(parts[0][0]) && + (strings.HasPrefix(parts[1], `\`) || strings.HasPrefix(parts[1], "/")) { + parts = append([]string{parts[0] + ":" + parts[1]}, parts[2:]...) + } + switch len(parts) { + case 2: + return parts[0], parts[1], "", nil + case 3: + return parts[0], parts[1], parts[2], nil + default: + return "", "", "", fmt.Errorf("expected \"host:container\" or \"host:container:ro\"") + } +} + +func isDriveLetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + // resolveHostPath expands a leading "~/" and makes a relative path absolute against configDir. func resolveHostPath(path, configDir string) (string, error) { if path == "~" || strings.HasPrefix(path, "~/") { diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index a08ba9c7..0e5e0f75 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -189,41 +189,46 @@ func TestValidate_NegativePort(t *testing.T) { } func TestParseVolume_TwoParts(t *testing.T) { - m, err := parseVolume("/host/data:/var/lib/localstack", "/cfg") + src := filepath.Join(t.TempDir(), "data") + m, err := parseVolume(src+":/var/lib/localstack", t.TempDir()) require.NoError(t, err) - assert.Equal(t, VolumeMount{Source: "/host/data", Target: "/var/lib/localstack", ReadOnly: false}, m) + assert.Equal(t, VolumeMount{Source: src, Target: "/var/lib/localstack", ReadOnly: false}, m) } func TestParseVolume_ReadOnly(t *testing.T) { - m, err := parseVolume("/host/seed:/seed:ro", "/cfg") + src := filepath.Join(t.TempDir(), "seed") + m, err := parseVolume(src+":/seed:ro", t.TempDir()) require.NoError(t, err) - assert.Equal(t, VolumeMount{Source: "/host/seed", Target: "/seed", ReadOnly: true}, m) + assert.Equal(t, VolumeMount{Source: src, Target: "/seed", ReadOnly: true}, m) } func TestParseVolume_ReadOnlyAmongOptions(t *testing.T) { - m, err := parseVolume("/host/seed:/seed:z,ro", "/cfg") + src := filepath.Join(t.TempDir(), "seed") + m, err := parseVolume(src+":/seed:z,ro", t.TempDir()) require.NoError(t, err) assert.True(t, m.ReadOnly) } func TestParseVolume_RelativeSourceResolvedAgainstConfigDir(t *testing.T) { - m, err := parseVolume("./init.sf.sql:/etc/localstack/init/ready.d/init.sf.sql", "/cfg/project") + cfgDir := t.TempDir() + m, err := parseVolume("./init.sf.sql:/etc/localstack/init/ready.d/init.sf.sql", cfgDir) require.NoError(t, err) - assert.Equal(t, "/cfg/project/init.sf.sql", m.Source) + assert.Equal(t, filepath.Join(cfgDir, "init.sf.sql"), m.Source) } func TestParseVolume_TildeExpanded(t *testing.T) { home, err := os.UserHomeDir() require.NoError(t, err) - m, err := parseVolume("~/scripts/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql", "/cfg") + m, err := parseVolume("~/scripts/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql", t.TempDir()) require.NoError(t, err) assert.Equal(t, filepath.Join(home, "scripts/x.sf.sql"), m.Source) } func TestParseVolume_AbsoluteSourceUnchanged(t *testing.T) { - m, err := parseVolume("/abs/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql", "/cfg") + src := filepath.Join(t.TempDir(), "x.sf.sql") + m, err := parseVolume(src+":/etc/localstack/init/ready.d/x.sf.sql", t.TempDir()) require.NoError(t, err) - assert.Equal(t, "/abs/x.sf.sql", m.Source) + assert.Equal(t, src, m.Source) } func TestParseVolume_Errors(t *testing.T) { @@ -242,15 +247,68 @@ func TestParseVolume_Errors(t *testing.T) { } } +func TestSplitVolumeSpec_NonWindows(t *testing.T) { + cases := []struct { + spec string + source, target, opts string + }{ + {"/host/data:/var/lib/localstack", "/host/data", "/var/lib/localstack", ""}, + {"./rel:/seed:ro", "./rel", "/seed", "ro"}, + // On non-Windows a single-letter host dir must NOT be treated as a drive. + {"a:/data", "a", "/data", ""}, + } + for _, tc := range cases { + t.Run(tc.spec, func(t *testing.T) { + source, target, opts, err := splitVolumeSpec(tc.spec, false) + require.NoError(t, err) + assert.Equal(t, tc.source, source) + assert.Equal(t, tc.target, target) + assert.Equal(t, tc.opts, opts) + }) + } +} + +func TestSplitVolumeSpec_WindowsDriveLetter(t *testing.T) { + cases := []struct { + spec string + source, target, opts string + }{ + {`C:\Users\me\persist:/var/lib/localstack`, `C:\Users\me\persist`, "/var/lib/localstack", ""}, + {`C:\data:/seed:ro`, `C:\data`, "/seed", "ro"}, + {"C:/forward:/seed", "C:/forward", "/seed", ""}, + // No drive letter: behaves like the normal split. + {"./rel:/seed", "./rel", "/seed", ""}, + } + for _, tc := range cases { + t.Run(tc.spec, func(t *testing.T) { + source, target, opts, err := splitVolumeSpec(tc.spec, true) + require.NoError(t, err) + assert.Equal(t, tc.source, source) + assert.Equal(t, tc.target, target) + assert.Equal(t, tc.opts, opts) + }) + } +} + +func TestParseVolume_ContainerTargetIsUnixAbsolute(t *testing.T) { + // The target is always a path inside the Linux container, so a leading-slash path must be + // accepted regardless of the host OS (filepath.IsAbs would reject it on Windows). + m, err := parseVolume("/host/data:/var/lib/localstack", "/cfg") + require.NoError(t, err) + assert.Equal(t, "/var/lib/localstack", m.Target) +} + func TestVolumeDir_VolumesEntryTargetingPersistenceWins(t *testing.T) { + persist := filepath.Join(t.TempDir(), "persist") + extra := filepath.Join(t.TempDir(), "x.sf.sql") c := &ContainerConfig{ Type: EmulatorAWS, Volume: "", // not set - Volumes: []string{"/persist/dir:/var/lib/localstack", "/abs/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql"}, + Volumes: []string{persist + ":/var/lib/localstack", extra + ":/etc/localstack/init/ready.d/x.sf.sql"}, } dir, err := c.VolumeDir() require.NoError(t, err) - assert.Equal(t, "/persist/dir", dir) + assert.Equal(t, persist, dir) } func TestVolumeDir_LegacyVolumeUsedWhenNoPersistenceEntry(t *testing.T) { @@ -274,19 +332,23 @@ func TestVolumeDir_DefaultsToCacheDirWhenNeitherSet(t *testing.T) { } func TestExtraVolumes_ExcludesPersistenceEntry(t *testing.T) { + dir := t.TempDir() + persist := filepath.Join(dir, "persist") + a := filepath.Join(dir, "a.sf.sql") + b := filepath.Join(dir, "b.sf.sql") c := &ContainerConfig{ Type: EmulatorAWS, Volumes: []string{ - "/persist/dir:/var/lib/localstack", - "/abs/a.sf.sql:/etc/localstack/init/ready.d/a.sf.sql", - "/abs/b.sf.sql:/etc/localstack/init/ready.d/b.sf.sql:ro", + persist + ":/var/lib/localstack", + a + ":/etc/localstack/init/ready.d/a.sf.sql", + b + ":/etc/localstack/init/ready.d/b.sf.sql:ro", }, } extras, err := c.ExtraVolumes() require.NoError(t, err) require.Len(t, extras, 2) - assert.Equal(t, VolumeMount{Source: "/abs/a.sf.sql", Target: "/etc/localstack/init/ready.d/a.sf.sql"}, extras[0]) - assert.Equal(t, VolumeMount{Source: "/abs/b.sf.sql", Target: "/etc/localstack/init/ready.d/b.sf.sql", ReadOnly: true}, extras[1]) + assert.Equal(t, VolumeMount{Source: a, Target: "/etc/localstack/init/ready.d/a.sf.sql"}, extras[0]) + assert.Equal(t, VolumeMount{Source: b, Target: "/etc/localstack/init/ready.d/b.sf.sql", ReadOnly: true}, extras[1]) } func TestValidate_RejectsMalformedVolume(t *testing.T) { From 54b0cb437abac94bcb8f84a526bb74a5b0f9fe0e Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 25 Jun 2026 13:07:12 +0200 Subject: [PATCH 3/4] Document volume vs volumes distinction Clarify in the README and CLAUDE.md that the singular `volume` and plural `volumes` overlap only for the persistence mount: `volume` only sets the /var/lib/localstack dir and is used verbatim, while `volumes` is a superset that also handles arbitrary mounts (init hooks) and resolves relative/~ paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 +++- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index adeb8037..67617c07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,9 @@ Created automatically on first run with defaults. Supports emulator types: `aws` Each `[[containers]]` block accepts a `volumes` list of Docker-style `"host:container[:ro]"` bind specs (e.g. for Snowflake init hooks mounted into `/etc/localstack/init/{boot,start,ready,shutdown}.d`). The persistence/cache mount to `/var/lib/localstack` is folded into this list: the entry whose container target is `/var/lib/localstack` (`persistenceTarget` in `internal/config/containers.go`) defines the host dir backing it, and that path is what `VolumeDir()`, `lstk volume path`, and `lstk volume clear` resolve. Resolution precedence in `VolumeDir()`: a `volumes` entry targeting `/var/lib/localstack` → the legacy singular `volume = "..."` field (still honored for backward compatibility) → the default OS cache dir. Setting the persistence dir via both `volume` and a `volumes` entry with differing sources is a validation error. -Parsing/resolution lives in `parseVolume`/`ExtraVolumes` in `internal/config/containers.go`. Relative host sources resolve against the **config file's directory** and a leading `~/` is expanded — this is required because the Docker SDK treats a non-absolute source as a *named volume* rather than a bind mount. `start.go` mounts the persistence dir (creating it via `MkdirAll`) and appends `ExtraVolumes()`; extra sources are not created (`os.Stat` + error if missing) since init-hook entries are files, not dirs. +`volume` (singular) and `volumes` (plural) are not interchangeable in general — they overlap only for the persistence mount. `volume` *only* sets the persistence dir (always mounted to `/var/lib/localstack`); `volumes` is a superset that can set the persistence dir **and** arbitrary mounts. Two further distinctions: `volume` cannot express init hooks or any non-persistence mount, and the legacy `volume` value is used **verbatim** (no path resolution) whereas a `volumes` source is resolved. So `volume = "/data"` and `volumes = ["/data:/var/lib/localstack"]` are equivalent for persistence, but `volume = "./data"` is passed raw (and would become a Docker named volume) while `volumes = ["./data:/var/lib/localstack"]` resolves `./data` against the config dir. + +Parsing/resolution lives in `parseVolume`/`ExtraVolumes` in `internal/config/containers.go`. The container target is validated with `path.IsAbs` (slash semantics) — never `filepath.IsAbs`, which rejects `/var/lib/localstack` on Windows. `splitVolumeSpec` rejoins a leading Windows drive letter (`C:\…`) onto the host source so its `:` is not mistaken for the host/container separator (Windows-guarded so a single-letter relative dir like `a:/data` stays valid elsewhere, matching Docker). Relative host sources resolve against the **config file's directory** and a leading `~/` is expanded — this is required because the Docker SDK treats a non-absolute source as a *named volume* rather than a bind mount. `start.go` mounts the persistence dir (creating it via `MkdirAll`) and appends `ExtraVolumes()`; extra sources are not created (`os.Stat` + error if missing) since init-hook entries are files, not dirs. # Emulator Setup Commands diff --git a/README.md b/README.md index 745af766..0d3cd351 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ type = "aws" # Emulator type. Currently supported: "aws", "snowflake", "azur tag = "latest" # Docker image tag, e.g. "latest", "2026.03" port = "4566" # Host port the emulator will be accessible on # volume = "" # Host directory for persistent state (default: OS cache dir) +# volumes = [] # Extra bind mounts, "host:container[:ro]" (see below) # env = [] # Named environment profiles to apply (see [env.*] sections below) ``` @@ -141,6 +142,7 @@ port = "4566" # Host port the emulator will be accessible on - `tag`: Docker image tag for LocalStack (e.g. `"latest"`, `"4.14.0"`); useful for pinning a version - `port`: port LocalStack listens on (default `4566`) - `volume`: (optional) host directory for persistent emulator state (default: OS cache dir) +- `volumes`: (optional) list of `"host:container[:ro]"` bind mounts, e.g. for init hooks (see below) - `env`: (optional) list of named environment variable groups to inject into the container (see below) ### Passing environment variables to the container @@ -166,6 +168,44 @@ EAGER_SERVICE_LOADING = "1" Host environment variables prefixed with `LOCALSTACK_` are also forwarded to the emulator. +### Mounting volumes and init hooks + +Use `volumes` to bind-mount host files or directories into the emulator, given as Docker-style `"host:container[:ro]"` strings. The most common use is [init hooks](https://docs.localstack.cloud/snowflake/capabilities/init-hooks/) — scripts LocalStack runs automatically on startup when mounted into `/etc/localstack/init/{boot,start,ready,shutdown}.d`: + +```toml +[[containers]] +type = "snowflake" +port = "4566" +volumes = ["./init.sf.sql:/etc/localstack/init/ready.d/init.sf.sql"] +``` + +- Relative host paths resolve against the config file's directory, and a leading `~/` is expanded. +- Append `:ro` to mount read-only. +- Host sources must already exist (init-hook entries are files, so `lstk` does not create them). + +#### `volume` vs `volumes` + +The singular `volume` and the plural `volumes` are **not** general synonyms — they overlap only for the persistence directory: + +- `volume` *only* sets the persistent-state directory, which is always mounted to `/var/lib/localstack` (the dir managed by `lstk volume path` / `lstk volume clear`). +- `volumes` is a superset: it can mount arbitrary paths **and** set the persistence directory, via the entry whose container target is `/var/lib/localstack`. + +So these two are equivalent for persistence: + +```toml +volume = "/data" +# is the same persistence mount as +volumes = ["/data:/var/lib/localstack"] +``` + +Differences to keep in mind: + +- `volume` cannot express init hooks or any non-persistence mount — use `volumes` for those. +- A `volumes` source is path-resolved (relative → config dir, `~/` expanded); the legacy `volume` value is used **verbatim**, so prefer an absolute path there. +- Declaring the persistence directory in **both** `volume` and a `volumes` entry with different sources is a configuration error. The same source in both is allowed. + +`volume` remains supported for backward compatibility; reach for `volumes` when you need init hooks, extra mounts, or path resolution. + ## Interactive And Non-Interactive Mode `lstk` uses the TUI in an interactive terminal and plain output elsewhere. Use `--non-interactive` to force plain output even in a TTY: From 6da84997115c5b9bb47b28cf292dac019fc09cad Mon Sep 17 00:00:00 2001 From: Anisa Oshafi Date: Thu, 25 Jun 2026 13:20:57 +0200 Subject: [PATCH 4/4] Nits --- README.md | 25 +++++----------------- internal/config/containers.go | 34 ++++++++++++++++-------------- internal/config/containers_test.go | 9 ++++++++ 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 0d3cd351..22a5b572 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,7 @@ lstk --config /path/to/config.toml start type = "aws" # Emulator type. Currently supported: "aws", "snowflake", "azure" tag = "latest" # Docker image tag, e.g. "latest", "2026.03" port = "4566" # Host port the emulator will be accessible on -# volume = "" # Host directory for persistent state (default: OS cache dir) -# volumes = [] # Extra bind mounts, "host:container[:ro]" (see below) +# volumes = [] # Bind mounts, "host:container[:ro]" (see below) # env = [] # Named environment profiles to apply (see [env.*] sections below) ``` @@ -141,8 +140,7 @@ port = "4566" # Host port the emulator will be accessible on - `type`: emulator type; one of `"aws"`, `"snowflake"`, or `"azure"` - `tag`: Docker image tag for LocalStack (e.g. `"latest"`, `"4.14.0"`); useful for pinning a version - `port`: port LocalStack listens on (default `4566`) -- `volume`: (optional) host directory for persistent emulator state (default: OS cache dir) -- `volumes`: (optional) list of `"host:container[:ro]"` bind mounts, e.g. for init hooks (see below) +- `volumes`: (optional) list of `"host:container[:ro]"` bind mounts, e.g. for init hooks or the persistent-state directory (see below) - `env`: (optional) list of named environment variable groups to inject into the container (see below) ### Passing environment variables to the container @@ -183,28 +181,15 @@ volumes = ["./init.sf.sql:/etc/localstack/init/ready.d/init.sf.sql"] - Append `:ro` to mount read-only. - Host sources must already exist (init-hook entries are files, so `lstk` does not create them). -#### `volume` vs `volumes` +#### Persistent state -The singular `volume` and the plural `volumes` are **not** general synonyms — they overlap only for the persistence directory: - -- `volume` *only* sets the persistent-state directory, which is always mounted to `/var/lib/localstack` (the dir managed by `lstk volume path` / `lstk volume clear`). -- `volumes` is a superset: it can mount arbitrary paths **and** set the persistence directory, via the entry whose container target is `/var/lib/localstack`. - -So these two are equivalent for persistence: +The persistent-state directory (mounted at `/var/lib/localstack`, managed by `lstk volume path` / `lstk volume clear`) defaults to the OS cache dir. Point it elsewhere with a `volumes` entry targeting that path: ```toml -volume = "/data" -# is the same persistence mount as volumes = ["/data:/var/lib/localstack"] ``` -Differences to keep in mind: - -- `volume` cannot express init hooks or any non-persistence mount — use `volumes` for those. -- A `volumes` source is path-resolved (relative → config dir, `~/` expanded); the legacy `volume` value is used **verbatim**, so prefer an absolute path there. -- Declaring the persistence directory in **both** `volume` and a `volumes` entry with different sources is a configuration error. The same source in both is allowed. - -`volume` remains supported for backward compatibility; reach for `volumes` when you need init hooks, extra mounts, or path resolution. +> The singular `volume = "..."` field is a legacy way to set only this directory. It still works, but `volumes` is preferred and is the only option for init hooks or other mounts. ## Interactive And Non-Interactive Mode diff --git a/internal/config/containers.go b/internal/config/containers.go index bf299013..8216b738 100644 --- a/internal/config/containers.go +++ b/internal/config/containers.go @@ -206,29 +206,29 @@ func isDriveLetter(b byte) bool { } // resolveHostPath expands a leading "~/" and makes a relative path absolute against configDir. -func resolveHostPath(path, configDir string) (string, error) { - if path == "~" || strings.HasPrefix(path, "~/") { +func resolveHostPath(hostPath, configDir string) (string, error) { + if hostPath == "~" || strings.HasPrefix(hostPath, "~/") { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("failed to expand ~: %w", err) } - path = filepath.Join(home, strings.TrimPrefix(path, "~")) + hostPath = filepath.Join(home, strings.TrimPrefix(hostPath, "~")) } - if filepath.IsAbs(path) { - return path, nil + if filepath.IsAbs(hostPath) { + return hostPath, nil } - return filepath.Join(configDir, path), nil + return filepath.Join(configDir, hostPath), nil } // configDirForRelativePaths returns the directory used to resolve relative volume sources: // the directory of the resolved config file. It falls back to the current working directory // when no config file is in use (e.g. in-memory defaults). func configDirForRelativePaths() string { - path, err := ConfigFilePath() - if err != nil || path == "" { + cfgPath, err := ConfigFilePath() + if err != nil || cfgPath == "" { return "." } - return filepath.Dir(path) + return filepath.Dir(cfgPath) } // parsedVolumes parses every entry in Volumes, resolving sources against the config dir. @@ -342,19 +342,21 @@ func (c *ContainerConfig) Validate() error { // declaring the persistence directory twice with conflicting sources. It does not touch the // filesystem (existence of sources is checked at start time). func (c *ContainerConfig) validateVolumes() error { - configDir := configDirForRelativePaths() + mounts, err := c.parsedVolumes() + if err != nil { + return err + } var persistenceSource string - for _, spec := range c.Volumes { - m, err := parseVolume(spec, configDir) - if err != nil { - return err - } + for _, m := range mounts { if m.Target == persistenceTarget { + if m.ReadOnly { + return fmt.Errorf("persistence directory (%s) cannot be mounted read-only", persistenceTarget) + } persistenceSource = m.Source } } if c.Volume != "" && persistenceSource != "" { - resolved, err := resolveHostPath(c.Volume, configDir) + resolved, err := resolveHostPath(c.Volume, configDirForRelativePaths()) if err != nil { return err } diff --git a/internal/config/containers_test.go b/internal/config/containers_test.go index 0e5e0f75..c5d45654 100644 --- a/internal/config/containers_test.go +++ b/internal/config/containers_test.go @@ -366,6 +366,15 @@ func TestValidate_RejectsConflictingPersistenceSources(t *testing.T) { assert.ErrorContains(t, c.Validate(), "persistence directory set both") } +func TestValidate_RejectsReadOnlyPersistenceMount(t *testing.T) { + c := &ContainerConfig{ + Type: EmulatorAWS, + Port: "4566", + Volumes: []string{"/persist:/var/lib/localstack:ro"}, + } + assert.ErrorContains(t, c.Validate(), "cannot be mounted read-only") +} + func TestValidate_AllowsMatchingPersistenceSources(t *testing.T) { c := &ContainerConfig{ Type: EmulatorAWS,