diff --git a/README.md b/README.md index b1840fa..9bbd1de 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/ This server eliminates custom scripts and manual LocalStack management with direct access to: - Start, stop, restart, and monitor LocalStack for AWS container status with built-in auth. -- Deploy CDK and Terraform projects with automatic configuration detection. +- Deploy CDK, Terraform, and SAM projects with automatic configuration detection. - Search LocalStack documentation for guides, API references, and configuration details. - Parse logs, catch errors, and auto-generate IAM policies from violations. (requires active license) - Inject chaos faults and network effects into LocalStack to test system resilience. (requires active license) @@ -23,7 +23,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 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-deployer`](./src/tools/localstack-deployer.ts) | Handles infrastructure deployment to LocalStack for AWS environments | - Automatically run CDK, Terraform, and SAM 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), [`tflocal`](https://github.com/localstack/terraform-local), or [`samlocal`](https://github.com/localstack/aws-sam-cli-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 | | [`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 | @@ -42,7 +42,7 @@ For other MCP Clients, refer to the [configuration guide](#configuration). ### Prerequisites - [LocalStack CLI](https://docs.localstack.cloud/getting-started/installation/#localstack-cli) and Docker installed in your system path -- [`cdklocal`](https://github.com/localstack/aws-cdk-local) or [`tflocal`](https://github.com/localstack/terraform-local) installed in your system path for running infrastructure deployment tooling +- [`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 for running infrastructure deployment tooling - A [valid LocalStack Auth Token](https://docs.localstack.cloud/aws/getting-started/auth-token/) to enable Pro services, IAM Policy Analyzer, Cloud Pods, Chaos Injector, and Extensions tools (**optional**) - [Node.js v22.x](https://nodejs.org/en/download/) installed in your system path diff --git a/src/core/analytics.ts b/src/core/analytics.ts index d740ed5..c6c8aa0 100644 --- a/src/core/analytics.ts +++ b/src/core/analytics.ts @@ -19,7 +19,16 @@ export const TOOL_ARG_ALLOWLIST: Record = { "localstack-aws-client": ["command"], "localstack-chaos-injector": ["action", "rules_count", "latency_ms"], "localstack-cloud-pods": ["action", "pod_name"], - "localstack-deployer": ["action", "projectType", "directory", "stackName", "templatePath"], + "localstack-deployer": [ + "action", + "projectType", + "directory", + "stackName", + "templatePath", + "s3Bucket", + "resolveS3", + "saveParams", + ], "localstack-docs": ["query", "limit"], "localstack-extensions": ["action", "name", "source"], "localstack-iam-policy-analyzer": ["action", "mode"], diff --git a/src/lib/deployment/deployment-utils.test.ts b/src/lib/deployment/deployment-utils.test.ts index dacbcd7..d1da989 100644 --- a/src/lib/deployment/deployment-utils.test.ts +++ b/src/lib/deployment/deployment-utils.test.ts @@ -1,4 +1,12 @@ -import { parseCdkOutputs, parseTerraformOutputs, validateVariables } from "./deployment-utils"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + inferProjectType, + parseCdkOutputs, + parseTerraformOutputs, + validateVariables, +} from "./deployment-utils"; describe("deployment-utils", () => { describe("validateVariables", () => { @@ -35,4 +43,25 @@ describe("deployment-utils", () => { expect(result).toContain("No outputs defined"); }); }); + + describe("inferProjectType", () => { + it("detects SAM projects via samconfig.toml", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ls-mcp-samcfg-")); + fs.writeFileSync(path.join(dir, "samconfig.toml"), "version = 0.1"); + + await expect(inferProjectType(dir)).resolves.toBe("sam"); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("detects SAM projects via template with AWS::Serverless resources", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ls-mcp-samtpl-")); + fs.writeFileSync( + path.join(dir, "template.yaml"), + "Resources:\n MyFunction:\n Type: AWS::Serverless::Function\n" + ); + + await expect(inferProjectType(dir)).resolves.toBe("sam"); + fs.rmSync(dir, { recursive: true, force: true }); + }); + }); }); diff --git a/src/lib/deployment/deployment-utils.ts b/src/lib/deployment/deployment-utils.ts index c91a516..7e7b0df 100644 --- a/src/lib/deployment/deployment-utils.ts +++ b/src/lib/deployment/deployment-utils.ts @@ -9,7 +9,13 @@ export interface DependencyCheckResult { errorMessage?: string; } -export type ProjectType = "cdk" | "terraform" | "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 @@ -17,9 +23,10 @@ export type ProjectType = "cdk" | "terraform" | "cloudformation" | "ambiguous" | * @returns Promise with availability status and tool information */ export async function checkDependencies( - projectType: "cdk" | "terraform" + projectType: "cdk" | "terraform" | "sam" ): Promise { - const tool = projectType === "cdk" ? "cdklocal" : "tflocal"; + const tool = + projectType === "cdk" ? "cdklocal" : projectType === "terraform" ? "tflocal" : "samlocal"; try { const { stdout, error } = await runCommand(tool, ["--version"], { timeout: 10000 }); @@ -42,7 +49,8 @@ Installation: npm install -g aws-cdk-local aws-cdk After installation, make sure the 'cdklocal' command is available in your PATH.` - : `❌ tflocal is not installed or not available in PATH. + : projectType === "terraform" + ? `❌ tflocal is not installed or not available in PATH. Please install terraform-local by following the official documentation: https://github.com/localstack/terraform-local @@ -50,7 +58,16 @@ https://github.com/localstack/terraform-local Installation: pip install terraform-local -After installation, make sure the 'tflocal' command is available in your PATH.`; +After installation, make sure the 'tflocal' command is available in your PATH.` + : `❌ samlocal is not installed or not available in PATH. + +Please install aws-sam-cli-local by following the official documentation: +https://github.com/localstack/aws-sam-cli-local + +Installation: +pip install aws-sam-cli-local + +After installation, make sure the 'samlocal' command is available in your PATH.`; return { isAvailable: false, @@ -84,22 +101,37 @@ export async function inferProjectType(directory: string): Promise (file) => file.endsWith(".tf") || file.endsWith(".tf.json") ); - const hasCloudFormationTemplates = files.some( - (file) => file.endsWith(".yaml") || file.endsWith(".yml") - ); + const hasTemplateYaml = files.includes("template.yaml") || files.includes("template.yml"); + const hasSamConfig = files.includes("samconfig.toml"); + + let hasServerlessResources = false; + 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"); + hasServerlessResources = /AWS::Serverless::[A-Za-z]+/.test(templateContent); + } catch { + hasServerlessResources = false; + } + } + + const hasCloudFormationTemplates = files.some((file) => file.endsWith(".yaml") || file.endsWith(".yml")); const isCdk = hasCdkJson || hasCdkFiles; const isTerraform = hasTerraformFiles; - const isCloudFormation = hasCloudFormationTemplates; + const isSam = hasSamConfig || hasServerlessResources; + const isCloudFormation = hasCloudFormationTemplates && !isSam; if ( - [isCdk, isTerraform, isCloudFormation].filter(Boolean).length > 1 + [isCdk, isTerraform, isSam, isCloudFormation].filter(Boolean).length > 1 ) { return "ambiguous"; } else if (isCdk) { return "cdk"; } else if (isTerraform) { return "terraform"; + } else if (isSam) { + return "sam"; } else if (isCloudFormation) { return "cloudformation"; } else { diff --git a/src/tools/localstack-deployer.ts b/src/tools/localstack-deployer.ts index a38db5e..f287fa9 100644 --- a/src/tools/localstack-deployer.ts +++ b/src/tools/localstack-deployer.ts @@ -27,10 +27,10 @@ export const schema = { "The action to perform: 'deploy'/'destroy' for CDK/Terraform, or 'create-stack'/'delete-stack' for CloudFormation." ), projectType: z - .enum(["cdk", "terraform", "auto"]) + .enum(["cdk", "terraform", "sam", "auto"]) .default("auto") .describe( - "The type of project. 'auto' (default) infers from files. Specify 'cdk' or 'terraform' to override." + "The type of project. 'auto' (default) infers from files. Specify 'cdk', 'terraform', 'cloudformation', or 'sam' to override." ), directory: z .string() @@ -47,17 +47,35 @@ export const schema = { stackName: z .string() .optional() - .describe("The name of the CloudFormation stack. Required for 'create-stack' and 'delete-stack'."), + .describe( + "The stack name used by CloudFormation and SAM. Required for 'create-stack'/'delete-stack' actions, and optional for SAM deploy/destroy (defaults can be inferred)." + ), templatePath: z .string() .optional() - .describe("The local file path to the CloudFormation template. Required for 'create-stack' if not discoverable from 'directory'."), + .describe( + "The local template file path used by CloudFormation and SAM. Required for 'create-stack' if not discoverable from 'directory', and optional for SAM as a template override." + ), + s3Bucket: z + .string() + .optional() + .describe("S3 bucket name used by SAM deployments. If omitted, SAM can use --resolve-s3."), + resolveS3: z + .boolean() + .optional() + .describe("For SAM deployments, whether to use --resolve-s3 when no s3Bucket is provided."), + saveParams: z + .boolean() + .optional() + .describe( + "For SAM deployments, whether to persist resolved parameters to samconfig.toml using --save-params." + ), }; // Define tool metadata export const metadata: ToolMetadata = { name: "localstack-deployer", - description: "Deploys or destroys AWS infrastructure on LocalStack using CDK or Terraform.", + description: "Deploys or destroys AWS infrastructure on LocalStack using CDK, Terraform, or SAM.", annotations: { title: "LocalStack Deployer", readOnlyHint: false, @@ -74,12 +92,17 @@ export default async function localstackDeployer({ variables, stackName, templatePath, + s3Bucket, + resolveS3, + saveParams, }: InferSchema) { return withToolAnalytics( "localstack-deployer", - { action, projectType, directory, stackName, templatePath, variables }, + { action, projectType, directory, stackName, templatePath, variables, s3Bucket, resolveS3, saveParams }, async () => { if (action === "deploy" || action === "destroy") { + const preflightError = await runPreflights([requireLocalStackRunning()]); + if (preflightError) return preflightError; const cliError = await ensureLocalStackCli(); if (cliError) return cliError; } else { @@ -230,7 +253,7 @@ export default async function localstackDeployer({ } } - let resolvedProjectType: "cdk" | "terraform"; + let resolvedProjectType: "cdk" | "terraform" | "sam"; try { if (!directory) { @@ -248,10 +271,11 @@ export default async function localstackDeployer({ if (inferredType === "ambiguous") { return ResponseBuilder.error( "Ambiguous Project Type", - `The directory "${directory}" contains both CDK and Terraform files. Please specify the project type explicitly: + `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: 'terraform'\` to deploy as a Terraform project +- Use \`projectType: 'sam'\` to deploy as a SAM project` ); } @@ -263,14 +287,15 @@ export default async function localstackDeployer({ Expected files: - **CDK**: \`cdk.json\`, \`app.py\`, \`app.js\`, or \`app.ts\` - **Terraform**: \`*.tf\` or \`*.tf.json\` 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"; + resolvedProjectType = inferredType as "cdk" | "terraform" | "sam"; } else { - resolvedProjectType = projectType as "cdk" | "terraform"; + resolvedProjectType = projectType as "cdk" | "terraform" | "sam"; } // Check Dependencies @@ -299,7 +324,8 @@ Please review your variables and ensure they don't contain shell metacharacters resolvedProjectType, action, nonNullDirectory, - variables + variables, + { stackName, templatePath, s3Bucket, resolveS3, saveParams } ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -318,17 +344,26 @@ Please check the directory path and ensure all prerequisites are met.` * Execute the deployment commands based on project type and action */ async function executeDeploymentCommands( - projectType: "cdk" | "terraform", + projectType: "cdk" | "terraform" | "sam", action: "deploy" | "destroy", directory: string, - variables?: Record + variables?: Record, + samOptions?: { + stackName?: string; + templatePath?: string; + s3Bucket?: string; + resolveS3?: boolean; + saveParams?: boolean; + } ) { const absoluteDirectory = path.resolve(directory); const baseTitle = `🚀 LocalStack ${projectType.toUpperCase()} ${action === "deploy" ? "Deployment" : "Destruction"}`; const events = projectType === "terraform" ? await executeTerraformCommands(action, absoluteDirectory, variables) - : await executeCdkCommands(action, absoluteDirectory, variables); + : projectType === "sam" + ? await executeSamCommands(action, absoluteDirectory, variables, samOptions) + : await executeCdkCommands(action, absoluteDirectory, variables); const report = formatDeploymentReport(baseTitle, events); return ResponseBuilder.markdown(report); } @@ -404,6 +439,148 @@ async function executeTerraformCommands( return events; } +interface SamConfigDefaults { + stackName?: string; + s3Bucket?: string; + resolveS3?: boolean; + saveParams?: boolean; + templatePath?: string; +} + +async function loadSamConfigDefaults(directory: string): Promise { + const configPath = path.join(directory, "samconfig.toml"); + try { + const content = await fs.promises.readFile(configPath, "utf-8"); + + const readString = (key: string): string | undefined => { + const match = content.match(new RegExp(`${key}\\s*=\\s*"([^"]+)"`)); + return match?.[1]; + }; + const readBoolean = (key: string): boolean | undefined => { + const match = content.match(new RegExp(`${key}\\s*=\\s*(true|false)`)); + return match ? match[1] === "true" : undefined; + }; + + return { + stackName: readString("stack_name"), + s3Bucket: readString("s3_bucket"), + resolveS3: readBoolean("resolve_s3"), + saveParams: readBoolean("save_params"), + templatePath: readString("template_file"), + }; + } catch { + return {}; + } +} + +async function executeSamCommands( + action: "deploy" | "destroy", + directory: string, + variables?: Record, + samOptions?: { + stackName?: string; + templatePath?: string; + s3Bucket?: string; + resolveS3?: boolean; + saveParams?: boolean; + } +): Promise { + const events: DeploymentEvent[] = []; + const baseCommand = "samlocal"; + const defaults = await loadSamConfigDefaults(directory); + const inferredStackName = `${path.basename(directory).replace(/[^a-zA-Z0-9-]/g, "-")}-stack`; + + const resolvedStackName = samOptions?.stackName || defaults.stackName || inferredStackName; + const resolvedRegion = "us-east-1"; // Not configurable at the moment + const resolvedTemplatePath = samOptions?.templatePath || defaults.templatePath; + const resolvedS3Bucket = samOptions?.s3Bucket || defaults.s3Bucket; + const shouldResolveS3 = samOptions?.resolveS3 ?? defaults.resolveS3 ?? !resolvedS3Bucket; + const shouldSaveParams = samOptions?.saveParams ?? defaults.saveParams ?? false; + + if (action === "deploy") { + events.push({ type: "header", title: "🏗️ Building SAM Application", content: "" }); + const buildArgs = ["build", "--cached"]; + if (resolvedTemplatePath) { + buildArgs.push("--template-file", resolvedTemplatePath); + } + events.push({ type: "command", content: `${baseCommand} ${buildArgs.join(" ")}` }); + const buildRes = await runCommand(baseCommand, buildArgs, { cwd: directory }); + events.push({ type: "output", content: stripAnsiCodes(buildRes.stdout) }); + if (buildRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(buildRes.stderr) }); + if (buildRes.error) { + events.push({ + type: "error", + title: "Error during `samlocal build`", + content: buildRes.error.message, + }); + return events; + } + + events.push({ type: "header", title: "🚀 Deploying SAM Application", content: "" }); + const deployArgs = [ + "deploy", + "--no-confirm-changeset", + "--no-fail-on-empty-changeset", + "--region", + resolvedRegion, + "--stack-name", + resolvedStackName, + "--capabilities", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM", + ]; + if (resolvedTemplatePath) { + deployArgs.push("--template-file", resolvedTemplatePath); + } + if (resolvedS3Bucket) { + deployArgs.push("--s3-bucket", resolvedS3Bucket); + } else if (shouldResolveS3) { + deployArgs.push("--resolve-s3"); + } + if (shouldSaveParams) { + deployArgs.push("--save-params"); + } + if (variables && Object.keys(variables).length > 0) { + deployArgs.push("--parameter-overrides", ...Object.entries(variables).map(([k, v]) => `${k}=${v}`)); + } + + events.push({ type: "command", content: `${baseCommand} ${deployArgs.join(" ")}` }); + const deployRes = await runCommand(baseCommand, deployArgs, { + cwd: directory, + env: { ...process.env, CI: "true" }, + }); + events.push({ type: "output", content: stripAnsiCodes(deployRes.stdout) }); + if (deployRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(deployRes.stderr) }); + if (deployRes.error) { + events.push({ + type: "error", + title: "Error during `samlocal deploy`", + content: deployRes.error.message, + }); + return events; + } + 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]; + events.push({ type: "command", content: `${baseCommand} ${deleteArgs.join(" ")}` }); + const deleteRes = await runCommand(baseCommand, deleteArgs, { cwd: directory }); + events.push({ type: "output", content: stripAnsiCodes(deleteRes.stdout) }); + if (deleteRes.stderr) events.push({ type: "warning", content: stripAnsiCodes(deleteRes.stderr) }); + if (deleteRes.error) { + events.push({ + type: "error", + title: "Error during `samlocal delete`", + content: deleteRes.error.message, + }); + return events; + } + events.push({ type: "success", content: `SAM application in ${directory} has been deleted.` }); + } + + return events; +} + /** * Execute CDK commands */