Skip to content

Commit d367c1f

Browse files
committed
Support mounting volumes and init hooks via config
1 parent 888a3f1 commit d367c1f

7 files changed

Lines changed: 364 additions & 25 deletions

File tree

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,17 +122,40 @@ lstk --config /path/to/config.toml start
122122
type = "aws" # Emulator type. Currently supported: "aws", "snowflake", "azure"
123123
tag = "latest" # Docker image tag, e.g. "latest", "2026.03"
124124
port = "4566" # Host port the emulator will be accessible on
125-
# volume = "" # Host directory for persistent state (default: OS cache dir)
125+
# volumes = [] # Bind mounts as "host:container[:ro]" (see "Mounting volumes and init hooks")
126126
# env = [] # Named environment profiles to apply (see [env.*] sections below)
127127
```
128128

129129
**Fields:**
130130
- `type`: emulator type; one of `"aws"`, `"snowflake"`, or `"azure"`
131131
- `tag`: Docker image tag for LocalStack (e.g. `"latest"`, `"4.14.0"`); useful for pinning a version
132132
- `port`: port LocalStack listens on (default `4566`)
133-
- `volume`: (optional) host directory for persistent emulator state (default: OS cache dir)
133+
- `volumes`: (optional) bind mounts in `"host:container[:ro]"` form — for init hooks, or to set the persistence directory by mounting to `/var/lib/localstack` (see below); `volume` is an accepted alias
134134
- `env`: (optional) list of named environment variable groups to inject into the container (see below)
135135

136+
### Mounting volumes and init hooks
137+
138+
Use `volumes` to mount host files or directories into the emulator. Each entry is a docker-style `"host:container"` (add `:ro` for read-only). Relative paths and a leading `~` are resolved automatically. `volume` and `volumes` are interchangeable aliases — both take the same list and their entries are combined. A bare host path with no container part (e.g. `volume = "/my/data"`) is mounted as the persistence directory, which keeps the legacy single-string form working.
139+
140+
A common use is [init hooks](https://docs.localstack.cloud/snowflake/capabilities/init-hooks/) — scripts the emulator runs on startup. Mount them into one of `/etc/localstack/init/{boot,start,ready,shutdown}.d`. For example, to seed Snowflake data once the emulator is ready:
141+
142+
```toml
143+
[[containers]]
144+
type = "snowflake"
145+
port = "4566"
146+
volumes = ["./init:/etc/localstack/init/ready.d:ro"]
147+
```
148+
149+
Any `*.sf.sql` file in `./init` then runs automatically when the emulator is ready.
150+
151+
The emulator's persistent state lives at `/var/lib/localstack`. By default lstk mounts a directory under your OS cache dir there; mount your own host directory to that path to control where state is stored:
152+
153+
```toml
154+
volumes = ["/my/data:/var/lib/localstack"]
155+
```
156+
157+
`lstk volume path` prints this directory and `lstk volume clear` empties it.
158+
136159
### Passing environment variables to the container
137160

138161
Define reusable named env sets and reference them per container:

internal/config/containers.go

Lines changed: 147 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ const (
2020

2121
DefaultPort = "4566"
2222
dockerRegistry = "localstack"
23+
24+
// PersistenceContainerPath is the container path holding the emulator's
25+
// persistent state. The volumes entry targeting it is the persistence mount;
26+
// if none does, lstk mounts a default host directory there.
27+
PersistenceContainerPath = "/var/lib/localstack"
2328
)
2429

2530
var emulatorDisplayNames = map[EmulatorType]string{
@@ -112,20 +117,150 @@ func KnownImageReposForType(t EmulatorType) []string {
112117
}
113118

114119
type ContainerConfig struct {
115-
Type EmulatorType `mapstructure:"type"`
116-
Tag string `mapstructure:"tag"`
117-
Port string `mapstructure:"port"`
118-
Volume string `mapstructure:"volume"`
120+
Type EmulatorType `mapstructure:"type"`
121+
Tag string `mapstructure:"tag"`
122+
Port string `mapstructure:"port"`
123+
// Volumes is a list of bind mounts in "host:container[:ro|:rw]" form,
124+
// e.g. "/home/me/init:/etc/localstack/init/ready.d:ro". This is how init hooks
125+
// are mounted. The entry whose container path is PersistenceContainerPath
126+
// ("/var/lib/localstack") holds the emulator's persistent state; if no entry
127+
// targets it, lstk mounts a default host directory under the OS cache dir there.
128+
Volumes []string `mapstructure:"volumes"`
129+
// Volume is an accepted alias for Volumes; entries from both are combined.
130+
Volume []string `mapstructure:"volume"`
119131
// Env is a list of named environment references defined in the top-level [env.*] config sections.
120132
Env []string `mapstructure:"env"`
121133
}
122134

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>.
135+
// VolumeSpecs returns the combined bind-mount specs from the Volumes and Volume
136+
// fields, which are interchangeable aliases.
137+
func (c *ContainerConfig) VolumeSpecs() []string {
138+
specs := make([]string, 0, len(c.Volumes)+len(c.Volume))
139+
specs = append(specs, c.Volumes...)
140+
specs = append(specs, c.Volume...)
141+
return specs
142+
}
143+
144+
// VolumeMount is a parsed bind mount from the Volumes config field. It mirrors
145+
// runtime.BindMount but lives in config to avoid a dependency from config on the
146+
// runtime package; the container start path converts these into runtime.BindMount.
147+
type VolumeMount struct {
148+
HostPath string
149+
ContainerPath string
150+
ReadOnly bool
151+
}
152+
153+
// windowsDrivePathRe matches a leading Windows drive prefix like "C:\" or "c:/"
154+
// so the host:container split does not treat the drive-letter colon as a separator.
155+
var windowsDrivePathRe = regexp.MustCompile(`^[A-Za-z]:[\\/]`)
156+
157+
// ParsedVolumes parses the combined Volumes/Volume config entries into VolumeMounts.
158+
// Each entry is "host:container" or "host:container:ro" (":rw" is also accepted and
159+
// means read-write). A bare "host" with no container part is mounted at the
160+
// persistence path (the legacy `volume = "/my/data"` form). Relative host paths are
161+
// made absolute and a leading "~" is expanded. The container path must be absolute.
162+
func (c *ContainerConfig) ParsedVolumes() ([]VolumeMount, error) {
163+
var mounts []VolumeMount
164+
for _, raw := range c.VolumeSpecs() {
165+
m, err := parseVolume(raw)
166+
if err != nil {
167+
return nil, err
168+
}
169+
mounts = append(mounts, m)
170+
}
171+
return mounts, nil
172+
}
173+
174+
func parseVolume(raw string) (VolumeMount, error) {
175+
invalid := func() (VolumeMount, error) {
176+
return VolumeMount{}, fmt.Errorf("invalid volume %q: expected \"host\" or \"host:container[:ro]\"", raw)
177+
}
178+
179+
spec := raw
180+
readOnly := false
181+
// Strip an optional trailing mode (":ro" / ":rw"). Any other trailing
182+
// token after the final colon is left as part of the container path.
183+
if idx := strings.LastIndex(spec, ":"); idx != -1 {
184+
switch spec[idx+1:] {
185+
case "ro":
186+
readOnly = true
187+
spec = spec[:idx]
188+
case "rw":
189+
spec = spec[:idx]
190+
}
191+
}
192+
193+
// Split host:container on the first colon, skipping a Windows drive prefix.
194+
searchFrom := 0
195+
if windowsDrivePathRe.MatchString(spec) {
196+
searchFrom = 2
197+
}
198+
idx := strings.Index(spec[searchFrom:], ":")
199+
200+
// No host:container separator: treat the whole spec as a host path mounted at
201+
// the persistence path. This supports the legacy form `volume = "/my/data"`.
202+
if idx == -1 {
203+
if spec == "" {
204+
return invalid()
205+
}
206+
host, err := resolveHostPath(spec)
207+
if err != nil {
208+
return VolumeMount{}, fmt.Errorf("invalid volume %q: %w", raw, err)
209+
}
210+
return VolumeMount{HostPath: host, ContainerPath: PersistenceContainerPath, ReadOnly: readOnly}, nil
211+
}
212+
idx += searchFrom
213+
214+
host := spec[:idx]
215+
container := spec[idx+1:]
216+
if host == "" || container == "" {
217+
return invalid()
218+
}
219+
if !strings.HasPrefix(container, "/") && !windowsDrivePathRe.MatchString(container) {
220+
return VolumeMount{}, fmt.Errorf("invalid volume %q: container path %q must be absolute", raw, container)
221+
}
222+
223+
host, err := resolveHostPath(host)
224+
if err != nil {
225+
return VolumeMount{}, fmt.Errorf("invalid volume %q: %w", raw, err)
226+
}
227+
228+
return VolumeMount{HostPath: host, ContainerPath: container, ReadOnly: readOnly}, nil
229+
}
230+
231+
func resolveHostPath(host string) (string, error) {
232+
if host == "~" || strings.HasPrefix(host, "~/") {
233+
home, err := os.UserHomeDir()
234+
if err != nil {
235+
return "", fmt.Errorf("failed to expand \"~\": %w", err)
236+
}
237+
host = filepath.Join(home, strings.TrimPrefix(host, "~"))
238+
}
239+
// A Windows drive-absolute path (e.g. "C:\data") is already absolute; leave it
240+
// as-is so parsing is host-OS-independent (filepath.Abs on non-Windows would
241+
// wrongly treat it as relative and prepend the working directory).
242+
if windowsDrivePathRe.MatchString(host) {
243+
return host, nil
244+
}
245+
abs, err := filepath.Abs(host)
246+
if err != nil {
247+
return "", fmt.Errorf("failed to resolve host path %q: %w", host, err)
248+
}
249+
return abs, nil
250+
}
251+
252+
// VolumeDir returns the host directory mounted for the emulator's persistent state
253+
// (at PersistenceContainerPath). If a volumes entry targets that path, its host side
254+
// is returned; otherwise a default is computed from os.UserCacheDir()/lstk/volume/<container-name>.
126255
func (c *ContainerConfig) VolumeDir() (string, error) {
127-
if c.Volume != "" {
128-
return c.Volume, nil
256+
mounts, err := c.ParsedVolumes()
257+
if err != nil {
258+
return "", err
259+
}
260+
for _, m := range mounts {
261+
if m.ContainerPath == PersistenceContainerPath {
262+
return m.HostPath, nil
263+
}
129264
}
130265
cacheDir, err := os.UserCacheDir()
131266
if err != nil {
@@ -182,6 +317,9 @@ func (c *ContainerConfig) Validate() error {
182317
if port < 1 || port > 65535 {
183318
return fmt.Errorf("port %d is out of range (must be 1–65535)", port)
184319
}
320+
if _, err := c.ParsedVolumes(); err != nil {
321+
return err
322+
}
185323
return nil
186324
}
187325

internal/config/containers_test.go

Lines changed: 109 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,104 @@ func TestValidate_NegativePort(t *testing.T) {
185187
err := c.Validate()
186188
assert.ErrorContains(t, err, "out of range")
187189
}
190+
191+
func TestParsedVolumes_BasicAndReadOnly(t *testing.T) {
192+
c := &ContainerConfig{Volumes: []string{
193+
"/host/data:/data",
194+
"/host/init:/etc/localstack/init/ready.d:ro",
195+
"/host/rw:/rw:rw",
196+
}}
197+
mounts, err := c.ParsedVolumes()
198+
require.NoError(t, err)
199+
require.Len(t, mounts, 3)
200+
201+
assert.Equal(t, VolumeMount{HostPath: "/host/data", ContainerPath: "/data", ReadOnly: false}, mounts[0])
202+
assert.Equal(t, VolumeMount{HostPath: "/host/init", ContainerPath: "/etc/localstack/init/ready.d", ReadOnly: true}, mounts[1])
203+
assert.Equal(t, VolumeMount{HostPath: "/host/rw", ContainerPath: "/rw", ReadOnly: false}, mounts[2])
204+
}
205+
206+
func TestParsedVolumes_RelativeHostMadeAbsolute(t *testing.T) {
207+
c := &ContainerConfig{Volumes: []string{"./init:/etc/localstack/init/ready.d"}}
208+
mounts, err := c.ParsedVolumes()
209+
require.NoError(t, err)
210+
require.Len(t, mounts, 1)
211+
assert.True(t, filepath.IsAbs(mounts[0].HostPath), "host path should be absolute, got %q", mounts[0].HostPath)
212+
assert.True(t, strings.HasSuffix(mounts[0].HostPath, filepath.FromSlash("/init")), "got %q", mounts[0].HostPath)
213+
}
214+
215+
func TestParsedVolumes_TildeExpanded(t *testing.T) {
216+
home, err := os.UserHomeDir()
217+
require.NoError(t, err)
218+
c := &ContainerConfig{Volumes: []string{"~/init:/data"}}
219+
mounts, err := c.ParsedVolumes()
220+
require.NoError(t, err)
221+
require.Len(t, mounts, 1)
222+
assert.Equal(t, filepath.Join(home, "init"), mounts[0].HostPath)
223+
}
224+
225+
func TestParsedVolumes_WindowsDrivePath(t *testing.T) {
226+
c := &ContainerConfig{Volumes: []string{`C:\data:/data:ro`}}
227+
mounts, err := c.ParsedVolumes()
228+
require.NoError(t, err)
229+
require.Len(t, mounts, 1)
230+
assert.Equal(t, `C:\data`, mounts[0].HostPath)
231+
assert.Equal(t, "/data", mounts[0].ContainerPath)
232+
assert.True(t, mounts[0].ReadOnly)
233+
}
234+
235+
func TestParsedVolumes_BarePathIsPersistenceMount(t *testing.T) {
236+
// Legacy form: a bare host path (no container part) mounts at the persistence path.
237+
c := &ContainerConfig{Volume: []string{"/my/data"}}
238+
mounts, err := c.ParsedVolumes()
239+
require.NoError(t, err)
240+
require.Len(t, mounts, 1)
241+
assert.Equal(t, VolumeMount{HostPath: "/my/data", ContainerPath: PersistenceContainerPath}, mounts[0])
242+
}
243+
244+
func TestVolumeDir_ResolvesFromLegacyBarePath(t *testing.T) {
245+
c := &ContainerConfig{Volume: []string{"/my/data"}}
246+
dir, err := c.VolumeDir()
247+
require.NoError(t, err)
248+
assert.Equal(t, "/my/data", dir)
249+
}
250+
251+
func TestParsedVolumes_Errors(t *testing.T) {
252+
for _, raw := range []string{
253+
"", // empty
254+
":/data", // empty host
255+
"/host:", // empty container
256+
"/host:relative", // container not absolute
257+
} {
258+
t.Run(raw, func(t *testing.T) {
259+
c := &ContainerConfig{Volumes: []string{raw}}
260+
_, err := c.ParsedVolumes()
261+
assert.Error(t, err)
262+
})
263+
}
264+
}
265+
266+
func TestParsedVolumes_VolumeAliasCombined(t *testing.T) {
267+
// "volume" and "volumes" are interchangeable aliases; entries are combined.
268+
c := &ContainerConfig{
269+
Volumes: []string{"/host/a:/a"},
270+
Volume: []string{"/host/b:/b:ro"},
271+
}
272+
mounts, err := c.ParsedVolumes()
273+
require.NoError(t, err)
274+
require.Len(t, mounts, 2)
275+
assert.Equal(t, VolumeMount{HostPath: "/host/a", ContainerPath: "/a"}, mounts[0])
276+
assert.Equal(t, VolumeMount{HostPath: "/host/b", ContainerPath: "/b", ReadOnly: true}, mounts[1])
277+
}
278+
279+
func TestVolumeDir_ResolvesFromVolumeAlias(t *testing.T) {
280+
c := &ContainerConfig{Volume: []string{"/host/state:/var/lib/localstack"}}
281+
dir, err := c.VolumeDir()
282+
require.NoError(t, err)
283+
assert.Equal(t, "/host/state", dir)
284+
}
285+
286+
func TestValidate_InvalidVolume(t *testing.T) {
287+
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", Volumes: []string{"/host:relative"}}
288+
err := c.Validate()
289+
assert.ErrorContains(t, err, "invalid volume")
290+
}

internal/config/default_config.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99
type = "aws" # Emulator type. Currently supported: "aws", "snowflake", "azure"
1010
tag = "latest" # Docker image tag, e.g. "latest", "2026.4"
1111
port = "4566" # Host port the emulator will be accessible on
12-
# volume = "" # Host directory for persistent state (default: OS cache dir)
12+
# volumes = [] # Bind mounts as "host:container[:ro]" ("volume" is an accepted alias).
13+
# # Use to mount init hooks (scripts the emulator runs at startup) or to
14+
# # set the persistence directory by mounting to /var/lib/localstack
15+
# # (default: OS cache dir). A bare host path sets the persistence dir.
16+
# # Examples:
17+
# # volumes = ["./init:/etc/localstack/init/ready.d:ro"]
18+
# # volumes = ["/my/data:/var/lib/localstack"]
19+
# # volume = "/my/data" # legacy form, same as the line above
1320
# env = [] # Named environment profiles to apply (see [env.*] sections below)
1421

1522
# Environment profiles let you group environment variables and reference

0 commit comments

Comments
 (0)