From b552a6559b65bd12fed30b87d27df6299b1056fe Mon Sep 17 00:00:00 2001 From: Harsh Mishra Date: Sun, 15 Mar 2026 23:46:52 +0530 Subject: [PATCH] add localstack ephemeral instances tool --- README.md | 2 + manifest.json | 4 + src/core/analytics.ts | 8 + src/tools/localstack-ephemeral-instances.ts | 296 ++++++++++++++++++++ tests/mcp/direct.spec.mjs | 1 + 5 files changed, 311 insertions(+) create mode 100644 src/tools/localstack-ephemeral-instances.ts diff --git a/README.md b/README.md index f236113..7bfeab1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This server eliminates custom scripts and manual LocalStack management with dire - Inject chaos faults and network effects into LocalStack to test system resilience. (requires active license) - Manage LocalStack state snapshots via [Cloud Pods](https://docs.localstack.cloud/aws/capabilities/state-management/cloud-pods/) for development workflows. (requires active license) - Install, remove, list, and discover [LocalStack Extensions](https://docs.localstack.cloud/aws/capabilities/extensions/) from the marketplace. (requires active license) +- Launch and manage [Ephemeral Instances](https://docs.localstack.cloud/aws/capabilities/cloud-sandbox/ephemeral-instances/) for remote LocalStack testing workflows. - Connect AI assistants and dev tools for automated cloud testing workflows. ## Tools Reference @@ -29,6 +30,7 @@ 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 LocalStack state snapshots for development workflows | - Save current state as Cloud Pods
- Load previously saved Cloud Pods instantly
- Delete Cloud Pods or reset to a clean state
- 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 support | +| [`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-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-docs`](./src/tools/localstack-docs.ts) | Searches LocalStack documentation through CrawlChat | - Queries LocalStack docs through a public CrawlChat collection
- Returns focused snippets with source links only
- Helps answer coverage, configuration, and setup questions without requiring LocalStack runtime | diff --git a/manifest.json b/manifest.json index ce07292..727930e 100644 --- a/manifest.json +++ b/manifest.json @@ -51,6 +51,10 @@ "name": "localstack-extensions", "description": "Install, uninstall, list, and discover LocalStack Extensions from the marketplace" }, + { + "name": "localstack-ephemeral-instances", + "description": "Manage cloud-hosted LocalStack Ephemeral Instances by creating, listing, viewing logs, and deleting instances" + }, { "name": "localstack-docs", "description": "Search the LocalStack documentation to find guides, API references, and configuration details" diff --git a/src/core/analytics.ts b/src/core/analytics.ts index c6c8aa0..a3fb203 100644 --- a/src/core/analytics.ts +++ b/src/core/analytics.ts @@ -30,6 +30,14 @@ export const TOOL_ARG_ALLOWLIST: Record = { "saveParams", ], "localstack-docs": ["query", "limit"], + "localstack-ephemeral-instances": [ + "action", + "name", + "lifetime", + "extension", + "cloudPod", + "envVarKeys", + ], "localstack-extensions": ["action", "name", "source"], "localstack-iam-policy-analyzer": ["action", "mode"], "localstack-logs-analysis": ["analysisType", "lines", "service", "operation", "filter"], diff --git a/src/tools/localstack-ephemeral-instances.ts b/src/tools/localstack-ephemeral-instances.ts new file mode 100644 index 0000000..0862338 --- /dev/null +++ b/src/tools/localstack-ephemeral-instances.ts @@ -0,0 +1,296 @@ +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 { ResponseBuilder } from "../core/response-builder"; +import { withToolAnalytics } from "../core/analytics"; + +export const schema = { + action: z + .enum(["create", "list", "logs", "delete"]) + .describe("The Ephemeral Instances action to perform."), + name: z + .string() + .optional() + .describe("Instance name. Required for create, logs, and delete actions."), + lifetime: z + .number() + .int() + .positive() + .optional() + .describe("Lifetime in minutes for create action. Defaults to CLI default when omitted."), + extension: z + .string() + .optional() + .describe( + "Optional extension package to preload for create action. This is passed as EXTENSION_AUTO_INSTALL." + ), + cloudPod: z + .string() + .optional() + .describe( + "Optional Cloud Pod name to initialize state for create action. This is passed as CLOUD_POD_NAME." + ), + envVars: z + .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." + ), +}; + +export const metadata: ToolMetadata = { + name: "localstack-ephemeral-instances", + description: + "Manage cloud-hosted LocalStack Ephemeral Instances: create, list, fetch logs, and delete.", + annotations: { + title: "LocalStack Ephemeral Instances", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }, +}; + +export default async function localstackEphemeralInstances({ + action, + name, + lifetime, + extension, + cloudPod, + envVars, +}: InferSchema) { + return withToolAnalytics( + "localstack-ephemeral-instances", + { + action, + name, + lifetime, + extension, + cloudPod, + envVarKeys: envVars ? Object.keys(envVars) : [], + }, + async () => { + const authError = requireAuthToken(); + if (authError) return authError; + + const preflightError = await runPreflights([requireLocalStackCli()]); + if (preflightError) return preflightError; + + switch (action) { + case "create": + return await handleCreate({ name, lifetime, extension, cloudPod, envVars }); + case "list": + return await handleList(); + case "logs": + return await handleLogs({ name }); + case "delete": + return await handleDelete({ name }); + default: + return ResponseBuilder.error("Unknown action", `Unsupported action: ${action}`); + } + } + ); +} + +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 { + const endpoint = String(payload.endpoint_url ?? "N/A"); + const id = String(payload.id ?? "N/A"); + const status = String(payload.status ?? "unknown"); + const creationTime = String(payload.creation_time ?? "N/A"); + const expiryTime = String(payload.expiry_time ?? "N/A"); + + return `## Ephemeral Instance Created + +- **ID:** ${id} +- **Status:** ${status} +- **Endpoint URL:** ${endpoint} +- **Creation Time:** ${creationTime} +- **Expiry Time:** ${expiryTime} + +\`\`\`json +${JSON.stringify(payload, null, 2)} +\`\`\` + +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; +}) { + if (!name?.trim()) { + return ResponseBuilder.error( + "Missing Required Parameter", + "The `create` action requires the `name` parameter." + ); + } + + 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; + } + if (cloudPod) { + mergedEnvVars.CLOUD_POD_NAME = cloudPod; + } + + for (const [key, value] of Object.entries(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) { + return ResponseBuilder.error( + "Create Failed", + cleaned.combined || "Failed to create ephemeral instance." + ); + } + + 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) { + return ResponseBuilder.markdown( + `## Ephemeral Instances\n\n\`\`\`\n${cleaned.combined || "No instances found."}\n\`\`\`` + ); + } + + return ResponseBuilder.markdown( + `## Ephemeral Instances\n\n\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\`` + ); +} + +async function handleLogs({ name }: { name?: string }) { + if (!name?.trim()) { + return ResponseBuilder.error( + "Missing Required Parameter", + "The `logs` action requires the `name` parameter." + ); + } + + 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) { + return ResponseBuilder.error( + "Logs Failed", + cleaned.combined || `Failed to fetch logs for 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 }) { + if (!name?.trim()) { + return ResponseBuilder.error( + "Missing Required Parameter", + "The `delete` action requires the `name` parameter." + ); + } + + 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) { + return ResponseBuilder.error( + "Delete Failed", + cleaned.combined || `Failed to delete ephemeral instance '${name}'.` + ); + } + + return ResponseBuilder.markdown(cleaned.combined || `Successfully deleted instance: ${name} ✅`); +} diff --git a/tests/mcp/direct.spec.mjs b/tests/mcp/direct.spec.mjs index 6b52fd9..2530169 100644 --- a/tests/mcp/direct.spec.mjs +++ b/tests/mcp/direct.spec.mjs @@ -8,6 +8,7 @@ const EXPECTED_TOOLS = [ "localstack-chaos-injector", "localstack-cloud-pods", "localstack-extensions", + "localstack-ephemeral-instances", "localstack-aws-client", "localstack-docs", ];