diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3ab7f5..cc5c644 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,12 @@ jobs: - name: Install dependencies run: yarn + - name: Type-check + run: npx tsc --noEmit + + - name: Unit tests + run: yarn test + - name: Build the project run: yarn build diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index fff2d3f..339607c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -60,12 +60,9 @@ jobs: HARNESS_SKIP: cloudpods,ephemeral,replicator,chaos run: | npm ci --prefix data/sample-cdk - mkdir -p "$HOME/.localstack-mcp" node tests/docker/validate-image.mjs -- \ docker run -i --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ - -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \ - -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \ --add-host host.docker.internal:host-gateway \ --add-host s3.host.docker.internal:host-gateway \ --add-host snowflake.localhost.localstack.cloud:host-gateway \ diff --git a/Dockerfile b/Dockerfile index 0037552..d4f8a0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,40 +8,40 @@ RUN yarn install --frozen-lockfile COPY . . RUN yarn build -FROM python:3.12-slim-bookworm AS runtime +# The server drives LocalStack through the Docker Engine API (dockerode) and REST — +# no localstack CLI, docker CLI, or awscli needed in the image. Python remains only +# for the IaC/Snowflake CLIs (tflocal, samlocal, snow) used by the deployer and +# snowflake-client tools. +FROM node:22-bookworm-slim AS runtime ENV DEBIAN_FRONTEND=noninteractive \ - PYTHONDONTWRITEBYTECODE=1 + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/opt/venv/bin:${PATH}" RUN set -eux; \ apt-get update; \ - apt-get install -y --no-install-recommends ca-certificates curl gnupg unzip git; \ + apt-get install -y --no-install-recommends ca-certificates curl gnupg unzip git \ + python3 python3-pip python3-venv; \ install -m 0755 -d /usr/share/keyrings; \ - curl -fsSL https://deb.nodesource.com/setup_22.x | bash -; \ curl -fsSL https://apt.releases.hashicorp.com/gpg \ | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg; \ chmod a+r /usr/share/keyrings/hashicorp-archive-keyring.gpg; \ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com bookworm main" \ > /etc/apt/sources.list.d/hashicorp.list; \ - curl -fsSL https://download.docker.com/linux/debian/gpg \ - | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg; \ - chmod a+r /usr/share/keyrings/docker-archive-keyring.gpg; \ - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" \ - > /etc/apt/sources.list.d/docker.list; \ apt-get update; \ - apt-get install -y --no-install-recommends nodejs terraform docker-ce-cli; \ + apt-get install -y --no-install-recommends terraform; \ apt-get clean; \ rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir --upgrade pip \ +# Debian's python carries the PEP 668 externally-managed marker — install the IaC +# CLIs into a dedicated venv (its bin dir is on PATH for runCommand spawns). +RUN python3 -m venv /opt/venv \ + && pip install --no-cache-dir --upgrade pip \ && pip install --no-cache-dir --no-compile \ - localstack \ - awscli \ - awscli-local \ terraform-local \ aws-sam-cli \ aws-sam-cli-local \ snowflake-cli \ - && find /usr/local/lib/python3.12/site-packages \ + && find /opt/venv/lib \ \( -type d \( -name __pycache__ -o -name tests -o -name test \) -o -type f \( -name '*.pyc' -o -name '*.pyo' \) \) \ -prune -exec rm -rf '{}' + @@ -50,7 +50,10 @@ RUN npm install -g aws-cdk@2.1114.0 aws-cdk-local \ RUN node <<'NODE' const fs = require("fs"); -const file = "/usr/lib/node_modules/aws-cdk/lib/index.js"; +const path = require("path"); +const { execSync } = require("child_process"); +const globalRoot = execSync("npm root -g").toString().trim(); +const file = path.join(globalRoot, "aws-cdk/lib/index.js"); const source = fs.readFileSync(file, "utf8"); const target = ` s3() {\n const client = new import_client_s33.S3Client(this.config);`; const replacement = ` s3() {\n if (/^(1|true|yes)$/i.test(process.env.AWS_S3_FORCE_PATH_STYLE || "")) {\n this.config.forcePathStyle = true;\n }\n const client = new import_client_s33.S3Client(this.config);`; @@ -64,7 +67,7 @@ if (!source.includes(replacement)) { NODE WORKDIR /app -RUN mkdir -p /usr/lib/localstack /tmp/dockerode-deps \ +RUN mkdir -p /tmp/dockerode-deps \ && npm install --prefix /tmp/dockerode-deps --omit=dev --ignore-scripts --no-audit --no-fund dockerode@4.0.7 \ && mkdir -p /app/node_modules \ && cp -R /tmp/dockerode-deps/node_modules/. /app/node_modules/ \ @@ -74,20 +77,16 @@ COPY --from=builder /app/dist ./dist COPY --from=builder /app/package.json ./package.json RUN set -eux; \ - localstack --version; \ - aws --version; \ - awslocal --version; \ terraform version; \ tflocal --version; \ sam --version; \ command -v samlocal; \ cdklocal --version; \ snow --version; \ - docker --version; \ node -e "require('dockerode'); console.log('dockerode ok')" LABEL org.opencontainers.image.title="LocalStack MCP Server" \ - org.opencontainers.image.description="Self-contained MCP server for managing LocalStack (CLI, CDK, Terraform, SAM, awslocal baked in)" \ + org.opencontainers.image.description="Self-contained MCP server for managing LocalStack via the Docker API (CDK, Terraform, SAM, Snowflake CLIs baked in)" \ org.opencontainers.image.source="https://github.com/localstack/localstack-mcp-server" \ org.opencontainers.image.licenses="Apache-2.0" diff --git a/README.md b/README.md index b2c590c..ae9e96f 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ This server provides your AI with dedicated tools for managing your LocalStack e | [`localstack-chaos-injector`](./src/tools/localstack-chaos-injector.ts) | Injects and manages chaos experiment faults for system resilience testing | - Inject, add, remove, and clear service fault rules
- Configure network latency effects
- Comprehensive fault targeting by service, region, and operation
- Built-in workflow guidance for chaos experiments
- Requires a valid LocalStack Auth Token | | [`localstack-cloud-pods`](./src/tools/localstack-cloud-pods.ts) | Manages remote LocalStack Cloud Pods for development workflows | - Save current state as a Cloud Pod
- Load previously saved Cloud Pods instantly
- Delete Cloud Pods from remote cloud-backed storage
- Use this for managed remote state snapshots, not local export/import files
- Requires a valid LocalStack Auth Token | | [`localstack-state-management`](./src/tools/localstack-state-management.ts) | Manages local file-based LocalStack state export/import workflows | - Export LocalStack state to a local file on disk through the LocalStack State REST API
- Import LocalStack state from a local file
- Inspect current LocalStack state as JSON metamodel data
- Reset all state or only selected services
- Supports service-level granularity for export, reset, and inspect
- Use this for local disk workflows; use Cloud Pods for remote cloud-backed snapshots
- Requires a valid LocalStack Auth Token | -| [`localstack-extensions`](./src/tools/localstack-extensions.ts) | Installs, uninstalls, lists, and discovers LocalStack Extensions | - Manage installed extensions via CLI actions (`list`, `install`, `uninstall`)
- Browse the LocalStack Extensions marketplace (`available`)
- Requires a valid LocalStack Auth Token | -| [`localstack-ephemeral-instances`](./src/tools/localstack-ephemeral-instances.ts) | Manages cloud-hosted LocalStack Ephemeral Instances | - Create temporary cloud-hosted LocalStack instances and get an endpoint URL
- List available ephemeral instances, fetch logs, and delete instances
- Supports lifetime, extension preload, Cloud Pod preload, and custom env vars on create
- Requires a valid LocalStack Auth Token and LocalStack CLI | +| [`localstack-extensions`](./src/tools/localstack-extensions.ts) | Installs, uninstalls, lists, and discovers LocalStack Extensions | - Manage installed extensions (`list`, `install`, `uninstall`) inside the running container
- Browse the LocalStack Extensions marketplace (`available`)
- Requires a valid LocalStack Auth Token | +| [`localstack-ephemeral-instances`](./src/tools/localstack-ephemeral-instances.ts) | Manages cloud-hosted LocalStack Ephemeral Instances | - Create temporary cloud-hosted LocalStack instances and get an endpoint URL
- List available ephemeral instances, fetch logs, and delete instances
- Supports lifetime, extension preload, Cloud Pod preload, and custom env vars on create
- Requires a valid LocalStack Auth Token (talks to the LocalStack platform API directly) | | [`localstack-aws-client`](./src/tools/localstack-aws-client.ts) | Runs AWS CLI commands inside the LocalStack for AWS container | - Executes commands via `awslocal` inside the running container
- Sanitizes commands to block shell chaining
- Auto-detects LocalStack coverage errors and links to docs | | [`localstack-aws-replicator`](./src/tools/localstack-aws-replicator.ts) | Replicates external AWS resources into a running LocalStack instance | - Start single-resource replication jobs with a resource type and identifier or ARN
- Start batch replication jobs, such as SSM parameters under a path prefix
- Poll job status by job ID and list existing jobs
- List resource types supported by the running Replicator extension
- Reads source AWS credentials from the MCP server environment and supports optional target account or region overrides | | [`localstack-app-inspector`](./src/tools/localstack-app-inspector.ts) | Inspects LocalStack application traces, spans, events, and IAM evaluations | - Enable or disable App Inspector for the running LocalStack instance
- List and inspect traces to understand AWS service-to-service flows
- Drill into spans, events, payload metadata, and IAM policy evaluation events
- Filter by service, region, operation, resource, ARN, status, and time range
- Requires a valid LocalStack Auth Token and the App Inspector feature in the connected LocalStack license | @@ -100,7 +100,7 @@ npx -y @localstack/localstack-mcp-server init The wizard: - lets you choose how to run the server (`npx` on your machine, or the self-contained Docker image), -- checks the prerequisites (Node.js, a LocalStack lifecycle CLI, Docker) and tells you how to fix anything missing, +- checks the prerequisites (Node.js, Docker) and tells you how to fix anything missing, - picks up your `LOCALSTACK_AUTH_TOKEN` from the environment, or asks for it, - lets you pass extra LocalStack config (e.g. `DEBUG=1,PERSISTENCE=1`), - detects your installed MCP clients (Cursor, Antigravity, Claude Code, Claude Desktop, VS Code, Codex, OpenCode, Amazon Q CLI) and writes the right configuration for each one you select. @@ -121,7 +121,7 @@ Run `npx -y @localstack/localstack-mcp-server init --help` for all options. ### Prerequisites -- A LocalStack lifecycle CLI (`localstack` or `lstk`) and Docker installed in your system path if you want the MCP server to start or restart LocalStack +- Docker installed and running — the MCP server manages the LocalStack container directly through the Docker API (no LocalStack CLI needed) - [`cdklocal`](https://github.com/localstack/aws-cdk-local), [`tflocal`](https://github.com/localstack/terraform-local), or [`samlocal`](https://github.com/localstack/aws-sam-cli-local) installed in your system path if you want to deploy CDK, Terraform, or SAM projects - Snowflake CLI (`snow`) installed in your system path if you want to use the Snowflake tool - A [valid LocalStack Auth Token](https://docs.localstack.cloud/aws/getting-started/auth-token/) configured as `LOCALSTACK_AUTH_TOKEN` (**required for all MCP tools**) @@ -129,7 +129,7 @@ Run `npx -y @localstack/localstack-mcp-server init --help` for all options. ### Run with npx -Add the following to your MCP client's configuration file (e.g., `~/.cursor/mcp.json`). This configuration uses `npx` to run the server, which will automatically download and install the package if needed. LocalStack and any deployment CLIs used by tools run from your host PATH. +Add the following to your MCP client's configuration file (e.g., `~/.cursor/mcp.json`). This configuration uses `npx` to run the server, which will automatically download and install the package if needed. The server manages LocalStack through your Docker daemon; any deployment CLIs used by tools run from your host PATH. ```json { @@ -167,7 +167,7 @@ If you installed from source, change `command` and `args` to point to your local ### Run with Docker -The `localstack/localstack-mcp-server` Docker image bundles the LocalStack CLI, `awslocal`, Terraform/`tflocal`, CDK/`cdklocal`, SAM/`samlocal`, Snowflake CLI, and Docker CLI. The only required host dependency is Docker. The container uses the mounted Docker socket to run LocalStack as a sibling container on the host. +The `localstack/localstack-mcp-server` Docker image bundles Terraform/`tflocal`, CDK/`cdklocal`, SAM/`samlocal`, and the Snowflake CLI. The only required host dependency is Docker. The container uses the mounted Docker socket to run LocalStack as a sibling container on the host; state lives in a named Docker volume (`localstack-mcp`) unless `LOCALSTACK_VOLUME_DIR` points at a host directory. If you use the deployer tool with local Terraform, CDK, or SAM projects, bind-mount those project paths into the MCP container and pass the in-container path to the tool. The simplest convention is to mount projects at the same absolute path they use on the host. @@ -177,16 +177,23 @@ If you use the deployer tool with local Terraform, CDK, or SAM projects, bind-mo "localstack": { "command": "docker", "args": [ - "run", "-i", "--rm", - "-v", "/var/run/docker.sock:/var/run/docker.sock", - "-v", "/Users/you/.localstack-mcp:/Users/you/.localstack-mcp", - "-e", "XDG_CACHE_HOME=/Users/you/.localstack-mcp", - "--add-host", "host.docker.internal:host-gateway", - "--add-host", "s3.host.docker.internal:host-gateway", - "--add-host", "snowflake.localhost.localstack.cloud:host-gateway", - "-e", "LOCALSTACK_AUTH_TOKEN", - "-e", "LOCALSTACK_HOSTNAME=host.docker.internal", - "-v", "/Users/you/projects:/Users/you/projects", + "run", + "-i", + "--rm", + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + "--add-host", + "host.docker.internal:host-gateway", + "--add-host", + "s3.host.docker.internal:host-gateway", + "--add-host", + "snowflake.localhost.localstack.cloud:host-gateway", + "-e", + "LOCALSTACK_AUTH_TOKEN", + "-e", + "LOCALSTACK_HOSTNAME=host.docker.internal", + "-v", + "/Users/you/projects:/Users/you/projects", "localstack/localstack-mcp-server:latest" ], "env": { "LOCALSTACK_AUTH_TOKEN": "" } @@ -199,15 +206,28 @@ See **[docs/DOCKER.md](./docs/DOCKER.md)** for the run command, MCP client confi ## LocalStack configuration -| Variable Name | Description | Default Value | -| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| `LOCALSTACK_AUTH_TOKEN` (**required**) | The LocalStack Auth Token to use for the MCP server | None | -| `MAIN_CONTAINER_NAME` | The explicit LocalStack container name to use for Docker-based tools. When unset, the server auto-detects standard LocalStack containers such as `localstack-main` and `localstack-aws`, then LocalStack Docker images. | Auto-detect | -| `MCP_ANALYTICS_DISABLED` | Disable MCP analytics when set to `1` | `0` | -| `APP_INSPECTOR` | Set to `1` in the LocalStack container environment to enable App Inspector by default across restarts. The MCP tool can also toggle App Inspector at runtime with `set-status`. | `0` | -| `AWS_ACCESS_KEY_ID` (**required for AWS Replicator tool**) | Source AWS access key used by AWS Replicator to read external AWS resources | None | -| `AWS_SECRET_ACCESS_KEY` (**required for AWS Replicator tool**) | Source AWS secret access key used by AWS Replicator to read external AWS resources | None | -| `AWS_DEFAULT_REGION` (**required for AWS Replicator tool**) | Source AWS region used by AWS Replicator | None | +| Variable Name | Description | Default Value | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| `LOCALSTACK_AUTH_TOKEN` (**required**) | The LocalStack Auth Token to use for the MCP server | None | +| `MAIN_CONTAINER_NAME` | The explicit LocalStack container name to use for Docker-based tools. When unset, the server auto-detects standard LocalStack containers such as `localstack-main` and `localstack-aws`, then LocalStack Docker images. | Auto-detect | +| `LOCALSTACK_IMAGE_NAME` / `IMAGE_NAME` | Docker image the `start` action launches for the AWS stack. `LOCALSTACK_IMAGE_NAME` wins when both are set (beware: bare `IMAGE_NAME` is a common CI variable). | `localstack/localstack-pro:latest` | +| `LOCALSTACK_VOLUME_DIR` | Host directory mounted at `/var/lib/localstack` in the LocalStack container. Defaults to the same per-OS cache path the LocalStack CLI used (state carries over); inside Docker it defaults to the `localstack-mcp` named volume. | Per-OS cache dir | +| `GATEWAY_LISTEN` | Override the gateway port bindings for `start` (comma-separated `[host]:port`). When set, the implicit `443` binding is skipped. | `:4566,:443` | +| `DOCKER_HOST` | Docker daemon endpoint used for all container operations (`unix://`, `npipe://`, `tcp://`). | Platform default socket | +| `MCP_ANALYTICS_DISABLED` | Disable MCP analytics when set to `1` | `0` | +| `APP_INSPECTOR` | Set to `1` in the LocalStack container environment to enable App Inspector by default across restarts. The MCP tool can also toggle App Inspector at runtime with `set-status`. | `0` | +| `AWS_ACCESS_KEY_ID` (**required for AWS Replicator tool**) | Source AWS access key used by AWS Replicator to read external AWS resources | None | +| `AWS_SECRET_ACCESS_KEY` (**required for AWS Replicator tool**) | Source AWS secret access key used by AWS Replicator to read external AWS resources | None | +| `AWS_DEFAULT_REGION` (**required for AWS Replicator tool**) | Source AWS region used by AWS Replicator | None | + +### Migration notes (CLI-free lifecycle) + +Since v0.6.0 the MCP server no longer uses (or requires) the `localstack`/`lstk` CLI — the LocalStack container is created directly through the Docker Engine API. Behavioral differences from CLI-driven starts: + +- `~/.localstack/*.env` config profiles and `DOCKER_FLAGS` are **not** read at start. Pass configuration through the `envVars` argument of the `localstack-management` start action, or set `LOCALSTACK_`-prefixed variables in the MCP server's environment (LocalStack aliases `LOCALSTACK_` to `` natively). +- Besides `LOCALSTACK_*`/`PROVIDER_OVERRIDE_*`, only a curated set of common unprefixed config variables is forwarded from the host environment (`DEBUG`, `LS_LOG`, `SERVICES`, `PERSISTENCE`, `EAGER_SERVICE_LOADING`, `ENFORCE_IAM`, `IAM_SOFT_MODE`, `EXTENSION_AUTO_INSTALL`, `APP_INSPECTOR`, `DNS_ADDRESS`, `MAIN_DOCKER_NETWORK`, and the `LAMBDA_*`/`CFN_*`/`SNOWFLAKE_*`/`SF_*` families). +- Port 443 is published by default (matching the current LocalStack CLI). If something else uses port 443 locally, set `GATEWAY_LISTEN=:4566` to skip it. +- If you previously used `lstk`, note the container the server creates is named `localstack-main` (externally started `localstack-aws` containers are still detected and managed; `restart` preserves their name, image, and volume). For AWS Replicator-specific source credentials, you can use the `AWS_REPLICATOR_SOURCE_` prefixed variants instead of the unprefixed variants. Do not mix the prefixed and unprefixed source credential groups; when any `AWS_REPLICATOR_SOURCE_` variable is set, the Replicator tool reads the source configuration only from that group. diff --git a/data/evals/gemini-comprehensive.json b/data/evals/gemini-comprehensive.json index bb6e341..a03bc9c 100644 --- a/data/evals/gemini-comprehensive.json +++ b/data/evals/gemini-comprehensive.json @@ -112,7 +112,7 @@ "id": "scenario-iam-soft-mode", "description": "localstack-iam-policy-analyzer: read mode, then set SOFT_MODE.", "mode": "mcp_host", - "scenario": "Check the current IAM enforcement mode in LocalStack using the IAM policy analyzer, then set the enforcement mode to SOFT_MODE.", + "scenario": "Check the current IAM enforcement mode in LocalStack using the IAM policy analyzer, then set the enforcement mode to SOFT_MODE with the set-mode action. Perform the set-mode call even if the current mode is already SOFT_MODE, so the setting is explicitly confirmed.", "mcpHostConfig": { "provider": "google", "model": "gemini-2.5-flash", "temperature": 0 }, "iterations": 5, "accuracyThreshold": 0.8, diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 352184e..319a0dd 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -1,20 +1,21 @@ # Running the LocalStack MCP Server in Docker -The published image bundles everything the server shells out to — the LocalStack -CLI, `awslocal`, Terraform + `tflocal`, AWS CDK + `cdklocal`, AWS SAM + `samlocal`, -the Snowflake `snow` CLI, and the Docker CLI — so the **only dependency on your -machine is Docker itself**. +The published image bundles everything the server shells out to — Terraform + +`tflocal`, AWS CDK + `cdklocal`, AWS SAM + `samlocal`, and the Snowflake `snow` +CLI — so the **only dependency on your machine is Docker itself**. LocalStack +lifecycle, logs, and `awslocal` run through the Docker Engine API and LocalStack's +REST APIs; no LocalStack CLI is involved anywhere. The image is multi-arch (`linux/amd64` and `linux/arm64`). ## How it works (Docker-out-of-Docker) The container talks to your **host Docker daemon** through the bind-mounted -`/var/run/docker.sock`. When you ask the server to start LocalStack, the bundled -`localstack start` launches a **sibling** `localstack-main` container on the host -(not nested inside the MCP container). Stop/restart operations then act on the -detected sibling container through the Docker API. The MCP server and the IaC CLIs -reach that sibling over the host gateway. +`/var/run/docker.sock`. When you ask the server to start LocalStack, it creates a +**sibling** `localstack-main` container on the host (not nested inside the MCP +container) directly via the Docker API. Stop/restart operations act on the detected +sibling container the same way. The MCP server and the IaC CLIs reach that sibling +over the host gateway. ``` MCP client ── stdio ──► docker run … (MCP server) @@ -24,27 +25,27 @@ MCP client ── stdio ──► docker run … (MCP server) └─ localstack-main (sibling, publishes :4566 on the host) ``` -Because LocalStack is a sibling container, two things must be configured at run time: +Because LocalStack is a sibling container, one thing must be configured at run time: -1. **Reachability** — set `LOCALSTACK_HOSTNAME=host.docker.internal` so the server - and the IaC CLIs target the sibling's published port instead of the container's - own `localhost`. -2. **Host-resolvable mounts** — `localstack start` asks the **host** daemon to - bind-mount its license/state files into `localstack-main`. Those mount *sources* - must exist at an **identical path** on the host and inside the MCP container, so - point LocalStack's cache at a directory you bind-mount one-to-one (via - `XDG_CACHE_HOME`). Without this you get `Mounts denied: … is not shared from the - host`. +- **Reachability** — set `LOCALSTACK_HOSTNAME=host.docker.internal` so the server + and the IaC CLIs target the sibling's published port instead of the container's + own `localhost`. + +LocalStack state lives in a **named Docker volume** (`localstack-mcp`) by default, +so no host directory needs to be mounted or path-mirrored. To keep state in a host +directory instead, set `-e LOCALSTACK_VOLUME_DIR=/absolute/host/path` (the path is +interpreted by the **host** daemon). + +> **Upgrading from an older image?** Previous versions required a one-to-one cache +> mount plus `XDG_CACHE_HOME`. Old configs keep working — when `XDG_CACHE_HOME` is +> set, the server keeps using `$XDG_CACHE_HOME/localstack/volume` for state, so +> your persisted resources survive the upgrade. New configs need neither flag. ## Quick start ```bash -mkdir -p "$HOME/.localstack-mcp" - docker run -i --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ - -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \ - -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \ --add-host host.docker.internal:host-gateway \ --add-host s3.host.docker.internal:host-gateway \ --add-host snowflake.localhost.localstack.cloud:host-gateway \ @@ -53,15 +54,14 @@ docker run -i --rm \ localstack/localstack-mcp-server:latest ``` -| Flag | Why it's needed | -| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `-v /var/run/docker.sock:/var/run/docker.sock` | Lets the bundled LocalStack CLI start the sibling container and lets Docker-based tools stop/restart it and run `awslocal` inside it. | -| `-v "$HOME/.localstack-mcp:$HOME/.localstack-mcp"` + `-e XDG_CACHE_HOME=…` | Puts LocalStack's license/machine/volume files on an **identically-pathed** host directory so the host daemon can bind-mount them into `localstack-main`. | -| `--add-host host.docker.internal:host-gateway` | Resolves `host.docker.internal` on Linux. Harmless on Docker Desktop (Mac/Windows), where it already resolves. | -| `--add-host s3.host.docker.internal:host-gateway` | Lets CDK's virtual-hosted S3 endpoint resolve when `cdklocal` uses `AWS_ENDPOINT_URL_S3=http://s3.host.docker.internal:4566`. | -| `--add-host snowflake.localhost.localstack.cloud:host-gateway` | Lets the Snowflake CLI reach the sibling Snowflake emulator through the hostname the emulator expects for routing. | -| `-e LOCALSTACK_AUTH_TOKEN` | Required by **every** tool in this server. | -| `-e LOCALSTACK_HOSTNAME=host.docker.internal` | Tells the server + IaC CLIs where the sibling LocalStack lives. | +| Flag | Why it's needed | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `-v /var/run/docker.sock:/var/run/docker.sock` | Lets the server create/stop/restart the sibling LocalStack container, read its logs, and run `awslocal` inside it. | +| `--add-host host.docker.internal:host-gateway` | Resolves `host.docker.internal` on Linux. Harmless on Docker Desktop (Mac/Windows), where it already resolves. | +| `--add-host s3.host.docker.internal:host-gateway` | Lets CDK's virtual-hosted S3 endpoint resolve when `cdklocal` uses `AWS_ENDPOINT_URL_S3=http://s3.host.docker.internal:4566`. | +| `--add-host snowflake.localhost.localstack.cloud:host-gateway` | Lets the Snowflake CLI reach the sibling Snowflake emulator through the hostname the emulator expects for routing. | +| `-e LOCALSTACK_AUTH_TOKEN` | Required by **every** tool in this server. | +| `-e LOCALSTACK_HOSTNAME=host.docker.internal` | Tells the server + IaC CLIs where the sibling LocalStack lives. | ## MCP client configuration @@ -74,21 +74,28 @@ expand `$HOME`/`$PWD` — use absolute paths. "localstack-mcp-server": { "command": "docker", "args": [ - "run", "-i", "--rm", - "-v", "/var/run/docker.sock:/var/run/docker.sock", - "-v", "/Users/you/.localstack-mcp:/Users/you/.localstack-mcp", - "-e", "XDG_CACHE_HOME=/Users/you/.localstack-mcp", - "--add-host", "host.docker.internal:host-gateway", - "--add-host", "s3.host.docker.internal:host-gateway", - "--add-host", "snowflake.localhost.localstack.cloud:host-gateway", - "-e", "LOCALSTACK_AUTH_TOKEN", - "-e", "LOCALSTACK_HOSTNAME=host.docker.internal", - "-v", "/Users/you/projects:/Users/you/projects", - "localstack/localstack-mcp-server:latest" + "run", + "-i", + "--rm", + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + "--add-host", + "host.docker.internal:host-gateway", + "--add-host", + "s3.host.docker.internal:host-gateway", + "--add-host", + "snowflake.localhost.localstack.cloud:host-gateway", + "-e", + "LOCALSTACK_AUTH_TOKEN", + "-e", + "LOCALSTACK_HOSTNAME=host.docker.internal", + "-v", + "/Users/you/projects:/Users/you/projects", + "localstack/localstack-mcp-server:latest", ], - "env": { "LOCALSTACK_AUTH_TOKEN": "" } - } - } + "env": { "LOCALSTACK_AUTH_TOKEN": "" }, + }, + }, } ``` @@ -112,21 +119,22 @@ alias covers bootstrap asset uploads. ## Known limitations - **Extra host aliases.** Include the aliases shown in the quick-start command. -- **First cold start** of LocalStack can take up to ~2 minutes while the runtime - initializes; subsequent starts reuse the persisted volume under - `$XDG_CACHE_HOME`. +- **First cold start** of LocalStack can take up to ~2 minutes while the image is + pulled and the runtime initializes; subsequent starts reuse the persisted volume. - **Persistence across MCP restarts.** The sibling `localstack-main` keeps running on the host even if your editor restarts the MCP container — reconnecting finds - your stack still up. State persists in `$XDG_CACHE_HOME/localstack/volume`. + your stack still up. State persists in the `localstack-mcp` named volume (or + `LOCALSTACK_VOLUME_DIR` if you set one). ## Troubleshooting -| Symptom | Cause / fix | -| ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Mounts denied: … is not shared from the host` | The cache bind mount / `XDG_CACHE_HOME` is missing or not under a Docker-shared root. Use a path under your home directory and mount it one-to-one. | -| Tools report `LocalStack Not Running` after `start` | Check `LOCALSTACK_HOSTNAME=host.docker.internal` is set and `--add-host` is present (Linux). | -| `Auth Token Required` | `LOCALSTACK_AUTH_TOKEN` must be passed through (every tool requires it). | -| `LocalStack container not found` or `Could not find a running LocalStack container named "localstack-main"` | Set `MAIN_CONTAINER_NAME` if you use a custom LocalStack container name. | +| Symptom | Cause / fix | +| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Tools report `LocalStack Not Running` after `start` | Check `LOCALSTACK_HOSTNAME=host.docker.internal` is set and `--add-host` is present (Linux). | +| `Auth Token Required` | `LOCALSTACK_AUTH_TOKEN` must be passed through (every tool requires it). | +| `Docker Not Available` / daemon unreachable | Ensure `/var/run/docker.sock` is mounted (or pass `DOCKER_HOST` for a non-default daemon). | +| `LocalStack container not found` or `Could not find a running LocalStack container named "localstack-main"` | Set `MAIN_CONTAINER_NAME` if you use a custom LocalStack container name. | +| State disappeared after upgrading the image | Old configs stored state under `$XDG_CACHE_HOME/localstack/volume` — keep that env var, or point `LOCALSTACK_VOLUME_DIR` at the old directory. | ## Validating an image yourself @@ -139,8 +147,6 @@ HARNESS_TOKEN_REAL=1 \ node tests/docker/validate-image.mjs -- \ docker run -i --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ - -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \ - -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \ --add-host host.docker.internal:host-gateway \ --add-host s3.host.docker.internal:host-gateway \ --add-host snowflake.localhost.localstack.cloud:host-gateway \ diff --git a/server.json b/server.json index 363a938..2c9414f 100644 --- a/server.json +++ b/server.json @@ -12,7 +12,9 @@ { "src": "https://raw.githubusercontent.com/localstack/localstack-mcp-server/main/icon.png", "mimeType": "image/png", - "sizes": ["256x256"] + "sizes": [ + "256x256" + ] } ], "packages": [ @@ -101,6 +103,41 @@ "format": "string", "isSecret": false, "name": "APP_INSPECTOR" + }, + { + "description": "Docker image the management start action launches for the AWS stack (LOCALSTACK_IMAGE_NAME wins over IMAGE_NAME). Defaults to localstack/localstack-pro:latest.", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "LOCALSTACK_IMAGE_NAME" + }, + { + "description": "Host directory mounted at /var/lib/localstack in the LocalStack container. Defaults to the per-OS cache path the LocalStack CLI used; inside Docker it defaults to the localstack-mcp named volume.", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "LOCALSTACK_VOLUME_DIR" + }, + { + "description": "Override the gateway port bindings for the start action (comma-separated [host]:port entries). When set, the implicit 443 binding is skipped.", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "GATEWAY_LISTEN" + }, + { + "description": "Explicit LocalStack container name for Docker-based tools and the container created by start. Defaults to localstack-main (auto-detection covers common names).", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "MAIN_CONTAINER_NAME" + }, + { + "description": "Docker daemon endpoint used for all container operations (unix://, npipe://, tcp://). Defaults to the platform's standard socket.", + "isRequired": false, + "format": "string", + "isSecret": false, + "name": "DOCKER_HOST" } ] } diff --git a/src/cli/help.ts b/src/cli/help.ts index cbde579..816de0a 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -13,7 +13,7 @@ init options: Valid: ${ALL_CLIENT_IDS.join(", ")} --token LocalStack Auth Token (default: $LOCALSTACK_AUTH_TOKEN) --config Extra LocalStack config vars, e.g. "DEBUG=1,PERSISTENCE=1" - --cache-dir [docker] State/cache dir mounted into the container + --cache-dir [docker] Deprecated and ignored (state lives in a named Docker volume) (default: ~/.localstack-mcp) --workspace [docker] Workspace dir to mount for IaC deployments (default: current directory; pass "" to skip) diff --git a/src/cli/init.ts b/src/cli/init.ts index 5e2ff5a..db884f4 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -85,7 +85,7 @@ async function resolveMethod( { value: "npx", label: "npx (Node on this machine)", - hint: "uses your local Node 20+, localstack or lstk, and Docker", + hint: "uses your local Node 20+ and Docker", }, { value: "docker", @@ -158,25 +158,18 @@ async function resolveDockerOptions( ctx: ClientContext ): Promise { const defaults = { - cacheDir: path.join(ctx.homeDir, ".localstack-mcp"), workspace: process.cwd(), imageTag: "latest", }; const prompts = interactive && !yes; - let cacheDir = defaults.cacheDir; + // The server now creates the LocalStack container itself (named volume by + // default), so no cache/state dir needs to be mounted into the MCP container. if (flags.cacheDir?.trim()) { - cacheDir = expandPath(flags.cacheDir, ctx.homeDir); - } else if (prompts) { - const answer = preWriteAnswer( - await p.text({ - message: "State/cache directory for Docker runs", - initialValue: defaults.cacheDir, - validate: (value) => (value?.trim() ? undefined : "A directory is required"), - }) + p.log.warn( + "--cache-dir is deprecated and ignored: LocalStack state lives in a named Docker volume (set LOCALSTACK_VOLUME_DIR to use a host directory instead)." ); - cacheDir = expandPath(answer, ctx.homeDir); } // flags.workspace === "" is meaningful: skip the workspace mount. @@ -204,7 +197,7 @@ async function resolveDockerOptions( ).trim(); } - return { cacheDir, workspaceDir: workspace || undefined, imageTag }; + return { workspaceDir: workspace || undefined, imageTag }; } async function resolveExtraEnv( diff --git a/src/core/preflight.test.ts b/src/core/preflight.test.ts index 3014566..1bf1fda 100644 --- a/src/core/preflight.test.ts +++ b/src/core/preflight.test.ts @@ -1,19 +1,24 @@ -import { requireLocalStackRunning } from "./preflight"; +import { requireDockerDaemon, requireLocalStackRunning } from "./preflight"; import { getGatewayHealth } from "../lib/localstack/localstack.utils"; +import { DockerApiClient } from "../lib/docker/docker.client"; jest.mock("../lib/localstack/localstack.utils", () => ({ getGatewayHealth: jest.fn(), - ensureLocalStackCli: jest.fn(), ensureSnowflakeCli: jest.fn(), })); +jest.mock("../lib/docker/docker.client", () => ({ + DockerApiClient: jest.fn(), +})); + const mockedGetGatewayHealth = getGatewayHealth as jest.MockedFunction; +const MockedDockerApiClient = DockerApiClient as jest.MockedClass; describe("requireLocalStackRunning", () => { beforeEach(() => mockedGetGatewayHealth.mockReset()); - test("passes for any reachable gateway, regardless of container name or CLI", async () => { - // e.g. an `lstk`-started container named `localstack-aws` with no Python CLI. + test("passes for any reachable gateway, regardless of container name or provenance", async () => { + // e.g. an externally-started container named `localstack-aws`. mockedGetGatewayHealth.mockResolvedValueOnce({ reachable: true, ready: true, @@ -23,11 +28,38 @@ describe("requireLocalStackRunning", () => { expect(await requireLocalStackRunning()).toBeNull(); }); - test("blocks with an error when the gateway is unreachable", async () => { + test("blocks with an error pointing at the management tool when the gateway is unreachable", async () => { mockedGetGatewayHealth.mockResolvedValueOnce({ reachable: false, ready: false }); const result = await requireLocalStackRunning(); expect(result).not.toBeNull(); expect(result?.content[0].text).toMatch(/LocalStack Not Running/i); + expect(result?.content[0].text).toMatch(/localstack-management/); + // No stale advice to install/run a CLI. + expect(result?.content[0].text).not.toMatch(/localstack start|lstk/); + }); +}); + +describe("requireDockerDaemon", () => { + beforeEach(() => MockedDockerApiClient.mockReset()); + + test("passes when the daemon answers the ping", async () => { + MockedDockerApiClient.mockImplementation( + () => ({ ping: jest.fn().mockResolvedValue(undefined) }) as any + ); + expect(await requireDockerDaemon()).toBeNull(); + }); + + test("blocks with the friendly daemon message when the ping fails", async () => { + MockedDockerApiClient.mockImplementation( + () => + ({ + ping: jest.fn().mockRejectedValue(new Error("Docker daemon is not reachable. (ENOENT)")), + }) as any + ); + const result = await requireDockerDaemon(); + expect(result).not.toBeNull(); + expect(result?.content[0].text).toMatch(/Docker Not Available/); + expect(result?.content[0].text).toMatch(/Docker daemon is not reachable/); }); }); diff --git a/src/core/preflight.ts b/src/core/preflight.ts index 474a5c8..dc467a5 100644 --- a/src/core/preflight.ts +++ b/src/core/preflight.ts @@ -1,24 +1,33 @@ -import { - ensureLocalStackCli, - ensureSnowflakeCli, - getGatewayHealth, -} from "../lib/localstack/localstack.utils"; +import { ensureSnowflakeCli, getGatewayHealth } from "../lib/localstack/localstack.utils"; +import { DockerApiClient } from "../lib/docker/docker.client"; import { checkProFeature, ProFeature } from "../lib/localstack/license-checker"; import { LOCALSTACK_BASE_URL } from "./config"; import { ResponseBuilder } from "./response-builder"; type ToolResponse = ReturnType; -export const requireLocalStackCli = async (): Promise => { - const cliCheck = await ensureLocalStackCli(); - return cliCheck ? (cliCheck as ToolResponse) : null; -}; - export const requireSnowflakeCli = async (): Promise => { const cliCheck = await ensureSnowflakeCli(); return cliCheck ? (cliCheck as ToolResponse) : null; }; +/** + * Gate for actions that talk to the Docker daemon (container lifecycle, log reads, + * in-container exec). Surfaces socket/daemon failures as an actionable message + * instead of a raw ENOENT. + */ +export const requireDockerDaemon = async (): Promise => { + try { + await new DockerApiClient().ping(); + return null; + } catch (error) { + return ResponseBuilder.error( + "Docker Not Available", + error instanceof Error ? error.message : String(error) + ); + } +}; + export const requireProFeature = async (feature: ProFeature): Promise => { const licenseCheck = await checkProFeature(feature); return !licenseCheck.isSupported @@ -45,13 +54,13 @@ export const runPreflights = async ( export const requireLocalStackRunning = async (): Promise => { // Provenance-agnostic gate: probe the gateway directly instead of looking for a - // CLI-named container, so an `lstk`-started (or otherwise externally managed) - // runtime that is healthy and reachable is not falsely reported as "not running". + // specific container, so an externally managed runtime that is healthy and + // reachable is not falsely reported as "not running". const health = await getGatewayHealth(); if (!health.reachable) { return ResponseBuilder.error( "LocalStack Not Running", - `LocalStack is not reachable at ${LOCALSTACK_BASE_URL}. Start it with \`localstack start\` (or \`lstk start\`) and try again. ` + + `LocalStack is not reachable at ${LOCALSTACK_BASE_URL}. Start it with the localstack-management tool (action: start) and try again. ` + `If it is running on a non-default host or port, set LOCALSTACK_HOSTNAME / LOCALSTACK_PORT for the MCP server.` ); } diff --git a/src/lib/deployment/deployment-utils.ts b/src/lib/deployment/deployment-utils.ts index 3e97cdb..6105398 100644 --- a/src/lib/deployment/deployment-utils.ts +++ b/src/lib/deployment/deployment-utils.ts @@ -9,13 +9,7 @@ export interface DependencyCheckResult { errorMessage?: string; } -export type ProjectType = - | "cdk" - | "terraform" - | "sam" - | "cloudformation" - | "ambiguous" - | "unknown"; +export type ProjectType = "cdk" | "terraform" | "sam" | "cloudformation" | "ambiguous" | "unknown"; /** * Check if the required deployment tool (cdklocal or tflocal) is available in the system PATH @@ -108,23 +102,26 @@ export async function inferProjectType(directory: string): Promise if (hasTemplateYaml) { const samTemplateFile = files.includes("template.yaml") ? "template.yaml" : "template.yml"; try { - const templateContent = await fs.promises.readFile(path.join(directory, samTemplateFile), "utf-8"); + const templateContent = await fs.promises.readFile( + path.join(directory, samTemplateFile), + "utf-8" + ); hasServerlessResources = /AWS::Serverless::[A-Za-z]+/.test(templateContent); } catch { hasServerlessResources = false; } } - const hasCloudFormationTemplates = files.some((file) => file.endsWith(".yaml") || file.endsWith(".yml")); + const hasCloudFormationTemplates = files.some( + (file) => file.endsWith(".yaml") || file.endsWith(".yml") + ); const isCdk = hasCdkJson || hasCdkFiles; const isTerraform = hasTerraformFiles; const isSam = hasSamConfig || hasServerlessResources; const isCloudFormation = hasCloudFormationTemplates && !isSam; - if ( - [isCdk, isTerraform, isSam, isCloudFormation].filter(Boolean).length > 1 - ) { + if ([isCdk, isTerraform, isSam, isCloudFormation].filter(Boolean).length > 1) { return "ambiguous"; } else if (isCdk) { return "cdk"; diff --git a/src/lib/docker/docker.client.test.ts b/src/lib/docker/docker.client.test.ts index ca099bf..984d2bc 100644 --- a/src/lib/docker/docker.client.test.ts +++ b/src/lib/docker/docker.client.test.ts @@ -324,6 +324,13 @@ describe("DockerApiClient", () => { Image: "localstack/localstack-pro:latest", Env: ["MAIN_CONTAINER_NAME=localstack-aws"], }, + Mounts: [ + { + Type: "bind", + Source: "/home/user/.cache/localstack/volume", + Destination: "/var/lib/localstack", + }, + ], }); const client = new DockerApiClient(); @@ -332,6 +339,14 @@ describe("DockerApiClient", () => { name: "localstack-aws", image: "localstack/localstack-pro:latest", env: ["MAIN_CONTAINER_NAME=localstack-aws"], + mounts: [ + { + type: "bind", + name: undefined, + source: "/home/user/.cache/localstack/volume", + destination: "/var/lib/localstack", + }, + ], }); }); @@ -397,3 +412,51 @@ describe("DockerApiClient", () => { expect(res.stderr).toContain("something went wrong"); }); }); + +describe("decodeDockerLogBuffer", () => { + const frame = (streamType: number, text: string) => { + const payload = Buffer.from(text, "utf8"); + const header = Buffer.alloc(8); + header[0] = streamType; + header.writeUInt32BE(payload.length, 4); + return Buffer.concat([header, payload]); + }; + + test("preserves chronological interleaving of stdout and stderr frames", () => { + const { decodeDockerLogBuffer } = jest.requireActual("./docker.client"); + const multiplexed = Buffer.concat([ + frame(1, "line one (stdout)\n"), + frame(2, "line two (stderr)\n"), + frame(1, "line three (stdout)\n"), + ]); + expect(decodeDockerLogBuffer(multiplexed)).toBe( + "line one (stdout)\nline two (stderr)\nline three (stdout)\n" + ); + }); + + test("passes through raw (TTY) output without multiplex headers", () => { + const { decodeDockerLogBuffer } = jest.requireActual("./docker.client"); + expect(decodeDockerLogBuffer(Buffer.from("plain text log\n", "utf8"))).toBe("plain text log\n"); + }); + + test("handles empty buffers", () => { + const { decodeDockerLogBuffer } = jest.requireActual("./docker.client"); + expect(decodeDockerLogBuffer(Buffer.alloc(0))).toBe(""); + }); +}); + +describe("describeDockerConnectivityError", () => { + test("maps socket errors to an actionable daemon-unreachable message", () => { + const { describeDockerConnectivityError } = jest.requireActual("./docker.client"); + const err = Object.assign(new Error("connect ENOENT /var/run/docker.sock"), { + code: "ENOENT", + }); + expect(describeDockerConnectivityError(err)).toMatch(/Docker daemon is not reachable/); + expect(describeDockerConnectivityError(err)).toMatch(/DOCKER_HOST/); + }); + + test("passes through unrelated errors", () => { + const { describeDockerConnectivityError } = jest.requireActual("./docker.client"); + expect(describeDockerConnectivityError(new Error("kaboom"))).toBe("kaboom"); + }); +}); diff --git a/src/lib/docker/docker.client.ts b/src/lib/docker/docker.client.ts index 4394181..bbd4526 100644 --- a/src/lib/docker/docker.client.ts +++ b/src/lib/docker/docker.client.ts @@ -1,5 +1,6 @@ import { PassThrough } from "stream"; import { LOCALSTACK_PORT } from "../../core/config"; +import type { LocalStackContainerSpec } from "../localstack/container-spec.logic"; export interface ContainerExecResult { stdout: string; @@ -7,11 +8,94 @@ export interface ContainerExecResult { exitCode: number; } +export interface ContainerStateInfo { + id: string; + name: string; + state: string; + image?: string; + running: boolean; +} + +/** + * Rolling log tail attached to a container from the moment it starts. The buffer + * survives AutoRemove (the stream stays readable even after the daemon removes the + * container), and the stream ending doubles as the container-exit signal — the only + * reliable way to diagnose startup crashes of an AutoRemove container. + */ +export interface LogBufferHandle { + getBuffered(): string; + hasExited(): boolean; + onExit(callback: () => void): void; + destroy(): void; +} + +const LOG_BUFFER_MAX_CHARS = 64 * 1024; + +/** + * Decode a Docker multiplexed log payload (returned as a single Buffer by + * `container.logs({follow: false})`) into chronologically ordered text. Each frame + * carries an 8-byte header: [stream_type, 0, 0, 0, size(u32 BE)]. Walking frames in + * order preserves the stdout/stderr interleaving `docker logs` shows; demuxing into + * separate sinks would reorder lines and naive toString() leaves binary headers that + * break line-anchored parsing. + */ +export function decodeDockerLogBuffer(buffer: Buffer): string { + if (buffer.length === 0) return ""; + const firstByte = buffer[0]; + // TTY containers stream raw output without multiplex headers. + if (firstByte !== 0 && firstByte !== 1 && firstByte !== 2) return buffer.toString("utf8"); + + const chunks: Buffer[] = []; + let offset = 0; + while (offset + 8 <= buffer.length) { + const streamType = buffer[offset]; + if (streamType !== 0 && streamType !== 1 && streamType !== 2) break; + const size = buffer.readUInt32BE(offset + 4); + const end = Math.min(offset + 8 + size, buffer.length); + chunks.push(buffer.subarray(offset + 8, end)); + offset += 8 + size; + } + if (chunks.length === 0) return buffer.toString("utf8"); + return Buffer.concat(chunks).toString("utf8"); +} + +/** + * Translate raw Docker connectivity failures into an actionable message. Socket-level + * errors otherwise surface as bare "connect ENOENT /var/run/docker.sock" strings. + */ +export function describeDockerConnectivityError(error: unknown): string { + const err = error as { code?: string; message?: string } | undefined; + const code = err?.code || ""; + const message = err?.message || String(error); + if ( + code === "ENOENT" || + code === "ECONNREFUSED" || + code === "EACCES" || + code === "EPERM" || + /docker_engine|docker\.sock/i.test(message) + ) { + return ( + "Docker daemon is not reachable. Start Docker (Desktop) and try again. " + + "If your daemon uses a non-default socket, set DOCKER_HOST. " + + `(${message})` + ); + } + return message; +} + +export interface ContainerMountInfo { + type?: string; + name?: string; + source?: string; + destination?: string; +} + export interface ContainerMetadata { id: string; name?: string; image?: string; env?: string[]; + mounts?: ContainerMountInfo[]; } export class LocalStackContainerNotFoundError extends Error { @@ -166,6 +250,14 @@ export class DockerApiClient { name: this.normalizeContainerName(inspect?.Name), image: inspect?.Config?.Image, env: inspect?.Config?.Env, + mounts: (inspect?.Mounts || []).map( + (mount: { Type?: string; Name?: string; Source?: string; Destination?: string }) => ({ + type: mount.Type, + name: mount.Name, + source: mount.Source, + destination: mount.Destination, + }) + ), }; } @@ -181,11 +273,16 @@ export class DockerApiClient { requestTimeoutMs = 60000 ): Promise { const container = this.docker.getContainer(containerId); - await this.withDockerRequestTimeout( - container.stop({ t: timeoutSeconds }), - requestTimeoutMs, - "Docker container stop" - ); + try { + await this.withDockerRequestTimeout( + container.stop({ t: timeoutSeconds }), + requestTimeoutMs, + "Docker container stop" + ); + } catch (error) { + // 304 = already stopped; continue so the remove below still runs. + if ((error as { statusCode?: number })?.statusCode !== 304) throw error; + } try { await this.withDockerRequestTimeout( @@ -201,7 +298,8 @@ export class DockerApiClient { async executeInContainer( containerId: string, command: string[], - stdin?: string + stdin?: string, + options?: { env?: string[]; timeoutMs?: number } ): Promise { const container = this.docker.getContainer(containerId); @@ -209,6 +307,7 @@ export class DockerApiClient { Cmd: command, AttachStdout: true, AttachStderr: true, + ...(options?.env?.length ? { Env: options.env } : {}), ...(stdin ? { AttachStdin: true } : {}), }); @@ -235,8 +334,21 @@ export class DockerApiClient { await new Promise((resolve, reject) => { // demux combined docker stream into stdout/stderr (this.docker as any).modem.demuxStream(stream as any, stdoutStream, stderrStream); - stream.on("end", () => resolve()); - stream.on("error", (e) => reject(e)); + let timer: NodeJS.Timeout | undefined; + if (options?.timeoutMs) { + timer = setTimeout(() => { + (stream as unknown as { destroy?: () => void }).destroy?.(); + reject(new Error(`Container exec timed out after ${options.timeoutMs}ms`)); + }, options.timeoutMs); + } + stream.on("end", () => { + if (timer) clearTimeout(timer); + resolve(); + }); + stream.on("error", (e) => { + if (timer) clearTimeout(timer); + reject(e); + }); }); const inspect = (await exec.inspect()) as { ExitCode: number | null }; @@ -247,4 +359,223 @@ export class DockerApiClient { return { stdout, stderr, exitCode }; } + + /** Docker daemon reachability probe with a friendly error for the common failures. */ + async ping(timeoutMs = 5000): Promise { + try { + await this.withDockerRequestTimeout(this.docker.ping(), timeoutMs, "Docker daemon ping"); + } catch (error) { + throw new Error(describeDockerConnectivityError(error)); + } + } + + async imageExists(imageName: string): Promise { + try { + await this.docker.getImage(imageName).inspect(); + return true; + } catch (error) { + const statusCode = (error as { statusCode?: number })?.statusCode; + if (statusCode === 404) return false; + throw new Error(describeDockerConnectivityError(error)); + } + } + + /** + * Pull an image, consuming the progress stream manually. docker-modem's + * followProgress reports success even when the stream carries an `error` event and + * throws uncaught on malformed progress lines — both fatal for a stdio MCP process. + */ + async pullImage(imageName: string, timeoutMs = 600000): Promise { + const stream: NodeJS.ReadableStream = await new Promise((resolve, reject) => { + this.docker.pull(imageName, (err: unknown, pullStream: NodeJS.ReadableStream) => { + if (err) return reject(new Error(describeDockerConnectivityError(err))); + resolve(pullStream); + }); + }); + + await this.withDockerRequestTimeout( + new Promise((resolve, reject) => { + let pending = ""; + let pullError: string | undefined; + stream.on("data", (chunk: Buffer) => { + pending += chunk.toString("utf8"); + const lines = pending.split("\n"); + pending = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line) as { + error?: string; + errorDetail?: { message?: string }; + }; + if (event.error || event.errorDetail) { + pullError = event.error || event.errorDetail?.message || "unknown pull error"; + } + } catch { + // Malformed progress lines are ignored; only well-formed error events fail the pull. + } + } + }); + stream.on("error", (err: Error) => reject(err)); + stream.on("end", () => { + if (pullError) reject(new Error(`Failed to pull image ${imageName}: ${pullError}`)); + else resolve(); + }); + }), + timeoutMs, + `Docker image pull (${imageName})` + ); + } + + /** Create a user-defined network when missing (Lambda containers must reach the gateway on it). */ + async ensureNetwork(name: string): Promise { + if (!name || name === "host" || name === "bridge" || name === "none") return; + const networks = (await this.docker.listNetworks({ + filters: { name: [name] }, + })) as Array<{ Name?: string }>; + if ((networks || []).some((network) => network.Name === name)) return; + await this.docker.createNetwork({ Name: name }); + } + + async createAndStartContainer(spec: LocalStackContainerSpec): Promise { + const { name, ...createOptions } = spec; + let container; + try { + container = await this.docker.createContainer({ ...createOptions, name }); + } catch (error) { + const statusCode = (error as { statusCode?: number })?.statusCode; + if (statusCode === 409) { + throw new LocalStackContainerConflictError(`A container named "${name}" already exists.`); + } + throw new Error(describeDockerConnectivityError(error)); + } + await container.start(); + return container.id as string; + } + + /** + * Attach a follow-mode log stream right after start and keep a rolling tail. + * See LogBufferHandle for why this replaces post-exit `container.logs()` reads. + */ + async attachLogBuffer(containerId: string): Promise { + const container = this.docker.getContainer(containerId); + const stream: NodeJS.ReadableStream = await container.logs({ + follow: true, + stdout: true, + stderr: true, + tail: 200, + }); + + // Demux into a single sink so stdout/stderr keep their chronological interleaving. + const sink = new PassThrough(); + (this.docker as any).modem.demuxStream(stream, sink, sink); + + let buffered = ""; + let exited = false; + const exitCallbacks: Array<() => void> = []; + sink.on("data", (data: Buffer) => { + buffered += data.toString("utf8"); + if (buffered.length > LOG_BUFFER_MAX_CHARS) { + buffered = buffered.slice(-LOG_BUFFER_MAX_CHARS); + } + }); + const markExited = () => { + if (exited) return; + exited = true; + for (const callback of exitCallbacks) callback(); + }; + stream.on("end", markExited); + stream.on("close", markExited); + stream.on("error", markExited); + + return { + getBuffered: () => buffered, + hasExited: () => exited, + onExit: (callback) => { + if (exited) callback(); + else exitCallbacks.push(callback); + }, + destroy: () => { + (stream as unknown as { destroy?: () => void }).destroy?.(); + }, + }; + } + + /** One-shot chronologically ordered log fetch (docker logs equivalent). */ + async getContainerLogs( + containerId: string, + { tail, timeoutMs = 30000 }: { tail: number; timeoutMs?: number } + ): Promise { + const container = this.docker.getContainer(containerId); + const result: Buffer | string = await this.withDockerRequestTimeout( + container.logs({ follow: false, stdout: true, stderr: true, tail }), + timeoutMs, + "Docker container logs" + ); + if (typeof result === "string") return result; + return decodeDockerLogBuffer(result); + } + + /** + * Find a container by exact name in ANY state — the pre-start conflict check. + * `findLocalStackContainer` deliberately sees only running containers. + */ + async findContainerByNameAnyState(name: string): Promise { + const containers = (await this.withDockerRequestTimeout( + (this.docker.listContainers as any)({ all: true, filters: { name: [name] } }), + 10000, + "Docker container list" + )) as Array<{ Id: string; Names?: string[]; State?: string; Image?: string }>; + + const match = (containers || []).find((container) => + this.matchesConfiguredContainerName(container, name) + ); + if (!match) return null; + return { + id: match.Id, + name, + state: match.State || "unknown", + image: match.Image, + running: match.State === "running", + }; + } + + /** Remove a container, tolerating it already being gone. */ + async removeContainer(containerId: string, requestTimeoutMs = 60000): Promise { + const container = this.docker.getContainer(containerId); + try { + await this.withDockerRequestTimeout( + container.remove({ force: true }), + requestTimeoutMs, + "Docker container remove" + ); + } catch (error) { + if (!this.isContainerAlreadyGone(error)) throw error; + } + } + + /** + * Wait until a container is fully removed (AutoRemove removal is asynchronous — + * recreating the same name too early races a 409). + */ + async waitForRemoval(containerId: string, timeoutMs = 60000): Promise { + const container = this.docker.getContainer(containerId); + try { + await this.withDockerRequestTimeout( + container.wait({ condition: "removed" }), + timeoutMs, + "Docker container removal wait" + ); + } catch (error) { + if (this.isContainerAlreadyGone(error)) return; + throw error; + } + } +} + +export class LocalStackContainerConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "LocalStackContainerConflictError"; + } } diff --git a/src/lib/localstack/container-spec.logic.test.ts b/src/lib/localstack/container-spec.logic.test.ts new file mode 100644 index 0000000..5b8dc5c --- /dev/null +++ b/src/lib/localstack/container-spec.logic.test.ts @@ -0,0 +1,310 @@ +import { + buildLocalStackContainerSpec, + detectAiAgent, + parseGatewayListen, + resolveContainerName, + resolveImage, + resolveVolume, + type ContainerSpecInput, +} from "./container-spec.logic"; + +const baseInput = (overrides: Partial = {}): ContainerSpecInput => ({ + stack: "aws", + hostEnv: {}, + authToken: "ls-test-token", + isInDocker: false, + volume: { type: "bind", source: "/home/user/.cache/localstack/volume" }, + serverVersion: "0.5.0", + ...overrides, +}); + +const envMap = (spec: ReturnType) => + Object.fromEntries( + spec.Env.map((entry) => [ + entry.slice(0, entry.indexOf("=")), + entry.slice(entry.indexOf("=") + 1), + ]) + ); + +describe("resolveImage", () => { + test("defaults to the pro image for the aws stack", () => { + expect(resolveImage("aws", {})).toBe("localstack/localstack-pro:latest"); + }); + + test("honors LOCALSTACK_IMAGE_NAME over IMAGE_NAME", () => { + expect( + resolveImage("aws", { LOCALSTACK_IMAGE_NAME: "my/img:1", IMAGE_NAME: "other/img:2" }) + ).toBe("my/img:1"); + expect(resolveImage("aws", { IMAGE_NAME: "other/img:2" })).toBe("other/img:2"); + }); + + test("snowflake stack always uses the snowflake image (stack wins over IMAGE_NAME)", () => { + expect(resolveImage("snowflake", { IMAGE_NAME: "other/img:2" })).toBe( + "localstack/snowflake:latest" + ); + }); +}); + +describe("resolveContainerName", () => { + test("defaults to localstack-main and honors MAIN_CONTAINER_NAME", () => { + expect(resolveContainerName({})).toBe("localstack-main"); + expect(resolveContainerName({ MAIN_CONTAINER_NAME: "my-ls" })).toBe("my-ls"); + }); +}); + +describe("parseGatewayListen", () => { + test("fills in the default bind IP for host-less entries", () => { + expect(parseGatewayListen(":4566", "127.0.0.1")).toEqual([{ host: "127.0.0.1", port: 4566 }]); + }); + + test("parses multiple entries with explicit hosts", () => { + expect(parseGatewayListen("127.0.0.1:4566,0.0.0.0:5000", "127.0.0.1")).toEqual([ + { host: "127.0.0.1", port: 4566 }, + { host: "0.0.0.0", port: 5000 }, + ]); + }); + + test("rejects invalid ports", () => { + expect(() => parseGatewayListen("abc", "127.0.0.1")).toThrow(/Invalid GATEWAY_LISTEN/); + }); +}); + +describe("resolveVolume", () => { + test("host darwin defaults to the CLI-identical caches path", () => { + expect( + resolveVolume({ hostEnv: {}, isInDocker: false, platform: "darwin", homedir: "/Users/me" }) + ).toEqual({ type: "bind", source: "/Users/me/Library/Caches/localstack/volume" }); + }); + + test("host linux honors XDG_CACHE_HOME when absolute", () => { + expect( + resolveVolume({ + hostEnv: { XDG_CACHE_HOME: "/custom/cache" }, + isInDocker: false, + platform: "linux", + homedir: "/home/me", + }) + ).toEqual({ type: "bind", source: "/custom/cache/localstack/volume" }); + expect( + resolveVolume({ hostEnv: {}, isInDocker: false, platform: "linux", homedir: "/home/me" }) + ).toEqual({ type: "bind", source: "/home/me/.cache/localstack/volume" }); + }); + + test("host windows builds a forward-slash LOCALAPPDATA path", () => { + expect( + resolveVolume({ + hostEnv: { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local" }, + isInDocker: false, + platform: "win32", + homedir: "C:\\Users\\me", + }) + ).toEqual({ type: "bind", source: "C:/Users/me/AppData/Local/cache/localstack/volume" }); + }); + + test("explicit LOCALSTACK_VOLUME_DIR always wins", () => { + expect( + resolveVolume({ + hostEnv: { LOCALSTACK_VOLUME_DIR: "/data/ls" }, + isInDocker: true, + platform: "linux", + homedir: "/root", + }) + ).toEqual({ type: "bind", source: "/data/ls" }); + }); + + test("in docker: legacy XDG_CACHE_HOME keeps pre-migration DooD state", () => { + expect( + resolveVolume({ + hostEnv: { XDG_CACHE_HOME: "/Users/me/.localstack-mcp" }, + isInDocker: true, + platform: "linux", + homedir: "/root", + }) + ).toEqual({ type: "bind", source: "/Users/me/.localstack-mcp/localstack/volume" }); + }); + + test("in docker without hints: named volume (no host-path guessing)", () => { + expect( + resolveVolume({ hostEnv: {}, isInDocker: true, platform: "linux", homedir: "/root" }) + ).toEqual({ type: "volume", name: "localstack-mcp" }); + }); +}); + +describe("detectAiAgent", () => { + test("maps agent marker env vars like the CLI", () => { + expect(detectAiAgent({ CLAUDECODE: "1" })).toBe("claude-code"); + expect(detectAiAgent({ CURSOR_TRACE_ID: "x" })).toBe("cursor"); + expect(detectAiAgent({})).toBeUndefined(); + }); +}); + +describe("buildLocalStackContainerSpec", () => { + test("publishes 4566 + 443 + the 51-port service range (53 total) on 127.0.0.1 by default", () => { + const spec = buildLocalStackContainerSpec(baseInput()); + const keys = Object.keys(spec.HostConfig.PortBindings); + expect(keys).toHaveLength(53); + expect(spec.HostConfig.PortBindings["4566/tcp"]).toEqual([ + { HostIp: "127.0.0.1", HostPort: "4566" }, + ]); + expect(spec.HostConfig.PortBindings["443/tcp"]).toEqual([ + { HostIp: "127.0.0.1", HostPort: "443" }, + ]); + expect(spec.HostConfig.PortBindings["4510/tcp"]).toBeDefined(); + expect(spec.HostConfig.PortBindings["4560/tcp"]).toBeDefined(); + expect(Object.keys(spec.ExposedPorts)).toHaveLength(53); + }); + + test("binds to 0.0.0.0 when the server runs inside Docker (DooD reachability)", () => { + const spec = buildLocalStackContainerSpec(baseInput({ isInDocker: true })); + expect(spec.HostConfig.PortBindings["4566/tcp"][0].HostIp).toBe("0.0.0.0"); + expect(spec.HostConfig.PortBindings["443/tcp"][0].HostIp).toBe("0.0.0.0"); + }); + + test("LOCALSTACK_PORT changes only the host side of the gateway mapping", () => { + const spec = buildLocalStackContainerSpec(baseInput({ hostEnv: { LOCALSTACK_PORT: "4567" } })); + expect(spec.HostConfig.PortBindings["4566/tcp"]).toEqual([ + { HostIp: "127.0.0.1", HostPort: "4567" }, + ]); + expect(envMap(spec).GATEWAY_LISTEN).toBe(":4566,:443"); + expect(envMap(spec).LOCALSTACK_PORT).toBeUndefined(); + }); + + test("GATEWAY_LISTEN skips the implicit 443 and strips the default host in container env", () => { + const spec = buildLocalStackContainerSpec( + baseInput({ hostEnv: { GATEWAY_LISTEN: "127.0.0.1:4566" } }) + ); + expect(spec.HostConfig.PortBindings["443/tcp"]).toBeUndefined(); + expect(spec.HostConfig.PortBindings["4566/tcp"]).toEqual([ + { HostIp: "127.0.0.1", HostPort: "4566" }, + ]); + // Forwarding "127.0.0.1:4566" verbatim would make the gateway bind the + // container's loopback — must be host-stripped exactly like the CLI does. + expect(envMap(spec).GATEWAY_LISTEN).toBe(":4566"); + }); + + test("GATEWAY_LISTEN keeps non-default hosts in the container env (CLI parity)", () => { + const spec = buildLocalStackContainerSpec( + baseInput({ hostEnv: { GATEWAY_LISTEN: "0.0.0.0:5000" } }) + ); + expect(spec.HostConfig.PortBindings["5000/tcp"]).toEqual([ + { HostIp: "0.0.0.0", HostPort: "5000" }, + ]); + expect(envMap(spec).GATEWAY_LISTEN).toBe("0.0.0.0:5000"); + }); + + test("EXTERNAL_SERVICE_PORTS overrides move both the published range and the env", () => { + const spec = buildLocalStackContainerSpec( + baseInput({ + hostEnv: { EXTERNAL_SERVICE_PORTS_START: "4600", EXTERNAL_SERVICE_PORTS_END: "4610" }, + }) + ); + expect(spec.HostConfig.PortBindings["4600/tcp"]).toBeDefined(); + expect(spec.HostConfig.PortBindings["4610/tcp"]).toBeDefined(); + expect(spec.HostConfig.PortBindings["4510/tcp"]).toBeUndefined(); + expect(envMap(spec).EXTERNAL_SERVICE_PORTS_START).toBe("4600"); + expect(envMap(spec).EXTERNAL_SERVICE_PORTS_END).toBe("4610"); + }); + + test("env layering: shortlist < LOCALSTACK_* < envVars < reserved", () => { + const spec = buildLocalStackContainerSpec( + baseInput({ + hostEnv: { + DEBUG: "0", + LOCALSTACK_DEBUG: "0", + SERVICES: "s3,sqs", + LOCALSTACK_HOSTNAME: "host.docker.internal", // client-only: never forwarded + LOCALSTACK_PORT: "4566", // client-only + LOCALSTACK_AUTH_TOKEN: "ls-host-env-token", // reserved key wins + LOCALSTACK_IMAGE_NAME: "custom/image:1", // image selector, not runtime config + PROVIDER_OVERRIDE_S3: "v2", + UNRELATED_VAR: "nope", + }, + envVars: { DEBUG: "1", PERSISTENCE: "1" }, + authToken: "ls-real-token", + }) + ); + const env = envMap(spec); + expect(env.DEBUG).toBe("1"); // envVars beat host env + expect(env.PERSISTENCE).toBe("1"); + expect(env.SERVICES).toBe("s3,sqs"); + expect(env.PROVIDER_OVERRIDE_S3).toBe("v2"); + expect(env.LOCALSTACK_HOSTNAME).toBeUndefined(); + expect(env.LOCALSTACK_PORT).toBeUndefined(); + expect(env.LOCALSTACK_IMAGE_NAME).toBeUndefined(); + expect(env.UNRELATED_VAR).toBeUndefined(); + expect(env.LOCALSTACK_AUTH_TOKEN).toBe("ls-real-token"); + expect(env.MAIN_CONTAINER_NAME).toBe("localstack-main"); + expect(env.DOCKER_HOST).toBe("unix:///var/run/docker.sock"); + expect(env.LOCALSTACK_CLIENT_NAME).toBe("localstack-mcp-server"); + expect(env.LOCALSTACK_CLIENT_VERSION).toBe("0.5.0"); + }); + + test("uses structured Mounts (bind + docker socket) — no Binds strings", () => { + const spec = buildLocalStackContainerSpec(baseInput()); + expect(spec.HostConfig.Mounts).toEqual([ + { + Type: "bind", + Source: "/home/user/.cache/localstack/volume", + Target: "/var/lib/localstack", + }, + { Type: "bind", Source: "/var/run/docker.sock", Target: "/var/run/docker.sock" }, + ]); + expect((spec.HostConfig as any).Binds).toBeUndefined(); + }); + + test("named volume mount for DooD", () => { + const spec = buildLocalStackContainerSpec( + baseInput({ isInDocker: true, volume: { type: "volume", name: "localstack-mcp" } }) + ); + expect(spec.HostConfig.Mounts[0]).toEqual({ + Type: "volume", + Source: "localstack-mcp", + Target: "/var/lib/localstack", + }); + }); + + test("honors DOCKER_SOCK for the socket mount source", () => { + const spec = buildLocalStackContainerSpec( + baseInput({ hostEnv: { DOCKER_SOCK: "/run/user/1000/docker.sock" } }) + ); + expect(spec.HostConfig.Mounts[1].Source).toBe("/run/user/1000/docker.sock"); + expect(envMap(spec).DOCKER_HOST).toBe("unix:///var/run/docker.sock"); + }); + + test("MAIN_DOCKER_NETWORK becomes NetworkMode and is forwarded as env", () => { + const spec = buildLocalStackContainerSpec( + baseInput({ hostEnv: { MAIN_DOCKER_NETWORK: "ls-net" } }) + ); + expect(spec.HostConfig.NetworkMode).toBe("ls-net"); + expect(envMap(spec).MAIN_DOCKER_NETWORK).toBe("ls-net"); + const withoutNetwork = buildLocalStackContainerSpec(baseInput()); + expect(withoutNetwork.HostConfig.NetworkMode).toBeUndefined(); + }); + + test("AutoRemove is set (CLI --rm parity)", () => { + expect(buildLocalStackContainerSpec(baseInput()).HostConfig.AutoRemove).toBe(true); + }); + + test("snowflake stack: snowflake image, same container name", () => { + const spec = buildLocalStackContainerSpec(baseInput({ stack: "snowflake" })); + expect(spec.Image).toBe("localstack/snowflake:latest"); + expect(spec.name).toBe("localstack-main"); + }); + + test("restart recreation can pin the original image and name", () => { + const spec = buildLocalStackContainerSpec( + baseInput({ + imageOverride: "localstack/localstack-pro:3.9", + containerNameOverride: "localstack-aws", + }) + ); + expect(spec.Image).toBe("localstack/localstack-pro:3.9"); + expect(spec.name).toBe("localstack-aws"); + expect(envMap(spec).MAIN_CONTAINER_NAME).toBe("localstack-aws"); + }); + + test("tags agent-driven starts with AI_AGENT like the CLI", () => { + const spec = buildLocalStackContainerSpec(baseInput({ hostEnv: { CLAUDECODE: "1" } })); + expect(envMap(spec).AI_AGENT).toBe("claude-code"); + }); +}); diff --git a/src/lib/localstack/container-spec.logic.ts b/src/lib/localstack/container-spec.logic.ts new file mode 100644 index 0000000..2590bad --- /dev/null +++ b/src/lib/localstack/container-spec.logic.ts @@ -0,0 +1,403 @@ +/** + * Pure builder for the LocalStack container spec (dockerode `createContainer` options). + * + * Replicates what `localstack start` used to assemble via the Python CLI so the MCP + * server can create the runtime with no CLI installed. Behavioral anchors (verified + * against the 2026.x `localstack_cli` wheel source): + * - image `localstack/localstack-pro:latest` (pro unconditional), `--stack snowflake` + * ⇒ `localstack/snowflake:latest` + * - container name `localstack-main`, also injected as `MAIN_CONTAINER_NAME` env + * - ports 4566, 443 (only when GATEWAY_LISTEN is unset) and the external service + * range 4510–4560 inclusive; bind host 127.0.0.1 on a host, 0.0.0.0 in Docker + * - volume dir → /var/lib/localstack, docker socket → /var/run/docker.sock + * - GATEWAY_LISTEN forwarded host-stripped (`:4566,:443`) — the host part belongs + * only in the port bindings, never inside the container + */ + +export const DEFAULT_CONTAINER_NAME = "localstack-main"; +export const DEFAULT_AWS_IMAGE = "localstack/localstack-pro:latest"; +export const DEFAULT_SNOWFLAKE_IMAGE = "localstack/snowflake:latest"; +export const DEFAULT_GATEWAY_CONTAINER_PORT = 4566; +export const DEFAULT_HTTPS_GATEWAY_PORT = 443; +export const DEFAULT_SERVICE_PORT_START = 4510; +export const DEFAULT_SERVICE_PORT_END = 4560; // inclusive — 51 ports, CLI parity +export const NAMED_VOLUME_NAME = "localstack-mcp"; +const VOLUME_CONTAINER_PATH = "/var/lib/localstack"; +const DOCKER_SOCKET_CONTAINER_PATH = "/var/run/docker.sock"; + +/** + * Env vars that configure how MCP clients/tools reach LocalStack. They must never + * leak into the runtime container, where they would corrupt its own URL generation. + */ +const CLIENT_ONLY_ENV_KEYS = new Set([ + "HOSTNAME", + "LOCALSTACK_HOSTNAME", + "LOCALSTACK_PORT", + "AWS_ENDPOINT_URL", + "AWS_ENDPOINT_URL_S3", + "S3_ENDPOINT", + "AWS_S3_FORCE_PATH_STYLE", + "LOCALSTACK_AUTH_TOKEN", // re-added deliberately as a reserved key + "LOCALSTACK_API_KEY", + "LOCALSTACK_IMAGE_NAME", // selects the image; not runtime config + "LOCALSTACK_VOLUME_DIR", // host-side path; meaningless inside the container +]); + +/** + * Unprefixed LocalStack config vars commonly set on hosts that the builder forwards. + * The CLI forwarded ~250 documented names; anything not listed here still reaches the + * container via `LOCALSTACK_` prefixing, which the runtime aliases natively. + */ +const FORWARDED_CONFIG_ENV_NAMES = new Set([ + "DEBUG", + "LS_LOG", + "SERVICES", + "PERSISTENCE", + "EAGER_SERVICE_LOADING", + "ENFORCE_IAM", + "IAM_SOFT_MODE", + "EXTENSION_AUTO_INSTALL", + "APP_INSPECTOR", + "DNS_ADDRESS", + "MAIN_DOCKER_NETWORK", +]); +const FORWARDED_CONFIG_ENV_PREFIXES = ["LAMBDA_", "CFN_", "SNOWFLAKE_", "SF_"]; + +/** Same detector list the localstack CLI uses to tag agent-driven starts. */ +const AI_AGENT_DETECTORS: Array<[string, string[]]> = [ + ["cursor", ["CURSOR_TRACE_ID"]], + ["cursor-cli", ["CURSOR_AGENT"]], + ["gemini", ["GEMINI_CLI"]], + ["codex", ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"]], + ["cowork", ["CLAUDE_CODE_IS_COWORK"]], + ["claude-code", ["CLAUDECODE", "CLAUDE_CODE"]], + ["github-copilot", ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"]], + ["goose", ["GOOSE_PROVIDER"]], + ["augment", ["AUGMENT_AGENT"]], + ["opencode", ["OPENCODE", "OPENCODE_CALLER", "OPENCODE_CLIENT"]], + ["antigravity", ["ANTIGRAVITY_AGENT"]], + ["devin", ["__COG_BASHRC_SOURCED", "__COG_SHELL_INTEGRATION_SCRIPT", "__COG_SKIP_PYENV"]], + ["replit", ["REPL_ID"]], +]; + +export type LocalStackStack = "aws" | "snowflake"; + +export type VolumeResolution = { type: "bind"; source: string } | { type: "volume"; name: string }; + +export interface GatewayListenEntry { + host: string; + port: number; +} + +export interface ContainerSpecInput { + stack: LocalStackStack; + /** Tool-level env overrides (`envVars` argument of localstack-management start). */ + envVars?: Record; + hostEnv: Record; + authToken: string; + /** Whether the MCP server itself runs inside a container (changes bind IP + volume). */ + isInDocker: boolean; + volume: VolumeResolution; + serverVersion: string; + /** Overrides for restart-in-place recreation: reuse the original container's identity. */ + imageOverride?: string; + containerNameOverride?: string; +} + +export interface PortBindingEntry { + containerPort: number; + hostIp: string; + hostPort: number; +} + +export interface LocalStackContainerSpec { + name: string; + Image: string; + Env: string[]; + ExposedPorts: Record>; + HostConfig: { + AutoRemove: boolean; + PortBindings: Record>; + Mounts: Array<{ Type: "bind" | "volume"; Source: string; Target: string }>; + NetworkMode?: string; + }; +} + +export function resolveImage( + stack: LocalStackStack, + hostEnv: Record +): string { + if (stack === "snowflake") return DEFAULT_SNOWFLAKE_IMAGE; + const override = hostEnv.LOCALSTACK_IMAGE_NAME?.trim() || hostEnv.IMAGE_NAME?.trim(); + return override || DEFAULT_AWS_IMAGE; +} + +export function resolveContainerName(hostEnv: Record): string { + return ( + hostEnv.MAIN_CONTAINER_NAME?.trim() || + hostEnv.LOCALSTACK_MAIN_CONTAINER_NAME?.trim() || + DEFAULT_CONTAINER_NAME + ); +} + +/** + * Parse a GATEWAY_LISTEN value ("[host]:port" entries, comma-separated). Entries + * without a host get the platform default bind IP. + */ +export function parseGatewayListen(value: string, defaultIp: string): GatewayListenEntry[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const separator = entry.lastIndexOf(":"); + const host = separator > 0 ? entry.slice(0, separator) : ""; + const portText = separator >= 0 ? entry.slice(separator + 1) : entry; + const port = Number(portText); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid GATEWAY_LISTEN entry "${entry}": port must be 1-65535.`); + } + return { host: host || defaultIp, port }; + }); +} + +export function detectAiAgent(hostEnv: Record): string | undefined { + for (const [agent, markers] of AI_AGENT_DETECTORS) { + if (markers.some((marker) => hostEnv[marker])) return agent; + } + return undefined; +} + +export interface VolumeResolutionInput { + hostEnv: Record; + isInDocker: boolean; + platform: NodeJS.Platform; + homedir: string; +} + +/** + * Where LocalStack state lives. Host runs default to the exact per-OS directory the + * localstack CLI used, so existing users' state carries over. Inside Docker the + * source path must be meaningful to the HOST daemon: an explicit LOCALSTACK_VOLUME_DIR + * wins; a legacy XDG_CACHE_HOME (identical-path mount from the pre-migration setup) + * keeps old DooD state; otherwise a named volume avoids host paths entirely. + */ +export function resolveVolume({ + hostEnv, + isInDocker, + platform, + homedir, +}: VolumeResolutionInput): VolumeResolution { + const explicit = hostEnv.LOCALSTACK_VOLUME_DIR?.trim(); + if (explicit) return { type: "bind", source: normalizeBindPath(explicit) }; + + if (isInDocker) { + const legacyCache = hostEnv.XDG_CACHE_HOME?.trim(); + if (legacyCache) { + return { type: "bind", source: joinPosix(legacyCache, "localstack", "volume") }; + } + return { type: "volume", name: NAMED_VOLUME_NAME }; + } + + if (platform === "darwin") { + return { + type: "bind", + source: joinPosix(homedir, "Library", "Caches", "localstack", "volume"), + }; + } + if (platform === "win32") { + const localAppData = hostEnv.LOCALAPPDATA?.trim() || `${homedir}\\AppData\\Local`; + return { + type: "bind", + source: normalizeBindPath(`${localAppData}\\cache\\localstack\\volume`), + }; + } + const cacheHome = hostEnv.XDG_CACHE_HOME?.trim(); + const cacheBase = + cacheHome && cacheHome.startsWith("/") ? cacheHome : joinPosix(homedir, ".cache"); + return { type: "bind", source: joinPosix(cacheBase, "localstack", "volume") }; +} + +function joinPosix(...parts: string[]): string { + return parts.join("/").replace(/\/+/g, "/"); +} + +/** Docker Desktop accepts drive-letter paths with forward slashes; avoids `\` escaping woes. */ +function normalizeBindPath(p: string): string { + return p.replace(/\\/g, "/"); +} + +interface ResolvedPorts { + bindings: PortBindingEntry[]; + containerGatewayListen: string; + servicePortStart: number; + servicePortEnd: number; +} + +function firstDefined( + keys: string[], + ...sources: Array> +): string | undefined { + for (const source of sources) { + for (const key of keys) { + const value = source[key]; + if (value !== undefined && value !== "") return value; + } + } + return undefined; +} + +function resolvePorts(input: ContainerSpecInput): ResolvedPorts { + const { hostEnv, envVars = {} } = input; + const defaultIp = input.isInDocker ? "0.0.0.0" : "127.0.0.1"; + + const startRaw = firstDefined( + ["EXTERNAL_SERVICE_PORTS_START", "LOCALSTACK_EXTERNAL_SERVICE_PORTS_START"], + envVars, + hostEnv + ); + const servicePortStart = startRaw ? Number(startRaw) : DEFAULT_SERVICE_PORT_START; + const endRaw = firstDefined( + ["EXTERNAL_SERVICE_PORTS_END", "LOCALSTACK_EXTERNAL_SERVICE_PORTS_END"], + envVars, + hostEnv + ); + const servicePortEnd = endRaw ? Number(endRaw) : servicePortStart + 50; + if ( + !Number.isInteger(servicePortStart) || + !Number.isInteger(servicePortEnd) || + servicePortEnd < servicePortStart + ) { + throw new Error(`Invalid EXTERNAL_SERVICE_PORTS range: ${servicePortStart}-${servicePortEnd}.`); + } + + const bindings: PortBindingEntry[] = []; + let containerGatewayListen: string; + + const gatewayListenRaw = firstDefined( + ["GATEWAY_LISTEN", "LOCALSTACK_GATEWAY_LISTEN"], + envVars, + hostEnv + ); + if (gatewayListenRaw) { + // CLI parity: each entry publishes port:port; the container-side env strips + // hosts equal to the default bind IP (`:4566`) and keeps explicit others. + const entries = parseGatewayListen(gatewayListenRaw, defaultIp); + for (const entry of entries) { + bindings.push({ containerPort: entry.port, hostIp: entry.host, hostPort: entry.port }); + } + containerGatewayListen = entries + .map((entry) => `${entry.host === defaultIp ? "" : entry.host}:${entry.port}`) + .join(","); + } else { + // Default: gateway on host-side (container always 4566), + // plus the HTTPS gateway on 443 — the pro CLI adds it only when GATEWAY_LISTEN + // is unset, and so do we. + const hostGatewayPort = Number(hostEnv.LOCALSTACK_PORT || DEFAULT_GATEWAY_CONTAINER_PORT); + bindings.push({ + containerPort: DEFAULT_GATEWAY_CONTAINER_PORT, + hostIp: defaultIp, + hostPort: hostGatewayPort, + }); + bindings.push({ + containerPort: DEFAULT_HTTPS_GATEWAY_PORT, + hostIp: defaultIp, + hostPort: DEFAULT_HTTPS_GATEWAY_PORT, + }); + containerGatewayListen = `:${DEFAULT_GATEWAY_CONTAINER_PORT},:${DEFAULT_HTTPS_GATEWAY_PORT}`; + } + + // The Engine API has no range syntax — enumerate every service port. + for (let port = servicePortStart; port <= servicePortEnd; port++) { + bindings.push({ containerPort: port, hostIp: defaultIp, hostPort: port }); + } + + return { bindings, containerGatewayListen, servicePortStart, servicePortEnd }; +} + +function buildEnv(input: ContainerSpecInput, ports: ResolvedPorts, name: string): string[] { + const { hostEnv, envVars = {} } = input; + const env = new Map(); + + // (a) curated unprefixed config vars set on the host + for (const [key, value] of Object.entries(hostEnv)) { + if (value === undefined) continue; + const forwarded = + FORWARDED_CONFIG_ENV_NAMES.has(key) || + FORWARDED_CONFIG_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)); + if (forwarded) env.set(key, value); + } + + // (b) all LOCALSTACK_* / PROVIDER_OVERRIDE_* host vars minus client-only keys + for (const [key, value] of Object.entries(hostEnv)) { + if (value === undefined || CLIENT_ONLY_ENV_KEYS.has(key)) continue; + if (key.startsWith("LOCALSTACK_") || key.startsWith("PROVIDER_OVERRIDE_")) { + env.set(key, value); + } + } + + // (c) explicit tool envVars win over anything host-derived + for (const [key, value] of Object.entries(envVars)) { + if (CLIENT_ONLY_ENV_KEYS.has(key) && key !== "LOCALSTACK_AUTH_TOKEN") continue; + env.set(key, value); + } + + // (d) reserved keys the launcher owns — always last, always consistent with the + // published ports and mounts. + env.set("MAIN_CONTAINER_NAME", name); + env.set("GATEWAY_LISTEN", ports.containerGatewayListen); + env.set("EXTERNAL_SERVICE_PORTS_START", String(ports.servicePortStart)); + env.set("EXTERNAL_SERVICE_PORTS_END", String(ports.servicePortEnd)); + env.set("DOCKER_HOST", `unix://${DOCKER_SOCKET_CONTAINER_PATH}`); + env.set("LOCALSTACK_AUTH_TOKEN", input.authToken); + env.set("LOCALSTACK_CLIENT_NAME", "localstack-mcp-server"); + env.set("LOCALSTACK_CLIENT_VERSION", input.serverVersion); + const aiAgent = detectAiAgent(hostEnv); + if (aiAgent) env.set("AI_AGENT", aiAgent); + + return Array.from(env.entries()).map(([key, value]) => `${key}=${value}`); +} + +export function buildLocalStackContainerSpec(input: ContainerSpecInput): LocalStackContainerSpec { + const name = input.containerNameOverride?.trim() || resolveContainerName(input.hostEnv); + const image = input.imageOverride?.trim() || resolveImage(input.stack, input.hostEnv); + const ports = resolvePorts(input); + + const exposedPorts: Record> = {}; + const portBindings: Record> = {}; + for (const binding of ports.bindings) { + const key = `${binding.containerPort}/tcp`; + exposedPorts[key] = {}; + portBindings[key] = portBindings[key] || []; + portBindings[key].push({ HostIp: binding.hostIp, HostPort: String(binding.hostPort) }); + } + + const mounts: LocalStackContainerSpec["HostConfig"]["Mounts"] = [ + input.volume.type === "bind" + ? { Type: "bind", Source: input.volume.source, Target: VOLUME_CONTAINER_PATH } + : { Type: "volume", Source: input.volume.name, Target: VOLUME_CONTAINER_PATH }, + { + Type: "bind", + Source: input.hostEnv.DOCKER_SOCK?.trim() || DOCKER_SOCKET_CONTAINER_PATH, + Target: DOCKER_SOCKET_CONTAINER_PATH, + }, + ]; + + const networkMode = + input.envVars?.MAIN_DOCKER_NETWORK?.trim() || + input.hostEnv.MAIN_DOCKER_NETWORK?.trim() || + input.hostEnv.LOCALSTACK_MAIN_DOCKER_NETWORK?.trim(); + + return { + name, + Image: image, + Env: buildEnv(input, ports, name), + ExposedPorts: exposedPorts, + HostConfig: { + AutoRemove: true, + PortBindings: portBindings, + Mounts: mounts, + ...(networkMode ? { NetworkMode: networkMode } : {}), + }, + }; +} diff --git a/src/lib/localstack/extensions.logic.test.ts b/src/lib/localstack/extensions.logic.test.ts new file mode 100644 index 0000000..f06123e --- /dev/null +++ b/src/lib/localstack/extensions.logic.test.ts @@ -0,0 +1,140 @@ +import { + formatInstalledExtensions, + parseExtensionEvents, + parseInstalledExtensions, + summarizeInstall, + summarizeUninstall, +} from "./extensions.logic"; + +const installSuccessStream = [ + "2026-07-04T10:00:00.000 INFO --- [MainThread] l.p.c.b.licensingv2 : Successfully activated cached license", + '{"event": "status", "message": "Checking installed extensions"}', + '{"event": "status", "message": "Installing extension"}', + '{"event": "pip", "message": "Collecting localstack-extension-httpbin"}', + "not json at all", + '{"event": "log", "message": "Extension successfully installed"}', + '{"event": "extension", "message": "", "extra": {"name": "httpbin"}}', + '{"event": "status", "message": "Extension installation completed"}', +].join("\n"); + +describe("parseExtensionEvents", () => { + test("parses JSON-lines events and skips interleaved log noise", () => { + const events = parseExtensionEvents(installSuccessStream); + expect(events.map((event) => event.event)).toEqual([ + "status", + "status", + "pip", + "log", + "extension", + "status", + ]); + }); +}); + +describe("summarizeInstall", () => { + test("recognizes a successful install", () => { + const outcome = summarizeInstall(parseExtensionEvents(installSuccessStream)); + expect(outcome.kind).toBe("installed"); + expect(outcome.success).toBe(true); + expect(outcome.summaryLines).toContain("Extension successfully installed"); + // pip noise excluded from the summary + expect(outcome.summaryLines.join("\n")).not.toContain("Collecting"); + }); + + test("recognizes already-installed as success", () => { + const outcome = summarizeInstall( + parseExtensionEvents( + '{"event": "log", "message": "Extension localstack-keycloak (0.1.0 by LocalStack Team) already installed"}' + ) + ); + expect(outcome.kind).toBe("already-installed"); + expect(outcome.success).toBe(true); + }); + + test("maps unresolvable packages (incl. git URLs) to not-found", () => { + const outcome = summarizeInstall( + parseExtensionEvents( + [ + '{"event": "status", "message": "Installing extension"}', + '{"event": "pip", "message": "ERROR: No matching distribution found for no-such-ext"}', + '{"event": "error", "message": "Could not resolve package no-such-ext, please check the URL or that the package exists in pypi."}', + ].join("\n") + ) + ); + expect(outcome.kind).toBe("not-found"); + expect(outcome.success).toBe(false); + expect(outcome.errorDetail).toContain("Could not resolve package"); + }); + + test("maps module exceptions to failed", () => { + const outcome = summarizeInstall( + parseExtensionEvents( + '{"event": "exception", "message": "Error while installing extension: boom", "extra": {"traceback": "..."}}' + ) + ); + expect(outcome.kind).toBe("failed"); + expect(outcome.errorDetail).toContain("boom"); + }); + + test("treats a pip install that registered no extension as no-change failure", () => { + const outcome = summarizeInstall( + parseExtensionEvents('{"event": "log", "message": "No change"}') + ); + expect(outcome.kind).toBe("no-change"); + expect(outcome.success).toBe(false); + }); +}); + +describe("summarizeUninstall", () => { + test("recognizes a successful uninstall", () => { + const outcome = summarizeUninstall( + parseExtensionEvents( + [ + '{"event": "log", "message": "Uninstalling extension localstack-extension-httpbin (0.1.0)"}', + '{"event": "log", "message": "Extension successfully uninstalled"}', + ].join("\n") + ) + ); + expect(outcome.kind).toBe("uninstalled"); + expect(outcome.success).toBe(true); + }); + + test("maps not-installed to a distinct failure kind", () => { + const outcome = summarizeUninstall( + parseExtensionEvents('{"event": "log", "message": "Extension no-such is not installed"}') + ); + expect(outcome.kind).toBe("not-installed"); + expect(outcome.success).toBe(false); + }); +}); + +describe("parseInstalledExtensions / formatInstalledExtensions", () => { + test("parses list output (one plux metadata object per line)", () => { + const output = [ + "2026-07-04 INFO license activated", + JSON.stringify({ + namespace: "localstack.extensions", + name: "keycloak", + distribution: { + name: "localstack-keycloak", + version: "0.1.0", + summary: "Keycloak for IAM", + author: "LocalStack Team", + }, + }), + ].join("\n"); + + const extensions = parseInstalledExtensions(output); + expect(extensions).toHaveLength(1); + const markdown = formatInstalledExtensions(extensions); + expect(markdown).toContain("Installed LocalStack Extensions"); + expect(markdown).toContain("localstack-keycloak"); + expect(markdown).toContain("0.1.0"); + }); + + test("formats an empty list with marketplace guidance", () => { + expect(formatInstalledExtensions([])).toContain( + "No LocalStack extensions are currently installed" + ); + }); +}); diff --git a/src/lib/localstack/extensions.logic.ts b/src/lib/localstack/extensions.logic.ts new file mode 100644 index 0000000..763d407 --- /dev/null +++ b/src/lib/localstack/extensions.logic.ts @@ -0,0 +1,200 @@ +/** + * Parsing for the in-container LocalStack extensions manager + * (`python -m localstack.pro.core.bootstrap.extensions `), which streams + * JSON-lines events: {event: "status"|"log"|"pip"|"error"|"extension"|"exception", + * message, extra?}. `list` emits one plux metadata JSON object per line instead. + * + * Verified against the 2026.x pro image: the module exits 0 even on failure (errors + * are reported as `error`/`exception` events), so outcomes MUST be derived from the + * event stream, not the exit code. + */ + +export const EXTENSIONS_MANAGER_COMMAND = [ + "/opt/code/localstack/.venv/bin/python", + "-m", + "localstack.pro.core.bootstrap.extensions", +]; + +/** + * Ensure the extensions venv on /var/lib/localstack is usable before install / + * uninstall (the manager's `pip` calls need a working interpreter): + * - missing venv → `init` (what the CLI's _ensure_venv_initialized did) + * - broken interpreter symlinks → the venv on the shared volume was created by an + * older image whose python lived at a different path (e.g. /usr/local/bin vs + * /usr/bin). Re-link via `venv --upgrade` — repairs scripts without touching + * installed packages. (The official CLI fails hard on this case.) + * - missing pip → bootstrap via ensurepip. + */ +export const EXTENSIONS_VENV_REPAIR_SCRIPT = [ + "V=/var/lib/localstack/lib/extensions/python_venv", + "PY=/opt/code/localstack/.venv/bin/python", + 'if [ ! -e "$V/pyvenv.cfg" ]; then "$PY" -m localstack.pro.core.bootstrap.extensions init; fi', + 'if ! "$V/bin/python" -c "import sys" >/dev/null 2>&1; then rm -f "$V/bin/python" "$V/bin/python3" "$V"/bin/python3.*; "$PY" -m venv --upgrade "$V"; fi', + 'if [ ! -x "$V/bin/pip" ]; then "$V/bin/python" -m ensurepip --upgrade >/dev/null 2>&1 || true; fi', +].join("\n"); + +export interface ExtensionEvent { + event: string; + message?: string; + extra?: unknown; +} + +export interface InstalledExtension { + name?: string; + distribution?: { + name?: string; + version?: string; + summary?: string; + author?: string; + }; +} + +/** Parse a JSON-lines stream, skipping non-JSON noise (log lines, pip banners). */ +export function parseJsonLines(output: string): unknown[] { + const parsed: unknown[] = []; + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) continue; + try { + parsed.push(JSON.parse(trimmed)); + } catch { + // Interleaved runtime logging (e.g. license activation INFO lines) — skip. + } + } + return parsed; +} + +export function parseExtensionEvents(output: string): ExtensionEvent[] { + return parseJsonLines(output).filter( + (value): value is ExtensionEvent => + !!value && typeof value === "object" && typeof (value as ExtensionEvent).event === "string" + ); +} + +export function parseInstalledExtensions(output: string): InstalledExtension[] { + return parseJsonLines(output).filter( + (value): value is InstalledExtension => + !!value && + typeof value === "object" && + // plux metadata objects have name + factory/distribution, no `event` key + (value as ExtensionEvent).event === undefined && + typeof (value as InstalledExtension).name === "string" + ); +} + +export type ExtensionOutcomeKind = + | "installed" + | "already-installed" + | "no-change" + | "uninstalled" + | "not-installed" + | "not-found" + | "failed"; + +export interface ExtensionOutcome { + kind: ExtensionOutcomeKind; + success: boolean; + /** Human-readable log/status/error lines in stream order (pip noise excluded). */ + summaryLines: string[]; + errorDetail?: string; +} + +function collectSummaryLines(events: ExtensionEvent[]): string[] { + return events + .filter((event) => ["log", "status", "error", "exception"].includes(event.event)) + .map((event) => event.message || "") + .filter(Boolean); +} + +export function summarizeInstall(events: ExtensionEvent[]): ExtensionOutcome { + const summaryLines = collectSummaryLines(events); + const logMessages = events + .filter((event) => event.event === "log") + .map((event) => event.message || ""); + const errorEvent = events.find((event) => event.event === "error"); + const exceptionEvent = events.find((event) => event.event === "exception"); + + if (errorEvent?.message?.includes("Could not resolve package")) { + return { kind: "not-found", success: false, summaryLines, errorDetail: errorEvent.message }; + } + if (exceptionEvent || errorEvent) { + const detail = (exceptionEvent || errorEvent)?.message || "Extension installation failed."; + return { kind: "failed", success: false, summaryLines, errorDetail: detail }; + } + if (logMessages.some((message) => message.includes("already installed"))) { + return { kind: "already-installed", success: true, summaryLines }; + } + if (logMessages.some((message) => message.includes("Extension successfully installed"))) { + return { kind: "installed", success: true, summaryLines }; + } + if (logMessages.some((message) => message.includes("No change"))) { + return { + kind: "no-change", + success: false, + summaryLines, + errorDetail: "The package was installed but did not register any LocalStack extension.", + }; + } + return { + kind: "failed", + success: false, + summaryLines, + errorDetail: "The extension manager did not report a successful installation.", + }; +} + +export function summarizeUninstall(events: ExtensionEvent[]): ExtensionOutcome { + const summaryLines = collectSummaryLines(events); + const logMessages = events + .filter((event) => event.event === "log") + .map((event) => event.message || ""); + const exceptionEvent = events.find( + (event) => event.event === "exception" || event.event === "error" + ); + + if (exceptionEvent) { + return { + kind: "failed", + success: false, + summaryLines, + errorDetail: exceptionEvent.message || "Extension uninstall failed.", + }; + } + if (logMessages.some((message) => message.includes("is not installed"))) { + return { + kind: "not-installed", + success: false, + summaryLines, + errorDetail: logMessages.find((m) => m.includes("is not installed")), + }; + } + if (logMessages.some((message) => message.includes("Extension successfully uninstalled"))) { + return { kind: "uninstalled", success: true, summaryLines }; + } + return { + kind: "failed", + success: false, + summaryLines, + errorDetail: "The extension manager did not report a successful uninstall.", + }; +} + +export function formatInstalledExtensions(extensions: InstalledExtension[]): string { + if (extensions.length === 0) { + return "No LocalStack extensions are currently installed.\n\nUse the `available` action to browse the marketplace."; + } + let markdown = `## Installed LocalStack Extensions\n\n${extensions.length} extension(s) installed.\n`; + for (const extension of extensions) { + const dist = extension.distribution || {}; + markdown += `\n### ${dist.name || extension.name}\n`; + const meta = [ + dist.version ? `**Version:** ${dist.version}` : null, + dist.author ? `**Author:** ${dist.author}` : null, + ] + .filter(Boolean) + .join(" | "); + if (meta) markdown += `${meta}\n`; + if (dist.summary) markdown += `${dist.summary}\n`; + } + return markdown; +} diff --git a/src/lib/localstack/localstack.utils.test.ts b/src/lib/localstack/localstack.utils.test.ts index ccbc913..8e2996a 100644 --- a/src/lib/localstack/localstack.utils.test.ts +++ b/src/lib/localstack/localstack.utils.test.ts @@ -1,57 +1,98 @@ import { - checkLocalStackCli, - detectLifecycleCli, getGatewayHealth, getLocalStackStatus, getSnowflakeEmulatorStatus, - startRuntime, + launchRuntime, + restartRuntimeInPlace, } from "./localstack.utils"; -import { runCommand } from "../../core/command-runner"; import { httpClient } from "../../core/http-client"; -import { spawn } from "child_process"; +import { request as httpRequest } from "http"; import { EventEmitter } from "events"; -import { PassThrough } from "stream"; - -jest.mock("../../core/command-runner", () => ({ - runCommand: jest.fn(), -})); jest.mock("../../core/http-client", () => ({ httpClient: { request: jest.fn() }, HttpError: class HttpError extends Error {}, })); -jest.mock("child_process", () => ({ - spawn: jest.fn(), +jest.mock("http", () => ({ + request: jest.fn(), })); -const mockedRunCommand = runCommand as jest.MockedFunction; const mockedRequest = httpClient.request as jest.MockedFunction; -const mockedSpawn = spawn as jest.MockedFunction; - -function mockChildProcess() { - const child = new EventEmitter() as any; - child.stdout = new PassThrough(); - child.stderr = new PassThrough(); - mockedSpawn.mockReturnValueOnce(child); - return child; +const mockedHttpRequest = httpRequest as jest.MockedFunction; + +const gatewayUnreachable = () => mockedRequest.mockRejectedValue(new Error("ECONNREFUSED")); + +/** Simulate one node:http request/response round-trip for the Snowflake probe. */ +function mockHttpResponse({ statusCode = 200, body = "" }: { statusCode?: number; body?: string }) { + mockedHttpRequest.mockImplementationOnce(((options: any, callback: any) => { + const req = new EventEmitter() as any; + req.write = jest.fn(); + req.end = jest.fn(() => { + const res = new EventEmitter() as any; + res.statusCode = statusCode; + callback(res); + setImmediate(() => { + if (body) res.emit("data", Buffer.from(body)); + res.emit("end"); + }); + }); + req.destroy = jest.fn((err: Error) => req.emit("error", err)); + return req; + }) as any); } -const cliUnavailable = () => - mockedRunCommand.mockResolvedValue({ - stdout: "", - stderr: "command not found: localstack", - error: new Error("spawn localstack ENOENT"), - exitCode: null, - } as any); +function mockHttpError(message: string) { + mockedHttpRequest.mockImplementationOnce(((_options: any, _callback: any) => { + const req = new EventEmitter() as any; + req.write = jest.fn(); + req.end = jest.fn(() => { + setImmediate(() => req.emit("error", new Error(message))); + }); + req.destroy = jest.fn(); + return req; + }) as any); +} -const gatewayUnreachable = () => mockedRequest.mockRejectedValue(new Error("ECONNREFUSED")); +/** Minimal DockerApiClient stand-in for launchRuntime. */ +function mockDockerClient(overrides: Record = {}) { + const logHandle = { + getBuffered: jest.fn(() => ""), + hasExited: jest.fn(() => false), + onExit: jest.fn(), + destroy: jest.fn(), + }; + const client = { + ping: jest.fn().mockResolvedValue(undefined), + findContainerByNameAnyState: jest.fn().mockResolvedValue(null), + removeContainer: jest.fn().mockResolvedValue(undefined), + waitForRemoval: jest.fn().mockResolvedValue(undefined), + ensureNetwork: jest.fn().mockResolvedValue(undefined), + imageExists: jest.fn().mockResolvedValue(true), + pullImage: jest.fn().mockResolvedValue(undefined), + createAndStartContainer: jest.fn().mockResolvedValue("container-123"), + attachLogBuffer: jest.fn().mockResolvedValue(logHandle), + ...overrides, + }; + return { client: client as any, logHandle }; +} + +const launchDefaults = { + stack: "aws" as const, + processLabel: "LocalStack", + alreadyRunningMessage: "already running", + successTitle: "🚀 LocalStack started successfully!", + statusHeading: "Status", + timeoutMessage: "❌ timed out", + pollIntervalMs: 10, + maxWaitMs: 100, +}; describe("localstack.utils", () => { beforeEach(() => { - mockedRunCommand.mockReset(); mockedRequest.mockReset(); - mockedSpawn.mockReset(); + mockedHttpRequest.mockReset(); + process.env.LOCALSTACK_AUTH_TOKEN = "ls-test-token"; }); describe("getGatewayHealth", () => { @@ -96,224 +137,286 @@ describe("localstack.utils", () => { }); describe("getLocalStackStatus", () => { - test("does not mark instance as running from CLI output when the gateway is unreachable", async () => { + test("reports not running as informational status (never an error) when the gateway is down", async () => { + // The Docker smoke test's pre-start `status` scenario: must come back as plain + // status text, not an errorMessage — the management tool renders statusOutput + // without a leading ❌, which is exactly what the harness asserts. gatewayUnreachable(); - mockedRunCommand.mockResolvedValueOnce({ - stdout: "Runtime status: running (Ready)", - stderr: "", - exitCode: 0, - } as any); const result = await getLocalStackStatus(); expect(result.isRunning).toBe(false); expect(result.isReady).toBe(false); - expect(result.statusOutput).toContain("running"); + expect(result.statusOutput).toMatch(/not running/i); + expect(result.statusOutput?.startsWith("❌")).toBe(false); + expect(result.errorMessage).toBeUndefined(); }); - test("detects an lstk-managed runtime via the gateway even when the CLI is absent", async () => { - // The Python `localstack` binary is missing (lstk-only host), so the CLI yields - // nothing — but the gateway is healthy, so detection must still report running. - cliUnavailable(); - mockedRequest.mockResolvedValueOnce({ - services: { s3: "available" }, - edition: "pro", - } as any); + test("enriches running status from /_localstack/info", async () => { + mockedRequest.mockImplementation(async (endpoint: any) => { + if (String(endpoint).includes("/_localstack/health")) { + return { services: { s3: "available" }, edition: "pro", version: "4.0.0" } as any; + } + if (String(endpoint).includes("/_localstack/info")) { + return { is_license_activated: true, uptime: 42, session_id: "abc" } as any; + } + throw new Error(`unexpected endpoint ${endpoint}`); + }); const result = await getLocalStackStatus(); expect(result.isRunning).toBe(true); expect(result.isReady).toBe(true); expect(result.statusOutput).toContain("/_localstack/health"); + expect(result.statusOutput).toContain("License activated: yes"); + expect(result.statusOutput).toContain("Uptime: 42s"); }); - test("can skip CLI enrichment for lifecycle polling", async () => { - mockedRequest.mockResolvedValueOnce({ - services: { s3: "available" }, - } as any); + test("still reports running when /_localstack/info is unavailable", async () => { + mockedRequest.mockImplementation(async (endpoint: any) => { + if (String(endpoint).includes("/_localstack/health")) { + return { services: { s3: "available" } } as any; + } + throw new Error("info not available"); + }); - const result = await getLocalStackStatus({ includeCliStatus: false }); + const result = await getLocalStackStatus(); + expect(result.isRunning).toBe(true); + expect(result.statusOutput).toContain("Services initialized: 1/1"); + }); + }); + + describe("getSnowflakeEmulatorStatus", () => { + test("marks emulator healthy on success payload", async () => { + mockHttpResponse({ body: '{"success": true}' }); + + const result = await getSnowflakeEmulatorStatus(); expect(result.isRunning).toBe(true); expect(result.isReady).toBe(true); - expect(mockedRunCommand).not.toHaveBeenCalled(); + expect(result.statusOutput).toContain('"success": true'); + // The probe must send the Snowflake routing Host header (undici fetch drops it). + const options = mockedHttpRequest.mock.calls[0][0] as any; + expect(options.headers.Host).toMatch(/^snowflake\.localhost\.localstack\.cloud:/); }); - test("reports not running as informational status (never an error) when neither gateway nor CLI is available", async () => { - // This is the Docker smoke test's pre-start `status` scenario: the gateway is - // down and `localstack status` yields nothing usable. It must come back as plain - // status text, not an errorMessage — the management tool renders statusOutput - // without a leading ❌, which is exactly what the harness asserts. - gatewayUnreachable(); - cliUnavailable(); + test("reports unhealthy response", async () => { + mockHttpResponse({ body: '{"success": false}' }); - const result = await getLocalStackStatus(); + const result = await getSnowflakeEmulatorStatus(); expect(result.isRunning).toBe(false); expect(result.isReady).toBe(false); - expect(result.statusOutput).toMatch(/not running/i); - expect(result.statusOutput?.startsWith("❌")).toBe(false); - expect(result.errorMessage).toBeUndefined(); }); - test("keeps the CLI 'stopped' output as informational status when it exits non-zero", async () => { - // `localstack status` exits non-zero when LocalStack isn't running (on Linux), - // but still prints a "stopped" table. That output must surface as a normal - // status (no error), not be discarded as "CLI unavailable" — regression guard - // for the Docker image smoke test's pre-start `status` check. - gatewayUnreachable(); - mockedRunCommand.mockResolvedValueOnce({ - stdout: "Runtime status: stopped", - stderr: "", - error: new Error("Command failed with exit code 1"), - exitCode: 1, - } as any); + test("reports connection failures without throwing", async () => { + mockHttpError("connect ECONNREFUSED"); - const result = await getLocalStackStatus(); + const result = await getSnowflakeEmulatorStatus(); expect(result.isRunning).toBe(false); - expect(result.statusOutput).toContain("stopped"); - expect(result.errorMessage).toBeUndefined(); + expect(result.errorMessage).toContain("ECONNREFUSED"); }); }); - describe("detectLifecycleCli", () => { - const onlyAvailable = (bin: string) => - mockedRunCommand.mockImplementation(async (cmd: string) => - cmd === bin - ? ({ stdout: `${bin} 1.0.0`, stderr: "", exitCode: 0 } as never) - : ({ stdout: "", stderr: "", exitCode: null, error: new Error("ENOENT") } as never) - ); - - test("prefers the localstack CLI when available", async () => { - onlyAvailable("localstack"); - expect(await detectLifecycleCli()).toBe("localstack"); + describe("launchRuntime", () => { + test("returns the already-running message without touching Docker", async () => { + const { client } = mockDockerClient(); + const result = await launchRuntime({ + ...launchDefaults, + getStatus: jest.fn().mockResolvedValue({ isRunning: true, isReady: true }), + dockerClient: client, + }); + expect(result.content[0].text).toBe("already running"); + expect(client.createAndStartContainer).not.toHaveBeenCalled(); }); - test("falls back to lstk when only lstk is present", async () => { - onlyAvailable("lstk"); - expect(await detectLifecycleCli()).toBe("lstk"); - }); + test("starts the container and succeeds once the gateway becomes reachable", async () => { + const { client } = mockDockerClient(); + const getStatus = jest + .fn() + .mockResolvedValueOnce({ isRunning: false }) + .mockResolvedValue({ isRunning: true, isReady: true, statusOutput: "gateway reachable" }); + + const result = await launchRuntime({ + ...launchDefaults, + getStatus, + envVars: { DEBUG: "1" }, + dockerClient: client, + }); - test("uses the requested CLI preference order", async () => { - mockedRunCommand.mockResolvedValue({ - stdout: "ok", - stderr: "", - exitCode: 0, - } as never); - - expect(await detectLifecycleCli(["lstk", "localstack"])).toBe("lstk"); - expect(mockedRunCommand).toHaveBeenCalledWith( - "lstk", - ["--version"], - expect.objectContaining({ timeout: 5000 }) + const text = result.content[0].text; + expect(text).toContain("started successfully"); + expect(text).toContain( + "Custom environment variables passed to the LocalStack container: DEBUG" ); + expect(client.createAndStartContainer).toHaveBeenCalledTimes(1); + const spec = client.createAndStartContainer.mock.calls[0][0]; + expect(spec.Image).toBe("localstack/localstack-pro:latest"); + expect(spec.name).toBe("localstack-main"); }); - test("returns null when neither CLI is installed", async () => { - mockedRunCommand.mockResolvedValue({ - stdout: "", - stderr: "", - exitCode: null, - error: new Error("ENOENT"), - } as never); - expect(await detectLifecycleCli()).toBeNull(); + test("pulls the image when missing", async () => { + const { client } = mockDockerClient({ + imageExists: jest.fn().mockResolvedValue(false), + }); + const getStatus = jest + .fn() + .mockResolvedValueOnce({ isRunning: false }) + .mockResolvedValue({ isRunning: true, isReady: true }); + + await launchRuntime({ ...launchDefaults, getStatus, dockerClient: client }); + expect(client.pullImage).toHaveBeenCalledWith("localstack/localstack-pro:latest"); }); - }); - describe("checkLocalStackCli", () => { - test("reports unavailable when runCommand resolves with ENOENT", async () => { - mockedRunCommand.mockResolvedValue({ - stdout: "", - stderr: "", - exitCode: null, - error: new Error("spawn localstack ENOENT"), - } as never); - - const result = await checkLocalStackCli(); - expect(result.isAvailable).toBe(false); - expect(result.errorMessage).toMatch(/not installed|not available/i); + test("reports an actionable conflict when the container name is already running", async () => { + const { client } = mockDockerClient({ + findContainerByNameAnyState: jest.fn().mockResolvedValue({ + id: "abc", + name: "localstack-main", + state: "running", + running: true, + image: "localstack/snowflake:latest", + }), + }); + const getStatus = jest.fn().mockResolvedValue({ isRunning: false }); + + const result = await launchRuntime({ ...launchDefaults, getStatus, dockerClient: client }); + const text = result.content[0].text; + expect(text).toContain("already running"); + expect(text).toContain("Snowflake"); + expect(text).toContain("action: stop"); + expect(client.createAndStartContainer).not.toHaveBeenCalled(); }); - }); - describe("getSnowflakeEmulatorStatus", () => { - test("marks emulator healthy on success payload", async () => { - mockedRunCommand.mockResolvedValueOnce({ - stdout: '{"success": true}', - stderr: "", - exitCode: 0, - } as any); + test("removes a stale stopped container and proceeds", async () => { + const { client } = mockDockerClient({ + findContainerByNameAnyState: jest.fn().mockResolvedValue({ + id: "stale-1", + name: "localstack-main", + state: "exited", + running: false, + }), + }); + const getStatus = jest + .fn() + .mockResolvedValueOnce({ isRunning: false }) + .mockResolvedValue({ isRunning: true, isReady: true }); - const result = await getSnowflakeEmulatorStatus(); - expect(result.isRunning).toBe(true); - expect(result.isReady).toBe(true); - expect(result.statusOutput).toContain('"success": true'); + const result = await launchRuntime({ ...launchDefaults, getStatus, dockerClient: client }); + expect(client.removeContainer).toHaveBeenCalledWith("stale-1"); + expect(client.waitForRemoval).toHaveBeenCalledWith("stale-1"); + expect(result.content[0].text).toContain("started successfully"); }); - test("reports unhealthy response", async () => { - mockedRunCommand.mockResolvedValueOnce({ - stdout: '{"success": false}', - stderr: "", - exitCode: 0, - } as any); + test("fails with the buffered log tail when the container exits during startup", async () => { + let exitCallback: (() => void) | undefined; + const logHandle = { + getBuffered: jest.fn(() => "boot line\nLicense activation failed\n"), + hasExited: jest.fn(() => true), + onExit: jest.fn((cb: () => void) => { + exitCallback = cb; + }), + destroy: jest.fn(), + }; + const { client } = mockDockerClient({ + attachLogBuffer: jest.fn().mockResolvedValue(logHandle), + }); + const getStatus = jest.fn().mockResolvedValue({ isRunning: false }); - const result = await getSnowflakeEmulatorStatus(); - expect(result.isRunning).toBe(false); - expect(result.isReady).toBe(false); + const resultPromise = launchRuntime({ ...launchDefaults, getStatus, dockerClient: client }); + // wait for the launch flow to attach the buffer, then simulate container exit + await new Promise((resolve) => setTimeout(resolve, 5)); + exitCallback?.(); + + const text = (await resultPromise).content[0].text; + expect(text).toContain("exited unexpectedly"); + expect(text).toContain("License activation failed"); + expect(logHandle.destroy).toHaveBeenCalled(); }); - }); - describe("startRuntime", () => { - test("reports lstk zero-exit failures immediately with stdout when readiness is absent", async () => { - const child = mockChildProcess(); + test("times out with the timeout message when readiness never arrives", async () => { + const { client } = mockDockerClient(); + const getStatus = jest.fn().mockResolvedValue({ isRunning: false }); + + const result = await launchRuntime({ ...launchDefaults, getStatus, dockerClient: client }); + expect(result.content[0].text).toContain("timed out"); + }); + + test("runs the onReady gate before reporting success", async () => { + const { client } = mockDockerClient(); const getStatus = jest .fn() - .mockResolvedValueOnce({ isRunning: false, isReady: false }) - .mockResolvedValueOnce({ isRunning: false, isReady: false }); + .mockResolvedValueOnce({ isRunning: false }) + .mockResolvedValue({ isRunning: true, isReady: true }); + const onReady = jest.fn().mockResolvedValue({ + content: [{ type: "text", text: "❌ **Feature Not Available**" }], + }); - const resultPromise = startRuntime({ - cli: "lstk", - startArgs: ["start", "--non-interactive"], + const result = await launchRuntime({ + ...launchDefaults, getStatus, - processLabel: "LocalStack", - alreadyRunningMessage: "already running", - successTitle: "started", - statusHeading: "Status", - timeoutMessage: "timed out", + onReady, + dockerClient: client, }); + expect(onReady).toHaveBeenCalled(); + expect(result.content[0].text).toContain("Feature Not Available"); + }); - await Promise.resolve(); - child.stdout.write("Error: Port 4566 already in use\n"); - child.emit("close", 0); + test("fails cleanly when the auth token is missing", async () => { + delete process.env.LOCALSTACK_AUTH_TOKEN; + const { client } = mockDockerClient(); + const result = await launchRuntime({ + ...launchDefaults, + getStatus: jest.fn().mockResolvedValue({ isRunning: false }), + dockerClient: client, + }); + expect(result.content[0].text).toContain("LOCALSTACK_AUTH_TOKEN"); + }); + }); - const text = (await resultPromise).content[0].text; - expect(text).toMatch(/exited before it became ready/i); - expect(text).toContain("Port 4566 already in use"); + describe("restartRuntimeInPlace", () => { + test("waits for the session transition before reporting ready", async () => { + // Old process stays "ready" for a moment after the restart POST; the helper + // must not report success until the session changes (uptime reset). + let infoCalls = 0; + mockedRequest.mockImplementation(async (endpoint: any, options: any) => { + const url = String(endpoint); + if (url.includes("/_localstack/info")) { + infoCalls += 1; + return infoCalls <= 2 + ? ({ session_id: "old", uptime: 500 } as any) + : ({ session_id: "new", uptime: 3 } as any); + } + if (url.includes("/_localstack/health") && options?.method === "POST") return {} as any; + return { services: { s3: "available" } } as any; + }); + + const result = await restartRuntimeInPlace({ pollIntervalMs: 5, maxWaitMs: 500 }); + expect(result.ok).toBe(true); + expect(infoCalls).toBeGreaterThanOrEqual(3); }); - test("treats lstk zero-exit as success when readiness is already available", async () => { - const child = mockChildProcess(); - const getStatus = jest - .fn() - .mockResolvedValueOnce({ isRunning: false, isReady: false }) - .mockResolvedValueOnce({ - isRunning: true, - isReady: true, - statusOutput: "LocalStack gateway is reachable", - }); - - const resultPromise = startRuntime({ - cli: "lstk", - startArgs: ["start", "--non-interactive"], - getStatus, - processLabel: "LocalStack", - alreadyRunningMessage: "already running", - successTitle: "started", - statusHeading: "Status", - timeoutMessage: "timed out", + test("treats a gateway-down window as the restart transition", async () => { + let healthGets = 0; + mockedRequest.mockImplementation(async (endpoint: any, options: any) => { + const url = String(endpoint); + if (url.includes("/_localstack/info")) return { session_id: "old", uptime: 500 } as any; + if (url.includes("/_localstack/health") && options?.method === "POST") return {} as any; + healthGets += 1; + if (healthGets <= 2) throw new Error("ECONNREFUSED"); + return { services: { s3: "available" } } as any; }); - await Promise.resolve(); - child.emit("close", 0); + const result = await restartRuntimeInPlace({ pollIntervalMs: 5, maxWaitMs: 500 }); + expect(result.ok).toBe(true); + }); + + test("reports failure when the restart request itself fails", async () => { + mockedRequest.mockImplementation(async (endpoint: any, options: any) => { + if (String(endpoint).includes("/_localstack/info")) return { session_id: "old" } as any; + if (options?.method === "POST") throw new Error("connection refused"); + return { services: {} } as any; + }); - const text = (await resultPromise).content[0].text; - expect(text).toContain("started"); - expect(text).toContain("LocalStack gateway is reachable"); + const result = await restartRuntimeInPlace({ pollIntervalMs: 5, maxWaitMs: 100 }); + expect(result.ok).toBe(false); + expect(result.detail).toContain("Restart request failed"); }); }); }); diff --git a/src/lib/localstack/localstack.utils.ts b/src/lib/localstack/localstack.utils.ts index e73fee5..5fba420 100644 --- a/src/lib/localstack/localstack.utils.ts +++ b/src/lib/localstack/localstack.utils.ts @@ -1,14 +1,23 @@ -import { spawn } from "child_process"; +import { existsSync } from "fs"; +import { mkdir } from "fs/promises"; +import { homedir } from "os"; +import { request as httpRequest } from "http"; import { LOCALSTACK_BASE_URL, LOCALSTACK_HOSTNAME, LOCALSTACK_PORT } from "../../core/config"; import { runCommand } from "../../core/command-runner"; import { httpClient } from "../../core/http-client"; import { ResponseBuilder } from "../../core/response-builder"; - -export interface LocalStackCliCheckResult { - isAvailable: boolean; - version?: string; - errorMessage?: string; -} +import { + DockerApiClient, + LocalStackContainerConflictError, + type LogBufferHandle, +} from "../docker/docker.client"; +import { + buildLocalStackContainerSpec, + resolveContainerName, + resolveVolume, + type LocalStackStack, + type VolumeResolution, +} from "./container-spec.logic"; export interface SnowflakeCliCheckResult { isAvailable: boolean; @@ -16,85 +25,21 @@ export interface SnowflakeCliCheckResult { errorMessage?: string; } -const CLI_CHECK_TIMEOUT = 5000; const SNOWFLAKE_CLI_CHECK_TIMEOUT = 30000; -function cliSpawnOptions(timeoutMs: number = CLI_CHECK_TIMEOUT) { - return { - timeout: timeoutMs, - shell: process.platform === "win32", - }; +/** Version baked in at build time; used to tag containers we launch. */ +let SERVER_VERSION = "unknown"; +try { + // Statically inlined by the bundler. + // eslint-disable-next-line @typescript-eslint/no-var-requires + SERVER_VERSION = require("../../../package.json").version || "unknown"; +} catch { + // keep "unknown" } -function localStackCliUnavailable(details?: string): LocalStackCliCheckResult { - const suffix = details?.trim() ? `\n\nDetails: ${details.trim()}` : ""; - return { - isAvailable: false, - errorMessage: `❌ LocalStack CLI is not installed or not available in PATH. - -Please install LocalStack by following the official documentation: -https://docs.localstack.cloud/aws/getting-started/installation/ - -Installation options: -- Using pip: pip install localstack -- Using Docker: Use the LocalStack Docker image -- Using Homebrew (macOS): brew install localstack/tap/localstack-cli - -After installation, make sure the 'localstack' command is available in your PATH.${suffix}`, - }; -} - -/** - * Check if LocalStack CLI is installed and available in the system PATH - * @returns Promise with availability status, version (if available), and error message (if not available) - */ -export async function checkLocalStackCli(): Promise { - try { - const help = await runCommand("localstack", ["--help"], cliSpawnOptions()); - if (help.error || help.exitCode !== 0) { - return localStackCliUnavailable(help.error?.message || help.stderr); - } - - const versionResult = await runCommand("localstack", ["--version"], cliSpawnOptions()); - if (versionResult.error || versionResult.exitCode !== 0) { - return localStackCliUnavailable(versionResult.error?.message || versionResult.stderr); - } - - return { - isAvailable: true, - version: versionResult.stdout.trim(), - }; - } catch (error) { - return localStackCliUnavailable(error instanceof Error ? error.message : String(error)); - } -} - -export type LifecycleCli = "localstack" | "lstk"; - -/** - * Whether a CLI is usable. `runCommand` resolves (rather than throws) on a missing - * binary, so we inspect the actual exit code / error — this returns false when the - * binary isn't on PATH. - */ -async function cliAvailable(bin: string): Promise { - const { error, exitCode } = await runCommand(bin, ["--version"], cliSpawnOptions()); - return !error && exitCode === 0; -} - -/** - * Pick a CLI capable of starting LocalStack. Prefers the Python `localstack` CLI for - * backward compatibility, falling back to `lstk` (the newer Go CLI, which also forwards - * `LOCALSTACK_*` env). Returns null when neither is installed: a running container can - * still be detected and driven via the gateway + Docker API, but *creating* one needs a - * CLI. - */ -export async function detectLifecycleCli( - preferredOrder: LifecycleCli[] = ["localstack", "lstk"] -): Promise { - for (const cli of preferredOrder) { - if (await cliAvailable(cli)) return cli; - } - return null; +/** Whether the MCP server itself runs inside a container (changes bind IP + volume default). */ +export function isRunningInDocker(): boolean { + return existsSync("/.dockerenv"); } /** @@ -103,11 +48,10 @@ export async function detectLifecycleCli( */ export async function checkSnowflakeCli(): Promise { try { - const { stdout, error, exitCode, stderr } = await runCommand( - "snow", - ["--version"], - cliSpawnOptions(SNOWFLAKE_CLI_CHECK_TIMEOUT) - ); + const { stdout, error, exitCode, stderr } = await runCommand("snow", ["--version"], { + timeout: SNOWFLAKE_CLI_CHECK_TIMEOUT, + shell: process.platform === "win32", + }); if (error || exitCode !== 0) { throw error || new Error(stderr || `snow --version exited with code ${exitCode}`); } @@ -154,6 +98,14 @@ export interface GatewayHealth { version?: string; } +export interface SessionInfo { + version?: string; + edition?: string; + is_license_activated?: boolean; + session_id?: string; + uptime?: number; +} + export interface SnowflakeStatusResult { isRunning: boolean; statusOutput?: string; @@ -168,14 +120,7 @@ export interface RuntimeStatus { } const SNOWFLAKE_ROUTING_HOST = "snowflake.localhost.localstack.cloud"; -const CLIENT_ONLY_ENV_KEYS = [ - "HOSTNAME", - "LOCALSTACK_HOSTNAME", - "AWS_ENDPOINT_URL", - "AWS_ENDPOINT_URL_S3", - "S3_ENDPOINT", - "AWS_S3_FORCE_PATH_STYLE", -]; +const SNOWFLAKE_PROBE_TIMEOUT = 10000; function getLocalStackEndpointHost() { return process.env.LOCALSTACK_HOSTNAME?.trim() || LOCALSTACK_HOSTNAME; @@ -186,7 +131,7 @@ function getLocalStackEndpointPort() { } const GATEWAY_HEALTH_TIMEOUT = 3000; -const CLI_STATUS_TIMEOUT = 5000; +const SESSION_INFO_TIMEOUT = 3000; const READY_SERVICE_STATES = new Set(["available", "running"]); /** @@ -194,12 +139,10 @@ const READY_SERVICE_STATES = new Set(["available", "running"]); * * Probes the LocalStack gateway health endpoint (`/_localstack/health`) directly over * HTTP. Any container exposing the gateway on :4566 answers this — regardless of who - * started it (`localstack` CLI, `lstk`, docker-compose, raw `docker run`), what the - * container is named, or whether a host-side CLI is installed. + * started it (this server, `lstk`, docker-compose, raw `docker run`) or what the + * container is named. * - * This is the source of truth for "is LocalStack running?". A name + CLI check misses - * runtimes started by `lstk` (container `localstack-aws`) or any external tool, even - * though their gateway is healthy and reachable. + * This is the source of truth for "is LocalStack running?". */ export async function getGatewayHealth(): Promise { try { @@ -231,12 +174,28 @@ export async function getGatewayHealth(): Promise { } } -function describeGatewayHealth(health: GatewayHealth): string { +/** Best-effort read of `/_localstack/info` for status enrichment and restart detection. */ +export async function getSessionInfo(): Promise { + try { + const info = await httpClient.request("/_localstack/info", { + method: "GET", + timeout: SESSION_INFO_TIMEOUT, + }); + if (!info || typeof info !== "object") return null; + return info; + } catch { + return null; + } +} + +function describeGatewayHealth(health: GatewayHealth, info?: SessionInfo | null): string { const lines = [ `LocalStack gateway is reachable at ${LOCALSTACK_BASE_URL} (detected via /_localstack/health).`, ]; - if (health.edition) lines.push(`Edition: ${health.edition}`); - if (health.version) lines.push(`Version: ${health.version}`); + const edition = info?.edition || health.edition; + const version = info?.version || health.version; + if (edition) lines.push(`Edition: ${edition}`); + if (version) lines.push(`Version: ${version}`); if (health.services) { const total = Object.keys(health.services).length; const initialized = Object.values(health.services).filter((state) => @@ -244,211 +203,295 @@ function describeGatewayHealth(health: GatewayHealth): string { ).length; lines.push(`Services initialized: ${initialized}/${total}`); } + if (info?.is_license_activated !== undefined) { + lines.push(`License activated: ${info.is_license_activated ? "yes" : "no"}`); + } + if (typeof info?.uptime === "number") { + lines.push(`Uptime: ${info.uptime}s`); + } return lines.join("\n"); } -interface CliStatus { - output?: string; - running: boolean; - ready: boolean; -} - /** - * Best-effort read of `localstack status` for human-readable output only. - * - * Crucially, a non-zero exit is NOT treated as "CLI unavailable": `localstack status` - * exits non-zero when LocalStack isn't running (the exit code even differs by host — - * 0 on Windows, non-zero on Linux) yet still prints a useful "stopped" table to - * stdout. We use whatever stdout it produced and only report "unavailable" when there - * is genuinely nothing to show. + * Get LocalStack status information. Running state is decided by the gateway probe; + * display detail is enriched from `/_localstack/info` when available. */ -async function tryCliStatus(timeoutMs = CLI_STATUS_TIMEOUT): Promise { - const unavailable: CliStatus = { running: false, ready: false }; - try { - const result = await runCommand("localstack", ["status"], { timeout: timeoutMs }); - if (!result.stdout?.trim()) return unavailable; +export async function getLocalStackStatus(): Promise { + const health = await getGatewayHealth(); - const stdout = result.stdout; + if (!health.reachable) { return { - output: stdout.trim(), - running: stdout.includes("running"), - ready: stdout.includes("Ready") || stdout.includes("ready"), + isRunning: false, + isReady: false, + statusOutput: `LocalStack is not running — the gateway at ${LOCALSTACK_BASE_URL} is not reachable.`, }; - } catch { - return unavailable; - } -} - -interface LocalStackStatusOptions { - includeCliStatus?: boolean; -} - -/** - * Get LocalStack status information. - * - * Running state is decided by the gateway probe. The Python CLI's `status` - * output is layered on as display-only detail when requested. - * - * @returns Promise with status details including running state and raw output - */ -export async function getLocalStackStatus({ - includeCliStatus = true, -}: LocalStackStatusOptions = {}): Promise { - const [health, cli] = await Promise.all([ - getGatewayHealth(), - includeCliStatus ? tryCliStatus() : Promise.resolve(undefined), - ]); - - const isRunning = health.reachable; - const isReady = health.ready; - - if (!isRunning) { - const statusOutput = - cli?.output || - `LocalStack is not running — the gateway at ${LOCALSTACK_BASE_URL} is not reachable.`; - return { isRunning: false, isReady: false, statusOutput }; } + const info = await getSessionInfo(); return { - isRunning, - isReady, - statusOutput: cli?.running && cli.output ? cli.output : describeGatewayHealth(health), + isRunning: true, + isReady: health.ready, + statusOutput: describeGatewayHealth(health, info), }; } /** - * Get Snowflake emulator status information by checking the Snowflake session endpoint - * @returns Promise with status details including running state and raw output + * Get Snowflake emulator status by POSTing to its session endpoint. Uses node:http + * directly because the probe needs an explicit Host header + * (`snowflake.localhost.localstack.cloud`) — fetch/undici silently drops Host as a + * forbidden header. */ export async function getSnowflakeEmulatorStatus(): Promise { + const host = getLocalStackEndpointHost(); + const port = Number(getLocalStackEndpointPort()); + const body = "{}"; + try { - const host = getLocalStackEndpointHost(); - const port = getLocalStackEndpointPort(); - const { stdout, stderr, error, exitCode } = await runCommand("curl", [ - "-sS", - "-X", - "POST", - "-H", - "Content-Type: application/json", - "-H", - `Host: ${SNOWFLAKE_ROUTING_HOST}:${port}`, - "-d", - "{}", - `http://${host}:${port}/session`, - ]); - - const output = (stdout || "").trim(); + const response = await new Promise<{ statusCode: number; body: string }>((resolve, reject) => { + const req = httpRequest( + { + host, + port, + path: "/session", + method: "POST", + headers: { + "Content-Type": "application/json", + Host: `${SNOWFLAKE_ROUTING_HOST}:${port}`, + "Content-Length": Buffer.byteLength(body), + }, + timeout: SNOWFLAKE_PROBE_TIMEOUT, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk.toString(); + }); + res.on("end", () => resolve({ statusCode: res.statusCode || 0, body: data })); + } + ); + req.on("timeout", () => req.destroy(new Error("Snowflake health probe timed out"))); + req.on("error", reject); + req.write(body); + req.end(); + }); + + const output = response.body.trim(); const isSuccess = /"success"\s*:\s*true/.test(output); return { - isRunning: exitCode === 0 && isSuccess, - isReady: exitCode === 0 && isSuccess, - statusOutput: output || stderr.trim(), - ...(error - ? { - errorMessage: `Failed to reach Snowflake emulator endpoint: ${error.message}`, - } - : {}), + isRunning: isSuccess, + isReady: isSuccess, + statusOutput: output, }; } catch (error) { return { isRunning: false, - errorMessage: `Failed to get Snowflake emulator status: ${ + isReady: false, + errorMessage: `Failed to reach Snowflake emulator endpoint: ${ error instanceof Error ? error.message : String(error) }`, }; } } -/** - * Start a LocalStack runtime flavor and poll until it becomes available. - * Supports custom startup args (e.g. default stack vs Snowflake stack), optional - * environment overrides, and optional post-start validation hooks. - */ -export async function startRuntime({ - cli = "localstack", - startArgs, - getStatus, - processLabel, - alreadyRunningMessage, - successTitle, - statusHeading, - timeoutMessage, - envVars, - onReady, -}: { - /** Lifecycle CLI binary to spawn — `localstack` (default) or `lstk`. */ - cli?: LifecycleCli; - startArgs: string[]; +export interface LaunchRuntimeOptions { + stack: LocalStackStack; + envVars?: Record; getStatus: () => Promise; processLabel: string; alreadyRunningMessage: string; successTitle: string; statusHeading: string; timeoutMessage: string; - envVars?: Record; onReady?: () => Promise | null>; -}): Promise> { + /** Restart-in-place recreation: pin the original container's identity. */ + imageOverride?: string; + containerNameOverride?: string; + volumeOverride?: VolumeResolution; + /** Injectable for tests. */ + dockerClient?: DockerApiClient; + pollIntervalMs?: number; + maxWaitMs?: number; +} + +const POLL_INTERVAL_MS = 5000; +const MAX_WAIT_MS = 120000; +const CRASH_LOG_TAIL_LINES = 50; + +function tailLines(text: string, lines: number): string { + const allLines = text.split(/\r?\n/).filter((line) => line.trim()); + return allLines.slice(-lines).join("\n"); +} + +function conflictResponse(processLabel: string, containerName: string, image?: string) { + const otherStack = image?.includes("/snowflake") + ? "the Snowflake emulator" + : "another LocalStack stack (likely the AWS emulator)"; + return ResponseBuilder.error( + "LocalStack container already running", + `Starting ${processLabel} failed because a LocalStack container named "${containerName}"${ + image ? ` (image: ${image})` : "" + } is already running — it belongs to ${otherStack}. ` + + `Stop it first with the localstack-management tool (action: stop), then retry this start.` + ); +} + +/** + * Start a LocalStack runtime flavor directly through the Docker Engine API (no + * localstack/lstk CLI involved) and poll until it becomes available. + */ +export async function launchRuntime( + options: LaunchRuntimeOptions +): Promise> { + const { + stack, + envVars, + getStatus, + processLabel, + alreadyRunningMessage, + successTitle, + statusHeading, + timeoutMessage, + onReady, + } = options; + const statusCheck = await getStatus(); if (statusCheck.isReady || statusCheck.isRunning) { return ResponseBuilder.markdown(alreadyRunningMessage); } - const environment = { ...process.env } as Record; - for (const key of CLIENT_ONLY_ENV_KEYS) { - delete environment[key]; - } - Object.assign(environment, envVars || {}); - if (process.env.LOCALSTACK_AUTH_TOKEN) { - environment.LOCALSTACK_AUTH_TOKEN = process.env.LOCALSTACK_AUTH_TOKEN; + const authToken = process.env.LOCALSTACK_AUTH_TOKEN?.trim(); + if (!authToken) { + return ResponseBuilder.error( + "Auth Token Required", + "LOCALSTACK_AUTH_TOKEN is required to start LocalStack." + ); } - // Force UTF-8 for the spawned Python `localstack` CLI so its emoji output doesn't - // throw UnicodeEncodeError under the Windows cp1252 code page. - if (cli === "localstack") { - if (!environment.PYTHONIOENCODING) environment.PYTHONIOENCODING = "utf-8"; - if (!environment.PYTHONUTF8) environment.PYTHONUTF8 = "1"; + + const docker = options.dockerClient ?? new DockerApiClient(); + try { + await docker.ping(); + } catch (error) { + return ResponseBuilder.error( + "Docker Not Available", + error instanceof Error ? error.message : String(error) + ); } - return new Promise((resolve) => { - const child = spawn(cli, startArgs, { - env: environment, - stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", + const inDocker = isRunningInDocker(); + const volume = + options.volumeOverride ?? + resolveVolume({ + hostEnv: process.env, + isInDocker: inDocker, + platform: process.platform, + homedir: homedir(), }); + if (volume.type === "bind" && !inDocker) { + try { + await mkdir(volume.source, { recursive: true }); + } catch (error) { + return ResponseBuilder.error( + "Volume Directory Error", + `Could not create the LocalStack volume directory at ${volume.source}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } - let stdout = ""; - let stderr = ""; - child.stdout?.on("data", (data) => { - stdout += data.toString(); - }); - child.stderr?.on("data", (data) => { - stderr += data.toString(); - }); + const spec = buildLocalStackContainerSpec({ + stack, + envVars, + hostEnv: process.env, + authToken, + isInDocker: inDocker, + volume, + serverVersion: SERVER_VERSION, + imageOverride: options.imageOverride, + containerNameOverride: options.containerNameOverride, + }); - let earlyExit = false; + // Fail fast (or clean up) when the container name is taken. A running container is + // an actionable conflict; a stopped one is a stale leftover we remove ourselves. + try { + const existing = await docker.findContainerByNameAnyState(spec.name); + if (existing) { + if (existing.running) { + return conflictResponse(processLabel, spec.name, existing.image); + } + await docker.removeContainer(existing.id); + await docker.waitForRemoval(existing.id); + } + } catch (error) { + return ResponseBuilder.error( + "Docker lookup failed", + `Could not check for existing LocalStack containers: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + try { + if (spec.HostConfig.NetworkMode) { + await docker.ensureNetwork(spec.HostConfig.NetworkMode); + } + if (!(await docker.imageExists(spec.Image))) { + await docker.pullImage(spec.Image); + } + } catch (error) { + return ResponseBuilder.error( + "Failed to prepare LocalStack start", + error instanceof Error ? error.message : String(error) + ); + } + + let containerId: string; + try { + containerId = await docker.createAndStartContainer(spec); + } catch (error) { + if (error instanceof LocalStackContainerConflictError) { + return conflictResponse(processLabel, spec.name); + } + return ResponseBuilder.error( + `Failed to start ${processLabel}`, + error instanceof Error ? error.message : String(error) + ); + } + + let logBuffer: LogBufferHandle | undefined; + try { + logBuffer = await docker.attachLogBuffer(containerId); + } catch { + // Diagnostics-only: readiness polling still works without the log stream. + } + + return new Promise((resolve) => { let poll: NodeJS.Timeout; let resolved = false; const finish = (response: ReturnType) => { if (resolved) return; resolved = true; if (poll) clearInterval(poll); + logBuffer?.destroy(); resolve(response); }; + const failureDetails = () => { - const details: string[] = []; - const trimmedStdout = stdout.trim(); - const trimmedStderr = stderr.trim(); - if (trimmedStdout) details.push(`Stdout:\n${trimmedStdout}`); - if (trimmedStderr) details.push(`Stderr:\n${trimmedStderr}`); - return details.length ? `\n\n${details.join("\n\n")}` : ""; + const buffered = logBuffer ? tailLines(logBuffer.getBuffered(), CRASH_LOG_TAIL_LINES) : ""; + return buffered + ? `\n\nContainer logs (last ${CRASH_LOG_TAIL_LINES} lines):\n${buffered}` + : ""; }; + const successResponse = (status: RuntimeStatus) => { let resultMessage = `${successTitle}\n\n`; if (envVars) - resultMessage += `✅ Custom environment variables passed to lifecycle CLI: ${Object.keys(envVars).join(", ")}\n`; + resultMessage += `✅ Custom environment variables passed to the LocalStack container: ${Object.keys(envVars).join(", ")}\n`; if (status.statusOutput) resultMessage += `\n**${statusHeading}:**\n${status.statusOutput}`; return ResponseBuilder.markdown(resultMessage); }; + const finishIfReady = async (): Promise => { const status = await getStatus(); if (!(status.isReady || status.isRunning)) return false; @@ -465,58 +508,27 @@ export async function startRuntime({ return true; }; - child.on("error", (err) => { - earlyExit = true; + // The follow-stream ending means the container exited (AutoRemove removes it + // immediately, so this buffered tail is the only surviving diagnostic). + logBuffer?.onExit(async () => { + // The runtime may have become ready in the same instant (unlikely but cheap to check). + try { + if (await finishIfReady()) return; + } catch { + // fall through to the failure response + } finish( - ResponseBuilder.markdown(`❌ Failed to start ${processLabel} process: ${err.message}`) + ResponseBuilder.markdown( + `❌ ${processLabel} container exited unexpectedly before becoming ready.${failureDetails()}` + ) ); }); - child.on("close", async (code) => { - if (earlyExit) return; - // A non-zero exit is a real failure: stop polling and report it. - // A zero exit is expected in non-interactive environments (e.g. inside a - // container, where `localstack start` launches the runtime and returns - // instead of staying attached to stream logs). In that case we must keep - // polling so readiness is still detected and the promise resolves — clearing - // the interval here would leave the start call hanging forever. - if (code !== 0) { - finish( - ResponseBuilder.markdown( - `❌ ${processLabel} process exited unexpectedly with code ${code}.${failureDetails()}` - ) - ); - return; - } - - if (cli === "lstk") { - try { - if (await finishIfReady()) return; - } catch (error) { - finish( - ResponseBuilder.markdown( - `❌ ${processLabel} process exited before it became ready. Failed to verify status: ${ - error instanceof Error ? error.message : String(error) - }${failureDetails()}` - ) - ); - return; - } - - finish( - ResponseBuilder.markdown( - `❌ ${processLabel} process exited before it became ready.${failureDetails()}` - ) - ); - } - }); - - const pollInterval = 5000; - const maxWaitTime = 120000; + const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS; + const maxWaitMs = options.maxWaitMs ?? MAX_WAIT_MS; let timeWaited = 0; - poll = setInterval(async () => { - timeWaited += pollInterval; + timeWaited += pollIntervalMs; try { if (await finishIfReady()) return; } catch (error) { @@ -530,28 +542,79 @@ export async function startRuntime({ return; } - if (timeWaited >= maxWaitTime) { + if (timeWaited >= maxWaitMs) { const details = failureDetails(); finish(ResponseBuilder.markdown(details ? `${timeoutMessage}${details}` : timeoutMessage)); } - }, pollInterval); + }, pollIntervalMs); }); } +export interface InPlaceRestartResult { + ok: boolean; + detail: string; +} + /** - * Validate LocalStack CLI availability and return early if not available - * This is a helper function for tools that require LocalStack CLI + * Restart the LocalStack runtime in place (`POST /_localstack/health {"action": + * "restart"}`) and wait for the NEW process to become ready. The POST returns + * immediately while the old process keeps answering health checks for a moment, so + * readiness is detected via the session transition (session_id change / uptime + * reset), not via the first successful health poll. */ -export async function ensureLocalStackCli() { - const cliCheck = await checkLocalStackCli(); +export async function restartRuntimeInPlace({ + pollIntervalMs = 2000, + maxWaitMs = 120000, +}: { pollIntervalMs?: number; maxWaitMs?: number } = {}): Promise { + const before = await getSessionInfo(); - if (!cliCheck.isAvailable) { + try { + await httpClient.request("/_localstack/health", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "restart" }), + }); + } catch (error) { return { - content: [{ type: "text", text: cliCheck.errorMessage! }], + ok: false, + detail: `Restart request failed: ${error instanceof Error ? error.message : String(error)}`, }; } - return null; // CLI is available, continue with tool execution + let sawTransition = false; + const deadline = Date.now() + maxWaitMs; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + + const health = await getGatewayHealth(); + if (!health.reachable) { + // Gateway went down — the restart is in progress. + sawTransition = true; + continue; + } + + if (!sawTransition && before) { + const info = await getSessionInfo(); + if (info) { + const sessionChanged = + (info.session_id && before.session_id && info.session_id !== before.session_id) || + (typeof info.uptime === "number" && + typeof before.uptime === "number" && + info.uptime < before.uptime); + if (sessionChanged) sawTransition = true; + } + } + + const restarted = sawTransition || !before; + if (restarted && health.ready) { + return { ok: true, detail: "LocalStack runtime restarted and is ready." }; + } + } + + return { + ok: false, + detail: `LocalStack did not report ready within ${Math.round(maxWaitMs / 1000)}s after the restart request. It may still be restarting in the background.`, + }; } /** @@ -569,3 +632,5 @@ export async function ensureSnowflakeCli() { return null; // CLI is available, continue with tool execution } + +export { resolveContainerName }; diff --git a/src/lib/localstack/platform.client.test.ts b/src/lib/localstack/platform.client.test.ts new file mode 100644 index 0000000..c647aed --- /dev/null +++ b/src/lib/localstack/platform.client.test.ts @@ -0,0 +1,82 @@ +import { PlatformApiClient, describePlatformError } from "./platform.client"; +import { HttpError } from "../../core/http-client"; + +const fetchMock = jest.fn(); +global.fetch = fetchMock as any; + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + statusText: "OK", + url: "https://api.localstack.cloud/v1/test", + headers: new Map([["content-type", "application/json"]]) as any, + json: async () => body, + text: async () => JSON.stringify(body), + }; +} + +describe("PlatformApiClient", () => { + beforeEach(() => fetchMock.mockReset()); + + test("sends Basic auth with empty username and the token as password", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse([])); + const client = new PlatformApiClient("ls-test-token"); + await client.listEphemeralInstances(); + + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.localstack.cloud/v1/compute/instances"); + const expected = `Basic ${Buffer.from(":ls-test-token").toString("base64")}`; + expect(options.headers.Authorization).toBe(expected); + }); + + test("create posts instance_name/lifetime/env_vars in the CLI wire format", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ instance_name: "demo", status: "creating" })); + const client = new PlatformApiClient("ls-test-token"); + await client.createEphemeralInstance({ + name: "demo", + lifetime: 30, + envVars: { EXTENSION_AUTO_INSTALL: "localstack-extension-httpbin" }, + }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.method).toBe("POST"); + expect(JSON.parse(options.body)).toEqual({ + instance_name: "demo", + lifetime: 30, + env_vars: { EXTENSION_AUTO_INSTALL: "localstack-extension-httpbin" }, + }); + }); + + test("list tolerates both bare-array and wrapped responses", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ instances: [{ instance_name: "a" }] })); + const client = new PlatformApiClient("t"); + expect(await client.listEphemeralInstances()).toEqual([{ instance_name: "a" }]); + }); + + test("logs joins content entries into plain text", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse([{ content: "line 1" }, { content: "line 2" }])); + const client = new PlatformApiClient("t"); + expect(await client.getEphemeralInstanceLogs("demo")).toBe("line 1\nline 2"); + const [url] = fetchMock.mock.calls[0]; + expect(url).toBe("https://api.localstack.cloud/v1/compute/instances/demo/logs"); + }); +}); + +describe("describePlatformError", () => { + test("maps 401/403 to a token hint", () => { + const error = new HttpError(401, "Unauthorized", "", "HTTP Error"); + expect(describePlatformError(error, "ephemeral instances")).toMatch(/LOCALSTACK_AUTH_TOKEN/); + }); + + test("maps 404 to not-found for the subject", () => { + const error = new HttpError(404, "Not Found", "", "HTTP Error"); + expect(describePlatformError(error, "ephemeral instance 'x'")).toBe( + "ephemeral instance 'x' was not found." + ); + }); + + test("passes through non-HTTP errors", () => { + expect(describePlatformError(new Error("boom"), "x")).toBe("boom"); + }); +}); diff --git a/src/lib/localstack/platform.client.ts b/src/lib/localstack/platform.client.ts new file mode 100644 index 0000000..89f9ce9 --- /dev/null +++ b/src/lib/localstack/platform.client.ts @@ -0,0 +1,127 @@ +import { HttpClient, HttpError } from "../../core/http-client"; + +export const PLATFORM_API_BASE_URL = "https://api.localstack.cloud/v1"; + +export interface EphemeralInstance { + instance_name?: string; + id?: string; + status?: string; + endpoint_url?: string; + creation_time?: string | number; + expiry_time?: string | number; + [key: string]: unknown; +} + +export interface CreateEphemeralInstanceRequest { + name: string; + lifetime?: number; + envVars?: Record; +} + +export interface MarketplaceExtension { + name?: string; + summary?: string; + description?: string; + author?: string; + version?: string; +} + +/** + * Client for the LocalStack Cloud platform API — the same REST surface the + * `localstack ephemeral` CLI commands wrap. Auth is HTTP Basic with an empty + * username and the auth token as password. + */ +export class PlatformApiClient { + private readonly http = new HttpClient(); + + constructor(private readonly authToken: string) {} + + private headers(): Record { + const encoded = Buffer.from(`:${this.authToken}`).toString("base64"); + return { + Authorization: `Basic ${encoded}`, + Accept: "application/json", + "Content-Type": "application/json", + }; + } + + private request(path: string, options: RequestInit & { timeout?: number } = {}): Promise { + return this.http.request(path, { + baseUrl: PLATFORM_API_BASE_URL, + timeout: options.timeout ?? 60000, + ...options, + headers: { ...this.headers(), ...(options.headers as Record | undefined) }, + }); + } + + async createEphemeralInstance({ + name, + lifetime, + envVars, + }: CreateEphemeralInstanceRequest): Promise { + return this.request("/compute/instances", { + method: "POST", + body: JSON.stringify({ + instance_name: name, + ...(lifetime !== undefined ? { lifetime } : {}), + ...(envVars && Object.keys(envVars).length > 0 ? { env_vars: envVars } : {}), + }), + // Instance provisioning can take a while server-side. + timeout: 180000, + }); + } + + async listEphemeralInstances(): Promise { + const result = await this.request( + "/compute/instances", + { method: "GET" } + ); + if (Array.isArray(result)) return result; + if (result && Array.isArray(result.instances)) return result.instances; + return []; + } + + async getEphemeralInstanceLogs(name: string): Promise { + const result = await this.request | string>( + `/compute/instances/${encodeURIComponent(name)}/logs`, + { method: "GET", timeout: 120000 } + ); + if (typeof result === "string") return result; + if (Array.isArray(result)) { + return result + .map((entry) => (typeof entry === "string" ? entry : entry?.content || "")) + .filter(Boolean) + .join("\n"); + } + return JSON.stringify(result, null, 2); + } + + async deleteEphemeralInstance(name: string): Promise { + await this.request(`/compute/instances/${encodeURIComponent(name)}`, { + method: "DELETE", + timeout: 120000, + }); + } + + async getExtensionsMarketplace(): Promise { + const result = await this.request("/extensions/marketplace", { + method: "GET", + }); + return Array.isArray(result) ? result : []; + } +} + +/** Map platform API failures to the user-facing message pattern used by tools. */ +export function describePlatformError(error: unknown, subject: string): string { + if (error instanceof HttpError) { + if (error.status === 401 || error.status === 403) { + return `Authentication with the LocalStack platform failed. Ensure LOCALSTACK_AUTH_TOKEN is set correctly and your workspace has access to ${subject}.`; + } + if (error.status === 404) { + return `${subject} was not found.`; + } + const body = error.body?.slice(0, 500); + return `Platform API error (${error.status} ${error.statusText})${body ? `: ${body}` : ""}`; + } + return error instanceof Error ? error.message : String(error); +} diff --git a/src/lib/logs/log-retriever.test.ts b/src/lib/logs/log-retriever.test.ts index afa5156..a95bd50 100644 --- a/src/lib/logs/log-retriever.test.ts +++ b/src/lib/logs/log-retriever.test.ts @@ -1,28 +1,104 @@ -import { runCommand } from "../../core/command-runner"; import { LocalStackLogRetriever } from "./log-retriever"; +import { DockerApiClient } from "../docker/docker.client"; -jest.mock("../../core/command-runner", () => ({ - runCommand: jest.fn(), -})); +jest.mock("../docker/docker.client", () => { + const actual = jest.requireActual("../docker/docker.client"); + return { + ...actual, + DockerApiClient: jest.fn(), + }; +}); + +const MockedDockerApiClient = DockerApiClient as jest.MockedClass; -const mockedRunCommand = runCommand as jest.MockedFunction; +function mockDocker({ + findLocalStackContainer = jest.fn().mockResolvedValue("container-1"), + getContainerLogs = jest.fn().mockResolvedValue(""), +}: Record = {}) { + MockedDockerApiClient.mockImplementation( + () => ({ findLocalStackContainer, getContainerLogs }) as any + ); + return { findLocalStackContainer, getContainerLogs }; +} describe("LocalStackLogRetriever", () => { - beforeEach(() => { - mockedRunCommand.mockReset(); + beforeEach(() => MockedDockerApiClient.mockReset()); + + test("retrieves and parses container logs via the Docker API", async () => { + const { getContainerLogs } = mockDocker({ + getContainerLogs: jest + .fn() + .mockResolvedValue( + "2025-07-23T10:58:58.710 INFO --- AWS s3.CreateBucket => 200\n" + + "2025-07-23T10:58:59.100 ERROR --- AWS s3.PutObject => 404 (NoSuchBucket)\n" + ), + }); + + const result = await new LocalStackLogRetriever().retrieveLogs(100); + expect(result.success).toBe(true); + expect(result.totalLines).toBe(2); + expect(getContainerLogs).toHaveBeenCalledWith("container-1", { tail: 100 }); + expect(result.logs[1].isError).toBe(true); + expect(result.logs[1].service).toBe("s3"); + expect(result.logs[1].operation).toBe("PutObject"); + expect(result.logs[1].statusCode).toBe(404); + }); + + test("reports a missing LocalStack container as a retrieval failure", async () => { + const actual = jest.requireActual("../docker/docker.client"); + mockDocker({ + findLocalStackContainer: jest + .fn() + .mockRejectedValue( + new actual.LocalStackContainerNotFoundError( + 'Could not find a running LocalStack container named "localstack-main".' + ) + ), + }); + + const result = await new LocalStackLogRetriever().retrieveLogs(10); + expect(result.success).toBe(false); + expect(result.errorMessage).toMatch(/no running LocalStack container/i); }); - test("reports command failures instead of parsing empty output as clean logs", async () => { - mockedRunCommand.mockResolvedValueOnce({ - stdout: "", - stderr: "", - exitCode: null, - error: new Error("spawn localstack ENOENT"), - } as never); + test("surfaces Docker daemon failures without CLI-era wording", async () => { + mockDocker({ + findLocalStackContainer: jest + .fn() + .mockRejectedValue(new Error("connect ENOENT /var/run/docker.sock")), + }); const result = await new LocalStackLogRetriever().retrieveLogs(10); expect(result.success).toBe(false); - expect(result.errorMessage).toMatch(/Failed to retrieve logs/i); - expect(result.errorMessage).toMatch(/ENOENT/i); + expect(result.errorMessage).toMatch(/Docker API/i); + expect(result.errorMessage).toMatch(/ENOENT/); + expect(result.errorMessage).not.toMatch(/CLI/); + }); + + test("maps log-read timeouts to the friendly guidance", async () => { + mockDocker({ + getContainerLogs: jest + .fn() + .mockRejectedValue(new Error("Docker container logs timed out after 30000ms")), + }); + + const result = await new LocalStackLogRetriever().retrieveLogs(10); + expect(result.success).toBe(false); + expect(result.errorMessage).toMatch(/timed out/i); + expect(result.errorMessage).toMatch(/reducing the number of lines/i); + }); + + test("applies the case-insensitive substring filter", async () => { + mockDocker({ + getContainerLogs: jest + .fn() + .mockResolvedValue("line about S3 bucket\nline about lambda\nanother S3 line\n"), + }); + + const result = await new LocalStackLogRetriever().retrieveLogs(10, "s3"); + expect(result.success).toBe(true); + expect(result.totalLines).toBe(3); + expect(result.filteredLines).toBe(2); + expect(result.logs).toHaveLength(2); }); }); diff --git a/src/lib/logs/log-retriever.ts b/src/lib/logs/log-retriever.ts index bcfe9b4..232d316 100644 --- a/src/lib/logs/log-retriever.ts +++ b/src/lib/logs/log-retriever.ts @@ -1,4 +1,4 @@ -import { runCommand } from "../../core/command-runner"; +import { DockerApiClient, isLocalStackContainerNotFoundError } from "../docker/docker.client"; export interface LogEntry { timestamp?: string; @@ -31,40 +31,40 @@ export interface LogRetrievalResult { */ export class LocalStackLogRetriever { /** - * Retrieve logs from LocalStack + * Retrieve logs from the LocalStack container via the Docker Engine API + * (equivalent to `docker logs --tail N` on the main container). */ async retrieveLogs(lines: number = 10000, filter?: string): Promise { try { - const cmd = await runCommand("localstack", ["logs", "--tail", String(lines)], { - timeout: 30000, - }); - - if (cmd.error || cmd.exitCode !== 0) { - const details = [cmd.stderr, cmd.stdout, cmd.error?.message] - .filter((part) => part && part.trim()) - .join("\n"); - return { - success: false, - logs: [], - totalLines: 0, - errorMessage: details - ? `Failed to retrieve logs: ${details}` - : "Failed to retrieve logs from the LocalStack CLI.", - }; - } - - if (!cmd.stdout && cmd.stderr) { + const docker = new DockerApiClient(); + let output: string; + try { + const containerId = await docker.findLocalStackContainer(); + output = await docker.getContainerLogs(containerId, { tail: lines }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.toLowerCase().includes("timed out")) { + return { + success: false, + logs: [], + totalLines: 0, + errorMessage: + "Log retrieval timed out. LocalStack may be generating large amounts of logs. Try reducing the number of lines or check if LocalStack is experiencing issues.", + }; + } return { success: false, logs: [], totalLines: 0, - errorMessage: `Failed to retrieve logs: ${cmd.stderr}`, + errorMessage: isLocalStackContainerNotFoundError(error) + ? `Failed to retrieve logs: no running LocalStack container was found. ${message}` + : `Failed to retrieve logs from the Docker API: ${message}`, }; } - // Split on both LF and CRLF so a trailing \r from Windows child-process - // stdout doesn't pollute the parsed line text (fullLine/message). - const rawLines = (cmd.stdout || "").split(/\r?\n/).filter((line) => line.trim()); + // Split on both LF and CRLF so stray \r characters don't pollute the parsed + // line text (fullLine/message). + const rawLines = (output || "").split(/\r?\n/).filter((line) => line.trim()); let filteredLines = rawLines; if (filter) { @@ -84,7 +84,7 @@ export class LocalStackLogRetriever { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("timeout")) { + if (errorMessage.toLowerCase().includes("timed out")) { return { success: false, logs: [], @@ -94,16 +94,6 @@ export class LocalStackLogRetriever { }; } - if (errorMessage.includes("Command failed") || errorMessage.includes("not found")) { - return { - success: false, - logs: [], - totalLines: 0, - errorMessage: - "Unable to execute 'localstack logs' command. Please ensure LocalStack CLI is installed and LocalStack is running.", - }; - } - return { success: false, logs: [], diff --git a/src/lib/wizard/cli-args.logic.ts b/src/lib/wizard/cli-args.logic.ts index ceffb23..72a3ce1 100644 --- a/src/lib/wizard/cli-args.logic.ts +++ b/src/lib/wizard/cli-args.logic.ts @@ -7,6 +7,7 @@ export interface InitFlags { clients?: ClientId[]; token?: string; config?: string; + /** Deprecated: accepted for backward compatibility, ignored. */ cacheDir?: string; workspace?: string; imageTag?: string; diff --git a/src/lib/wizard/paths.logic.ts b/src/lib/wizard/paths.logic.ts index fe2e4e8..01d5331 100644 --- a/src/lib/wizard/paths.logic.ts +++ b/src/lib/wizard/paths.logic.ts @@ -31,7 +31,13 @@ export function antigravityConfigPath(ctx: ClientContext): string { export function claudeDesktopConfigPath(ctx: ClientContext): string | null { const p = pathFor(ctx); if (ctx.platform === "darwin") { - return p.join(ctx.homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json"); + return p.join( + ctx.homeDir, + "Library", + "Application Support", + "Claude", + "claude_desktop_config.json" + ); } if (ctx.platform === "win32") { const appData = ctx.env.APPDATA || p.join(ctx.homeDir, "AppData", "Roaming"); diff --git a/src/lib/wizard/prereqs.ts b/src/lib/wizard/prereqs.ts index 2c257f7..8b1230c 100644 --- a/src/lib/wizard/prereqs.ts +++ b/src/lib/wizard/prereqs.ts @@ -49,22 +49,9 @@ export async function checkPrereqs(method: InstallMethod): Promise localstackOk || lstkOk) - : Promise.resolve(false); if (method === "npx") { results.push(checkNodeVersion()); - results.push({ - name: "LocalStack lifecycle CLI", - ok: await lifecycleCliPromise, - fatal: false, - hint: "install either `localstack` (`brew install localstack/tap/localstack-cli` or `pip install localstack`) or `lstk` — needed when the MCP server starts or restarts LocalStack", - }); } const { installed: dockerInstalled, running: dockerRunning } = await dockerPromise; @@ -72,8 +59,8 @@ export async function checkPrereqs(method: InstallMethod): Promise { describe("buildDockerServerSpec", () => { const options = { - cacheDir: "/Users/you/.localstack-mcp", workspaceDir: "/Users/you/projects", imageTag: "latest", }; @@ -38,10 +37,6 @@ describe("buildDockerServerSpec", () => { "--rm", "-v", "/var/run/docker.sock:/var/run/docker.sock", - "-v", - "/Users/you/.localstack-mcp:/Users/you/.localstack-mcp", - "-e", - "XDG_CACHE_HOME=/Users/you/.localstack-mcp", "--add-host", "host.docker.internal:host-gateway", "--add-host", @@ -89,7 +84,7 @@ describe("windowsSpawnSafeSpec", () => { }); it("leaves non-npx commands untouched", () => { - const docker = buildDockerServerSpec("tok", {}, { cacheDir: "/c", imageTag: "latest" }); + const docker = buildDockerServerSpec("tok", {}, { imageTag: "latest" }); expect(windowsSpawnSafeSpec(docker)).toBe(docker); }); }); diff --git a/src/lib/wizard/server-config.logic.ts b/src/lib/wizard/server-config.logic.ts index 4e37e28..87d7948 100644 --- a/src/lib/wizard/server-config.logic.ts +++ b/src/lib/wizard/server-config.logic.ts @@ -33,10 +33,6 @@ export function buildDockerServerSpec( "--rm", "-v", "/var/run/docker.sock:/var/run/docker.sock", - "-v", - `${options.cacheDir}:${options.cacheDir}`, - "-e", - `XDG_CACHE_HOME=${options.cacheDir}`, "--add-host", "host.docker.internal:host-gateway", "--add-host", diff --git a/src/lib/wizard/types.ts b/src/lib/wizard/types.ts index 1497f92..0b21198 100644 --- a/src/lib/wizard/types.ts +++ b/src/lib/wizard/types.ts @@ -22,7 +22,6 @@ export interface ServerSpec { } export interface DockerOptions { - cacheDir: string; workspaceDir?: string; imageTag: string; } diff --git a/src/prompts/infrastructure-tester.ts b/src/prompts/infrastructure-tester.ts index a7e4d03..fcceadf 100644 --- a/src/prompts/infrastructure-tester.ts +++ b/src/prompts/infrastructure-tester.ts @@ -10,7 +10,9 @@ export const schema = { iac_type: z .string() .optional() - .describe("(Optional) What IaC framework is it? Use auto, cdk, terraform, sam, or cloudformation."), + .describe( + "(Optional) What IaC framework is it? Use auto, cdk, terraform, sam, or cloudformation." + ), test_language: z .string() .optional() @@ -22,11 +24,15 @@ export const schema = { mode: z .string() .optional() - .describe("(Optional) Run validation only, or also write and run tests? Use 'validate-only' or 'full'."), + .describe( + "(Optional) Run validation only, or also write and run tests? Use 'validate-only' or 'full'." + ), services_focus: z .string() .optional() - .describe("(Optional) Which AWS services should get extra attention? Example: s3,lambda,dynamodb."), + .describe( + "(Optional) Which AWS services should get extra attention? Example: s3,lambda,dynamodb." + ), user_focus: z .string() .optional() diff --git a/src/tools/localstack-aws-client.ts b/src/tools/localstack-aws-client.ts index 17f1e7f..79d7b7b 100644 --- a/src/tools/localstack-aws-client.ts +++ b/src/tools/localstack-aws-client.ts @@ -49,8 +49,12 @@ export default async function localstackAwsClient({ command }: InferSchema - rulesMatch(currentRule, ruleToRemove) - ); - if (!ruleExists) { - return ResponseBuilder.markdown(`⚠️ The specified rule was not found in the current configuration. No changes were made. + case "remove-fault-rule": { + if (!rules || rules.length === 0) { + return { + content: [ + { + type: "text", + text: "❌ **Error:** The `remove-fault-rule` action requires the `rules` parameter to be specified.", + }, + ], + }; + } + + // First get current rules to check if the rule exists + const getCurrentResult = await client.getFaults(); + if (!getCurrentResult.success) { + return ResponseBuilder.error("Chaos API Error", getCurrentResult.message); + } + + // Check if all rules to remove exist in current configuration + const currentRules = getCurrentResult.data || []; + const rulesToRemove = rules; + + for (const ruleToRemove of rulesToRemove) { + const ruleExists = currentRules.some((currentRule: any) => + rulesMatch(currentRule, ruleToRemove) + ); + if (!ruleExists) { + return ResponseBuilder.markdown(`⚠️ The specified rule was not found in the current configuration. No changes were made. Current configuration: ${formatFaultRules(currentRules)}`); - } - } + } + } - // Rule exists, proceed with removal - const removeResult = await client.removeFaultRules(rulesToRemove); - if (!removeResult.success) { - return ResponseBuilder.error("Chaos API Error", removeResult.message); - } + // Rule exists, proceed with removal + const removeResult = await client.removeFaultRules(rulesToRemove); + if (!removeResult.success) { + return ResponseBuilder.error("Chaos API Error", removeResult.message); + } - // Get current state after removal to confirm - const getUpdatedResult = await client.getFaults(); - if (!getUpdatedResult.success) { - return ResponseBuilder.error("Chaos API Error", getUpdatedResult.message); - } + // Get current state after removal to confirm + const getUpdatedResult = await client.getFaults(); + if (!getUpdatedResult.success) { + return ResponseBuilder.error("Chaos API Error", getUpdatedResult.message); + } - const message = `✅ The specified fault rule(s) have been removed. The current active faults are: + const message = `✅ The specified fault rule(s) have been removed. The current active faults are: ${formatFaultRules(getUpdatedResult.data)}`; - return ResponseBuilder.markdown(message); - } + return ResponseBuilder.markdown(message); + } - case "get-latency": { - const result = await client.getEffects(); - if (!result.success) { - return ResponseBuilder.error("Chaos API Error", result.message); - } + case "get-latency": { + const result = await client.getEffects(); + if (!result.success) { + return ResponseBuilder.error("Chaos API Error", result.message); + } - const latency = (result.data as any)?.latency || 0; - return ResponseBuilder.markdown(`The current network latency is ${latency}ms.`); - } + const latency = (result.data as any)?.latency || 0; + return ResponseBuilder.markdown(`The current network latency is ${latency}ms.`); + } - case "clear-latency": { - const result = await client.setEffects({ latency: 0 }); - if (!result.success) { - return ResponseBuilder.error("Chaos API Error", result.message); - } + case "clear-latency": { + const result = await client.setEffects({ latency: 0 }); + if (!result.success) { + return ResponseBuilder.error("Chaos API Error", result.message); + } - // Get current state to confirm - const getCurrentResult = await client.getEffects(); - if (!getCurrentResult.success) { - return ResponseBuilder.error("Chaos API Error", getCurrentResult.message); - } + // Get current state to confirm + const getCurrentResult = await client.getEffects(); + if (!getCurrentResult.success) { + return ResponseBuilder.error("Chaos API Error", getCurrentResult.message); + } - const message = `✅ Network latency has been cleared. The current effects are: + const message = `✅ Network latency has been cleared. The current effects are: \`\`\`json ${JSON.stringify(getCurrentResult.data, null, 2)} \`\`\``; - return ResponseBuilder.markdown(message); - } - - case "inject-latency": { - if (latency_ms === undefined || latency_ms === null) { - return { - content: [ - { - type: "text", - text: "❌ **Error:** The `inject-latency` action requires the `latency_ms` parameter to be specified.", - }, - ], - }; - } - - const result = await client.setEffects({ latency: latency_ms }); - if (!result.success) { - return ResponseBuilder.error("Chaos API Error", result.message); - } - - // Get current state to confirm - const getCurrentResult = await client.getEffects(); - if (!getCurrentResult.success) { - return ResponseBuilder.error("Chaos API Error", getCurrentResult.message); - } + return ResponseBuilder.markdown(message); + } - const message = `✅ Latency of ${latency_ms}ms has been injected. The current network effects are: + case "inject-latency": { + if (latency_ms === undefined || latency_ms === null) { + return { + content: [ + { + type: "text", + text: "❌ **Error:** The `inject-latency` action requires the `latency_ms` parameter to be specified.", + }, + ], + }; + } + + const result = await client.setEffects({ latency: latency_ms }); + if (!result.success) { + return ResponseBuilder.error("Chaos API Error", result.message); + } + + // Get current state to confirm + const getCurrentResult = await client.getEffects(); + if (!getCurrentResult.success) { + return ResponseBuilder.error("Chaos API Error", getCurrentResult.message); + } + + const message = `✅ Latency of ${latency_ms}ms has been injected. The current network effects are: \`\`\`json ${JSON.stringify(getCurrentResult.data, null, 2)} \`\`\``; - return ResponseBuilder.markdown(addWorkflowGuidance(message)); - } + return ResponseBuilder.markdown(addWorkflowGuidance(message)); + } default: return ResponseBuilder.error("Unknown action", `Unsupported action: ${action}`); diff --git a/src/tools/localstack-cloud-pods.ts b/src/tools/localstack-cloud-pods.ts index 4206173..7330f39 100644 --- a/src/tools/localstack-cloud-pods.ts +++ b/src/tools/localstack-cloud-pods.ts @@ -7,18 +7,13 @@ import { runPreflights, requireAuthToken, requireLocalStackRunning, - requireLocalStackCli, requireProFeature, } from "../core/preflight"; import { withToolAnalytics } from "../core/analytics"; // Define the schema for tool parameters export const schema = { - action: z - .enum(["save", "load", "delete"]) - .describe( - "The Cloud Pods action to perform." - ), + action: z.enum(["save", "load", "delete"]).describe("The Cloud Pods action to perform."), pod_name: z .string() @@ -55,7 +50,6 @@ export default async function localstackCloudPods({ const preflightError = await runPreflights([ requireAuthToken(), requireLocalStackRunning(), - requireLocalStackCli(), requireProFeature(ProFeature.CLOUD_PODS), ]); if (preflightError) return preflightError; diff --git a/src/tools/localstack-deployer.ts b/src/tools/localstack-deployer.ts index e8e8a24..e96e4cc 100644 --- a/src/tools/localstack-deployer.ts +++ b/src/tools/localstack-deployer.ts @@ -3,11 +3,7 @@ import { type ToolMetadata, type InferSchema } from "xmcp"; import { runCommand, stripAnsiCodes } from "../core/command-runner"; import path from "path"; import fs from "fs"; -import { - runPreflights, - requireAuthToken, - requireLocalStackRunning, -} from "../core/preflight"; +import { runPreflights, requireAuthToken, requireLocalStackRunning } from "../core/preflight"; import { DockerApiClient } from "../lib/docker/docker.client"; import { checkDependencies, @@ -102,153 +98,161 @@ export default async function localstackDeployer({ }: InferSchema) { return withToolAnalytics( "localstack-deployer", - { action, projectType, directory, stackName, templatePath, variables, s3Bucket, resolveS3, saveParams }, + { + action, + projectType, + directory, + stackName, + templatePath, + variables, + s3Bucket, + resolveS3, + saveParams, + }, async () => { const preflightError = await runPreflights([requireAuthToken(), requireLocalStackRunning()]); if (preflightError) return preflightError; - if (action === "create-stack") { - if (!stackName) { - return ResponseBuilder.error( - "Missing Parameter", - "The parameter 'stackName' is required for action 'create-stack'." - ); - } - let resolvedTemplatePath = templatePath; - if (!resolvedTemplatePath) { - if (!directory) { - return ResponseBuilder.error( - "Missing Parameter", - "Provide 'templatePath' or a 'directory' containing a single .yaml/.yml CloudFormation template." - ); - } - try { - const files = await fs.promises.readdir(directory); - const yamlFiles = files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")); - if (yamlFiles.length === 0) { + if (action === "create-stack") { + if (!stackName) { return ResponseBuilder.error( - "Template Not Found", - `No .yaml/.yml template found in directory '${directory}'.` + "Missing Parameter", + "The parameter 'stackName' is required for action 'create-stack'." ); } - if (yamlFiles.length > 1) { + let resolvedTemplatePath = templatePath; + if (!resolvedTemplatePath) { + if (!directory) { + return ResponseBuilder.error( + "Missing Parameter", + "Provide 'templatePath' or a 'directory' containing a single .yaml/.yml CloudFormation template." + ); + } + try { + const files = await fs.promises.readdir(directory); + const yamlFiles = files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")); + if (yamlFiles.length === 0) { + return ResponseBuilder.error( + "Template Not Found", + `No .yaml/.yml template found in directory '${directory}'.` + ); + } + if (yamlFiles.length > 1) { + return ResponseBuilder.error( + "Multiple Templates Found", + `Multiple .yaml/.yml templates found in '${directory}'. Please specify 'templatePath'.\n\nFound:\n${yamlFiles + .map((f) => `- ${f}`) + .join("\n")}` + ); + } + resolvedTemplatePath = path.join(directory, yamlFiles[0]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return ResponseBuilder.error( + "Directory Read Error", + `Failed to read directory '${directory}'. ${message}` + ); + } + } + + let templateBody = ""; + try { + templateBody = await fs.promises.readFile(resolvedTemplatePath, "utf-8"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); return ResponseBuilder.error( - "Multiple Templates Found", - `Multiple .yaml/.yml templates found in '${directory}'. Please specify 'templatePath'.\n\nFound:\n${yamlFiles - .map((f) => `- ${f}`) - .join("\n")}` + "Template Read Error", + `Failed to read template file at '${resolvedTemplatePath}'. ${message}` ); } - resolvedTemplatePath = path.join(directory, yamlFiles[0]); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return ResponseBuilder.error( - "Directory Read Error", - `Failed to read directory '${directory}'. ${message}` - ); - } - } - - let templateBody = ""; - try { - templateBody = await fs.promises.readFile(resolvedTemplatePath, "utf-8"); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return ResponseBuilder.error( - "Template Read Error", - `Failed to read template file at '${resolvedTemplatePath}'. ${message}` - ); - } - - try { - const dockerClient = new DockerApiClient(); - const containerId = await dockerClient.findLocalStackContainer(); - - const tempPath = `/tmp/ls-cfn-${Date.now()}.yaml`; - const writeRes = await dockerClient.executeInContainer( - containerId, - ["/bin/sh", "-c", `cat > ${tempPath}`], - templateBody - ); - if (writeRes.exitCode !== 0) { - return ResponseBuilder.error( - "Template Upload Failed", - writeRes.stderr || `Failed to write template to ${tempPath}` - ); - } - const createCmd = [ - "awslocal", - "cloudformation", - "create-stack", - "--stack-name", - stackName, - "--template-body", - `file://${tempPath}`, - ]; - const createRes = await dockerClient.executeInContainer(containerId, createCmd); + try { + const dockerClient = new DockerApiClient(); + const containerId = await dockerClient.findLocalStackContainer(); - try { - await dockerClient.executeInContainer(containerId, ["/bin/sh", "-c", `rm -f ${tempPath}`]); - } catch {} - - if (createRes.exitCode === 0) { - return ResponseBuilder.markdown( - (createRes.stdout && createRes.stdout.trim()) - ? createRes.stdout - : `Stack '${stackName}' creation initiated.\n\nTip: Use the 'localstack-aws-client' tool with 'cloudformation describe-stacks' to monitor stack status and wait for CREATE_COMPLETE.` - ); + const tempPath = `/tmp/ls-cfn-${Date.now()}.yaml`; + const writeRes = await dockerClient.executeInContainer( + containerId, + ["/bin/sh", "-c", `cat > ${tempPath}`], + templateBody + ); + if (writeRes.exitCode !== 0) { + return ResponseBuilder.error( + "Template Upload Failed", + writeRes.stderr || `Failed to write template to ${tempPath}` + ); + } + + const createCmd = [ + "awslocal", + "cloudformation", + "create-stack", + "--stack-name", + stackName, + "--template-body", + `file://${tempPath}`, + ]; + const createRes = await dockerClient.executeInContainer(containerId, createCmd); + + try { + await dockerClient.executeInContainer(containerId, [ + "/bin/sh", + "-c", + `rm -f ${tempPath}`, + ]); + } catch {} + + if (createRes.exitCode === 0) { + return ResponseBuilder.markdown( + createRes.stdout && createRes.stdout.trim() + ? createRes.stdout + : `Stack '${stackName}' creation initiated.\n\nTip: Use the 'localstack-aws-client' tool with 'cloudformation describe-stacks' to monitor stack status and wait for CREATE_COMPLETE.` + ); + } + return ResponseBuilder.error( + "CloudFormation create-stack failed", + createRes.stderr || "Unknown error" + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return ResponseBuilder.error( + "CloudFormation Error", + `An unexpected error occurred: ${errorMessage}` + ); + } } - return ResponseBuilder.error( - "CloudFormation create-stack failed", - createRes.stderr || "Unknown error" - ); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return ResponseBuilder.error( - "CloudFormation Error", - `An unexpected error occurred: ${errorMessage}` - ); - } - } - if (action === "delete-stack") { - if (!stackName) { - return ResponseBuilder.error( - "Missing Parameter", - "The parameter 'stackName' is required for action 'delete-stack'." - ); - } - try { - const dockerClient = new DockerApiClient(); - const containerId = await dockerClient.findLocalStackContainer(); - const command = [ - "awslocal", - "cloudformation", - "delete-stack", - "--stack-name", - stackName, - ]; - const result = await dockerClient.executeInContainer(containerId, command); - if (result.exitCode === 0) { - return ResponseBuilder.markdown( - (result.stdout && result.stdout.trim()) - ? result.stdout - : `Stack '${stackName}' deletion initiated.\n\nTip: Use the 'localstack-aws-client' tool with 'cloudformation describe-stacks' to monitor deletion status until DELETE_COMPLETE.` - ); + if (action === "delete-stack") { + if (!stackName) { + return ResponseBuilder.error( + "Missing Parameter", + "The parameter 'stackName' is required for action 'delete-stack'." + ); + } + try { + const dockerClient = new DockerApiClient(); + const containerId = await dockerClient.findLocalStackContainer(); + const command = ["awslocal", "cloudformation", "delete-stack", "--stack-name", stackName]; + const result = await dockerClient.executeInContainer(containerId, command); + if (result.exitCode === 0) { + return ResponseBuilder.markdown( + result.stdout && result.stdout.trim() + ? result.stdout + : `Stack '${stackName}' deletion initiated.\n\nTip: Use the 'localstack-aws-client' tool with 'cloudformation describe-stacks' to monitor deletion status until DELETE_COMPLETE.` + ); + } + return ResponseBuilder.error( + "CloudFormation delete-stack failed", + result.stderr || "Unknown error" + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return ResponseBuilder.error( + "CloudFormation Error", + `An unexpected error occurred: ${errorMessage}` + ); + } } - return ResponseBuilder.error( - "CloudFormation delete-stack failed", - result.stderr || "Unknown error" - ); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return ResponseBuilder.error( - "CloudFormation Error", - `An unexpected error occurred: ${errorMessage}` - ); - } - } let resolvedProjectType: "cdk" | "terraform" | "sam"; @@ -261,38 +265,41 @@ export default async function localstackDeployer({ } const nonNullDirectory = directory as string; - // Validate the directory up front; a missing cwd otherwise surfaces as a misleading - // "spawn cdklocal/tflocal/samlocal ENOENT". Hint differs by run mode (Docker vs npx). - const resolvedDir = path.resolve(nonNullDirectory); - const dirExists = await fs.promises.stat(resolvedDir).then((s) => s.isDirectory()).catch(() => false); - if (!dirExists) { - return ResponseBuilder.error( - "Project Directory Not Found", - fs.existsSync("/.dockerenv") - ? `The directory "${resolvedDir}" was not found inside the container. Bind-mount your project at the same absolute path (\`-v ${resolvedDir}:${resolvedDir}\`) and pass that path as 'directory'.` - : `The directory "${resolvedDir}" was not found. Please check the path and try again.` - ); - } + // Validate the directory up front; a missing cwd otherwise surfaces as a misleading + // "spawn cdklocal/tflocal/samlocal ENOENT". Hint differs by run mode (Docker vs npx). + const resolvedDir = path.resolve(nonNullDirectory); + const dirExists = await fs.promises + .stat(resolvedDir) + .then((s) => s.isDirectory()) + .catch(() => false); + if (!dirExists) { + return ResponseBuilder.error( + "Project Directory Not Found", + fs.existsSync("/.dockerenv") + ? `The directory "${resolvedDir}" was not found inside the container. Bind-mount your project at the same absolute path (\`-v ${resolvedDir}:${resolvedDir}\`) and pass that path as 'directory'.` + : `The directory "${resolvedDir}" was not found. Please check the path and try again.` + ); + } - // Step 1: Project Type Resolution - if (projectType === "auto") { - const inferredType = await inferProjectType(nonNullDirectory); + // Step 1: Project Type Resolution + if (projectType === "auto") { + const inferredType = await inferProjectType(nonNullDirectory); - if (inferredType === "ambiguous") { - return ResponseBuilder.error( - "Ambiguous Project Type", - `The directory "${directory}" contains multiple infrastructure project types. Please specify the project type explicitly: + if (inferredType === "ambiguous") { + return ResponseBuilder.error( + "Ambiguous Project Type", + `The directory "${directory}" contains multiple infrastructure project types. Please specify the project type explicitly: - Use \`projectType: 'cdk'\` to deploy as a CDK project - Use \`projectType: 'terraform'\` to deploy as a Terraform project - Use \`projectType: 'sam'\` to deploy as a SAM project` - ); - } + ); + } - if (inferredType === "unknown") { - return ResponseBuilder.error( - "Unknown Project Type", - `The directory "${directory}" does not appear to contain recognizable infrastructure-as-code files. + if (inferredType === "unknown") { + return ResponseBuilder.error( + "Unknown Project Type", + `The directory "${directory}" does not appear to contain recognizable infrastructure-as-code files. Expected files: - **CDK**: \`cdk.json\`, \`app.py\`, \`app.js\`, or \`app.ts\` @@ -300,34 +307,34 @@ Expected files: - **SAM**: \`samconfig.toml\` or \`template.yaml/.yml\` with \`AWS::Serverless::*\` resources Please check the directory path or specify the project type explicitly.` - ); - } + ); + } - resolvedProjectType = inferredType as "cdk" | "terraform" | "sam"; - } else { - resolvedProjectType = projectType as "cdk" | "terraform" | "sam"; - } + resolvedProjectType = inferredType as "cdk" | "terraform" | "sam"; + } else { + resolvedProjectType = projectType as "cdk" | "terraform" | "sam"; + } - // Check Dependencies - const dependencyCheck = await checkDependencies(resolvedProjectType); - if (!dependencyCheck.isAvailable) { - return ResponseBuilder.error("Dependency Not Available", dependencyCheck.errorMessage!); - } + // Check Dependencies + const dependencyCheck = await checkDependencies(resolvedProjectType); + if (!dependencyCheck.isAvailable) { + return ResponseBuilder.error("Dependency Not Available", dependencyCheck.errorMessage!); + } - // Security Validation - const validationErrors = validateVariables(variables); - if (validationErrors.length > 0) { - return ResponseBuilder.error( - "Security Violation Detected", - `🛡️ **Security Violation Detected** + // Security Validation + const validationErrors = validateVariables(variables); + if (validationErrors.length > 0) { + return ResponseBuilder.error( + "Security Violation Detected", + `🛡️ **Security Violation Detected** Command injection attempt prevented. The following issues were found: ${validationErrors.map((error) => `- ${error}`).join("\n")} Please review your variables and ensure they don't contain shell metacharacters or invalid identifiers.` - ); - } + ); + } // Execute Commands Based on Project Type and Action return await executeDeploymentCommands( @@ -394,7 +401,10 @@ async function executeTerraformCommands( if (action === "deploy") { events.push({ type: "header", title: "📦 Initializing Terraform", content: "" }); - const initRes = await runCommand(baseCommand, ["init"], { cwd: directory, env: buildIacCliEnv() }); + const initRes = await runCommand(baseCommand, ["init"], { + cwd: directory, + env: buildIacCliEnv(), + }); events.push({ type: "output", content: stripAnsiCodes(initRes.stdout) }); if (initRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(initRes.stderr) }); if (initRes.error) { @@ -408,7 +418,10 @@ async function executeTerraformCommands( events.push({ type: "header", title: "🔨 Applying Terraform Configuration", content: "" }); const applyArgs = ["apply", "-auto-approve", ...varArgs]; - const applyRes = await runCommand(baseCommand, applyArgs, { cwd: directory, env: buildIacCliEnv() }); + const applyRes = await runCommand(baseCommand, applyArgs, { + cwd: directory, + env: buildIacCliEnv(), + }); events.push({ type: "output", content: stripAnsiCodes(applyRes.stdout) }); if (applyRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(applyRes.stderr) }); if (applyRes.error) { @@ -420,7 +433,10 @@ async function executeTerraformCommands( return events; } - const outputRes = await runCommand(baseCommand, ["output", "-json"], { cwd: directory, env: buildIacCliEnv() }); + const outputRes = await runCommand(baseCommand, ["output", "-json"], { + cwd: directory, + env: buildIacCliEnv(), + }); if (outputRes.stdout.trim()) { const parsed = parseTerraformOutputs(outputRes.stdout); events.push({ type: "output", content: parsed }); @@ -429,7 +445,10 @@ async function executeTerraformCommands( } else { events.push({ type: "header", title: "💥 Destroying Terraform Resources", content: "" }); const destroyArgs = ["destroy", "-auto-approve", ...varArgs]; - const destroyRes = await runCommand(baseCommand, destroyArgs, { cwd: directory, env: buildIacCliEnv() }); + const destroyRes = await runCommand(baseCommand, destroyArgs, { + cwd: directory, + env: buildIacCliEnv(), + }); events.push({ type: "output", content: stripAnsiCodes(destroyRes.stdout) }); if (destroyRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(destroyRes.stderr) }); @@ -514,7 +533,10 @@ async function executeSamCommands( buildArgs.push("--template-file", resolvedTemplatePath); } events.push({ type: "command", content: `${baseCommand} ${buildArgs.join(" ")}` }); - const buildRes = await runCommand(baseCommand, buildArgs, { cwd: directory, env: buildIacCliEnv() }); + const buildRes = await runCommand(baseCommand, buildArgs, { + cwd: directory, + env: buildIacCliEnv(), + }); events.push({ type: "output", content: stripAnsiCodes(buildRes.stdout) }); if (buildRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(buildRes.stderr) }); if (buildRes.error) { @@ -551,7 +573,10 @@ async function executeSamCommands( deployArgs.push("--save-params"); } if (variables && Object.keys(variables).length > 0) { - deployArgs.push("--parameter-overrides", ...Object.entries(variables).map(([k, v]) => `${k}=${v}`)); + deployArgs.push( + "--parameter-overrides", + ...Object.entries(variables).map(([k, v]) => `${k}=${v}`) + ); } events.push({ type: "command", content: `${baseCommand} ${deployArgs.join(" ")}` }); @@ -560,7 +585,8 @@ async function executeSamCommands( env: buildIacCliEnv({ CI: "true" }), }); events.push({ type: "output", content: stripAnsiCodes(deployRes.stdout) }); - if (deployRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(deployRes.stderr) }); + if (deployRes.stderr) + events.push({ type: "warning", content: stripAnsiCodes(deployRes.stderr) }); if (deployRes.error) { events.push({ type: "error", @@ -572,11 +598,22 @@ async function executeSamCommands( events.push({ type: "success", content: "SAM application deployed successfully!" }); } else { events.push({ type: "header", title: "💥 Deleting SAM Application", content: "" }); - const deleteArgs = ["delete", "--no-prompts", "--region", resolvedRegion, "--stack-name", resolvedStackName]; + const deleteArgs = [ + "delete", + "--no-prompts", + "--region", + resolvedRegion, + "--stack-name", + resolvedStackName, + ]; events.push({ type: "command", content: `${baseCommand} ${deleteArgs.join(" ")}` }); - const deleteRes = await runCommand(baseCommand, deleteArgs, { cwd: directory, env: buildIacCliEnv() }); + const deleteRes = await runCommand(baseCommand, deleteArgs, { + cwd: directory, + env: buildIacCliEnv(), + }); events.push({ type: "output", content: stripAnsiCodes(deleteRes.stdout) }); - if (deleteRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(deleteRes.stderr) }); + if (deleteRes.stderr) + events.push({ type: "warning", content: stripAnsiCodes(deleteRes.stderr) }); if (deleteRes.error) { events.push({ type: "error", diff --git a/src/tools/localstack-docs.ts b/src/tools/localstack-docs.ts index 9098dbc..809f8d7 100644 --- a/src/tools/localstack-docs.ts +++ b/src/tools/localstack-docs.ts @@ -5,8 +5,7 @@ import { runPreflights, requireAuthToken } from "../core/preflight"; import { ResponseBuilder } from "../core/response-builder"; import { withToolAnalytics } from "../core/analytics"; -const CRAWLCHAT_DOCS_ENDPOINT = - "https://wings.crawlchat.app/mcp/698f2c11e688991df3c7e020"; +const CRAWLCHAT_DOCS_ENDPOINT = "https://wings.crawlchat.app/mcp/698f2c11e688991df3c7e020"; type CrawlChatDocsResult = { content: string; diff --git a/src/tools/localstack-ephemeral-instances.ts b/src/tools/localstack-ephemeral-instances.ts index 0862338..d95620e 100644 --- a/src/tools/localstack-ephemeral-instances.ts +++ b/src/tools/localstack-ephemeral-instances.ts @@ -1,9 +1,13 @@ import { z } from "zod"; import { type ToolMetadata, type InferSchema } from "xmcp"; -import { runCommand, stripAnsiCodes } from "../core/command-runner"; -import { runPreflights, requireLocalStackCli, requireAuthToken } from "../core/preflight"; +import { requireAuthToken } from "../core/preflight"; import { ResponseBuilder } from "../core/response-builder"; import { withToolAnalytics } from "../core/analytics"; +import { + PlatformApiClient, + describePlatformError, + type EphemeralInstance, +} from "../lib/localstack/platform.client"; export const schema = { action: z @@ -18,7 +22,9 @@ export const schema = { .int() .positive() .optional() - .describe("Lifetime in minutes for create action. Defaults to CLI default when omitted."), + .describe( + "Lifetime in minutes for create action. Defaults to the platform default when omitted." + ), extension: z .string() .optional() @@ -35,7 +41,7 @@ export const schema = { .record(z.string(), z.string()) .optional() .describe( - "Additional environment variables to pass to the ephemeral instance (create action only), translated to repeated --env KEY=VALUE flags." + "Additional environment variables to pass to the ephemeral instance (create action only)." ), }; @@ -73,18 +79,19 @@ export default async function localstackEphemeralInstances({ const authError = requireAuthToken(); if (authError) return authError; - const preflightError = await runPreflights([requireLocalStackCli()]); - if (preflightError) return preflightError; + // Ephemeral instances are cloud-hosted: everything goes through the LocalStack + // platform API — no local container, CLI, or Docker daemon involved. + const client = new PlatformApiClient(process.env.LOCALSTACK_AUTH_TOKEN!.trim()); switch (action) { case "create": - return await handleCreate({ name, lifetime, extension, cloudPod, envVars }); + return await handleCreate(client, { name, lifetime, extension, cloudPod, envVars }); case "list": - return await handleList(); + return await handleList(client); case "logs": - return await handleLogs({ name }); + return await handleLogs(client, { name }); case "delete": - return await handleDelete({ name }); + return await handleDelete(client, { name }); default: return ResponseBuilder.error("Unknown action", `Unsupported action: ${action}`); } @@ -92,46 +99,9 @@ export default async function localstackEphemeralInstances({ ); } -function cleanOutput(stdout: string, stderr: string): { stdout: string; stderr: string; combined: string } { - const cleanStdout = stripAnsiCodes(stdout || "").trim(); - const cleanStderr = stripAnsiCodes(stderr || "").trim(); - const combined = [cleanStdout, cleanStderr].filter((part) => part.length > 0).join("\n").trim(); - return { stdout: cleanStdout, stderr: cleanStderr, combined }; -} - -function parseJsonFromText(text: string): unknown { - const trimmed = text.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed); - } catch { - const startObject = trimmed.indexOf("{"); - const endObject = trimmed.lastIndexOf("}"); - if (startObject !== -1 && endObject > startObject) { - const candidate = trimmed.slice(startObject, endObject + 1); - try { - return JSON.parse(candidate); - } catch { - // continue - } - } - const startArray = trimmed.indexOf("["); - const endArray = trimmed.lastIndexOf("]"); - if (startArray !== -1 && endArray > startArray) { - const candidate = trimmed.slice(startArray, endArray + 1); - try { - return JSON.parse(candidate); - } catch { - // continue - } - } - return null; - } -} - -function formatCreateResponse(payload: Record): string { +function formatCreateResponse(payload: EphemeralInstance): string { const endpoint = String(payload.endpoint_url ?? "N/A"); - const id = String(payload.id ?? "N/A"); + const id = String(payload.id ?? payload.instance_name ?? "N/A"); const status = String(payload.status ?? "unknown"); const creationTime = String(payload.creation_time ?? "N/A"); const expiryTime = String(payload.expiry_time ?? "N/A"); @@ -152,19 +122,22 @@ Use this endpoint with your tools, for example: \`aws --endpoint-url=${endpoint} s3 ls\``; } -async function handleCreate({ - name, - lifetime, - extension, - cloudPod, - envVars, -}: { - name?: string; - lifetime?: number; - extension?: string; - cloudPod?: string; - envVars?: Record; -}) { +async function handleCreate( + client: PlatformApiClient, + { + name, + lifetime, + extension, + cloudPod, + envVars, + }: { + name?: string; + lifetime?: number; + extension?: string; + cloudPod?: string; + envVars?: Record; + } +) { if (!name?.trim()) { return ResponseBuilder.error( "Missing Required Parameter", @@ -172,11 +145,6 @@ async function handleCreate({ ); } - const args = ["ephemeral", "create", "--name", name.trim()]; - if (lifetime !== undefined) { - args.push("--lifetime", String(lifetime)); - } - const mergedEnvVars: Record = { ...(envVars || {}) }; if (extension) { mergedEnvVars.EXTENSION_AUTO_INSTALL = extension; @@ -185,63 +153,50 @@ async function handleCreate({ mergedEnvVars.CLOUD_POD_NAME = cloudPod; } - for (const [key, value] of Object.entries(mergedEnvVars)) { + for (const key of Object.keys(mergedEnvVars)) { if (!key || key.includes("=")) { return ResponseBuilder.error( "Invalid Environment Variable Key", `Invalid env var key '${key}'. Keys must be non-empty and cannot contain '='.` ); } - args.push("--env", `${key}=${value}`); } - const result = await runCommand("localstack", args, { - env: { ...process.env }, - timeout: 180000, - }); - const cleaned = cleanOutput(result.stdout, result.stderr); - - if (result.exitCode !== 0) { + try { + const instance = await client.createEphemeralInstance({ + name: name.trim(), + lifetime, + envVars: mergedEnvVars, + }); + return ResponseBuilder.markdown(formatCreateResponse(instance)); + } catch (error) { return ResponseBuilder.error( "Create Failed", - cleaned.combined || "Failed to create ephemeral instance." + describePlatformError(error, `ephemeral instance '${name}'`) ); } - - const parsed = parseJsonFromText(cleaned.stdout) || parseJsonFromText(cleaned.combined); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return ResponseBuilder.markdown(formatCreateResponse(parsed as Record)); - } - - return ResponseBuilder.markdown( - `## Ephemeral Instance Created\n\n${cleaned.combined || "Instance created successfully."}` - ); } -async function handleList() { - const result = await runCommand("localstack", ["ephemeral", "list"], { - env: { ...process.env }, - timeout: 120000, - }); - const cleaned = cleanOutput(result.stdout, result.stderr); - - if (result.exitCode !== 0) { - return ResponseBuilder.error("List Failed", cleaned.combined || "Failed to list ephemeral instances."); - } - - const parsed = parseJsonFromText(cleaned.stdout) || parseJsonFromText(cleaned.combined); - if (parsed === null) { +async function handleList(client: PlatformApiClient) { + try { + const instances = await client.listEphemeralInstances(); + if (instances.length === 0) { + return ResponseBuilder.markdown( + "## Ephemeral Instances\n\nNo ephemeral instances found in your LocalStack Cloud workspace." + ); + } return ResponseBuilder.markdown( - `## Ephemeral Instances\n\n\`\`\`\n${cleaned.combined || "No instances found."}\n\`\`\`` + `## Ephemeral Instances\n\n\`\`\`json\n${JSON.stringify(instances, null, 2)}\n\`\`\`` + ); + } catch (error) { + return ResponseBuilder.error( + "List Failed", + describePlatformError(error, "ephemeral instances") ); } - - return ResponseBuilder.markdown( - `## Ephemeral Instances\n\n\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\`` - ); } -async function handleLogs({ name }: { name?: string }) { +async function handleLogs(client: PlatformApiClient, { name }: { name?: string }) { if (!name?.trim()) { return ResponseBuilder.error( "Missing Required Parameter", @@ -249,29 +204,23 @@ async function handleLogs({ name }: { name?: string }) { ); } - const result = await runCommand("localstack", ["ephemeral", "logs", "--name", name.trim()], { - env: { ...process.env }, - timeout: 180000, - }); - const cleaned = cleanOutput(result.stdout, result.stderr); - - if (result.exitCode !== 0) { + try { + const logs = await client.getEphemeralInstanceLogs(name.trim()); + if (!logs.trim()) { + return ResponseBuilder.markdown(`No logs available for ephemeral instance '${name}'.`); + } + return ResponseBuilder.markdown( + `## Ephemeral Instance Logs: ${name}\n\n\`\`\`\n${logs}\n\`\`\`` + ); + } catch (error) { return ResponseBuilder.error( "Logs Failed", - cleaned.combined || `Failed to fetch logs for instance '${name}'.` + describePlatformError(error, `ephemeral instance '${name}'`) ); } - - if (!cleaned.combined) { - return ResponseBuilder.markdown(`No logs available for ephemeral instance '${name}'.`); - } - - return ResponseBuilder.markdown( - `## Ephemeral Instance Logs: ${name}\n\n\`\`\`\n${cleaned.combined}\n\`\`\`` - ); } -async function handleDelete({ name }: { name?: string }) { +async function handleDelete(client: PlatformApiClient, { name }: { name?: string }) { if (!name?.trim()) { return ResponseBuilder.error( "Missing Required Parameter", @@ -279,18 +228,13 @@ async function handleDelete({ name }: { name?: string }) { ); } - const result = await runCommand("localstack", ["ephemeral", "delete", "--name", name.trim()], { - env: { ...process.env }, - timeout: 120000, - }); - const cleaned = cleanOutput(result.stdout, result.stderr); - - if (result.exitCode !== 0) { + try { + await client.deleteEphemeralInstance(name.trim()); + return ResponseBuilder.markdown(`Successfully deleted instance: ${name} ✅`); + } catch (error) { return ResponseBuilder.error( "Delete Failed", - cleaned.combined || `Failed to delete ephemeral instance '${name}'.` + describePlatformError(error, `ephemeral instance '${name}'`) ); } - - return ResponseBuilder.markdown(cleaned.combined || `Successfully deleted instance: ${name} ✅`); } diff --git a/src/tools/localstack-extensions.ts b/src/tools/localstack-extensions.ts index 1dc9bc1..b71dd87 100644 --- a/src/tools/localstack-extensions.ts +++ b/src/tools/localstack-extensions.ts @@ -1,17 +1,30 @@ import { z } from "zod"; import { type ToolMetadata, type InferSchema } from "xmcp"; -import { HttpClient, HttpError } from "../core/http-client"; -import { runCommand, stripAnsiCodes } from "../core/command-runner"; import { runPreflights, - requireLocalStackCli, requireLocalStackRunning, requireProFeature, requireAuthToken, + requireDockerDaemon, } from "../core/preflight"; import { ResponseBuilder } from "../core/response-builder"; import { ProFeature } from "../lib/localstack/license-checker"; import { withToolAnalytics } from "../core/analytics"; +import { DockerApiClient } from "../lib/docker/docker.client"; +import { restartRuntimeInPlace } from "../lib/localstack/localstack.utils"; +import { + EXTENSIONS_MANAGER_COMMAND, + EXTENSIONS_VENV_REPAIR_SCRIPT, + formatInstalledExtensions, + parseExtensionEvents, + parseInstalledExtensions, + summarizeInstall, + summarizeUninstall, + type ExtensionOutcome, +} from "../lib/localstack/extensions.logic"; +import { PlatformApiClient, describePlatformError } from "../lib/localstack/platform.client"; + +const EXTENSION_EXEC_TIMEOUT_MS = 120000; export const schema = { action: z @@ -44,26 +57,23 @@ export const metadata: ToolMetadata = { }, }; -interface MarketplaceExtension { - name?: string; - summary?: string; - description?: string; - author?: string; - version?: string; -} - export default async function localstackExtensions({ action, name, source, }: InferSchema) { return withToolAnalytics("localstack-extensions", { action, name, source }, async () => { - const checks = [ - requireAuthToken(), - requireLocalStackCli(), - requireLocalStackRunning(), - requireProFeature(ProFeature.EXTENSIONS), - ]; + // `available` only needs the platform API; the container-touching actions need a + // running LocalStack (the extension manager runs inside it) + the Docker daemon. + const checks = + action === "available" + ? [requireAuthToken()] + : [ + requireAuthToken(), + requireDockerDaemon(), + requireLocalStackRunning(), + requireProFeature(ProFeature.EXTENSIONS), + ]; const preflightError = await runPreflights(checks); if (preflightError) return preflightError; @@ -83,40 +93,71 @@ export default async function localstackExtensions({ }); } -function cleanOutput(stdout: string, stderr: string) { - return { - stdout: stripAnsiCodes(stdout || "").trim(), - stderr: stripAnsiCodes(stderr || "").trim(), - }; +/** + * Run the extension manager module inside the running LocalStack container. The + * manager pip-installs into the extensions venv on /var/lib/localstack — the exact + * mechanism the `localstack extensions` CLI used, minus the throwaway container. + * DEBUG=0 keeps the JSON-lines event stream free of runtime debug logging. + */ +async function runExtensionManager(args: string[], { repairVenv = false } = {}) { + const docker = new DockerApiClient(); + const containerId = await docker.findLocalStackContainer(); + if (repairVenv) { + await docker.executeInContainer( + containerId, + ["sh", "-c", EXTENSIONS_VENV_REPAIR_SCRIPT], + undefined, + { + env: ["DEBUG=0"], + timeoutMs: EXTENSION_EXEC_TIMEOUT_MS, + } + ); + } + return await docker.executeInContainer( + containerId, + [...EXTENSIONS_MANAGER_COMMAND, ...args], + undefined, + { env: ["DEBUG=0"], timeoutMs: EXTENSION_EXEC_TIMEOUT_MS } + ); } -function combineOutput(stdout: string, stderr: string): string { - return [stdout, stderr].filter((part) => part.trim().length > 0).join("\n").trim(); +function outcomeSummaryBlock(outcome: ExtensionOutcome): string { + return outcome.summaryLines.length + ? `\n\n\`\`\`\n${outcome.summaryLines.join("\n")}\n\`\`\`` + : ""; } -async function handleList() { - const cmd = await runCommand("localstack", ["extensions", "list"], { - env: { ...process.env }, - }); - const cleaned = cleanOutput(cmd.stdout, cmd.stderr); - const combined = combineOutput(cleaned.stdout, cleaned.stderr); - const combinedLower = combined.toLowerCase(); +/** Activate an install/uninstall by restarting the runtime in place. */ +async function activationSuffix(): Promise { + const restart = await restartRuntimeInPlace(); + if (restart.ok) { + return "\n\nLocalStack was restarted to activate the change. ✅"; + } + return `\n\n⚠️ LocalStack restart did not confirm readiness: ${restart.detail} Please verify LocalStack status.`; +} - if (cmd.exitCode !== 0 && !combined) { - return ResponseBuilder.error("List Failed", cleaned.stderr || "Failed to list installed extensions."); +async function handleList() { + let result; + try { + result = await runExtensionManager(["list"]); + } catch (error) { + return ResponseBuilder.error( + "List Failed", + `Could not run the extension manager in the LocalStack container: ${ + error instanceof Error ? error.message : String(error) + }` + ); } - const looksEmpty = - !combined || - combinedLower.includes("no extensions installed") || - combinedLower.includes("no extension installed"); - if (looksEmpty) { - return ResponseBuilder.markdown( - "No LocalStack extensions are currently installed.\n\nUse the `available` action to browse the marketplace." + const extensions = parseInstalledExtensions(result.stdout); + if (extensions.length === 0 && result.exitCode !== 0) { + return ResponseBuilder.error( + "List Failed", + result.stderr || result.stdout || "Failed to list installed extensions." ); } - return ResponseBuilder.markdown(`## Installed LocalStack Extensions\n\n\`\`\`\n${combined}\n\`\`\``); + return ResponseBuilder.markdown(formatInstalledExtensions(extensions)); } async function handleInstall(name?: string, source?: string) { @@ -130,60 +171,43 @@ async function handleInstall(name?: string, source?: string) { } const target = source || name!; - const cmd = await runCommand("localstack", ["extensions", "install", target], { - env: { ...process.env }, - timeout: 120000, - }); - const cleaned = cleanOutput(cmd.stdout, cmd.stderr); - const combined = combineOutput(cleaned.stdout, cleaned.stderr); - const combinedLower = combined.toLowerCase(); - - if (combinedLower.includes("could not resolve package")) { + let result; + try { + result = await runExtensionManager(["install", target], { repairVenv: true }); + } catch (error) { return ResponseBuilder.error( - "Extension Not Found", - `Could not resolve the extension package '${name || target}'. Please verify it exists on PyPI, or provide a git repository URL using the source parameter.` + "Install Failed", + `Could not run the extension manager in the LocalStack container: ${ + error instanceof Error ? error.message : String(error) + }` ); } - if (combinedLower.includes("no module named 'localstack.pro'")) { + const outcome = summarizeInstall(parseExtensionEvents(result.stdout)); + + if (outcome.kind === "not-found") { return ResponseBuilder.error( - "Auth Token Required", - "LocalStack Pro modules are not available. Ensure LOCALSTACK_AUTH_TOKEN is set correctly and LocalStack is running with a valid license." + "Extension Not Found", + `Could not resolve the extension package '${target}'. Please verify it exists on PyPI, or provide a git repository URL using the source parameter.` ); } - - if ( - combinedLower.includes("non-zero exit status") || - combinedLower.includes("returned non-zero") - ) { + if (!outcome.success) { return ResponseBuilder.error( "Install Failed", - "The extension could not be installed from the provided source. The repository may not contain valid LocalStack extension code. Run the command again with --verbose for more details, or check that the repository contains a proper LocalStack extension." + `${outcome.errorDetail || "Extension installation failed."}${outcomeSummaryBlock(outcome)}` ); } - const hasSuccessPattern = combinedLower.includes("extension successfully installed"); - if (cmd.exitCode !== 0 && !hasSuccessPattern) { - return ResponseBuilder.error("Install Failed", cleaned.stderr || "Extension installation failed."); - } - - if (hasSuccessPattern || cmd.exitCode === 0) { - const restartCmd = await runCommand("localstack", ["restart"], { timeout: 60000 }); - const restartCleaned = cleanOutput(restartCmd.stdout, restartCmd.stderr); - const restartCombined = combineOutput(restartCleaned.stdout, restartCleaned.stderr); - - let response = `## Extension Installation Result\n\n\`\`\`\n${combined || "Extension successfully installed."}\n\`\`\`\n\n`; - response += "LocalStack was restarted to activate the extension."; - if (restartCombined) { - response += `\n\n### Restart Output\n\n\`\`\`\n${restartCombined}\n\`\`\``; - } - if (restartCmd.exitCode !== 0) { - response += "\n\n⚠️ Restart command reported an issue. Please verify LocalStack status."; - } - return ResponseBuilder.markdown(response); + if (outcome.kind === "already-installed") { + return ResponseBuilder.markdown( + `## Extension Installation Result${outcomeSummaryBlock(outcome)}\n\nThe extension is already installed — no restart needed.` + ); } - return ResponseBuilder.error("Install Failed", cleaned.stderr || "Extension installation failed."); + const restartNote = await activationSuffix(); + return ResponseBuilder.markdown( + `## Extension Installation Result${outcomeSummaryBlock(outcome)}${restartNote}` + ); } async function handleUninstall(name?: string) { @@ -194,67 +218,45 @@ async function handleUninstall(name?: string) { ); } - const cmd = await runCommand("localstack", ["extensions", "uninstall", name], { - env: { ...process.env }, - timeout: 60000, - }); - const cleaned = cleanOutput(cmd.stdout, cmd.stderr); - const combined = combineOutput(cleaned.stdout, cleaned.stderr); - const combinedLower = combined.toLowerCase(); - - if (combinedLower.includes("no module named 'localstack.pro'")) { + let result; + try { + result = await runExtensionManager(["uninstall", name], { repairVenv: true }); + } catch (error) { return ResponseBuilder.error( - "Auth Token Required", - "LocalStack Pro modules are not available. Ensure LOCALSTACK_AUTH_TOKEN is set correctly and LocalStack is running with a valid license." + "Uninstall Failed", + `Could not run the extension manager in the LocalStack container: ${ + error instanceof Error ? error.message : String(error) + }` ); } - const hasSuccessPattern = combinedLower.includes("extension successfully uninstalled"); - if (cmd.exitCode !== 0 && !hasSuccessPattern) { - return ResponseBuilder.error("Uninstall Failed", cleaned.stderr || "Extension uninstallation failed."); - } - - if (hasSuccessPattern || cmd.exitCode === 0) { - const restartCmd = await runCommand("localstack", ["restart"], { timeout: 60000 }); - const restartCleaned = cleanOutput(restartCmd.stdout, restartCmd.stderr); - const restartCombined = combineOutput(restartCleaned.stdout, restartCleaned.stderr); + const outcome = summarizeUninstall(parseExtensionEvents(result.stdout)); - let response = `## Extension Uninstall Result\n\n\`\`\`\n${combined || "Extension successfully uninstalled."}\n\`\`\`\n\n`; - response += "LocalStack was restarted to apply extension removal."; - if (restartCombined) { - response += `\n\n### Restart Output\n\n\`\`\`\n${restartCombined}\n\`\`\``; - } - if (restartCmd.exitCode !== 0) { - response += "\n\n⚠️ Restart command reported an issue. Please verify LocalStack status."; - } - return ResponseBuilder.markdown(response); + if (outcome.kind === "not-installed") { + return ResponseBuilder.error( + "Extension Not Installed", + outcome.errorDetail || `Extension '${name}' is not installed.` + ); + } + if (!outcome.success) { + return ResponseBuilder.error( + "Uninstall Failed", + `${outcome.errorDetail || "Extension uninstallation failed."}${outcomeSummaryBlock(outcome)}` + ); } - return ResponseBuilder.error("Uninstall Failed", cleaned.stderr || "Extension uninstallation failed."); + const restartNote = await activationSuffix(); + return ResponseBuilder.markdown( + `## Extension Uninstall Result${outcomeSummaryBlock(outcome)}${restartNote}` + ); } async function handleAvailable() { const token = process.env.LOCALSTACK_AUTH_TOKEN!; - - const encoded = Buffer.from(`:${token}`).toString("base64"); - const client = new HttpClient(); + const client = new PlatformApiClient(token); try { - const marketplace = await client.request( - "https://api.localstack.cloud/v1/extensions/marketplace", - { - method: "GET", - baseUrl: "", - headers: { - Authorization: `Basic ${encoded}`, - Accept: "application/json", - }, - } - ); - - if (!Array.isArray(marketplace)) { - return ResponseBuilder.error("Marketplace Fetch Failed", "Unexpected marketplace response format."); - } + const marketplace = await client.getExtensionsMarketplace(); const simplified = marketplace.map((item) => ({ name: item.name || "unknown-extension", @@ -270,14 +272,9 @@ async function handleAvailable() { return ResponseBuilder.markdown(markdown); } catch (error) { - if (error instanceof HttpError && (error.status === 401 || error.status === 403)) { - return ResponseBuilder.error( - "Authentication Failed", - "Could not fetch the marketplace. Ensure LOCALSTACK_AUTH_TOKEN is set correctly." - ); - } - - const message = error instanceof Error ? error.message : String(error); - return ResponseBuilder.error("Marketplace Fetch Failed", message); + return ResponseBuilder.error( + "Marketplace Fetch Failed", + describePlatformError(error, "the extensions marketplace") + ); } } diff --git a/src/tools/localstack-iam-policy-analyzer.ts b/src/tools/localstack-iam-policy-analyzer.ts index 8cbae0f..4926acd 100644 --- a/src/tools/localstack-iam-policy-analyzer.ts +++ b/src/tools/localstack-iam-policy-analyzer.ts @@ -13,7 +13,6 @@ import { import { runPreflights, requireAuthToken, - requireLocalStackCli, requireLocalStackRunning, requireProFeature, } from "../core/preflight"; @@ -58,7 +57,6 @@ export default async function localstackIamPolicyAnalyzer({ return withToolAnalytics("localstack-iam-policy-analyzer", { action, mode }, async () => { const preflightError = await runPreflights([ requireAuthToken(), - requireLocalStackCli(), requireLocalStackRunning(), requireProFeature(ProFeature.IAM_ENFORCEMENT), ]); diff --git a/src/tools/localstack-logs-analysis.ts b/src/tools/localstack-logs-analysis.ts index 672b8a0..4cd63d7 100644 --- a/src/tools/localstack-logs-analysis.ts +++ b/src/tools/localstack-logs-analysis.ts @@ -1,12 +1,7 @@ import { z } from "zod"; import { type ToolMetadata, type InferSchema } from "xmcp"; import { LocalStackLogRetriever, type LogEntry } from "../lib/logs/log-retriever"; -import { - runPreflights, - requireAuthToken, - requireLocalStackCli, - requireLocalStackRunning, -} from "../core/preflight"; +import { runPreflights, requireAuthToken, requireLocalStackRunning } from "../core/preflight"; import { ResponseBuilder } from "../core/response-builder"; import { withToolAnalytics } from "../core/analytics"; @@ -61,11 +56,7 @@ export default async function localstackLogsAnalysis({ "localstack-logs-analysis", { analysisType, lines, service, operation, filter }, async () => { - const preflightError = await runPreflights([ - requireAuthToken(), - requireLocalStackCli(), - requireLocalStackRunning(), - ]); + const preflightError = await runPreflights([requireAuthToken(), requireLocalStackRunning()]); if (preflightError) return preflightError; const retriever = new LocalStackLogRetriever(); @@ -73,7 +64,10 @@ export default async function localstackLogsAnalysis({ const logResult = await retriever.retrieveLogs(lines, retrievalFilter); if (!logResult.success) { - return ResponseBuilder.error("Log Retrieval Failed", logResult.errorMessage || "Unknown error"); + return ResponseBuilder.error( + "Log Retrieval Failed", + logResult.errorMessage || "Unknown error" + ); } switch (analysisType) { diff --git a/src/tools/localstack-management.ts b/src/tools/localstack-management.ts index 3ba1079..c76e593 100644 --- a/src/tools/localstack-management.ts +++ b/src/tools/localstack-management.ts @@ -1,23 +1,27 @@ import { z } from "zod"; import { type ToolMetadata, type InferSchema } from "xmcp"; import { - detectLifecycleCli, - type LifecycleCli, getLocalStackStatus, getSnowflakeEmulatorStatus, - startRuntime, + launchRuntime, + resolveContainerName, } from "../lib/localstack/localstack.utils"; import { DockerApiClient, isLocalStackContainerNotFoundError, type ContainerMetadata, } from "../lib/docker/docker.client"; -import { runPreflights, requireProFeature, requireAuthToken } from "../core/preflight"; +import type { VolumeResolution } from "../lib/localstack/container-spec.logic"; +import { + runPreflights, + requireProFeature, + requireAuthToken, + requireDockerDaemon, +} from "../core/preflight"; import { ResponseBuilder } from "../core/response-builder"; import { ProFeature } from "../lib/localstack/license-checker"; import { withToolAnalytics } from "../core/analytics"; -type ToolResponse = ReturnType; const AWS_ALREADY_RUNNING_MESSAGE = "⚠️ LocalStack is already running. Use 'restart' if you want to apply new configuration."; const SNOWFLAKE_ALREADY_RUNNING_MESSAGE = @@ -56,10 +60,15 @@ export default async function localstackManagement({ envVars, }: InferSchema) { return withToolAnalytics("localstack-management", { action, service, envVars }, async () => { - // No CLI preflight: stop/restart/status drive LocalStack via the Docker API + - // gateway, and start detects whichever lifecycle CLI is present (localstack or - // lstk) itself — so an lstk-only host is no longer blocked here. - const checks: Array> = [requireAuthToken()]; + // Lifecycle runs entirely through the Docker Engine API + the LocalStack gateway; + // no localstack/lstk CLI is required (or used). + const checks: Array< + ReturnType | Promise> + > = [requireAuthToken()]; + + if (action === "start" || action === "restart" || action === "stop") { + checks.push(requireDockerDaemon()); + } if (service === "snowflake") { // `start` can run when no LocalStack runtime is currently up; validate feature after startup. @@ -87,74 +96,55 @@ export default async function localstackManagement({ }); } +interface StartOverrides { + imageOverride?: string; + containerNameOverride?: string; + volumeOverride?: VolumeResolution; +} + // Handle start action async function handleStart({ envVars, service, - cli, + overrides, }: { envVars?: Record; service: "aws" | "snowflake"; - cli?: LifecycleCli; + overrides?: StartOverrides; }) { if (service === "snowflake") { - return await handleSnowflakeStart({ envVars }); - } - - const status = await getLocalStackStatus({ includeCliStatus: false }); - if (status.isReady || status.isRunning) { - return ResponseBuilder.markdown(AWS_ALREADY_RUNNING_MESSAGE); + return await launchRuntime({ + stack: "snowflake", + envVars, + getStatus: getSnowflakeEmulatorStatus, + processLabel: "Snowflake emulator", + alreadyRunningMessage: SNOWFLAKE_ALREADY_RUNNING_MESSAGE, + successTitle: "🚀 Snowflake emulator started successfully!", + statusHeading: "Health check", + timeoutMessage: + '❌ Snowflake emulator start timed out after 120 seconds. Health check endpoint did not return {"success": true}. If this was the first start, the image pull may still be in progress — retry in a bit.', + onReady: async () => await requireProFeature(ProFeature.SNOWFLAKE), + ...overrides, + }); } - const lifecycleCli = cli || (await detectLifecycleCli()); - if (!lifecycleCli) return noLifecycleCliFoundResponse("Starting"); - - return await startRuntime({ - cli: lifecycleCli, - // lstk would otherwise prompt; force non-interactive when spawned headless. - startArgs: lifecycleCli === "lstk" ? ["start", "--non-interactive"] : ["start"], - getStatus: () => getLocalStackStatus({ includeCliStatus: false }), + return await launchRuntime({ + stack: "aws", + envVars, + getStatus: getLocalStackStatus, processLabel: "LocalStack", alreadyRunningMessage: AWS_ALREADY_RUNNING_MESSAGE, successTitle: "🚀 LocalStack started successfully!", statusHeading: "Status", timeoutMessage: - "❌ LocalStack start timed out after 120 seconds. It may still be starting in the background.", - envVars, - }); -} - -async function handleSnowflakeStart({ envVars }: { envVars?: Record }) { - const status = await getSnowflakeEmulatorStatus(); - if (status.isReady || status.isRunning) { - return ResponseBuilder.markdown(SNOWFLAKE_ALREADY_RUNNING_MESSAGE); - } - - // The Snowflake stack is localstack-only (`--stack snowflake` has no lstk equivalent). - if ((await detectLifecycleCli(["localstack"])) !== "localstack") { - return ResponseBuilder.error( - "localstack CLI required", - "Starting the Snowflake stack requires the Python `localstack` CLI (the `--stack snowflake` flag is localstack-only). Install it with `pip install localstack`." - ); - } - - return await startRuntime({ - cli: "localstack", - startArgs: ["start", "--stack", "snowflake"], - getStatus: getSnowflakeEmulatorStatus, - processLabel: "Snowflake emulator", - alreadyRunningMessage: SNOWFLAKE_ALREADY_RUNNING_MESSAGE, - successTitle: "🚀 Snowflake emulator started successfully!", - statusHeading: "Health check", - timeoutMessage: - '❌ Snowflake emulator start timed out after 120 seconds. Health check endpoint did not return {"success": true}.', - envVars, - onReady: async () => await requireProFeature(ProFeature.SNOWFLAKE), + "❌ LocalStack start timed out after 120 seconds. It may still be starting in the background. If this was the first start, the image pull may still be in progress — retry in a bit.", + ...overrides, }); } -// Handle stop action — stop the detected container via the Docker API (no CLI needed, -// works regardless of which CLI started it). +// Handle stop action — stop the detected container via the Docker API. Also cleans up +// stopped/stale containers occupying a LocalStack name, so start's conflict advice +// ("stop it first") always has a working recovery path. async function handleStop() { const dockerClient = new DockerApiClient(); let containerId: string; @@ -167,7 +157,22 @@ async function handleStop() { `Could not inspect Docker containers: ${error instanceof Error ? error.message : String(error)}` ); } - const status = await getLocalStackStatus({ includeCliStatus: false }); + + // No RUNNING container found — check for a stale stopped one holding the name. + try { + const stale = await dockerClient.findContainerByNameAnyState( + resolveContainerName(process.env) + ); + if (stale && !stale.running) { + await dockerClient.removeContainer(stale.id); + await dockerClient.waitForRemoval(stale.id); + return ResponseBuilder.markdown(`🛑 Removed stopped LocalStack container "${stale.name}".`); + } + } catch { + // fall through to the gateway-based reporting below + } + + const status = await getLocalStackStatus(); if (status.isRunning) { return ResponseBuilder.error( "LocalStack container not found", @@ -191,8 +196,10 @@ async function handleStop() { } } -// Handle restart action — stop the running container (Docker API), then start fresh -// (applies any new envVars). Falls through to start if nothing is running. +// Handle restart action — stop the running container, then start fresh (applies any +// new envVars). The recreate reuses the original container's image, name, and volume +// so an externally-provisioned runtime (lstk's localstack-aws, custom names/images) +// is not silently replaced by our defaults — that would strand its state. async function handleRestart({ envVars, service, @@ -213,7 +220,7 @@ async function handleRestart({ }` ); } - const status = await getLocalStackStatus({ includeCliStatus: false }); + const status = await getLocalStackStatus(); if (status.isRunning) { return ResponseBuilder.error( "LocalStack container not found", @@ -224,18 +231,16 @@ async function handleRestart({ return await handleStart({ envVars, service }); } - const cli = await detectCliForRestart(dockerClient, containerId, service); - if (!cli) { - return service === "snowflake" - ? ResponseBuilder.error( - "localstack CLI required", - "Restarting the Snowflake stack requires the Python `localstack` CLI on PATH." - ) - : noLifecycleCliFoundResponse("Restarting"); + let metadata: ContainerMetadata | undefined; + try { + metadata = await dockerClient.inspectContainer(containerId); + } catch { + metadata = undefined; } try { await dockerClient.stopContainer(containerId); + await dockerClient.waitForRemoval(containerId); } catch (error) { return ResponseBuilder.error( "Failed to stop LocalStack", @@ -245,47 +250,37 @@ async function handleRestart({ ); } - await new Promise((resolve) => setTimeout(resolve, 2000)); - return await handleStart({ envVars, service, cli }); + return await handleStart({ envVars, service, overrides: recreateOverrides(metadata, service) }); } -function noLifecycleCliFoundResponse(action: "Starting" | "Restarting") { - return ResponseBuilder.error( - "No LocalStack CLI found", - `${action} LocalStack needs the \`localstack\` or \`lstk\` CLI on PATH, but neither was found. ` + - "Install one (`pip install localstack`, or the `lstk` CLI), or start LocalStack yourself (e.g. `lstk start`) — " + - "the other tools drive it via the Docker API and gateway." - ); -} - -async function detectCliForRestart( - dockerClient: DockerApiClient, - containerId: string, +/** + * Derive start overrides from the container we just stopped. Reuse only applies when + * the previous image matches the requested stack — restarting with service=snowflake + * while the AWS stack was running deliberately switches stacks (previous behavior). + */ +function recreateOverrides( + metadata: ContainerMetadata | undefined, service: "aws" | "snowflake" -): Promise { - if (service === "snowflake") return await detectLifecycleCli(["localstack"]); - - let metadata: ContainerMetadata | undefined; - try { - metadata = await dockerClient.inspectContainer(containerId); - } catch { - metadata = undefined; - } - - return await detectLifecycleCli(preferredCliOrder(metadata)); -} - -function preferredCliOrder(metadata?: ContainerMetadata): LifecycleCli[] { - const name = metadata?.name || ""; - const mainContainerName = (metadata?.env || []) - .find((entry) => entry.startsWith("MAIN_CONTAINER_NAME=")) - ?.slice("MAIN_CONTAINER_NAME=".length); - - if (name === "localstack-aws" || mainContainerName === "localstack-aws") { - return ["lstk", "localstack"]; +): StartOverrides | undefined { + if (!metadata?.image) return undefined; + const previousStack = metadata.image.includes("/snowflake") ? "snowflake" : "aws"; + if (previousStack !== service) return undefined; + + const overrides: StartOverrides = { + imageOverride: metadata.image, + containerNameOverride: metadata.name, + }; + + const volumeMount = (metadata.mounts || []).find( + (mount) => mount.destination === "/var/lib/localstack" + ); + if (volumeMount?.type === "bind" && volumeMount.source) { + overrides.volumeOverride = { type: "bind", source: volumeMount.source }; + } else if (volumeMount?.type === "volume" && volumeMount.name) { + overrides.volumeOverride = { type: "volume", name: volumeMount.name }; } - return ["localstack", "lstk"]; + return overrides; } // Handle status action diff --git a/src/tools/localstack-snowflake-client.ts b/src/tools/localstack-snowflake-client.ts index d31e2fb..a2939f9 100644 --- a/src/tools/localstack-snowflake-client.ts +++ b/src/tools/localstack-snowflake-client.ts @@ -51,10 +51,7 @@ async function requireSnowflakeConnectionProfile() { const listResult = await runCommand("snow", ["connection", "list"], { env: { ...process.env } }); const listCombined = `${listResult.stdout || ""}\n${listResult.stderr || ""}`.toLowerCase(); - if ( - listResult.exitCode === 0 && - listCombined.includes(SNOWFLAKE_CONNECTION_NAME.toLowerCase()) - ) { + if (listResult.exitCode === 0 && listCombined.includes(SNOWFLAKE_CONNECTION_NAME.toLowerCase())) { return null; } @@ -135,7 +132,10 @@ export default async function localstackSnowflakeClient({ return ResponseBuilder.markdown(result.stdout || ""); } - return ResponseBuilder.error("Connection Check Failed", (result.stderr || result.stdout || "").trim()); + return ResponseBuilder.error( + "Connection Check Failed", + (result.stderr || result.stdout || "").trim() + ); } const hasQuery = !!query; @@ -160,7 +160,12 @@ export default async function localstackSnowflakeClient({ return ResponseBuilder.markdown(result.stdout || ""); } - const rawError = (result.stderr || result.stdout || result.error?.message || "Unknown error").trim(); + const rawError = ( + result.stderr || + result.stdout || + result.error?.message || + "Unknown error" + ).trim(); return ResponseBuilder.error( "Command Failed", `${rawError}\n\nCheck Snowflake feature coverage: https://docs.localstack.cloud/snowflake/feature-coverage/` diff --git a/tests/docker/validate-image.mjs b/tests/docker/validate-image.mjs index bae04d3..4d587aa 100644 --- a/tests/docker/validate-image.mjs +++ b/tests/docker/validate-image.mjs @@ -13,8 +13,6 @@ * node tests/docker/validate-image.mjs -- \ * docker run -i --rm \ * -v /var/run/docker.sock:/var/run/docker.sock \ - * -v "$HOME/.localstack-mcp:$HOME/.localstack-mcp" \ - * -e XDG_CACHE_HOME="$HOME/.localstack-mcp" \ * --add-host host.docker.internal:host-gateway \ * --add-host s3.host.docker.internal:host-gateway \ * --add-host snowflake.localhost.localstack.cloud:host-gateway \ @@ -22,10 +20,9 @@ * -v "$PWD/data:/work/data" \ * localstack/localstack-mcp-server:dev * - * The cache bind mount + XDG_CACHE_HOME are required for the management tool's - * `start` action under Docker-out-of-Docker: `localstack start` asks the HOST - * daemon to bind-mount its license/machine/volume files, whose source paths must - * exist at an identical path on the host (see docs/DOCKER.md). + * The server creates the LocalStack sibling container itself through the mounted + * Docker socket; state lives in a named Docker volume by default (set + * LOCALSTACK_VOLUME_DIR to a HOST path to use a bind mount instead). * * Env knobs: * HARNESS_DEPLOY_DIR In-container path to the terraform sample (default /work/data/sample-terraform) @@ -52,7 +49,12 @@ const DEPLOY_DIR = process.env.HARNESS_DEPLOY_DIR || "/work/data/sample-terrafor const CDK_DIR = process.env.HARNESS_CDK_DIR || "/work/data/sample-cdk"; const SQL_FILE = process.env.HARNESS_SQL_FILE || "/work/data/sample-sql/snowflake_test.sql"; const TOKEN_REAL = process.env.HARNESS_TOKEN_REAL === "1"; -const SKIP = new Set((process.env.HARNESS_SKIP || "").split(",").map((s) => s.trim()).filter(Boolean)); +const SKIP = new Set( + (process.env.HARNESS_SKIP || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) +); const NO_CLEANUP = process.env.HARNESS_NO_CLEANUP === "1"; const RUN_REMOTE = process.env.HARNESS_RUN_REMOTE === "1"; const RUN_EPHEMERAL = RUN_REMOTE || process.env.HARNESS_RUN_EPHEMERAL === "1"; @@ -60,11 +62,20 @@ const RUN_REPLICATOR_START = process.env.HARNESS_RUN_REPLICATOR_START === "1"; const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const EXPECTED_TOOLS = [ - "localstack-management", "localstack-deployer", "localstack-logs-analysis", - "localstack-iam-policy-analyzer", "localstack-chaos-injector", "localstack-cloud-pods", - "localstack-state-management", "localstack-extensions", "localstack-snowflake-client", - "localstack-ephemeral-instances", "localstack-aws-client", "localstack-aws-replicator", - "localstack-docs", "localstack-app-inspector", + "localstack-management", + "localstack-deployer", + "localstack-logs-analysis", + "localstack-iam-policy-analyzer", + "localstack-chaos-injector", + "localstack-cloud-pods", + "localstack-state-management", + "localstack-extensions", + "localstack-snowflake-client", + "localstack-ephemeral-instances", + "localstack-aws-client", + "localstack-aws-replicator", + "localstack-docs", + "localstack-app-inspector", ]; // ---- JSON-RPC over stdio ---------------------------------------------------- @@ -82,7 +93,12 @@ child.stdout.on("data", (chunk) => { buf = buf.slice(nl + 1); if (!line) continue; let msg; - try { msg = JSON.parse(line); } catch { serverLog.push(`[stdout-nonjson] ${line}`); continue; } + try { + msg = JSON.parse(line); + } catch { + serverLog.push(`[stdout-nonjson] ${line}`); + continue; + } if (msg.id !== undefined && pending.has(msg.id)) { const { resolve, reject } = pending.get(msg.id); pending.delete(msg.id); @@ -93,7 +109,8 @@ child.stdout.on("data", (chunk) => { }); child.stderr.on("data", (c) => serverLog.push(`[stderr] ${c.toString().trimEnd()}`)); child.on("exit", (code, sig) => { - for (const { reject } of pending.values()) reject(new Error(`server exited (code=${code} sig=${sig})`)); + for (const { reject } of pending.values()) + reject(new Error(`server exited (code=${code} sig=${sig})`)); pending.clear(); }); @@ -106,8 +123,14 @@ function rpc(method, params, timeoutMs = 300000) { reject(new Error(`timeout after ${timeoutMs}ms waiting for ${method}`)); }, timeoutMs); pending.set(id, { - resolve: (r) => { clearTimeout(timer); resolve(r); }, - reject: (e) => { clearTimeout(timer); reject(e); }, + resolve: (r) => { + clearTimeout(timer); + resolve(r); + }, + reject: (e) => { + clearTimeout(timer); + reject(e); + }, }); child.stdin.write(payload); }); @@ -125,11 +148,18 @@ async function getPrompt(name, args, timeoutMs = 60000) { } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Call a tool, retrying until `ok(result)` (default: not an error) or attempts run out. -async function callToolUntil(name, args, { attempts = 6, delayMs = 5000, timeoutMs = 60000, ok } = {}) { +async function callToolUntil( + name, + args, + { attempts = 6, delayMs = 5000, timeoutMs = 60000, ok } = {} +) { let last; for (let i = 0; i < attempts; i++) { - try { last = await callTool(name, args, timeoutMs); } - catch (e) { last = { text: String(e.message), isError: true }; } + try { + last = await callTool(name, args, timeoutMs); + } catch (e) { + last = { text: String(e.message), isError: true }; + } if (ok ? ok(last) : !last.isError) return last; if (i < attempts - 1) await sleep(delayMs); } @@ -146,20 +176,24 @@ function record(key, name, ok, detail, note) { if (note) console.log(" ↳ " + note); } const snip = (t, n = 400) => (t || "").replace(/\s+/g, " ").trim().slice(0, n); -const hasAwsCreds = () => Boolean( - process.env.AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID || - process.env.AWS_ACCESS_KEY_ID -) && Boolean( - process.env.AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY || - process.env.AWS_SECRET_ACCESS_KEY -) && Boolean( - process.env.AWS_REPLICATOR_SOURCE_REGION_NAME || - process.env.AWS_DEFAULT_REGION || - process.env.AWS_REGION -); +const hasAwsCreds = () => + Boolean(process.env.AWS_REPLICATOR_SOURCE_AWS_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID) && + Boolean( + process.env.AWS_REPLICATOR_SOURCE_AWS_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY + ) && + Boolean( + process.env.AWS_REPLICATOR_SOURCE_REGION_NAME || + process.env.AWS_DEFAULT_REGION || + process.env.AWS_REGION + ); function gracefulProGate(result) { - return result.isError && /(Authentication|Auth Token|Feature Not Available|license|not seem to include|requires a LocalStack license)/i.test(result.text); + return ( + result.isError && + /(Authentication|Auth Token|Feature Not Available|license|not seem to include|requires a LocalStack license)/i.test( + result.text + ) + ); } function recordToolResult(key, name, result, predicate = (r) => !r.isError, note) { @@ -180,10 +214,17 @@ function recordToolResult(key, name, result, predicate = (r) => !r.isError, note function firstMarkdownTableValue(text) { for (const line of (text || "").split("\n")) { const trimmed = line.trim(); - if (!trimmed.startsWith("|") || /^[-|:\s]+$/.test(trimmed) || /Trace ID|Span ID|Event ID/.test(trimmed)) { + if ( + !trimmed.startsWith("|") || + /^[-|:\s]+$/.test(trimmed) || + /Trace ID|Span ID|Event ID/.test(trimmed) + ) { continue; } - const cells = trimmed.split("|").map((cell) => cell.trim()).filter(Boolean); + const cells = trimmed + .split("|") + .map((cell) => cell.trim()) + .filter(Boolean); if (cells[0] && cells[0] !== "-") return cells[0].replace(/^`|`$/g, ""); } return undefined; @@ -191,27 +232,42 @@ function firstMarkdownTableValue(text) { async function main() { // 1. Handshake - const init = await rpc("initialize", { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "ls-mcp-docker-validator", version: "1.0.0" }, - }, 60000); + const init = await rpc( + "initialize", + { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "ls-mcp-docker-validator", version: "1.0.0" }, + }, + 60000 + ); notify("notifications/initialized", {}); - console.log(`Connected. Server: ${init?.serverInfo?.name ?? "?"} ${init?.serverInfo?.version ?? ""}`); + console.log( + `Connected. Server: ${init?.serverInfo?.name ?? "?"} ${init?.serverInfo?.version ?? ""}` + ); // 2. tools/list const toolsRes = await rpc("tools/list", {}, 60000); const toolNames = (toolsRes?.tools || []).map((t) => t.name); const missing = EXPECTED_TOOLS.filter((t) => !toolNames.includes(t)); - record("tools", "tools/list exposes all 14 tools", missing.length === 0, - `found ${toolNames.length} tools`, missing.length ? `MISSING: ${missing.join(", ")}` : undefined); + record( + "tools", + "tools/list exposes all 14 tools", + missing.length === 0, + `found ${toolNames.length} tools`, + missing.length ? `MISSING: ${missing.join(", ")}` : undefined + ); // 2b. prompts/get if (!SKIP.has("prompt")) { try { const prompts = await rpc("prompts/list", {}, 60000); - const hasPrompt = (prompts?.prompts || []).some((prompt) => prompt.name === "infrastructure-tester"); - const prompt = await getPrompt("infrastructure-tester", { iac_path: "/work/data/sample-terraform" }); + const hasPrompt = (prompts?.prompts || []).some( + (prompt) => prompt.name === "infrastructure-tester" + ); + const prompt = await getPrompt("infrastructure-tester", { + iac_path: "/work/data/sample-terraform", + }); const text = (prompt?.messages || []).map((msg) => msg?.content?.text || "").join("\n"); record( "prompt", @@ -220,7 +276,9 @@ async function main() { snip(text, 400), hasPrompt ? undefined : "prompt missing from prompts/list" ); - } catch (e) { record("prompt", "infrastructure-tester prompt", false, String(e.message)); } + } catch (e) { + record("prompt", "infrastructure-tester prompt", false, String(e.message)); + } } // 3. docs (token-only; calls an external API, so retry once for transient blips) @@ -229,18 +287,32 @@ async function main() { const r = await callToolUntil( "localstack-docs", { query: "how to start localstack and configure auth token", limit: 2 }, - { attempts: 2, delayMs: 3000, timeoutMs: 60000, ok: (x) => !x.isError && /LocalStack Docs/i.test(x.text) } + { + attempts: 2, + delayMs: 3000, + timeoutMs: 60000, + ok: (x) => !x.isError && /LocalStack Docs/i.test(x.text), + } ); - record("docs", "localstack-docs returns snippets", !r.isError && /LocalStack Docs/i.test(r.text), snip(r.text)); - } catch (e) { record("docs", "localstack-docs", false, String(e.message)); } + record( + "docs", + "localstack-docs returns snippets", + !r.isError && /LocalStack Docs/i.test(r.text), + snip(r.text) + ); + } catch (e) { + record("docs", "localstack-docs", false, String(e.message)); + } } - // 4. management status (pre-start) — validates CLI + docker socket reachability + // 4. management status (pre-start) — validates gateway probe + docker socket reachability if (!SKIP.has("status")) { try { const r = await callTool("localstack-management", { action: "status" }, 60000); record("status", "localstack-management status (pre-start)", !r.isError, snip(r.text)); - } catch (e) { record("status", "management status", false, String(e.message)); } + } catch (e) { + record("status", "management status", false, String(e.message)); + } } // 5. management start @@ -249,7 +321,9 @@ async function main() { const r = await callTool("localstack-management", { action: "start" }, 240000); const ok = !r.isError && /(started successfully|already running)/i.test(r.text); record("start", "localstack-management start", ok, snip(r.text, 500)); - } catch (e) { record("start", "management start", false, String(e.message)); } + } catch (e) { + record("start", "management start", false, String(e.message)); + } } // 5b. Readiness gate — after a cold start the container reports "running" before @@ -261,31 +335,72 @@ async function main() { { command: "sts get-caller-identity" }, { attempts: 24, delayMs: 5000, timeoutMs: 30000 } ); - console.log(`\n[readiness] LocalStack services ${ready.isError ? "NOT ready after wait" : "ready"}`); + console.log( + `\n[readiness] LocalStack services ${ready.isError ? "NOT ready after wait" : "ready"}` + ); } // 6. aws-client — validates docker exec of awslocal inside the LS container if (!SKIP.has("aws")) { try { - const mb = await callTool("localstack-aws-client", { command: "s3 mb s3://harness-test-bucket" }, 60000); + const mb = await callTool( + "localstack-aws-client", + { command: "s3 mb s3://harness-test-bucket" }, + 60000 + ); const ls = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000); const ok = !mb.isError && !ls.isError && /harness-test-bucket/.test(ls.text); - record("aws", "localstack-aws-client (awslocal s3 mb + ls)", ok, `mb: ${snip(mb.text, 120)} | ls: ${snip(ls.text, 200)}`); - } catch (e) { record("aws", "aws-client", false, String(e.message)); } + record( + "aws", + "localstack-aws-client (awslocal s3 mb + ls)", + ok, + `mb: ${snip(mb.text, 120)} | ls: ${snip(ls.text, 200)}` + ); + } catch (e) { + record("aws", "aws-client", false, String(e.message)); + } } // 6b. logs-analysis — validates docker log access to the sibling LocalStack container. if (!SKIP.has("logs")) { try { - await callTool("localstack-aws-client", { command: "s3api head-bucket --bucket definitely-missing-harness-bucket" }, 60000).catch(() => {}); - const summary = await callTool("localstack-logs-analysis", { analysisType: "summary", lines: 1000 }, 60000); - const errors = await callTool("localstack-logs-analysis", { analysisType: "errors", lines: 1000, service: "s3" }, 60000); - const requests = await callTool("localstack-logs-analysis", { analysisType: "requests", lines: 1000, service: "s3", operation: "CreateBucket" }, 60000); - const raw = await callTool("localstack-logs-analysis", { analysisType: "logs", lines: 1000, filter: "harness-test-bucket" }, 60000); - const ok = [summary, errors, requests, raw].every((r) => !r.isError) && /LocalStack Summary|Summary/i.test(summary.text); - record("logs", "localstack-logs-analysis summary/errors/requests/logs", ok, - `summary: ${snip(summary.text, 180)} | errors: ${snip(errors.text, 120)} | requests: ${snip(requests.text, 120)} | raw: ${snip(raw.text, 120)}`); - } catch (e) { record("logs", "logs-analysis", false, String(e.message)); } + await callTool( + "localstack-aws-client", + { command: "s3api head-bucket --bucket definitely-missing-harness-bucket" }, + 60000 + ).catch(() => {}); + const summary = await callTool( + "localstack-logs-analysis", + { analysisType: "summary", lines: 1000 }, + 60000 + ); + const errors = await callTool( + "localstack-logs-analysis", + { analysisType: "errors", lines: 1000, service: "s3" }, + 60000 + ); + const requests = await callTool( + "localstack-logs-analysis", + { analysisType: "requests", lines: 1000, service: "s3", operation: "CreateBucket" }, + 60000 + ); + const raw = await callTool( + "localstack-logs-analysis", + { analysisType: "logs", lines: 1000, filter: "harness-test-bucket" }, + 60000 + ); + const ok = + [summary, errors, requests, raw].every((r) => !r.isError) && + /LocalStack Summary|Summary/i.test(summary.text); + record( + "logs", + "localstack-logs-analysis summary/errors/requests/logs", + ok, + `summary: ${snip(summary.text, 180)} | errors: ${snip(errors.text, 120)} | requests: ${snip(requests.text, 120)} | raw: ${snip(raw.text, 120)}` + ); + } catch (e) { + record("logs", "logs-analysis", false, String(e.message)); + } } // 6c. state-management — export local state to a mounted path, reset, import, inspect. @@ -294,15 +409,47 @@ async function main() { const statePath = `/work/data/harness-state-${RUN_ID}.zip`; try { await callTool("localstack-aws-client", { command: `s3 mb s3://${bucket}` }, 60000); - const exported = await callTool("localstack-state-management", { action: "export", file_path: statePath, services: ["s3"] }, 120000); - const inspected = await callTool("localstack-state-management", { action: "inspect", services: "s3" }, 60000); - const reset = await callTool("localstack-state-management", { action: "reset", services: ["s3"] }, 120000); + const exported = await callTool( + "localstack-state-management", + { action: "export", file_path: statePath, services: ["s3"] }, + 120000 + ); + const inspected = await callTool( + "localstack-state-management", + { action: "inspect", services: "s3" }, + 60000 + ); + const reset = await callTool( + "localstack-state-management", + { action: "reset", services: ["s3"] }, + 120000 + ); const afterReset = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000); - const imported = await callTool("localstack-state-management", { action: "import", file_path: statePath }, 120000); + const imported = await callTool( + "localstack-state-management", + { action: "import", file_path: statePath }, + 120000 + ); const afterImport = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000); - const ok = !exported.isError && !inspected.isError && !reset.isError && !imported.isError && /State Exported/i.test(exported.text) && afterImport.text.includes(bucket); - recordToolResult("state", "localstack-state-management export/inspect/reset/import", { text: `export: ${exported.text}\ninspect: ${inspected.text}\nreset: ${reset.text}\nimport: ${imported.text}\nafter reset: ${afterReset.text}\nafter import: ${afterImport.text}`, isError: !ok }, () => ok); - } catch (e) { record("state", "state-management", false, String(e.message)); } + const ok = + !exported.isError && + !inspected.isError && + !reset.isError && + !imported.isError && + /State Exported/i.test(exported.text) && + afterImport.text.includes(bucket); + recordToolResult( + "state", + "localstack-state-management export/inspect/reset/import", + { + text: `export: ${exported.text}\ninspect: ${inspected.text}\nreset: ${reset.text}\nimport: ${imported.text}\nafter reset: ${afterReset.text}\nafter import: ${afterImport.text}`, + isError: !ok, + }, + () => ok + ); + } catch (e) { + record("state", "state-management", false, String(e.message)); + } } // 6d. cloud-pods — remote/cloud-backed snapshot. Opt in because it creates account resources. @@ -310,20 +457,61 @@ async function main() { const podName = `mcp-harness-${RUN_ID}`.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80); const bucket = `harness-pod-${RUN_ID}`; if (!RUN_REMOTE) { - record("cloudpods", "localstack-cloud-pods save/load/delete", "warn", "skipped remote Cloud Pod create/load/delete", "set HARNESS_RUN_REMOTE=1 to exercise remote Cloud Pods"); + record( + "cloudpods", + "localstack-cloud-pods save/load/delete", + "warn", + "skipped remote Cloud Pod create/load/delete", + "set HARNESS_RUN_REMOTE=1 to exercise remote Cloud Pods" + ); } else { try { - await callTool("localstack-cloud-pods", { action: "delete", pod_name: podName }, 120000).catch(() => {}); + await callTool( + "localstack-cloud-pods", + { action: "delete", pod_name: podName }, + 120000 + ).catch(() => {}); await callTool("localstack-aws-client", { command: `s3 mb s3://${bucket}` }, 60000); - const save = await callTool("localstack-cloud-pods", { action: "save", pod_name: podName }, 300000); - const reset = await callTool("localstack-state-management", { action: "reset", services: ["s3"] }, 120000); - const load = await callTool("localstack-cloud-pods", { action: "load", pod_name: podName }, 300000); + const save = await callTool( + "localstack-cloud-pods", + { action: "save", pod_name: podName }, + 300000 + ); + const reset = await callTool( + "localstack-state-management", + { action: "reset", services: ["s3"] }, + 120000 + ); + const load = await callTool( + "localstack-cloud-pods", + { action: "load", pod_name: podName }, + 300000 + ); const ls = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000); - const del = await callTool("localstack-cloud-pods", { action: "delete", pod_name: podName }, 120000); - const ok = !save.isError && !reset.isError && !load.isError && !del.isError && ls.text.includes(bucket); - recordToolResult("cloudpods", "localstack-cloud-pods save/reset/load/delete", { text: `save: ${save.text}\nload: ${load.text}\ndelete: ${del.text}\nls: ${ls.text}`, isError: !ok }, () => ok); + const del = await callTool( + "localstack-cloud-pods", + { action: "delete", pod_name: podName }, + 120000 + ); + const ok = + !save.isError && + !reset.isError && + !load.isError && + !del.isError && + ls.text.includes(bucket); + recordToolResult( + "cloudpods", + "localstack-cloud-pods save/reset/load/delete", + { + text: `save: ${save.text}\nload: ${load.text}\ndelete: ${del.text}\nls: ${ls.text}`, + isError: !ok, + }, + () => ok + ); } catch (e) { - try { await callTool("localstack-cloud-pods", { action: "delete", pod_name: podName }, 120000); } catch {} + try { + await callTool("localstack-cloud-pods", { action: "delete", pod_name: podName }, 120000); + } catch {} record("cloudpods", "cloud-pods", false, String(e.message)); } } @@ -332,13 +520,26 @@ async function main() { // 6e. app-inspector — enable, generate traffic, list traces and drill into spans/events when present. if (!SKIP.has("appinspector")) { try { - const enable = await callTool("localstack-app-inspector", { action: "set-status", status: "enabled" }, 60000); - await callTool("localstack-aws-client", { command: `s3 mb s3://harness-ai-${RUN_ID}` }, 60000); + const enable = await callTool( + "localstack-app-inspector", + { action: "set-status", status: "enabled" }, + 60000 + ); + await callTool( + "localstack-aws-client", + { command: `s3 mb s3://harness-ai-${RUN_ID}` }, + 60000 + ); await sleep(2000); const traces = await callToolUntil( "localstack-app-inspector", { action: "list-traces", limit: 10 }, - { attempts: 6, delayMs: 3000, timeoutMs: 60000, ok: (r) => !r.isError && !/No traces found/i.test(r.text) } + { + attempts: 6, + delayMs: 3000, + timeoutMs: 60000, + ok: (r) => !r.isError && !/No traces found/i.test(r.text), + } ); const traceId = firstMarkdownTableValue(traces.text); let trace = { text: "", isError: false }; @@ -346,17 +547,50 @@ async function main() { let events = { text: "", isError: false }; let iamEvents = { text: "", isError: false }; if (traceId) { - trace = await callTool("localstack-app-inspector", { action: "get-trace", trace_id: traceId }, 60000); - spans = await callTool("localstack-app-inspector", { action: "list-spans", trace_id: traceId, limit: 10 }, 60000); + trace = await callTool( + "localstack-app-inspector", + { action: "get-trace", trace_id: traceId }, + 60000 + ); + spans = await callTool( + "localstack-app-inspector", + { action: "list-spans", trace_id: traceId, limit: 10 }, + 60000 + ); const spanId = firstMarkdownTableValue(spans.text); if (spanId) { - events = await callTool("localstack-app-inspector", { action: "list-events", trace_id: traceId, span_id: spanId, limit: 10 }, 60000); - iamEvents = await callTool("localstack-app-inspector", { action: "list-iam-events", trace_id: traceId, span_id: spanId, limit: 10 }, 60000); + events = await callTool( + "localstack-app-inspector", + { action: "list-events", trace_id: traceId, span_id: spanId, limit: 10 }, + 60000 + ); + iamEvents = await callTool( + "localstack-app-inspector", + { action: "list-iam-events", trace_id: traceId, span_id: spanId, limit: 10 }, + 60000 + ); } } - const ok = !enable.isError && !traces.isError && Boolean(traceId) && !trace.isError && !spans.isError && !events.isError && !iamEvents.isError; - recordToolResult("appinspector", "localstack-app-inspector enable/list/get/spans/events", { text: `enable: ${enable.text}\ntraces: ${traces.text}\ntrace: ${trace.text}\nspans: ${spans.text}\nevents: ${events.text}\niam: ${iamEvents.text}`, isError: !ok }, () => ok); - } catch (e) { record("appinspector", "app-inspector", false, String(e.message)); } + const ok = + !enable.isError && + !traces.isError && + Boolean(traceId) && + !trace.isError && + !spans.isError && + !events.isError && + !iamEvents.isError; + recordToolResult( + "appinspector", + "localstack-app-inspector enable/list/get/spans/events", + { + text: `enable: ${enable.text}\ntraces: ${traces.text}\ntrace: ${trace.text}\nspans: ${spans.text}\nevents: ${events.text}\niam: ${iamEvents.text}`, + isError: !ok, + }, + () => ok + ); + } catch (e) { + record("appinspector", "app-inspector", false, String(e.message)); + } } // 6f. chaos-injector — add a deterministic S3 ListBuckets fault, observe it, then clear faults/latency. @@ -369,18 +603,57 @@ async function main() { error: { statusCode: 503, code: "ServiceUnavailable" }, }; try { - const add = await callTool("localstack-chaos-injector", { action: "add-fault-rule", rules: [rule] }, 60000); + const add = await callTool( + "localstack-chaos-injector", + { action: "add-fault-rule", rules: [rule] }, + 60000 + ); const faults = await callTool("localstack-chaos-injector", { action: "get-faults" }, 60000); const affected = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000); - const latency = await callTool("localstack-chaos-injector", { action: "inject-latency", latency_ms: 25 }, 60000); - const getLatency = await callTool("localstack-chaos-injector", { action: "get-latency" }, 60000); - const clearLatency = await callTool("localstack-chaos-injector", { action: "clear-latency" }, 60000); - const clear = await callTool("localstack-chaos-injector", { action: "clear-all-faults" }, 60000); - const ok = !add.isError && !faults.isError && affected.isError && !latency.isError && !getLatency.isError && !clearLatency.isError && !clear.isError; - recordToolResult("chaos", "localstack-chaos-injector add/get/effect/clear", { text: `add: ${add.text}\nfaults: ${faults.text}\naffected: ${affected.text}\nlatency: ${latency.text}\nget latency: ${getLatency.text}\nclear latency: ${clearLatency.text}\nclear: ${clear.text}`, isError: !ok }, () => ok); + const latency = await callTool( + "localstack-chaos-injector", + { action: "inject-latency", latency_ms: 25 }, + 60000 + ); + const getLatency = await callTool( + "localstack-chaos-injector", + { action: "get-latency" }, + 60000 + ); + const clearLatency = await callTool( + "localstack-chaos-injector", + { action: "clear-latency" }, + 60000 + ); + const clear = await callTool( + "localstack-chaos-injector", + { action: "clear-all-faults" }, + 60000 + ); + const ok = + !add.isError && + !faults.isError && + affected.isError && + !latency.isError && + !getLatency.isError && + !clearLatency.isError && + !clear.isError; + recordToolResult( + "chaos", + "localstack-chaos-injector add/get/effect/clear", + { + text: `add: ${add.text}\nfaults: ${faults.text}\naffected: ${affected.text}\nlatency: ${latency.text}\nget latency: ${getLatency.text}\nclear latency: ${clearLatency.text}\nclear: ${clear.text}`, + isError: !ok, + }, + () => ok + ); } catch (e) { - try { await callTool("localstack-chaos-injector", { action: "clear-all-faults" }, 60000); } catch {} - try { await callTool("localstack-chaos-injector", { action: "clear-latency" }, 60000); } catch {} + try { + await callTool("localstack-chaos-injector", { action: "clear-all-faults" }, 60000); + } catch {} + try { + await callTool("localstack-chaos-injector", { action: "clear-latency" }, 60000); + } catch {} record("chaos", "chaos-injector", false, String(e.message)); } } @@ -388,15 +661,45 @@ async function main() { // 6g. IAM policy analyzer — mode transitions plus log analysis, then restore disabled. if (!SKIP.has("iam")) { try { - const status = await callTool("localstack-iam-policy-analyzer", { action: "get-status" }, 60000); - const soft = await callTool("localstack-iam-policy-analyzer", { action: "set-mode", mode: "SOFT_MODE" }, 60000); + const status = await callTool( + "localstack-iam-policy-analyzer", + { action: "get-status" }, + 60000 + ); + const soft = await callTool( + "localstack-iam-policy-analyzer", + { action: "set-mode", mode: "SOFT_MODE" }, + 60000 + ); await callTool("localstack-aws-client", { command: "s3 ls" }, 60000); - const analyze = await callTool("localstack-iam-policy-analyzer", { action: "analyze-policies" }, 60000); - const disabled = await callTool("localstack-iam-policy-analyzer", { action: "set-mode", mode: "DISABLED" }, 60000); + const analyze = await callTool( + "localstack-iam-policy-analyzer", + { action: "analyze-policies" }, + 60000 + ); + const disabled = await callTool( + "localstack-iam-policy-analyzer", + { action: "set-mode", mode: "DISABLED" }, + 60000 + ); const ok = !status.isError && !soft.isError && !analyze.isError && !disabled.isError; - recordToolResult("iam", "localstack-iam-policy-analyzer status/set/analyze/restore", { text: `status: ${status.text}\nsoft: ${soft.text}\nanalyze: ${analyze.text}\ndisabled: ${disabled.text}`, isError: !ok }, () => ok); + recordToolResult( + "iam", + "localstack-iam-policy-analyzer status/set/analyze/restore", + { + text: `status: ${status.text}\nsoft: ${soft.text}\nanalyze: ${analyze.text}\ndisabled: ${disabled.text}`, + isError: !ok, + }, + () => ok + ); } catch (e) { - try { await callTool("localstack-iam-policy-analyzer", { action: "set-mode", mode: "DISABLED" }, 60000); } catch {} + try { + await callTool( + "localstack-iam-policy-analyzer", + { action: "set-mode", mode: "DISABLED" }, + 60000 + ); + } catch {} record("iam", "iam-policy-analyzer", false, String(e.message)); } } @@ -404,21 +707,47 @@ async function main() { // 6h. aws-replicator — list endpoints always; start a job only when source AWS creds are explicitly available. if (!SKIP.has("replicator")) { try { - const resources = await callTool("localstack-aws-replicator", { action: "list-resources" }, 120000); + const resources = await callTool( + "localstack-aws-replicator", + { action: "list-resources" }, + 120000 + ); const jobs = await callTool("localstack-aws-replicator", { action: "list" }, 120000); - let start = { text: "start skipped: no HARNESS_RUN_REPLICATOR_START=1 or source AWS credentials", isError: false }; + let start = { + text: "start skipped: no HARNESS_RUN_REPLICATOR_START=1 or source AWS credentials", + isError: false, + }; if (RUN_REPLICATOR_START && hasAwsCreds()) { - start = await callTool("localstack-aws-replicator", { - action: "start", - replication_type: "SINGLE_RESOURCE", - resource_type: process.env.HARNESS_REPLICATOR_RESOURCE_TYPE || "AWS::SSM::Parameter", - resource_identifier: process.env.HARNESS_REPLICATOR_RESOURCE_IDENTIFIER || "/localstack-mcp-harness", - }, 300000); + start = await callTool( + "localstack-aws-replicator", + { + action: "start", + replication_type: "SINGLE_RESOURCE", + resource_type: process.env.HARNESS_REPLICATOR_RESOURCE_TYPE || "AWS::SSM::Parameter", + resource_identifier: + process.env.HARNESS_REPLICATOR_RESOURCE_IDENTIFIER || "/localstack-mcp-harness", + }, + 300000 + ); } const ok = !resources.isError && !jobs.isError && !start.isError; - const note = RUN_REPLICATOR_START && hasAwsCreds() ? undefined : "replication job start skipped unless HARNESS_RUN_REPLICATOR_START=1 and source AWS creds are set"; - recordToolResult("replicator", "localstack-aws-replicator list-resources/list/start-if-configured", { text: `resources: ${resources.text}\njobs: ${jobs.text}\nstart: ${start.text}`, isError: !ok }, () => ok, note); - } catch (e) { record("replicator", "aws-replicator", false, String(e.message)); } + const note = + RUN_REPLICATOR_START && hasAwsCreds() + ? undefined + : "replication job start skipped unless HARNESS_RUN_REPLICATOR_START=1 and source AWS creds are set"; + recordToolResult( + "replicator", + "localstack-aws-replicator list-resources/list/start-if-configured", + { + text: `resources: ${resources.text}\njobs: ${jobs.text}\nstart: ${start.text}`, + isError: !ok, + }, + () => ok, + note + ); + } catch (e) { + record("replicator", "aws-replicator", false, String(e.message)); + } } // 6i. ephemeral instances — list always; create/logs/delete only with explicit opt-in. @@ -427,9 +756,19 @@ async function main() { try { const list = await callTool("localstack-ephemeral-instances", { action: "list" }, 120000); if (!RUN_EPHEMERAL) { - recordToolResult("ephemeral", "localstack-ephemeral-instances list", list, (r) => !r.isError, "create/logs/delete skipped; set HARNESS_RUN_EPHEMERAL=1 to provision a short-lived cloud instance"); + recordToolResult( + "ephemeral", + "localstack-ephemeral-instances list", + list, + (r) => !r.isError, + "create/logs/delete skipped; set HARNESS_RUN_EPHEMERAL=1 to provision a short-lived cloud instance" + ); } else { - const create = await callTool("localstack-ephemeral-instances", { action: "create", name: instanceName, lifetime: 10 }, 240000); + const create = await callTool( + "localstack-ephemeral-instances", + { action: "create", name: instanceName, lifetime: 10 }, + 240000 + ); if (create.isError && /compute\.resource_exhausted|quota|limit/i.test(create.text)) { record( "ephemeral", @@ -439,15 +778,37 @@ async function main() { "platform quota exhausted; create/logs/delete could not be completed" ); } else { - const logs = await callTool("localstack-ephemeral-instances", { action: "logs", name: instanceName }, 180000); - const del = await callTool("localstack-ephemeral-instances", { action: "delete", name: instanceName }, 120000); + const logs = await callTool( + "localstack-ephemeral-instances", + { action: "logs", name: instanceName }, + 180000 + ); + const del = await callTool( + "localstack-ephemeral-instances", + { action: "delete", name: instanceName }, + 120000 + ); const ok = !list.isError && !create.isError && !logs.isError && !del.isError; - recordToolResult("ephemeral", "localstack-ephemeral-instances list/create/logs/delete", { text: `list: ${list.text}\ncreate: ${create.text}\nlogs: ${logs.text}\ndelete: ${del.text}`, isError: !ok }, () => ok); + recordToolResult( + "ephemeral", + "localstack-ephemeral-instances list/create/logs/delete", + { + text: `list: ${list.text}\ncreate: ${create.text}\nlogs: ${logs.text}\ndelete: ${del.text}`, + isError: !ok, + }, + () => ok + ); } } } catch (e) { if (RUN_EPHEMERAL) { - try { await callTool("localstack-ephemeral-instances", { action: "delete", name: instanceName }, 120000); } catch {} + try { + await callTool( + "localstack-ephemeral-instances", + { action: "delete", name: instanceName }, + 120000 + ); + } catch {} } record("ephemeral", "ephemeral-instances", false, String(e.message)); } @@ -456,36 +817,84 @@ async function main() { // 7. deployer terraform if (!SKIP.has("deploy")) { try { - const r = await callTool("localstack-deployer", { action: "deploy", projectType: "terraform", directory: DEPLOY_DIR }, 300000); - const ok = !r.isError && /(completed successfully|Apply complete|bucket_name|Terraform Outputs)/i.test(r.text); - record("deploy", "localstack-deployer terraform deploy (tflocal -> LS)", ok, snip(r.text, 600)); - } catch (e) { record("deploy", "deployer terraform", false, String(e.message)); } + const r = await callTool( + "localstack-deployer", + { action: "deploy", projectType: "terraform", directory: DEPLOY_DIR }, + 300000 + ); + const ok = + !r.isError && + /(completed successfully|Apply complete|bucket_name|Terraform Outputs)/i.test(r.text); + record( + "deploy", + "localstack-deployer terraform deploy (tflocal -> LS)", + ok, + snip(r.text, 600) + ); + } catch (e) { + record("deploy", "deployer terraform", false, String(e.message)); + } } // 7b. deployer CDK — validates cdklocal endpoint injection and path-style S3 asset uploads. if (!SKIP.has("deploy-cdk")) { try { - const r = await callTool("localstack-deployer", { action: "deploy", projectType: "cdk", directory: CDK_DIR }, 300000); + const r = await callTool( + "localstack-deployer", + { action: "deploy", projectType: "cdk", directory: CDK_DIR }, + 300000 + ); const ls = await callTool("localstack-aws-client", { command: "s3 ls" }, 60000); - const ok = !r.isError && !ls.isError && /CDK stack deployed successfully/i.test(r.text) && ls.text.includes("mcp-cdk-sample-bucket") && !/Error during `cdklocal/i.test(r.text); - record("deploy-cdk", "localstack-deployer CDK deploy (cdklocal -> LS)", ok, `deploy: ${snip(r.text, 500)} | s3 ls: ${snip(ls.text, 160)}`); - } catch (e) { record("deploy-cdk", "deployer CDK", false, String(e.message)); } + const ok = + !r.isError && + !ls.isError && + /CDK stack deployed successfully/i.test(r.text) && + ls.text.includes("mcp-cdk-sample-bucket") && + !/Error during `cdklocal/i.test(r.text); + record( + "deploy-cdk", + "localstack-deployer CDK deploy (cdklocal -> LS)", + ok, + `deploy: ${snip(r.text, 500)} | s3 ls: ${snip(ls.text, 160)}` + ); + } catch (e) { + record("deploy-cdk", "deployer CDK", false, String(e.message)); + } } // 8. extensions — Pro-gated (needs valid token + marketplace API) if (!SKIP.has("extensions")) { try { const r = await callTool("localstack-extensions", { action: "available" }, 60000); - recordToolResult("extensions", "localstack-extensions available (Pro)", r, (x) => !x.isError && /Marketplace|extensions available/i.test(x.text)); - } catch (e) { record("extensions", "extensions", false, String(e.message)); } + recordToolResult( + "extensions", + "localstack-extensions available (Pro)", + r, + (x) => !x.isError && /Marketplace|extensions available/i.test(x.text) + ); + } catch (e) { + record("extensions", "extensions", false, String(e.message)); + } } // 9. cleanup and remaining management lifecycle coverage if (!NO_CLEANUP && !SKIP.has("deploy")) { - try { await callTool("localstack-deployer", { action: "destroy", projectType: "terraform", directory: DEPLOY_DIR }, 180000); } catch {} + try { + await callTool( + "localstack-deployer", + { action: "destroy", projectType: "terraform", directory: DEPLOY_DIR }, + 180000 + ); + } catch {} } if (!NO_CLEANUP && !SKIP.has("deploy-cdk")) { - try { await callTool("localstack-deployer", { action: "destroy", projectType: "cdk", directory: CDK_DIR }, 180000); } catch {} + try { + await callTool( + "localstack-deployer", + { action: "destroy", projectType: "cdk", directory: CDK_DIR }, + 180000 + ); + } catch {} } if (!SKIP.has("restart")) { @@ -496,29 +905,63 @@ async function main() { { command: "sts get-caller-identity" }, { attempts: 12, delayMs: 5000, timeoutMs: 30000 } ); - record("restart", "localstack-management restart", !r.isError && !ready.isError, `restart: ${snip(r.text, 300)} | readiness: ${snip(ready.text, 160)}`); - } catch (e) { record("restart", "management restart", false, String(e.message)); } + record( + "restart", + "localstack-management restart", + !r.isError && !ready.isError, + `restart: ${snip(r.text, 300)} | readiness: ${snip(ready.text, 160)}` + ); + } catch (e) { + record("restart", "management restart", false, String(e.message)); + } } if (!SKIP.has("stop")) { try { const r = await callTool("localstack-management", { action: "stop" }, 60000); - record("stop", "localstack-management stop", !r.isError && /(stopped|stop command executed)/i.test(r.text), snip(r.text, 300)); - } catch (e) { record("stop", "management stop", false, String(e.message)); } + record( + "stop", + "localstack-management stop", + !r.isError && /(stopped|stop command executed)/i.test(r.text), + snip(r.text, 300) + ); + } catch (e) { + record("stop", "management stop", false, String(e.message)); + } } // 10. Snowflake stack — starts a separate runtime flavor after the AWS stack is stopped. if (!SKIP.has("snowflake")) { try { - const start = await callTool("localstack-management", { action: "start", service: "snowflake" }, 240000); - const check = await callTool("localstack-snowflake-client", { action: "check-connection" }, 120000); - const exec = await callTool("localstack-snowflake-client", { action: "execute", file_path: SQL_FILE }, 180000); + const start = await callTool( + "localstack-management", + { action: "start", service: "snowflake" }, + 240000 + ); + const check = await callTool( + "localstack-snowflake-client", + { action: "check-connection" }, + 120000 + ); + const exec = await callTool( + "localstack-snowflake-client", + { action: "execute", file_path: SQL_FILE }, + 180000 + ); const ok = !start.isError && !check.isError && !exec.isError; - recordToolResult("snowflake", "localstack-snowflake-client check-connection/execute file", { text: `start: ${start.text}\ncheck: ${check.text}\nexecute: ${exec.text}`, isError: !ok }, () => ok); - } catch (e) { record("snowflake", "snowflake-client", false, String(e.message)); } - finally { + recordToolResult( + "snowflake", + "localstack-snowflake-client check-connection/execute file", + { text: `start: ${start.text}\ncheck: ${check.text}\nexecute: ${exec.text}`, isError: !ok }, + () => ok + ); + } catch (e) { + record("snowflake", "snowflake-client", false, String(e.message)); + } finally { if (!NO_CLEANUP) { - try { await callTool("localstack-management", { action: "stop" }, 60000); } catch {} + try { + await callTool("localstack-management", { action: "stop" }, 60000); + } catch {} } } } @@ -526,11 +969,18 @@ async function main() { main() .then(() => finish()) - .catch((e) => { console.error("\nHARNESS ERROR:", e.message); finish(1); }); + .catch((e) => { + console.error("\nHARNESS ERROR:", e.message); + finish(1); + }); function finish(forceCode) { - try { child.stdin.end(); } catch {} - try { child.kill("SIGTERM"); } catch {} + try { + child.stdin.end(); + } catch {} + try { + child.kill("SIGTERM"); + } catch {} const hard = results.filter((r) => r.ok === false); const warn = results.filter((r) => r.ok === "warn"); const pass = results.filter((r) => r.ok === true);