Skip to content

Commit bdf5c25

Browse files
authored
add cloudformation support in deployer (#4)
1 parent 3b5a5a4 commit bdf5c25

5 files changed

Lines changed: 913 additions & 754 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"jest": "^30.1.3",
2525
"prettier": "^3.6.2",
2626
"swc-loader": "^0.2.6",
27-
"ts-jest": "^29.4.1"
27+
"ts-jest": "^29.4.1",
28+
"typescript": "^5.9.2"
2829
},
2930
"main": "./dist/stdio.js",
3031
"files": [

src/lib/deployment/deployment-utils.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export interface DependencyCheckResult {
99
errorMessage?: string;
1010
}
1111

12-
export type ProjectType = "cdk" | "terraform" | "ambiguous" | "unknown";
12+
export type ProjectType = "cdk" | "terraform" | "cloudformation" | "ambiguous" | "unknown";
1313

1414
/**
1515
* Check if the required deployment tool (cdklocal or tflocal) is available in the system PATH
@@ -84,15 +84,24 @@ export async function inferProjectType(directory: string): Promise<ProjectType>
8484
(file) => file.endsWith(".tf") || file.endsWith(".tf.json")
8585
);
8686

87+
const hasCloudFormationTemplates = files.some(
88+
(file) => file.endsWith(".yaml") || file.endsWith(".yml")
89+
);
90+
8791
const isCdk = hasCdkJson || hasCdkFiles;
8892
const isTerraform = hasTerraformFiles;
93+
const isCloudFormation = hasCloudFormationTemplates;
8994

90-
if (isCdk && isTerraform) {
95+
if (
96+
[isCdk, isTerraform, isCloudFormation].filter(Boolean).length > 1
97+
) {
9198
return "ambiguous";
9299
} else if (isCdk) {
93100
return "cdk";
94101
} else if (isTerraform) {
95102
return "terraform";
103+
} else if (isCloudFormation) {
104+
return "cloudformation";
96105
} else {
97106
return "unknown";
98107
}

src/lib/docker/docker.client.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,32 @@ export class DockerApiClient {
3131
throw new Error("Could not find a running LocalStack container named 'localstack-main'.");
3232
}
3333

34-
async executeInContainer(containerId: string, command: string[]): Promise<ContainerExecResult> {
34+
async executeInContainer(
35+
containerId: string,
36+
command: string[],
37+
stdin?: string
38+
): Promise<ContainerExecResult> {
3539
const container = this.docker.getContainer(containerId);
3640

3741
const exec = await container.exec({
3842
Cmd: command,
3943
AttachStdout: true,
4044
AttachStderr: true,
45+
...(stdin ? { AttachStdin: true } : {}),
4146
});
4247

43-
const stream: NodeJS.ReadableStream = await new Promise((resolve, reject) => {
44-
exec.start({ hijack: true, stdin: false } as any, (err: any, stream: any) => {
48+
const stream: NodeJS.ReadWriteStream = await new Promise((resolve, reject) => {
49+
exec.start({ hijack: true, stdin: Boolean(stdin) } as any, (err: any, stream: any) => {
4550
if (err) return reject(err);
46-
resolve(stream as NodeJS.ReadableStream);
51+
resolve(stream as NodeJS.ReadWriteStream);
4752
});
4853
});
4954

55+
if (stdin) {
56+
stream.write(stdin);
57+
stream.end();
58+
}
59+
5060
const stdoutStream = new PassThrough();
5161
const stderrStream = new PassThrough();
5262
const stdoutChunks: Buffer[] = [];

src/tools/localstack-deployer.ts

Lines changed: 182 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { z } from "zod";
22
import { type ToolMetadata, type InferSchema } from "xmcp";
33
import { runCommand, stripAnsiCodes } from "../core/command-runner";
44
import path from "path";
5+
import fs from "fs";
56
import { ensureLocalStackCli } from "../lib/localstack/localstack.utils";
7+
import { runPreflights, requireLocalStackRunning } from "../core/preflight";
8+
import { DockerApiClient } from "../lib/docker/docker.client";
69
import {
710
checkDependencies,
811
inferProjectType,
@@ -18,18 +21,19 @@ import { ResponseBuilder } from "../core/response-builder";
1821
// Define the schema for tool parameters
1922
export const schema = {
2023
action: z
21-
.enum(["deploy", "destroy"])
24+
.enum(["deploy", "destroy", "create-stack", "delete-stack"])
2225
.describe(
23-
"The deployment action to perform: 'deploy' to create/update resources, or 'destroy' to remove them."
26+
"The action to perform: 'deploy'/'destroy' for CDK/Terraform, or 'create-stack'/'delete-stack' for CloudFormation."
2427
),
2528
projectType: z
26-
.enum(["cdk", "terraform", "auto"])
29+
.enum(["cdk", "terraform", "auto"])
2730
.default("auto")
2831
.describe(
2932
"The type of project. 'auto' (default) infers from files. Specify 'cdk' or 'terraform' to override."
3033
),
3134
directory: z
3235
.string()
36+
.optional()
3337
.describe(
3438
"The required path to the project directory containing your infrastructure-as-code files."
3539
),
@@ -39,6 +43,14 @@ export const schema = {
3943
.describe(
4044
"Key-value pairs for parameterization. Used for Terraform variables (-var) or CDK context (-c)."
4145
),
46+
stackName: z
47+
.string()
48+
.optional()
49+
.describe("The name of the CloudFormation stack. Required for 'create-stack' and 'delete-stack'."),
50+
templatePath: z
51+
.string()
52+
.optional()
53+
.describe("The local file path to the CloudFormation template. Required for 'create-stack' if not discoverable from 'directory'."),
4254
};
4355

4456
// Define tool metadata
@@ -59,17 +71,174 @@ export default async function localstackDeployer({
5971
projectType,
6072
directory,
6173
variables,
74+
stackName,
75+
templatePath,
6276
}: InferSchema<typeof schema>) {
63-
// Check if LocalStack CLI is available first
64-
const cliError = await ensureLocalStackCli();
65-
if (cliError) return cliError;
77+
if (action === "deploy" || action === "destroy") {
78+
const cliError = await ensureLocalStackCli();
79+
if (cliError) return cliError;
80+
} else {
81+
const preflightError = await runPreflights([requireLocalStackRunning()]);
82+
if (preflightError) return preflightError;
83+
}
84+
85+
if (action === "create-stack") {
86+
if (!stackName) {
87+
return ResponseBuilder.error(
88+
"Missing Parameter",
89+
"The parameter 'stackName' is required for action 'create-stack'."
90+
);
91+
}
92+
let resolvedTemplatePath = templatePath;
93+
if (!resolvedTemplatePath) {
94+
if (!directory) {
95+
return ResponseBuilder.error(
96+
"Missing Parameter",
97+
"Provide 'templatePath' or a 'directory' containing a single .yaml/.yml CloudFormation template."
98+
);
99+
}
100+
try {
101+
const files = await fs.promises.readdir(directory);
102+
const yamlFiles = files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
103+
if (yamlFiles.length === 0) {
104+
return ResponseBuilder.error(
105+
"Template Not Found",
106+
`No .yaml/.yml template found in directory '${directory}'.`
107+
);
108+
}
109+
if (yamlFiles.length > 1) {
110+
return ResponseBuilder.error(
111+
"Multiple Templates Found",
112+
`Multiple .yaml/.yml templates found in '${directory}'. Please specify 'templatePath'.\n\nFound:\n${yamlFiles
113+
.map((f) => `- ${f}`)
114+
.join("\n")}`
115+
);
116+
}
117+
resolvedTemplatePath = path.join(directory, yamlFiles[0]);
118+
} catch (err) {
119+
const message = err instanceof Error ? err.message : String(err);
120+
return ResponseBuilder.error(
121+
"Directory Read Error",
122+
`Failed to read directory '${directory}'. ${message}`
123+
);
124+
}
125+
}
126+
127+
let templateBody = "";
128+
try {
129+
templateBody = await fs.promises.readFile(resolvedTemplatePath, "utf-8");
130+
} catch (err) {
131+
const message = err instanceof Error ? err.message : String(err);
132+
return ResponseBuilder.error(
133+
"Template Read Error",
134+
`Failed to read template file at '${resolvedTemplatePath}'. ${message}`
135+
);
136+
}
137+
138+
try {
139+
const dockerClient = new DockerApiClient();
140+
const containerId = await dockerClient.findLocalStackContainer();
141+
142+
const tempPath = `/tmp/ls-cfn-${Date.now()}.yaml`;
143+
const writeRes = await dockerClient.executeInContainer(
144+
containerId,
145+
["/bin/sh", "-c", `cat > ${tempPath}`],
146+
templateBody
147+
);
148+
if (writeRes.exitCode !== 0) {
149+
return ResponseBuilder.error(
150+
"Template Upload Failed",
151+
writeRes.stderr || `Failed to write template to ${tempPath}`
152+
);
153+
}
154+
155+
const createCmd = [
156+
"awslocal",
157+
"cloudformation",
158+
"create-stack",
159+
"--stack-name",
160+
stackName,
161+
"--template-body",
162+
`file://${tempPath}`,
163+
];
164+
const createRes = await dockerClient.executeInContainer(containerId, createCmd);
165+
166+
try {
167+
await dockerClient.executeInContainer(containerId, ["/bin/sh", "-c", `rm -f ${tempPath}`]);
168+
} catch {}
169+
170+
if (createRes.exitCode === 0) {
171+
return ResponseBuilder.markdown(
172+
(createRes.stdout && createRes.stdout.trim())
173+
? createRes.stdout
174+
: `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.`
175+
);
176+
}
177+
return ResponseBuilder.error(
178+
"CloudFormation create-stack failed",
179+
createRes.stderr || "Unknown error"
180+
);
181+
} catch (error) {
182+
const errorMessage = error instanceof Error ? error.message : String(error);
183+
return ResponseBuilder.error(
184+
"CloudFormation Error",
185+
`An unexpected error occurred: ${errorMessage}`
186+
);
187+
}
188+
}
189+
190+
if (action === "delete-stack") {
191+
if (!stackName) {
192+
return ResponseBuilder.error(
193+
"Missing Parameter",
194+
"The parameter 'stackName' is required for action 'delete-stack'."
195+
);
196+
}
197+
try {
198+
const dockerClient = new DockerApiClient();
199+
const containerId = await dockerClient.findLocalStackContainer();
200+
const command = [
201+
"awslocal",
202+
"cloudformation",
203+
"delete-stack",
204+
"--stack-name",
205+
stackName,
206+
];
207+
const result = await dockerClient.executeInContainer(containerId, command);
208+
if (result.exitCode === 0) {
209+
return ResponseBuilder.markdown(
210+
(result.stdout && result.stdout.trim())
211+
? result.stdout
212+
: `Stack '${stackName}' deletion initiated.\n\nTip: Use the 'localstack-aws-client' tool with 'cloudformation describe-stacks' to monitor deletion status until DELETE_COMPLETE.`
213+
);
214+
}
215+
return ResponseBuilder.error(
216+
"CloudFormation delete-stack failed",
217+
result.stderr || "Unknown error"
218+
);
219+
} catch (error) {
220+
const errorMessage = error instanceof Error ? error.message : String(error);
221+
return ResponseBuilder.error(
222+
"CloudFormation Error",
223+
`An unexpected error occurred: ${errorMessage}`
224+
);
225+
}
226+
}
66227

67228
let resolvedProjectType: "cdk" | "terraform";
68229

69230
try {
231+
if (!directory) {
232+
return ResponseBuilder.error(
233+
"Missing Parameter",
234+
"The parameter 'directory' is required for actions 'deploy' and 'destroy'."
235+
);
236+
}
237+
const nonNullDirectory = directory as string;
238+
70239
// Step 1: Project Type Resolution
71240
if (projectType === "auto") {
72-
const inferredType = await inferProjectType(directory);
241+
const inferredType = await inferProjectType(nonNullDirectory);
73242

74243
if (inferredType === "ambiguous") {
75244
return ResponseBuilder.error(
@@ -121,7 +290,12 @@ Please review your variables and ensure they don't contain shell metacharacters
121290
}
122291

123292
// Execute Commands Based on Project Type and Action
124-
return await executeDeploymentCommands(resolvedProjectType, action, directory, variables);
293+
return await executeDeploymentCommands(
294+
resolvedProjectType,
295+
action,
296+
nonNullDirectory,
297+
variables
298+
);
125299
} catch (error) {
126300
const errorMessage = error instanceof Error ? error.message : String(error);
127301
return ResponseBuilder.error(

0 commit comments

Comments
 (0)