diff --git a/README.md b/README.md index 39f7f13..20fdb49 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This server provides your AI with dedicated tools for managing your LocalStack e | Tool Name | Description | Key Features | | :-------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [`localstack-management`](./src/tools/localstack-management.ts) | Manages LocalStack for AWS container operations and settings | - Execute start, stop, restart, and status checks
- Integrate LocalStack authentication tokens
- Inject custom environment variables
- Verify real-time status and perform health monitoring | +| [`localstack-management`](./src/tools/localstack-management.ts) | Manages LocalStack runtime operations for AWS and Snowflake stacks | - Execute start, stop, restart, and status checks
- Integrate LocalStack authentication tokens
- Inject custom environment variables
- Verify real-time status and perform health monitoring | | [`localstack-deployer`](./src/tools/localstack-deployer.ts) | Handles infrastructure deployment to LocalStack for AWS environments | - Automatically run CDK and Terraform tooling to deploy infrastructure locally
- Enable parameterized deployments with variable support
- Process and present deployment results
- Requires you to have [`cdklocal`](https://github.com/localstack/aws-cdk-local) or [`tflocal`](https://github.com/localstack/terraform-local) installed in your system path | | [`localstack-logs-analysis`](./src/tools/localstack-logs-analysis.ts) | Analyzes LocalStack for AWS logs for troubleshooting and insights | - Offer multiple analysis options including summaries, errors, requests, and raw data
- Filter by specific services and operations
- Generate API call metrics and failure breakdowns
- Group errors intelligently and identify patterns | | [`localstack-iam-policy-analyzer`](./src/tools/localstack-iam-policy-analyzer.ts) | Handles IAM policy management and violation remediation | - Set IAM enforcement levels including `enforced`, `soft`, and `disabled` modes
- Search logs for permission-related violations
- Generate IAM policies automatically from detected access failures
- Requires a valid LocalStack Auth Token | diff --git a/src/core/preflight.ts b/src/core/preflight.ts index 0151738..6c8fd13 100644 --- a/src/core/preflight.ts +++ b/src/core/preflight.ts @@ -16,6 +16,16 @@ export const requireProFeature = async (feature: ProFeature): Promise { + if (!process.env.LOCALSTACK_AUTH_TOKEN) { + return ResponseBuilder.error( + "Auth Token Required", + "LOCALSTACK_AUTH_TOKEN is required for this operation." + ); + } + return null; +}; + export const runPreflights = async ( checks: Array> ): Promise => { diff --git a/src/lib/localstack/license-checker.ts b/src/lib/localstack/license-checker.ts index af7cf9e..2ce823f 100644 --- a/src/lib/localstack/license-checker.ts +++ b/src/lib/localstack/license-checker.ts @@ -8,6 +8,7 @@ export enum ProFeature { CLOUD_PODS = "localstack.platform.plugin/pods", CHAOS_ENGINEERING = "localstack.platform.plugin/chaos", EXTENSIONS = "localstack.platform.plugin/extensions", + SNOWFLAKE = "localstack.aws.provider/snowflake:pro", } export interface LicenseCheckResult { diff --git a/src/lib/localstack/localstack.utils.test.ts b/src/lib/localstack/localstack.utils.test.ts new file mode 100644 index 0000000..7703fb1 --- /dev/null +++ b/src/lib/localstack/localstack.utils.test.ts @@ -0,0 +1,51 @@ +import { getLocalStackStatus, getSnowflakeEmulatorStatus } from "./localstack.utils"; +import { runCommand } from "../../core/command-runner"; + +jest.mock("../../core/command-runner", () => ({ + runCommand: jest.fn(), +})); + +const mockedRunCommand = runCommand as jest.MockedFunction; + +describe("localstack.utils", () => { + beforeEach(() => { + mockedRunCommand.mockReset(); + }); + + test("getLocalStackStatus marks instance as running and ready", async () => { + mockedRunCommand.mockResolvedValueOnce({ + stdout: "Runtime status: running (Ready)", + stderr: "", + exitCode: 0, + } as any); + + const result = await getLocalStackStatus(); + expect(result.isRunning).toBe(true); + expect(result.isReady).toBe(true); + }); + + test("getSnowflakeEmulatorStatus marks emulator healthy on success payload", async () => { + mockedRunCommand.mockResolvedValueOnce({ + stdout: '{"success": true}', + stderr: "", + exitCode: 0, + } as any); + + const result = await getSnowflakeEmulatorStatus(); + expect(result.isRunning).toBe(true); + expect(result.isReady).toBe(true); + expect(result.statusOutput).toContain('"success": true'); + }); + + test("getSnowflakeEmulatorStatus reports unhealthy response", async () => { + mockedRunCommand.mockResolvedValueOnce({ + stdout: '{"success": false}', + stderr: "", + exitCode: 0, + } as any); + + const result = await getSnowflakeEmulatorStatus(); + expect(result.isRunning).toBe(false); + expect(result.isReady).toBe(false); + }); +}); diff --git a/src/lib/localstack/localstack.utils.ts b/src/lib/localstack/localstack.utils.ts index 39656a0..bb2a0ea 100644 --- a/src/lib/localstack/localstack.utils.ts +++ b/src/lib/localstack/localstack.utils.ts @@ -1,4 +1,6 @@ +import { spawn } from "child_process"; import { runCommand } from "../../core/command-runner"; +import { ResponseBuilder } from "../../core/response-builder"; export interface LocalStackCliCheckResult { isAvailable: boolean; @@ -44,6 +46,19 @@ export interface LocalStackStatusResult { isReady?: boolean; } +export interface SnowflakeStatusResult { + isRunning: boolean; + statusOutput?: string; + errorMessage?: string; + isReady?: boolean; +} + +export interface RuntimeStatus { + isRunning: boolean; + isReady?: boolean; + statusOutput?: string; +} + /** * Get LocalStack status information * @returns Promise with status details including running state and raw output @@ -68,6 +83,144 @@ export async function getLocalStackStatus(): Promise { } } +/** + * Get Snowflake emulator status information by checking the Snowflake session endpoint + * @returns Promise with status details including running state and raw output + */ +export async function getSnowflakeEmulatorStatus(): Promise { + try { + const { stdout, stderr, error, exitCode } = await runCommand("curl", [ + "-sS", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-d", + "{}", + "snowflake.localhost.localstack.cloud:4566/session", + ]); + + const output = (stdout || "").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}`, + } + : {}), + }; + } catch (error) { + return { + isRunning: false, + errorMessage: `Failed to get Snowflake emulator status: ${ + 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({ + startArgs, + getStatus, + processLabel, + alreadyRunningMessage, + successTitle, + statusHeading, + timeoutMessage, + envVars, + onReady, +}: { + startArgs: string[]; + getStatus: () => Promise; + processLabel: string; + alreadyRunningMessage: string; + successTitle: string; + statusHeading: string; + timeoutMessage: string; + envVars?: Record; + onReady?: () => Promise | null>; +}) { + const statusCheck = await getStatus(); + if (statusCheck.isReady || statusCheck.isRunning) { + return ResponseBuilder.markdown(alreadyRunningMessage); + } + + const environment = { ...process.env, ...(envVars || {}) } as Record; + if (process.env.LOCALSTACK_AUTH_TOKEN) { + environment.LOCALSTACK_AUTH_TOKEN = process.env.LOCALSTACK_AUTH_TOKEN; + } + + return new Promise((resolve) => { + const child = spawn("localstack", startArgs, { + env: environment, + stdio: ["ignore", "ignore", "pipe"], + }); + + let stderr = ""; + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + let earlyExit = false; + let poll: NodeJS.Timeout; + child.on("error", (err) => { + earlyExit = true; + if (poll) clearInterval(poll); + resolve(ResponseBuilder.markdown(`❌ Failed to start ${processLabel} process: ${err.message}`)); + }); + + child.on("close", (code) => { + if (earlyExit) return; + if (poll) clearInterval(poll); + if (code !== 0) { + resolve( + ResponseBuilder.markdown( + `❌ ${processLabel} process exited unexpectedly with code ${code}.\n\nStderr:\n${stderr}` + ) + ); + } + }); + + const pollInterval = 5000; + const maxWaitTime = 120000; + let timeWaited = 0; + + poll = setInterval(async () => { + timeWaited += pollInterval; + const status = await getStatus(); + if (status.isReady || status.isRunning) { + if (onReady) { + const preflight = await onReady(); + if (preflight) { + clearInterval(poll); + resolve(preflight); + return; + } + } + + clearInterval(poll); + let resultMessage = `${successTitle}\n\n`; + if (envVars) + resultMessage += `✅ Custom environment variables applied: ${Object.keys(envVars).join(", ")}\n`; + if (status.statusOutput) resultMessage += `\n**${statusHeading}:**\n${status.statusOutput}`; + resolve(ResponseBuilder.markdown(resultMessage)); + } else if (timeWaited >= maxWaitTime) { + clearInterval(poll); + resolve(ResponseBuilder.markdown(timeoutMessage)); + } + }, pollInterval); + }); +} + /** * Validate LocalStack CLI availability and return early if not available * This is a helper function for tools that require LocalStack CLI diff --git a/src/tools/localstack-extensions.ts b/src/tools/localstack-extensions.ts index 977899e..ec50885 100644 --- a/src/tools/localstack-extensions.ts +++ b/src/tools/localstack-extensions.ts @@ -7,6 +7,7 @@ import { requireLocalStackCli, requireLocalStackRunning, requireProFeature, + requireAuthToken, } from "../core/preflight"; import { ResponseBuilder } from "../core/response-builder"; import { ProFeature } from "../lib/localstack/license-checker"; @@ -50,9 +51,6 @@ interface MarketplaceExtension { version?: string; } -const AUTH_TOKEN_REQUIRED_MESSAGE = - "LOCALSTACK_AUTH_TOKEN is not set in your environment. LocalStack Extensions require a valid Auth Token. Please set it and try again."; - export default async function localstackExtensions({ action, name, @@ -81,13 +79,6 @@ export default async function localstackExtensions({ } } -function requireAuthTokenForCli() { - if (!process.env.LOCALSTACK_AUTH_TOKEN) { - return ResponseBuilder.error("Auth Token Required", AUTH_TOKEN_REQUIRED_MESSAGE); - } - return null; -} - function cleanOutput(stdout: string, stderr: string) { return { stdout: stripAnsiCodes(stdout || "").trim(), @@ -100,7 +91,7 @@ function combineOutput(stdout: string, stderr: string): string { } async function handleList() { - const authError = requireAuthTokenForCli(); + const authError = requireAuthToken(); if (authError) return authError; const cmd = await runCommand("localstack", ["extensions", "list"], { @@ -128,7 +119,7 @@ async function handleList() { } async function handleInstall(name?: string, source?: string) { - const authError = requireAuthTokenForCli(); + const authError = requireAuthToken(); if (authError) return authError; const hasName = !!name; @@ -198,7 +189,7 @@ async function handleInstall(name?: string, source?: string) { } async function handleUninstall(name?: string) { - const authError = requireAuthTokenForCli(); + const authError = requireAuthToken(); if (authError) return authError; if (!name) { diff --git a/src/tools/localstack-management.ts b/src/tools/localstack-management.ts index f65ed6e..a682b3b 100644 --- a/src/tools/localstack-management.ts +++ b/src/tools/localstack-management.ts @@ -1,15 +1,25 @@ import { z } from "zod"; import { type ToolMetadata, type InferSchema } from "xmcp"; -import { spawn } from "child_process"; -import { getLocalStackStatus } from "../lib/localstack/localstack.utils"; +import { + getLocalStackStatus, + getSnowflakeEmulatorStatus, + startRuntime, +} from "../lib/localstack/localstack.utils"; import { runCommand } from "../core/command-runner"; -import { runPreflights, requireLocalStackCli } from "../core/preflight"; +import { runPreflights, requireLocalStackCli, requireProFeature, requireAuthToken } from "../core/preflight"; import { ResponseBuilder } from "../core/response-builder"; +import { ProFeature } from "../lib/localstack/license-checker"; export const schema = { action: z .enum(["start", "stop", "restart", "status"]) .describe("The LocalStack management action to perform"), + service: z + .enum(["aws", "snowflake"]) + .default("aws") + .describe( + "The LocalStack stack/service to manage. Use 'aws' for the default AWS emulator, or 'snowflake' for the Snowflake emulator." + ), envVars: z .record(z.string()) .optional() @@ -29,20 +39,31 @@ export const metadata: ToolMetadata = { export default async function localstackManagement({ action, + service, envVars, }: InferSchema) { - const preflightError = await runPreflights([requireLocalStackCli()]); + const checks = [requireLocalStackCli()]; + + if (service === "snowflake") { + const authTokenError = requireAuthToken(); + if (authTokenError) return authTokenError; + + // `start` can run when no LocalStack runtime is currently up; validate feature after startup. + if (action !== "start") checks.push(requireProFeature(ProFeature.SNOWFLAKE)); + } + + const preflightError = await runPreflights(checks); if (preflightError) return preflightError; switch (action) { case "start": - return await handleStart({ envVars }); + return await handleStart({ envVars, service }); case "stop": return await handleStop(); case "restart": return await handleRestart(); case "status": - return await handleStatus(); + return await handleStatus({ service }); default: return ResponseBuilder.error( "Unknown action", @@ -52,73 +73,44 @@ export default async function localstackManagement({ } // Handle start action -async function handleStart({ envVars }: { envVars?: Record }) { - const statusCheck = await getLocalStackStatus(); - if (statusCheck.isRunning) { - return ResponseBuilder.markdown( - "⚠️ LocalStack is already running. Use 'restart' if you want to apply new configuration." - ); +async function handleStart({ + envVars, + service, +}: { + envVars?: Record; + service: "aws" | "snowflake"; +}) { + if (service === "snowflake") { + return await handleSnowflakeStart({ envVars }); } - const environment = { ...process.env, ...(envVars || {}) } as Record; - if (process.env.LOCALSTACK_AUTH_TOKEN) { - environment.LOCALSTACK_AUTH_TOKEN = process.env.LOCALSTACK_AUTH_TOKEN; - } + return await startRuntime({ + startArgs: ["start"], + getStatus: getLocalStackStatus, + processLabel: "LocalStack", + alreadyRunningMessage: + "⚠️ LocalStack is already running. Use 'restart' if you want to apply new configuration.", + successTitle: "🚀 LocalStack started successfully!", + statusHeading: "Status", + timeoutMessage: + "❌ LocalStack start timed out after 120 seconds. It may still be starting in the background.", + envVars, + }); +} - return new Promise((resolve) => { - const child = spawn("localstack", ["start"], { - env: environment, - stdio: ["ignore", "ignore", "pipe"], - }); - - let stderr = ""; - child.stderr.on("data", (data) => { - stderr += data.toString(); - }); - - let earlyExit = false; - let poll: NodeJS.Timeout; - child.on("error", (err) => { - earlyExit = true; - if (poll) clearInterval(poll); - resolve(ResponseBuilder.markdown(`❌ Failed to start LocalStack process: ${err.message}`)); - }); - - child.on("close", (code) => { - if (earlyExit) return; - if (poll) clearInterval(poll); - if (code !== 0) { - resolve( - ResponseBuilder.markdown( - `❌ LocalStack process exited unexpectedly with code ${code}.\n\nStderr:\n${stderr}` - ) - ); - } - }); - - const pollInterval = 5000; - const maxWaitTime = 120000; - let timeWaited = 0; - - poll = setInterval(async () => { - timeWaited += pollInterval; - const status = await getLocalStackStatus(); - if (status.isReady || status.isRunning) { - clearInterval(poll); - let resultMessage = "🚀 LocalStack started successfully!\n\n"; - if (envVars) - resultMessage += `✅ Custom environment variables applied: ${Object.keys(envVars).join(", ")}\n`; - resultMessage += `\n**Status:**\n${status.statusOutput}`; - resolve(ResponseBuilder.markdown(resultMessage)); - } else if (timeWaited >= maxWaitTime) { - clearInterval(poll); - resolve( - ResponseBuilder.markdown( - `❌ LocalStack start timed out after ${maxWaitTime / 1000} seconds. It may still be starting in the background.` - ) - ); - } - }, pollInterval); +async function handleSnowflakeStart({ envVars }: { envVars?: Record }) { + return await startRuntime({ + startArgs: ["start", "--stack", "snowflake"], + getStatus: getSnowflakeEmulatorStatus, + processLabel: "Snowflake emulator", + alreadyRunningMessage: + "⚠️ Snowflake emulator is already running. Use 'restart' if you want to apply new configuration.", + 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), }); } @@ -130,16 +122,16 @@ async function handleStop() { if (cmd.stderr.trim()) result += `\nMessages:\n${cmd.stderr}`; const statusResult = await getLocalStackStatus(); - if (!statusResult.isRunning) { + + if (!statusResult.isRunning || statusResult.errorMessage) { result += "\n\n✅ LocalStack has been stopped successfully."; - } else if (statusResult.errorMessage) { - result += "\n\n✅ LocalStack appears to be stopped."; } else { result += "\n\n⚠️ LocalStack may still be running. Check the status manually if needed."; } if (cmd.error) { - result = `❌ Failed to stop LocalStack: ${cmd.error.message}\n\nThis could happen if:\n- LocalStack is not currently running\n- There was an error executing the stop command\n- Permission issues\n\nYou can try checking the LocalStack status first to see if it's running.`; + result = + `❌ Failed to stop LocalStack: ${cmd.error.message}\n\nThis could happen if:\n- LocalStack is not currently running\n- There was an error executing the stop command\n- Permission issues\n\nYou can try checking the LocalStack status first to see if it's running.`; } return ResponseBuilder.markdown(result); @@ -157,8 +149,7 @@ async function handleRestart() { if (statusResult.statusOutput) { result += `\nStatus after restart:\n${statusResult.statusOutput}`; if (statusResult.isRunning) { - result += - "\n\n✅ LocalStack has been restarted successfully and is now running with a fresh state."; + result += "\n\n✅ LocalStack has been restarted successfully and is now running with a fresh state."; } else { result += "\n\n⚠️ LocalStack restart completed but may still be starting up. Check status again in a few moments."; @@ -176,20 +167,36 @@ async function handleRestart() { } // Handle status action -async function handleStatus() { +async function handleStatus({ service }: { service: "aws" | "snowflake" }) { const statusResult = await getLocalStackStatus(); if (statusResult.statusOutput) { let result = "📊 LocalStack Status:\n\n"; result += statusResult.statusOutput; - // Add helpful information based on the status - if (statusResult.isRunning) { - result += "\n\n✅ LocalStack is currently running and ready to accept requests."; - } else { + if (!statusResult.isRunning) { result += "\n\n⚠️ LocalStack is not currently running. Use the start action to start it."; + return ResponseBuilder.markdown(result); + } + + if (service === "snowflake") { + const snowflakeStatus = await getSnowflakeEmulatorStatus(); + + if (snowflakeStatus.isReady || snowflakeStatus.isRunning) { + result += "\n\n✅ LocalStack is running and Snowflake emulator health check passed."; + } else { + const diagnostics = [snowflakeStatus.statusOutput, snowflakeStatus.errorMessage] + .filter(Boolean) + .join(" | "); + result += + "\n\n⚠️ LocalStack is running, but Snowflake emulator health check did not pass." + + (diagnostics ? ` (${diagnostics})` : ""); + } + return ResponseBuilder.markdown(result); } + // Default aws service status check + result += "\n\n✅ LocalStack is currently running and ready to accept requests."; return ResponseBuilder.markdown(result); } else { const result = `❌ ${statusResult.errorMessage}