Skip to content

Remove the LocalStack CLI Requirement: Lifecycle via Docker API + REST - #53

Merged
HarshCasper merged 25 commits into
mainfrom
cli-free-lifecycle
Jul 24, 2026
Merged

Remove the LocalStack CLI Requirement: Lifecycle via Docker API + REST#53
HarshCasper merged 25 commits into
mainfrom
cli-free-lifecycle

Conversation

@HarshCasper

@HarshCasper HarshCasper commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

  • drop the requirement to install the localstack or lstk CLI: lifecycle, logs, extensions, and ephemeral instances now run through the Docker Engine API (dockerode) and LocalStack's REST APIs
  • start creates the localstack-main container directly with the same spec the CLI produced (pro image, ports 4566/443/4510–4560, docker socket + volume mounts, env forwarding); the default volume dir matches the CLI's per-OS path, so existing state carries over
  • restart reuses the running container's image, name, and volume instead of resetting to defaults, so externally started runtimes (lstk, docker-compose, custom names) keep their state; startup crashes are reported with the container's log tail
  • read logs via the Docker API (feeds localstack-logs-analysis and the IAM analyzer), and manage extensions by running the container's own extension manager over docker exec plus an in-place runtime restart — this also repairs extension venvs created by older images, a case the CLI fails on
  • call the LocalStack platform API directly for ephemeral instances and the extensions marketplace, replacing CLI output scraping with real JSON and status codes
  • slim the Docker image to a node base (~1.1 GB): no localstack CLI, docker CLI, or awscli; DooD state moves to the localstack-mcp named volume, retiring the identical-path XDG_CACHE_HOME mount (old configs that still set it keep their state)
  • new config knobs documented in README/server.json: LOCALSTACK_IMAGE_NAME, LOCALSTACK_VOLUME_DIR, GATEWAY_LISTEN, DOCKER_HOST; wizard drops the lifecycle-CLI prerequisite and deprecates --cache-dir; CI now runs the type-check and unit tests

… options)

Replicates the 2026.x localstack CLI start spec: pro image default, localstack-main
name, 4566/443/4510-4560 port enumeration with in-docker 0.0.0.0 bind, GATEWAY_LISTEN
host-stripping, structured Mounts, layered env forwarding, AI_AGENT tagging, and
per-OS CLI-identical volume dir resolution (named volume fallback for DooD).
ping (friendly daemon-unreachable errors), imageExists/pullImage (manual progress
stream consumption — followProgress misses in-stream errors and can throw uncaught),
createAndStartContainer (409 -> typed conflict error), attachLogBuffer (follow-stream
rolling tail that survives AutoRemove and doubles as exit signal), ordered
getContainerLogs (frame-walker over the multiplexed buffer), any-state name lookup,
removeContainer, waitForRemoval, ensureNetwork, and 304 tolerance in stopContainer.
- launchRuntime: pull-if-missing, any-state name-conflict handling (running ->
  actionable error, stopped -> auto-remove), attached log buffer for crash
  diagnostics + exit detection, readiness poll preserved (5s/120s)
- Snowflake health probe via node:http with explicit Host header (replaces curl
  shell-out; fetch/undici drops the Host header)
- getLocalStackStatus enriched from /_localstack/info instead of 'localstack status'
- restartRuntimeInPlace: POST /_localstack/health restart + session-transition polling
- management tool: restart recreates from the inspected container's image/name/volume
  (no provenance regression for externally-started runtimes); stop cleans up stale
  stopped containers; docker-daemon preflight on lifecycle actions
- delete checkLocalStackCli/detectLifecycleCli/tryCliStatus/startRuntime and the
  requireLocalStackCli preflight (vestigial on cloud-pods/iam/logs; extensions and
  ephemeral migrate off the CLI in follow-up commits)
log-retriever now finds the container (provenance-agnostic) and reads an ordered
log tail through dockerode instead of spawning 'localstack logs'. Feeds both
localstack-logs-analysis and the IAM analyzer's analyze-policies. Error strings
no longer reference the CLI.
list/install/uninstall now run the container's own extension manager module
(python -m localstack.pro.core.bootstrap.extensions) through docker exec with
DEBUG=0 and a 120s timeout, parsing its JSON-lines event protocol instead of
scraping CLI stdout. Activation uses POST /_localstack/health restart with
session-transition polling. Self-heals the extensions venv when its interpreter
symlinks were created by a different image (a case the official CLI fails on).
Marketplace fetch moves to the new PlatformApiClient. 'available' no longer
requires a running container. Validated live: install/uninstall round-trip of
localstack-extension-httpbin incl. restart; existing keycloak extension intact.
All four actions now call https://api.localstack.cloud/v1/compute/instances
directly (the same REST surface the CLI wrapped) with Basic token auth — real
JSON and HTTP status codes replace lenient stdout scraping, 401/404 map to
actionable messages. No CLI, container, or Docker daemon involved. Validated
live (read-only list).
The npx method needs only Node + Docker now. The Docker method's generated spec
loses the identical-path cache mount and XDG_CACHE_HOME (the server creates the
LocalStack container itself; state defaults to a named volume). --cache-dir is
accepted but ignored with a deprecation warning so existing scripts keep working.
Runtime base moves back to node:22-bookworm-slim (drops the python:3.12 pin the
localstack CLI forced and the NodeSource install). Removes pip localstack,
docker-ce-cli + its apt repo, and the unused awscli/awscli-local. IaC CLIs
(tflocal, samlocal, snow) live in a /opt/venv on PATH (PEP 668). The aws-cdk S3
patch resolves its path via npm root -g instead of the NodeSource-era literal.
Harness/docs/workflow drop the identical-path cache mount + XDG_CACHE_HOME —
the server creates the sibling container itself; state defaults to the
localstack-mcp named volume with a legacy XDG_CACHE_HOME continuity path.
…; CI runs tsc+jest

README drops the lifecycle-CLI prerequisite, documents LOCALSTACK_IMAGE_NAME /
LOCALSTACK_VOLUME_DIR / GATEWAY_LISTEN / DOCKER_HOST, and adds migration notes
(profiles/DOCKER_FLAGS/unprefixed-env forwarding no longer apply; 443 published
by default). server.json lists the new env vars. CLAUDE.md catalog/gotchas/§11
updated for the dockerode lifecycle. ci.yml gains type-check + unit-test steps
so the rewritten jest suites are actually enforced.
When an earlier iteration left the mode at SOFT_MODE, Gemini rationally skipped
set-mode after seeing get-status — a state-leak flake between iterations. The
scenario now asks for an explicit confirming set-mode call.
@HarshCasper HarshCasper changed the title Cli free lifecycle Remove the LocalStack CLI Requirement: Lifecycle via Docker API + REST Jul 4, 2026
@HarshCasper
HarshCasper force-pushed the cli-free-lifecycle branch from 1fcc301 to 317814e Compare July 4, 2026 21:50

@maximkasy maximkasy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff, and also built the branch and ran it live on Windows (Docker Desktop) — details at the bottom. Overall: strong PR, right architecture. Going CLI-free removes an entire class of environment bugs — including one we just hit where the official PyInstaller-frozen Windows CLI ignores PYTHONIOENCODING and charmap-crashes on piped emoji output; with this PR nothing Python is ever spawned, so that whole category is gone. I'd merge this after the items below.

Would fix before merge

1. Restart confirmation can report success prematurely / wait unnecessarilyrestartRuntimeInPlace (localstack.utils.ts ~596–608): when the initial getSessionInfo() returns null, the !before fallback treats missing session info as proof the restart completed. Conversely, a fast restart where the gateway never blips and session_id/uptime don't visibly change waits the full 120s despite having succeeded. Suggest requiring an explicit signal (gateway downtime OR session change) and treating before == null as "unknown", plus validating the restart POST response, rather than defaulting to success.

2. pip install specifiers are unvalidatedlocalstack-extensions.ts ~176: tool-provided name/source go straight to pip install inside the container. The exec-array kills shell injection, but pip itself accepts git+https://… URLs, local paths, and option syntax — and this container has the docker socket mounted, so container compromise ≈ host compromise. Tool args come from an LLM, so treating them as untrusted matters here. A conservative allowlist regex for package names and restricting source to known git hosts would cover it:

const EXTENSION_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9,._-]+\])?(==[A-Za-z0-9.*+!-]+)?$/;
if (name && !EXTENSION_NAME_RE.test(name)) {
  return ResponseBuilder.error("Invalid extension name", `"${name}" is not a valid extension package name.`);
}

3. Snowflake Pro gate skipped on startlocalstack-management.ts ~73–76: the license check is deferred to onReady, so an unlicensed user pulls the full image and starts the container before hearing "no". Moving requireProFeature(ProFeature.SNOWFLAKE) into the preflight for start too fails fast.

4. status service=snowflake probes the Snowflake endpoint even when the AWS stack is runninglocalstack-management.ts ~287–319: reports "Snowflake health check did not pass", which reads as a Snowflake failure rather than "a different stack is running". recreateOverrides() already detects the stack from the container image — reusing that here would give an accurate message.

Should fix (small, non-blocking)

5. DOCKER_SOCK handlingcontainer-spec.logic.ts ~381: the value isn't validated or normalizeBindPath()-ed, and when a bad path makes container creation fail, describeDockerConnectivityError matches docker.sock in the message and reports "Docker daemon is not reachable" — misleading, since the daemon is fine and the socket mount source is what's wrong. Validating existence when DOCKER_SOCK is explicitly set + a specific error message would fix the last of the "opaque errors" class. (For the record: the default works fine on Docker Desktop — verified live on Windows — the gap is rootless/custom-socket Linux setups.)

6. Venv-repair result ignoredextensions.logic.ts ~28 + localstack-extensions.ts ~106: the repair script hardcodes /opt/code/localstack/.venv/bin/python and its exec result (exit code/stderr) is discarded. On a custom image with a different layout it fails silently and the user gets an unrelated extension-manager error. Checking the exit code and surfacing it in the failure detail is enough.

7. Pre-start stale-container cleanup can block up to 120slocalstack.utils.ts ~418–424: removeContainer + waitForRemoval each default to 60s; if the daemon hangs, start blocks 2 minutes before erroring. Short fail-fast timeouts (~10s) for this pre-start path would be kinder.

Tests (both fail on real Windows machines today; CI hides it because jest runs on Linux only)

8. launchRuntime test doesn't isolate MAIN_CONTAINER_NAME — any machine with that env var set user-wide (i.e. anyone who used the old workaround this project is fixing!) fails localstack.utils.test.ts ("expected localstack-main, received localstack-aws"). Reproduced here. Fix is the same env cleanup docker.client.test.ts already does in beforeEach:

delete process.env.MAIN_CONTAINER_NAME;
delete process.env.LOCALSTACK_MAIN_CONTAINER_NAME;

9. antigravityConfigPath uses bare path.joinpaths.logic.ts:27: every other resolver in this file uses the pathFor(ctx) platform-flavor helper so the injected-platform tests pass on any host OS; this one (from #51) uses the host's path and fails on Windows. One-liner since this PR already touches the file:

export function antigravityConfigPath(ctx: ClientContext): string {
  return pathFor(ctx).join(ctx.homeDir, ".gemini", "config", "mcp_config.json");
}

Checked and fine (so nobody re-raises these)

  • DOCKER_HOST env handling: docker-modem reads it (incl. npipe) — the dockerode client doesn't need explicit options.
  • Auth token forwarded into the container: by design; the runtime needs it and the CLI does the same.
  • Token leakage via HTTP errors: HttpError never captures request headers; analytics redacts sensitive keys.
  • Image-pull vs readiness-poll timeouts: sequential, not shared — pull can't starve the poll.
  • Transient Dead container record after stop: races the AutoRemove, self-clears in seconds — cosmetic.

Live verification (Windows 11, Docker Desktop, real pro token)

  • Full lifecycle, zero CLI: start → healthy localstack-main with correct spec (pro image; 4566/443/4510–4560 all bound to 127.0.0.1; docker.sock + volume mounts; env parity incl. GATEWAY_LISTEN, EXTERNAL_SERVICE_PORTS_*) → aws-client s3 mb (in-container awslocal) → restart (preserved image/name) → logs via Docker API → stop with full removal → start again cleanly.
  • Volume-path parity confirmed empirically: the spec's Windows default (%LOCALAPPDATA%\cache\localstack\volume) is byte-identical to the directory the real pip CLI created on this machine — existing state genuinely carries over.
  • Externally-managed container accepted: raw docker run with a custom name (my-custom-ls) — status/aws-client/logs/stop all worked against it via the image+port fallback, and stop removed it cleanly.
  • tsc clean, build clean (14 tools), 191/193 tests (the 2 failures are items 8–9 above).

Nice work — the restart-preserving-overrides design and the container-spec parity are genuinely careful. 🚢

antigravityConfigPath now uses the platform-flavored pathFor helper like every
other resolver (bare path.join broke the injected-win32 test on Windows hosts),
and the launchRuntime suite isolates itself from user-wide container/image/
gateway env vars and uses a named test volume so no host directory is created.
A missing /_localstack/info baseline no longer counts as proof the restart
happened — success now needs observed gateway downtime or a session change
(session_id / uptime reset). When the baseline is unknown, the first info read
after the POST becomes the comparison point so a later change still confirms.
Unconfirmed restarts report honestly instead of claiming success.
Tool-provided name/source arguments run pip inside a container with the docker
socket mounted, so they are treated as untrusted: names must match a PyPI-style
specifier (extras + == pin allowed), and sources are limited to git+https URLs
on github.com/gitlab.com/bitbucket.org. Option syntax, path installs, and other
URL schemes are rejected with a clear message.
… AWS stack

status with service snowflake now inspects the running container's image: when
it is the AWS stack, the response says so (and how to switch) instead of
misreporting a Snowflake health-check failure. The SNOWFLAKE pro-feature
preflight only runs when the running container actually is the Snowflake stack —
checking the AWS container's licenseinfo produced a misleading 'license does
not include snowflake' error. Stack classification moves into a shared
stackFromImage helper (also used by restart's recreate-overrides).
An explicitly set DOCKER_SOCK that doesn't exist on the host is rejected before
container creation with a message naming the actual problem; the socket mount
source is path-normalized like other binds. describeDockerConnectivityError no
longer claims 'daemon not reachable' for daemon API errors that merely mention
a socket path (bad mount source) — only transport-level failures qualify. The
pre-start stale-container cleanup uses 10s timeouts instead of blocking a start
for up to two minutes on a hung daemon.
The repair script now ends with an explicit usability probe (interpreter runs +
pip present) so its exit code reflects whether the venv is actually usable, and
the tool checks that exit code — on a custom image where the hardcoded manager
interpreter path doesn't exist, the user now sees the real cause rather than an
unrelated extension-manager error.
…onfig

- H1: the container entrypoint re-exports LOCALSTACK_X -> X, so a host-set
  LOCALSTACK_GATEWAY_LISTEN / _MAIN_CONTAINER_NAME / _EXTERNAL_SERVICE_PORTS_* /
  _DOCKER_HOST (and the LOCALSTACK_ alias of any unprefixed envVars key) would
  override the value the builder computed. Strip those aliases so our value wins.
- parseGatewayListen: bracket-aware IPv6 parsing (lastIndexOf split the address),
  default gateway port for host-only IPv6, reject all-empty values.
- validate LOCALSTACK_PORT and EXTERNAL_SERVICE_PORTS bounds (1-65535, start<=end,
  span cap) instead of emitting bindings the daemon rejects with opaque errors.
- dedupe identical port bindings and skip service ports a GATEWAY_LISTEN entry
  already claims (CLI deduped; duplicates fail container start); bind the whole
  published range to the GATEWAY_LISTEN host (CLI parity).
- in-docker relative XDG_CACHE_HOME falls back to the named volume; collapse a
  trailing backslash in LOCALAPPDATA; drop the dead AUTH_TOKEN carve-out.
- pullImage: scan the final buffered line at stream end so a trailing {"error"}
  without a newline rejects (was swallowed, then surfaced as a confusing 'no such
  image' at create); destroy the pull stream when we give up (timeout).
- attachLogBuffer: a follow-stream error (e.g. TCP reset on tcp:// DOCKER_HOST) now
  marks the buffer degraded instead of firing a false 'container exited' signal;
  the initial attach is timeout-wrapped.
- decodeDockerLogBuffer: require header bytes 1-3 == 0 before treating the payload
  as multiplexed, so raw TTY output starting with a low control byte isn't
  mis-framed (first 8 bytes eaten).
- imageExists/createAndStartContainer/ensureNetwork: timeout-wrapped so a wedged
  daemon surfaces in seconds rather than hanging the start.
…ation

- getSnowflakeEmulatorStatus: add res 'aborted'/'error' + req 'close' handlers and a
  hard wall-clock deadline so a socket death mid-response rejects instead of hanging
  the probe (and the snowflake start that awaits it) forever.
- launchRuntime: an independent deadline timer now fires regardless of an in-flight
  getStatus(), so a hung status probe can't prevent the start from settling; polls
  self-schedule (no overlap) and finishIfReady/onExit are guarded by 'resolved' so
  onReady can't re-fire after resolution; the whole body is wrapped so a spec-builder
  or initial-status throw returns a ❌ result, not a raw protocol error.
- restartRuntimeInPlace: require sustained downtime (>=2 consecutive unreachable) or a
  real session change — a single health flap no longer latches a false 'restarted'.
- extension activation: when the in-place restart doesn't confirm (it can leave the
  runtime down under heavy Lambda load), fall back to recreateRunningContainer — a
  reliable stop+recreate preserving image/name/volume — instead of returning a silent
  success over a dead stack. recreateOverrides moved to a shared, tested helper.
- validateExtensionTarget: treat an empty name as absent (a source-only install is
  no longer falsely rejected); reject '..' path segments in the git source fragment
  so a pip subdirectory= can't point the build root outside the clone.
- add regression tests: host-confusion sources (suffix/userinfo tricks), the
  subdirectory traversal, empty-name-with-source, and a linear-time guard.
- localstack-snowflake-client now runs requireAuthToken() first, matching the
  all-tools auth contract every other tool follows.
…RNAL_SERVICE_PORTS

Unit-test the management handlers the migration added logic to: the snowflake
status guard now proven to report 'is the AWS stack' (not a Snowflake health
failure) when the AWS stack is running, the stale stopped-container cleanup on
stop, and the auth-token gate. Document the DOCKER_SOCK and
EXTERNAL_SERVICE_PORTS_START/END env vars (read by the code but previously
undocumented) in the README config table and server.json.
xmcp registers every src/tools/*.ts as a tool, so a colocated *.test.ts was
bundled into dist/stdio.js and crashed the server at runtime with 'jest is not
defined'. Relocate to src/tools-tests/ (a sibling xmcp doesn't scan, still under
src/** for tsc); the jest.mock specifiers resolve to the same modules.
@skyrpex

skyrpex commented Jul 13, 2026

Copy link
Copy Markdown

I'm totally onboard to making tools (the MCP in this case) as lightweight as possible. The LocalStack CLI is meant to help users managing LocalStack instances, but the underlying technology is quite simple: create a docker container, some status checks, etc. There's no reason to depend on an external CLI (external in the sense that isn't bundled in the MCP itself), or increasing the bundle size by megabytes just for reusing logic that's quite simple to maintain. The complexity turns out to be very high otherwise (something we learnt when building the LocalStack Toolkit 😄).

@maximkasy maximkasy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-validated the branch after the review-fix commits — everything checks out. Approving.

What I ran (Windows 11, Docker Desktop, real pro token, head 69e8649):

  • tsc clean, 230/230 tests across 23 suites — both Windows-host test failures from my review are fixed, and the suites now isolate themselves from user-wide env vars (MAIN_CONTAINER_NAME etc.), so they pass on machines that still carry the old workaround.
  • Full live lifecycle with zero CLI: startaws-clientstart again (clean already-running short-circuit) → restart of a 21-hour-old container (came back healthy, image/name preserved) → stop with full removal, no leftovers.
  • Probed two of the fixes live:
    • status service=snowflake while the AWS stack runs now says exactly what's going on ("the running container is the AWS stack — the Snowflake emulator is not running") instead of a misleading health-check failure.
    • extensions install with git+https://evil.example.com/pkg.git is rejected with a clear Invalid Extension Target error before anything reaches pip.
  • Code-read the rest: the restart logic now requires an explicit signal (unreachable streak or session change — the !before false-success path is gone), DOCKER_SOCK is validated with a precise error, venv-repair failures surface, pre-start housekeeping fails fast, and the container-spec builder is hardened against env aliasing.
  • CI: build / mcp-direct-tests / smoke all green.

On the Snowflake Pro gate: your pushback was right — there's no gateway to ask before a container exists, so gating start at preflight can't work; scoping the check to the actually-running stack is the better design.

Nice work turning the review around this thoroughly. 🚢

@HarshCasper
HarshCasper merged commit 947809c into main Jul 24, 2026
4 checks passed
@HarshCasper
HarshCasper deleted the cli-free-lifecycle branch July 24, 2026 06:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants