Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Created automatically on first run with defaults. Supports emulator types: `aws`

Only one `[[containers]]` block may be enabled at a time. `container.Start` rejects a config with more than one block up front (before health/auth checks and image pulls), since running multiple emulators together (e.g. AWS + Snowflake) is unsupported and would otherwise fail later during startup with container-name conflicts or port collisions. The guard lives on the start path (not `config.Get()`) on purpose: recovery/reporting commands like `stop`, `status`, and `logout` must still enumerate multiple running emulators.

Each `[[containers]]` block may set an optional `container_name` (override the derived container name; also what the emulator reports as `MAIN_CONTAINER_NAME`), an optional `image` (override the default Docker Hub image), and a `volumes` list of Docker-style bind specs (persistence dir, init hooks, arbitrary mounts). Container-name derivation and its deliberate decoupling from the default persistence directory, image/tag precedence, `volume` vs `volumes` semantics, and path-resolution rules are documented in `internal/config/CLAUDE.md`.
Each `[[containers]]` block may set an optional `container_name` (override the derived container name; also what the emulator reports as `MAIN_CONTAINER_NAME`), an optional `image` (override the default Docker Hub image), a `volumes` list of Docker-style bind specs (persistence dir, init hooks, arbitrary mounts), and an `expose_ports` list publishing container ports the gateway/service ranges don't cover (e.g. `expose_ports = [53]` for the emulator's DNS server). Container-name derivation and its deliberate decoupling from the default persistence directory, image/tag precedence, `volume` vs `volumes` semantics, path-resolution rules, and the `expose_ports` grammar are documented in `internal/config/CLAUDE.md`.

## Selecting the emulator (`--type`)

Expand Down
13 changes: 13 additions & 0 deletions internal/config/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ Validation is `validate.ContainerName` (`internal/validate`), called from `Conta

Each `[[containers]]` block may set an optional `image` to override the default Docker Hub image (e.g. an internal registry mirror or a locally loaded offline image). `ContainerConfig.Image()` returns `image` as-is when it already carries a tag (so the separately-configured `tag` is dropped in that case), otherwise it appends `tag` (or `latest`); the default `localstack/<product>:<tag>` is used when `image` is unset.

## Exposing additional ports (`expose_ports`)

Each `[[containers]]` block may set `expose_ports` to publish container ports beyond the ones lstk publishes on its own (the edge port, the extra `GATEWAY_LISTEN` ports, the 4510-4559 service range). The motivating case (DEVX-994) is the emulator's DNS server: `expose_ports = [53]` replaces the v1 CLI's `--host-dns` flag. There is deliberately no CLI flag — the ticket asked for a config setting, and every consumer of the value is the start path.

Grammar per entry, parsed by `parseExposePort`/`ExposedPorts` (`internal/config/containers.go`):

- Values may be TOML **integers or strings** in the same list (`expose_ports = [53, "5354:5353/udp"]`). Ints work because viper's decoder sets `WeaklyTypedInput`, so the `[]string` field accepts numbers; the mixed form is pinned by `TestGet_ExposePortsAcceptsIntsAndStrings`.
- A string is `[host:]container[/proto]`; a bare port publishes host-port == container-port. Ports are canonicalized through `strconv` (`"0053"` → `"53"`), so two spellings of the same port collide as expected.
- **An entry that names no protocol expands to both tcp and udp.** DNS serves queries over both, so `[53]` alone has to be enough; publishing a protocol nothing listens on costs nothing. An explicit `/tcp` or `/udp` publishes only that protocol.
- Two entries that would make Docker key the same binding twice — same container port + protocol with different host ports, or the same host port + protocol for different container ports — are a **validation error** (`Validate()` calls `ExposedPorts()`), because Docker would silently keep only one. Exact duplicates are de-duplicated instead.

Consumption is `mergeExposePorts` (`internal/container/expose.go`), appending `runtime.PortMapping`s with `Optional: false` — an explicitly requested port is a demand, so a busy or unbindable host port fails the start (same rule as a user-supplied `GATEWAY_LISTEN`). Entries clashing with a port lstk already publishes are skipped: silently when they ask for exactly what lstk already does, with a warning when the automatic mapping wins over what the user wrote. See `internal/container/CLAUDE.md` for the preflight's UDP exclusion.

## 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.
Expand Down
114 changes: 114 additions & 0 deletions internal/config/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ type ContainerConfig struct {
// arbitrary mounts (e.g. Snowflake init hooks) and may also contain the persistence
// mount (the entry targeting /var/lib/localstack).
Volumes []string `mapstructure:"volumes"`
// ExposePorts publishes additional container ports on the host, beyond the gateway
// and service ports lstk publishes by default — e.g. port 53 so the emulator's DNS
// server can be used as the host's resolver. Each entry is a bare port number
// (published on the same host port) or a Docker-style "[host:]container[/proto]"
// string. See ExposedPorts for the protocol rules.
ExposePorts []string `mapstructure:"expose_ports"`
// Env is a list of named environment references defined in the top-level [env.*] config sections.
Env []string `mapstructure:"env"`
// Snapshot is an optional snapshot REF (e.g. "pod:my-baseline" or a local path)
Expand Down Expand Up @@ -307,6 +313,111 @@ func (c *ContainerConfig) VolumeDir() (string, error) {
return filepath.Join(cacheDir, "lstk", "volume", c.defaultName()), nil
}

// PortSpec is one parsed expose_ports publication: a host port bound to a container
// port for a single protocol.
type PortSpec struct {
HostPort string
ContainerPort string
Protocol string // "tcp" or "udp"
}

func (p PortSpec) String() string {
return p.HostPort + ":" + p.ContainerPort + "/" + p.Protocol
}

// ExposedPorts returns the publications requested via expose_ports, in config order
// and de-duplicated. An entry that names no protocol expands to both tcp and udp:
// the motivating case is the emulator's DNS server (port 53), which serves both, and
// publishing a protocol nothing listens on is harmless.
func (c *ContainerConfig) ExposedPorts() ([]PortSpec, error) {
var specs []PortSpec
seen := map[PortSpec]bool{}
hostForContainer := map[string]string{}
containerForHost := map[string]string{}
for _, entry := range c.ExposePorts {
parsed, err := parseExposePort(entry)
if err != nil {
return nil, err
}
for _, s := range parsed {
if seen[s] {
continue
}
// Docker keys published ports by container port and by host port, so either
// kind of clash means one of the two entries would be silently discarded.
containerKey := s.ContainerPort + "/" + s.Protocol
if host, ok := hostForContainer[containerKey]; ok {
return nil, fmt.Errorf("invalid expose_ports: container port %s is published on both host port %s and %s", containerKey, host, s.HostPort)
}
hostKey := s.HostPort + "/" + s.Protocol
if container, ok := containerForHost[hostKey]; ok {
return nil, fmt.Errorf("invalid expose_ports: host port %s is claimed by both container port %s and %s", hostKey, container, s.ContainerPort)
}
seen[s] = true
hostForContainer[containerKey] = s.HostPort
containerForHost[hostKey] = s.ContainerPort
specs = append(specs, s)
}
}
return specs, nil
}

// parseExposePort parses a single expose_ports entry of the form
// "[host:]container[/proto]"; a bare "53" publishes container port 53 on host port
// 53. One PortSpec is returned per protocol, so an entry without a protocol yields
// two (tcp and udp).
func parseExposePort(entry string) ([]PortSpec, error) {
spec := strings.TrimSpace(entry)
if spec == "" {
return nil, errors.New("invalid expose_ports entry: entry is empty")
}

portPart, proto, hasProto := strings.Cut(spec, "/")
protocols := []string{"tcp", "udp"}
if hasProto {
proto = strings.ToLower(strings.TrimSpace(proto))
if proto != "tcp" && proto != "udp" {
return nil, fmt.Errorf("invalid expose_ports entry %q: protocol must be \"tcp\" or \"udp\"", entry)
}
protocols = []string{proto}
}

hostPort, containerPort, hasHost := strings.Cut(portPart, ":")
if !hasHost {
containerPort = hostPort
}
if strings.Contains(containerPort, ":") {
return nil, fmt.Errorf("invalid expose_ports entry %q: expected \"port\", \"host:container\" or \"host:container/proto\"", entry)
}
hostPort, err := parsePortNumber(entry, hostPort)
if err != nil {
return nil, err
}
containerPort, err = parsePortNumber(entry, containerPort)
if err != nil {
return nil, err
}

specs := make([]PortSpec, 0, len(protocols))
for _, p := range protocols {
specs = append(specs, PortSpec{HostPort: hostPort, ContainerPort: containerPort, Protocol: p})
}
return specs, nil
}

// parsePortNumber validates a port from an expose_ports entry and returns it in
// canonical form (so "0053" and "53" produce the same mapping key).
func parsePortNumber(entry, value string) (string, error) {
port, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return "", fmt.Errorf("invalid expose_ports entry %q: %q is not a valid port number", entry, value)
}
if port < 1 || port > 65535 {
return "", fmt.Errorf("invalid expose_ports entry %q: port %d is out of range (must be 1–65535)", entry, port)
}
return strconv.Itoa(port), nil
}

// TagSuggestion returns an actionable hint naming a recent calendar tag and
// "latest", for messages about tags the license server cannot parse.
func TagSuggestion() string {
Expand Down Expand Up @@ -366,6 +477,9 @@ func (c *ContainerConfig) Validate() error {
if port < 1 || port > 65535 {
return fmt.Errorf("port %d is out of range (must be 1–65535)", port)
}
if _, err := c.ExposedPorts(); err != nil {
return err
}
return c.validateVolumes()
}

Expand Down
110 changes: 110 additions & 0 deletions internal/config/expose_ports_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package config

import (
"os"
"path/filepath"
"testing"

"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestExposedPorts_BarePortCoversBothProtocols(t *testing.T) {
// A bare port expands to tcp and udp so `expose_ports = [53]` is enough for the
// emulator's DNS server, which serves queries over both.
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", ExposePorts: []string{"53"}}

specs, err := c.ExposedPorts()
require.NoError(t, err)
assert.Equal(t, []PortSpec{
{HostPort: "53", ContainerPort: "53", Protocol: "tcp"},
{HostPort: "53", ContainerPort: "53", Protocol: "udp"},
}, specs)
}

func TestExposedPorts_Forms(t *testing.T) {
tests := []struct {
entry string
want []PortSpec
}{
{"53/udp", []PortSpec{{HostPort: "53", ContainerPort: "53", Protocol: "udp"}}},
{"5354:53/udp", []PortSpec{{HostPort: "5354", ContainerPort: "53", Protocol: "udp"}}},
{"53/UDP", []PortSpec{{HostPort: "53", ContainerPort: "53", Protocol: "udp"}}},
{"9000:9000/tcp", []PortSpec{{HostPort: "9000", ContainerPort: "9000", Protocol: "tcp"}}},
{" 25 ", []PortSpec{
{HostPort: "25", ContainerPort: "25", Protocol: "tcp"},
{HostPort: "25", ContainerPort: "25", Protocol: "udp"},
}},
{"0053", []PortSpec{
{HostPort: "53", ContainerPort: "53", Protocol: "tcp"},
{HostPort: "53", ContainerPort: "53", Protocol: "udp"},
}},
}
for _, tt := range tests {
t.Run(tt.entry, func(t *testing.T) {
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", ExposePorts: []string{tt.entry}}
specs, err := c.ExposedPorts()
require.NoError(t, err)
assert.Equal(t, tt.want, specs)
})
}
}

func TestExposedPorts_DeduplicatesIdenticalPublications(t *testing.T) {
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", ExposePorts: []string{"53", "53/udp", "53/tcp"}}

specs, err := c.ExposedPorts()
require.NoError(t, err)
assert.Len(t, specs, 2)
}

func TestExposedPorts_InvalidEntries(t *testing.T) {
tests := []struct {
name string
entries []string
wantErr string
}{
{"empty", []string{""}, "entry is empty"},
{"non-numeric", []string{"dns"}, "not a valid port number"},
{"non-numeric host", []string{"dns:53"}, "not a valid port number"},
{"zero", []string{"0"}, "out of range"},
{"too high", []string{"65536"}, "out of range"},
{"unknown protocol", []string{"53/sctp"}, `protocol must be "tcp" or "udp"`},
{"too many colons", []string{"1:2:3"}, "expected"},
{"same container port, two host ports", []string{"53/udp", "5354:53/udp"}, "container port 53/udp is published on both"},
{"same host port, two container ports", []string{"1053:53/udp", "1053:54/udp"}, "host port 1053/udp is claimed by both"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &ContainerConfig{Type: EmulatorAWS, Port: "4566", ExposePorts: tt.entries}
_, err := c.ExposedPorts()
assert.ErrorContains(t, err, tt.wantErr)
// A bad entry must fail at config load, not at container creation.
assert.ErrorContains(t, c.Validate(), tt.wantErr)
})
}
}

// TestGet_ExposePortsAcceptsIntsAndStrings pins the TOML surface promised in the
// docs: numbers and strings may be mixed in the same list.
func TestGet_ExposePortsAcceptsIntsAndStrings(t *testing.T) {
// Cannot run in parallel: mutates process-wide viper state.
configFile := filepath.Join(t.TempDir(), configFileName)
require.NoError(t, os.WriteFile(configFile, []byte(`
[[containers]]
type = "aws"
port = "4566"
expose_ports = [53, "5354:5353/udp", 9000]
`), 0600))

viper.Reset()
t.Cleanup(viper.Reset)
viper.SetConfigFile(configFile)
require.NoError(t, viper.ReadInConfig())

cfg, err := Get()
require.NoError(t, err)
require.Len(t, cfg.Containers, 1)
assert.Equal(t, []string{"53", "5354:5353/udp", "9000"}, cfg.Containers[0].ExposePorts)
}
9 changes: 9 additions & 0 deletions internal/container/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ Detail moved out of the root CLAUDE.md.
- **Leftover self-heal, label-guarded**: every container lstk creates is stamped with the label `cloud.localstack.lstk=true` (`internal/runtime/docker.go`). When the name lstk wants is held by an existing non-running container, `healLeftoverContainer` removes it only when it is positively ours or positively on its way out: (a) labeled + `created` state = a create-succeeded-start-failed leftover — removed with a warning; (b) labeled + any other state, or unlabeled-but-AutoRemove and not `created` = a container mid-self-removal — removed/waited out **silently**, because `lstk restart` races the AutoRemove of the container it just stopped on every single run (this raced CI's restart tests when the guard was created-state-only), and the unlabeled variant covers containers from pre-label lstk versions. A foreign parked container (unlabeled, no AutoRemove, or unlabeled in `created` state) is never removed; the user gets a clear "name is already taken (image X), not created by lstk" error instead of the daemon's raw name-conflict. If removal fails, the error names the container and the manual `docker rm -f` command.
- **Bind-denied optional ports retry at start time**: the preflight *dials*, so it only sees ports someone is listening on — it cannot see that the daemon lacks permission to bind a port (e.g. 443 under Rancher Desktop without Administrative Access, or rootless Podman machine; daemon rejects at `ContainerStart` with `listen tcp …:443: bind: permission denied`). `startWithOptionalPortFallback` wraps `rt.Start`: when the error names an *optional* port's bind (`failedOptionalPortBind`), it warns (same drop warning, `portBindDenied` cause), removes the created-but-never-started container (AutoRemove only fires on exit, so the name would otherwise collide), and retries without that mapping. Non-bind errors and required-port binds pass through unchanged.

## User-requested ports (`expose_ports`)

`mergeExposePorts` (`internal/container/expose.go`) appends the config's `expose_ports` publications to the mappings derived above. Grammar and validation live in `internal/config/CLAUDE.md`; the start-path rules are:

- Mappings are **never `Optional`** — unlike the 443 lstk adds on its own, these were asked for, so a busy or unbindable host port fails the start rather than degrading silently.
- **UDP is excluded from the port preflight** (`requiredHostPorts`): `ports.CheckAvailable` dials TCP, so checking a UDP publication that way would report a phantom conflict whenever an unrelated TCP listener holds the same number. A real UDP conflict surfaces at `ContainerStart`.
- A required port whose bind the daemon refuses cannot be dropped, so `annotateRequiredPortBindError` appends the runtime-specific remedy (`tailoredPortDropHint`) to the daemon error instead. This is the common path for the sub-1024 ports `expose_ports` makes reachable: 53 under rootless Podman or Rancher Desktop without Administrative Access fails exactly like 443 does.
- Ports lstk already publishes cannot be re-declared (Docker keys bindings by container port *and* by host port, so one of the two declarations would vanish). Redundant entries are skipped silently; an entry the automatic mapping overrides is skipped with a warning.

## Offline / Enterprise degradation

There is no `--offline` flag. Instead `container.Start` degrades gracefully when internet requests fail (the common enterprise blockers: Docker Hub unreachable, proxy/TLS interception, license server unreachable):
Expand Down
Loading