Skip to content
Open
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
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ 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.

`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

Use `lstk setup <emulator>` to set up CLI integration for an emulator type:
Expand Down
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,15 @@ 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 = [] # Bind mounts, "host:container[:ro]" (see below)
# env = [] # Named environment profiles to apply (see [env.*] sections below)
```

**Fields:**
- `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 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
Expand All @@ -166,6 +166,31 @@ 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).

#### Persistent state

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
volumes = ["/data:/var/lib/localstack"]
```

> 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

`lstk` uses the TUI in an interactive terminal and plain output elsewhere. Use `--non-interactive` to force plain output even in a TTY:
Expand Down
196 changes: 189 additions & 7 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -112,18 +114,169 @@ 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/<container-name>.
// 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.
//
// 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) {
source, target, opts, err := splitVolumeSpec(spec, runtime.GOOS == "windows")
if err != nil {
return VolumeMount{}, fmt.Errorf("invalid volume %q: %w", spec, err)
}
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)
}
// 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)
}

resolved, err := resolveHostPath(source, configDir)
if err != nil {
return VolumeMount{}, fmt.Errorf("invalid volume %q: %w", spec, err)
}

var readOnly bool
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(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)
}
hostPath = filepath.Join(home, strings.TrimPrefix(hostPath, "~"))
}
if filepath.IsAbs(hostPath) {
return hostPath, 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 {
cfgPath, err := ConfigFilePath()
if err != nil || cfgPath == "" {
return "."
}
return filepath.Dir(cfgPath)
}

// 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/<container-name>.
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
}
Expand Down Expand Up @@ -182,6 +335,35 @@ 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 {
mounts, err := c.parsedVolumes()
if err != nil {
return err
}
var persistenceSource string
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, configDirForRelativePaths())
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
}

Expand Down
Loading
Loading