Skip to content

Commit 0d7b899

Browse files
anisaoshaficlaude
andcommitted
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) <noreply@anthropic.com>
1 parent c07621c commit 0d7b899

7 files changed

Lines changed: 434 additions & 13 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+
## Volume Mounts
70+
71+
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.
72+
73+
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.
74+
6975
# Emulator Setup Commands
7076

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

internal/config/containers.go

Lines changed: 163 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,18 +112,147 @@ 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+
// Volume is the legacy single-host-directory knob for the persistence mount
119+
// (target /var/lib/localstack). It is still honored; new configs can express the
120+
// same mount as a Volumes entry targeting persistenceTarget instead.
121+
Volume string `mapstructure:"volume"`
122+
// Volumes is the umbrella list of "host:container[:ro]" bind specs. It covers
123+
// arbitrary mounts (e.g. Snowflake init hooks) and may also contain the persistence
124+
// mount (the entry targeting /var/lib/localstack).
125+
Volumes []string `mapstructure:"volumes"`
119126
// Env is a list of named environment references defined in the top-level [env.*] config sections.
120127
Env []string `mapstructure:"env"`
121128
}
122129

123-
// VolumeDir returns the host directory to mount into the container for persistence/caching.
124-
// If Volume is set in the config, it is returned as-is. Otherwise, a default is computed
125-
// from os.UserCacheDir()/lstk/volume/<container-name>.
130+
// persistenceTarget is the container path of the managed persistence/cache mount.
131+
// The entry in Volumes targeting this path (or the legacy Volume field) defines the
132+
// host directory backing it; lstk creates it and `lstk volume clear`/`volume path` act on it.
133+
const persistenceTarget = "/var/lib/localstack"
134+
135+
// VolumeMount is a parsed bind specification with the host source resolved to an absolute path.
136+
type VolumeMount struct {
137+
Source string
138+
Target string
139+
ReadOnly bool
140+
}
141+
142+
// parseVolume parses a "host:container[:opts]" spec. The host source is resolved to an
143+
// absolute path: a leading "~/" is expanded to the user's home directory, and a relative
144+
// path is joined with configDir (the directory of the config file that declared it). This
145+
// is required because the Docker SDK treats a non-absolute source as a named volume rather
146+
// than a bind mount. opts is a comma-separated list; only "ro" is honored.
147+
//
148+
// Note: a Windows drive-letter source (e.g. "C:\\data") splits on ":" ambiguously — the same
149+
// limitation as the upstream LocalStack volume parser.
150+
func parseVolume(spec, configDir string) (VolumeMount, error) {
151+
parts := strings.Split(spec, ":")
152+
if len(parts) < 2 || len(parts) > 3 {
153+
return VolumeMount{}, fmt.Errorf("invalid volume %q: expected \"host:container\" or \"host:container:ro\"", spec)
154+
}
155+
156+
source, target := parts[0], parts[1]
157+
if source == "" {
158+
return VolumeMount{}, fmt.Errorf("invalid volume %q: host source is empty", spec)
159+
}
160+
if target == "" {
161+
return VolumeMount{}, fmt.Errorf("invalid volume %q: container target is empty", spec)
162+
}
163+
if !filepath.IsAbs(target) {
164+
return VolumeMount{}, fmt.Errorf("invalid volume %q: container target %q must be an absolute path", spec, target)
165+
}
166+
167+
resolved, err := resolveHostPath(source, configDir)
168+
if err != nil {
169+
return VolumeMount{}, fmt.Errorf("invalid volume %q: %w", spec, err)
170+
}
171+
172+
var readOnly bool
173+
if len(parts) == 3 {
174+
for _, opt := range strings.Split(parts[2], ",") {
175+
if opt == "ro" {
176+
readOnly = true
177+
}
178+
}
179+
}
180+
181+
return VolumeMount{Source: resolved, Target: target, ReadOnly: readOnly}, nil
182+
}
183+
184+
// resolveHostPath expands a leading "~/" and makes a relative path absolute against configDir.
185+
func resolveHostPath(path, configDir string) (string, error) {
186+
if path == "~" || strings.HasPrefix(path, "~/") {
187+
home, err := os.UserHomeDir()
188+
if err != nil {
189+
return "", fmt.Errorf("failed to expand ~: %w", err)
190+
}
191+
path = filepath.Join(home, strings.TrimPrefix(path, "~"))
192+
}
193+
if filepath.IsAbs(path) {
194+
return path, nil
195+
}
196+
return filepath.Join(configDir, path), nil
197+
}
198+
199+
// configDirForRelativePaths returns the directory used to resolve relative volume sources:
200+
// the directory of the resolved config file. It falls back to the current working directory
201+
// when no config file is in use (e.g. in-memory defaults).
202+
func configDirForRelativePaths() string {
203+
path, err := ConfigFilePath()
204+
if err != nil || path == "" {
205+
return "."
206+
}
207+
return filepath.Dir(path)
208+
}
209+
210+
// parsedVolumes parses every entry in Volumes, resolving sources against the config dir.
211+
func (c *ContainerConfig) parsedVolumes() ([]VolumeMount, error) {
212+
configDir := configDirForRelativePaths()
213+
mounts := make([]VolumeMount, 0, len(c.Volumes))
214+
for _, spec := range c.Volumes {
215+
m, err := parseVolume(spec, configDir)
216+
if err != nil {
217+
return nil, err
218+
}
219+
mounts = append(mounts, m)
220+
}
221+
return mounts, nil
222+
}
223+
224+
// ExtraVolumes returns the parsed bind mounts EXCLUDING the persistence entry
225+
// (target /var/lib/localstack), which start.go mounts separately via VolumeDir.
226+
func (c *ContainerConfig) ExtraVolumes() ([]VolumeMount, error) {
227+
mounts, err := c.parsedVolumes()
228+
if err != nil {
229+
return nil, err
230+
}
231+
extras := make([]VolumeMount, 0, len(mounts))
232+
for _, m := range mounts {
233+
if m.Target == persistenceTarget {
234+
continue
235+
}
236+
extras = append(extras, m)
237+
}
238+
return extras, nil
239+
}
240+
241+
// VolumeDir returns the host directory to mount into the container for persistence/caching
242+
// (the mount targeting /var/lib/localstack). Resolution precedence:
243+
// 1. A Volumes entry targeting persistenceTarget — its resolved host source.
244+
// 2. The legacy Volume field, if set — returned as-is.
245+
// 3. The default os.UserCacheDir()/lstk/volume/<container-name>.
126246
func (c *ContainerConfig) VolumeDir() (string, error) {
247+
mounts, err := c.parsedVolumes()
248+
if err != nil {
249+
return "", err
250+
}
251+
for _, m := range mounts {
252+
if m.Target == persistenceTarget {
253+
return m.Source, nil
254+
}
255+
}
127256
if c.Volume != "" {
128257
return c.Volume, nil
129258
}
@@ -182,6 +311,33 @@ func (c *ContainerConfig) Validate() error {
182311
if port < 1 || port > 65535 {
183312
return fmt.Errorf("port %d is out of range (must be 1–65535)", port)
184313
}
314+
return c.validateVolumes()
315+
}
316+
317+
// validateVolumes checks each Volumes entry is structurally parseable and guards against
318+
// declaring the persistence directory twice with conflicting sources. It does not touch the
319+
// filesystem (existence of sources is checked at start time).
320+
func (c *ContainerConfig) validateVolumes() error {
321+
configDir := configDirForRelativePaths()
322+
var persistenceSource string
323+
for _, spec := range c.Volumes {
324+
m, err := parseVolume(spec, configDir)
325+
if err != nil {
326+
return err
327+
}
328+
if m.Target == persistenceTarget {
329+
persistenceSource = m.Source
330+
}
331+
}
332+
if c.Volume != "" && persistenceSource != "" {
333+
resolved, err := resolveHostPath(c.Volume, configDir)
334+
if err != nil {
335+
return err
336+
}
337+
if resolved != persistenceSource {
338+
return fmt.Errorf("persistence directory set both via 'volume' and a 'volumes' entry targeting %s; use one or the other", persistenceTarget)
339+
}
340+
}
185341
return nil
186342
}
187343

internal/config/containers_test.go

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package config
22

33
import (
4+
"os"
5+
"path/filepath"
46
"sort"
57
"strings"
68
"testing"
@@ -80,12 +82,12 @@ func TestNormalizeTag(t *testing.T) {
8082

8183
func TestValidate_InvalidDockerTag_IsRejected(t *testing.T) {
8284
for _, tag := range []string{
83-
"my tag", // space
84-
"2026.4!", // special char
85-
".hidden", // starts with dot
86-
"-beta", // starts with hyphen
87-
"tag@sha", // @ not allowed
88-
"foo:bar", // colon not allowed
85+
"my tag", // space
86+
"2026.4!", // special char
87+
".hidden", // starts with dot
88+
"-beta", // starts with hyphen
89+
"tag@sha", // @ not allowed
90+
"foo:bar", // colon not allowed
8991
strings.Repeat("a", 129), // too long
9092
} {
9193
t.Run(tag, func(t *testing.T) {
@@ -185,3 +187,129 @@ func TestValidate_NegativePort(t *testing.T) {
185187
err := c.Validate()
186188
assert.ErrorContains(t, err, "out of range")
187189
}
190+
191+
func TestParseVolume_TwoParts(t *testing.T) {
192+
m, err := parseVolume("/host/data:/var/lib/localstack", "/cfg")
193+
require.NoError(t, err)
194+
assert.Equal(t, VolumeMount{Source: "/host/data", Target: "/var/lib/localstack", ReadOnly: false}, m)
195+
}
196+
197+
func TestParseVolume_ReadOnly(t *testing.T) {
198+
m, err := parseVolume("/host/seed:/seed:ro", "/cfg")
199+
require.NoError(t, err)
200+
assert.Equal(t, VolumeMount{Source: "/host/seed", Target: "/seed", ReadOnly: true}, m)
201+
}
202+
203+
func TestParseVolume_ReadOnlyAmongOptions(t *testing.T) {
204+
m, err := parseVolume("/host/seed:/seed:z,ro", "/cfg")
205+
require.NoError(t, err)
206+
assert.True(t, m.ReadOnly)
207+
}
208+
209+
func TestParseVolume_RelativeSourceResolvedAgainstConfigDir(t *testing.T) {
210+
m, err := parseVolume("./init.sf.sql:/etc/localstack/init/ready.d/init.sf.sql", "/cfg/project")
211+
require.NoError(t, err)
212+
assert.Equal(t, "/cfg/project/init.sf.sql", m.Source)
213+
}
214+
215+
func TestParseVolume_TildeExpanded(t *testing.T) {
216+
home, err := os.UserHomeDir()
217+
require.NoError(t, err)
218+
m, err := parseVolume("~/scripts/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql", "/cfg")
219+
require.NoError(t, err)
220+
assert.Equal(t, filepath.Join(home, "scripts/x.sf.sql"), m.Source)
221+
}
222+
223+
func TestParseVolume_AbsoluteSourceUnchanged(t *testing.T) {
224+
m, err := parseVolume("/abs/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql", "/cfg")
225+
require.NoError(t, err)
226+
assert.Equal(t, "/abs/x.sf.sql", m.Source)
227+
}
228+
229+
func TestParseVolume_Errors(t *testing.T) {
230+
cases := map[string]string{
231+
"one part": "/host/only",
232+
"four parts": "/h:/c:ro:extra",
233+
"empty source": ":/c",
234+
"empty target": "/h:",
235+
"relative target": "/h:relative/target",
236+
}
237+
for name, spec := range cases {
238+
t.Run(name, func(t *testing.T) {
239+
_, err := parseVolume(spec, "/cfg")
240+
assert.Error(t, err)
241+
})
242+
}
243+
}
244+
245+
func TestVolumeDir_VolumesEntryTargetingPersistenceWins(t *testing.T) {
246+
c := &ContainerConfig{
247+
Type: EmulatorAWS,
248+
Volume: "", // not set
249+
Volumes: []string{"/persist/dir:/var/lib/localstack", "/abs/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql"},
250+
}
251+
dir, err := c.VolumeDir()
252+
require.NoError(t, err)
253+
assert.Equal(t, "/persist/dir", dir)
254+
}
255+
256+
func TestVolumeDir_LegacyVolumeUsedWhenNoPersistenceEntry(t *testing.T) {
257+
c := &ContainerConfig{
258+
Type: EmulatorAWS,
259+
Volume: "/legacy/persist",
260+
Volumes: []string{"/abs/x.sf.sql:/etc/localstack/init/ready.d/x.sf.sql"},
261+
}
262+
dir, err := c.VolumeDir()
263+
require.NoError(t, err)
264+
assert.Equal(t, "/legacy/persist", dir)
265+
}
266+
267+
func TestVolumeDir_DefaultsToCacheDirWhenNeitherSet(t *testing.T) {
268+
cacheDir, err := os.UserCacheDir()
269+
require.NoError(t, err)
270+
c := &ContainerConfig{Type: EmulatorAWS}
271+
dir, err := c.VolumeDir()
272+
require.NoError(t, err)
273+
assert.Equal(t, filepath.Join(cacheDir, "lstk", "volume", c.Name()), dir)
274+
}
275+
276+
func TestExtraVolumes_ExcludesPersistenceEntry(t *testing.T) {
277+
c := &ContainerConfig{
278+
Type: EmulatorAWS,
279+
Volumes: []string{
280+
"/persist/dir:/var/lib/localstack",
281+
"/abs/a.sf.sql:/etc/localstack/init/ready.d/a.sf.sql",
282+
"/abs/b.sf.sql:/etc/localstack/init/ready.d/b.sf.sql:ro",
283+
},
284+
}
285+
extras, err := c.ExtraVolumes()
286+
require.NoError(t, err)
287+
require.Len(t, extras, 2)
288+
assert.Equal(t, VolumeMount{Source: "/abs/a.sf.sql", Target: "/etc/localstack/init/ready.d/a.sf.sql"}, extras[0])
289+
assert.Equal(t, VolumeMount{Source: "/abs/b.sf.sql", Target: "/etc/localstack/init/ready.d/b.sf.sql", ReadOnly: true}, extras[1])
290+
}
291+
292+
func TestValidate_RejectsMalformedVolume(t *testing.T) {
293+
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Volumes: []string{"/host/only"}}
294+
assert.ErrorContains(t, c.Validate(), "invalid volume")
295+
}
296+
297+
func TestValidate_RejectsConflictingPersistenceSources(t *testing.T) {
298+
c := &ContainerConfig{
299+
Type: EmulatorAWS,
300+
Port: "4566",
301+
Volume: "/persist/a",
302+
Volumes: []string{"/persist/b:/var/lib/localstack"},
303+
}
304+
assert.ErrorContains(t, c.Validate(), "persistence directory set both")
305+
}
306+
307+
func TestValidate_AllowsMatchingPersistenceSources(t *testing.T) {
308+
c := &ContainerConfig{
309+
Type: EmulatorAWS,
310+
Port: "4566",
311+
Volume: "/persist/same",
312+
Volumes: []string{"/persist/same:/var/lib/localstack"},
313+
}
314+
assert.NoError(t, c.Validate())
315+
}

internal/config/default_config.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ tag = "latest" # Docker image tag, e.g. "latest", "2026.4"
1111
port = "4566" # Host port the emulator will be accessible on
1212
# volume = "" # Host directory for persistent state (default: OS cache dir)
1313
# env = [] # Named environment profiles to apply (see [env.*] sections below)
14+
# volumes = [] # Extra bind mounts, each "host:container[:ro]". Relative host paths
15+
# # resolve against this config file's directory; a leading ~/ is expanded.
16+
# # A "volumes" entry targeting /var/lib/localstack sets the persistent
17+
# # state directory (equivalent to "volume" above).
18+
# #
19+
# # Mount Snowflake init hooks (scripts run on startup) — see
20+
# # https://docs.localstack.cloud/snowflake/capabilities/init-hooks/
21+
# # volumes = ["./test.sf.sql:/etc/localstack/init/ready.d/test.sf.sql"]
1422

1523
# Environment profiles let you group environment variables and reference
1624
# them by name in one or more containers via the 'env' field above.

internal/container/start.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,20 @@ func Start(ctx context.Context, rt runtime.Runtime, sink output.Sink, opts Start
139139
}
140140
binds = append(binds, runtime.BindMount{HostPath: volumeDir, ContainerPath: "/var/lib/localstack"})
141141

142+
// Extra user-defined mounts (e.g. Snowflake init hooks). Unlike the persistence
143+
// directory, these are not created — init-hook entries are files, so the source
144+
// must already exist; creating it would produce a wrong empty directory.
145+
extraVolumes, err := c.ExtraVolumes()
146+
if err != nil {
147+
return "", err
148+
}
149+
for _, m := range extraVolumes {
150+
if _, err := os.Stat(m.Source); err != nil {
151+
return "", fmt.Errorf("volume source %q does not exist: %w", m.Source, err)
152+
}
153+
binds = append(binds, runtime.BindMount{HostPath: m.Source, ContainerPath: m.Target, ReadOnly: m.ReadOnly})
154+
}
155+
142156
containers[i] = runtime.ContainerConfig{
143157
Image: image,
144158
Name: containerName,

0 commit comments

Comments
 (0)