Skip to content

Commit 3253ab0

Browse files
committed
feat(project): implement project deploy
Deploys a project by synthesizing it, bootstrapping each target environment, then deploying every stack. Synthesis reuses build's code path so what deploys is what was just synthesized, and build's dependency check runs before anything touches AWS. Stack environments come from agentcore/aws-targets.json, which create() scaffolds empty. An empty list is an error naming the file to fill in rather than an account resolved from the active credentials, which would let deploy guess where the user's infrastructure belongs. Bootstrap is idempotent and no-ops quickly on a current environment, so it runs every deploy instead of probing CloudFormation first; --skip-bootstrap opts out. Targets sharing an environment are bootstrapped once. Bootstrap and deploy drive @aws-cdk/toolkit-lib in-process rather than shelling out to npx cdk, so progress arrives as structured messages and failures as typed errors instead of scraped stdout. src/io/cdk.ts adapts the toolkit's push-based IIoHost to the generator the manager pulls from, and is injectable so tests exercise deploy without reaching AWS. Deploy reads the cdk.out assembly synthesis just wrote instead of re-synthesizing, which both avoids a second synth and makes "deploy exactly what was synthesized" structural. A deploy runs for minutes, so those messages stream to stderr as they arrive: ProjectEvent gains an output variant carrying the toolkit's own wording, with debug and trace levels left in the debug log rather than on screen.
1 parent 1976766 commit 3253ab0

14 files changed

Lines changed: 1892 additions & 91 deletions

File tree

bun.lock

Lines changed: 1157 additions & 68 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"typescript": "^5"
5050
},
5151
"dependencies": {
52+
"@aws-cdk/toolkit-lib": "^1.38.2",
5253
"@aws-sdk/client-bedrock-agentcore": "^3.1092.0",
5354
"@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0",
5455
"@aws-sdk/client-cloudwatch-logs": "^3.1092.0",

src/core/project/manager.test.ts

Lines changed: 298 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type ProjectEvent,
1212
} from "../../handlers/project/types";
1313
import { createSilentLogger } from "../../testing";
14+
import type { CdkEvent, CdkOperation, CdkRunOptions } from "../../io";
1415

1516
const originalCwd = process.cwd();
1617
const tempDirectories: string[] = [];
@@ -254,10 +255,22 @@ describe("FsProjectManager.build", () => {
254255

255256
const events = await drain(subject.build(project));
256257

258+
const cdkDir = join(directory, "example", "agentcore", "cdk");
257259
expect(commands).toEqual([
258260
{
259-
command: ["npm", "run", "cdk", "--", "synth", "--quiet"],
260-
cwd: join(directory, "example", "agentcore", "cdk"),
261+
// --output pins the assembly where deploy looks for it, so a project that
262+
// sets cdk.json's `output` cannot send synth somewhere deploy never reads.
263+
command: [
264+
"npm",
265+
"run",
266+
"cdk",
267+
"--",
268+
"synth",
269+
"--quiet",
270+
"--output",
271+
join(cdkDir, "cdk.out"),
272+
],
273+
cwd: cdkDir,
261274
},
262275
]);
263276
expect(events).toEqual([{ message: "Synthesizing CloudFormation templates" }]);
@@ -301,6 +314,289 @@ describe("FsProjectManager.build", () => {
301314
});
302315
});
303316

317+
describe("FsProjectManager.deploy", () => {
318+
// Synth is pinned to the directory deploy reads, so both name it the same way.
319+
function synthCommand(directory: string): string[] {
320+
return [
321+
"npm",
322+
"run",
323+
"cdk",
324+
"--",
325+
"synth",
326+
"--quiet",
327+
"--output",
328+
assemblyDirectory(directory),
329+
];
330+
}
331+
const REGION = "us-west-2";
332+
333+
type RecordedCommand = { command: string[]; cwd: string };
334+
type RecordedCdkRun = { operation: CdkOperation; options: CdkRunOptions };
335+
336+
// Like manager(), but also records the CDK operations the manager drives, and lets
337+
// a test supply the events one of them emits (and the failure it ends with).
338+
function deployManager(
339+
onCdk?: (operation: CdkOperation, emit: (event: CdkEvent) => void) => void,
340+
): { manager: FsProjectManager; commands: RecordedCommand[]; runs: RecordedCdkRun[] } {
341+
const commands: RecordedCommand[] = [];
342+
const runs: RecordedCdkRun[] = [];
343+
return {
344+
manager: new FsProjectManager({
345+
logger: createSilentLogger(),
346+
runner: async (command, { cwd }) => {
347+
commands.push({ command, cwd });
348+
},
349+
cdk: async function* (operation, options) {
350+
runs.push({ operation, options });
351+
const events: CdkEvent[] = [];
352+
let failure: unknown;
353+
try {
354+
onCdk?.(operation, (event) => events.push(event));
355+
} catch (error) {
356+
failure = error;
357+
}
358+
// Emit before throwing, as the real runner does: the output explaining a
359+
// failure is only useful if the consumer sees it.
360+
yield* events;
361+
if (failure) throw failure;
362+
},
363+
checkTool: async () => {},
364+
}),
365+
commands,
366+
runs,
367+
};
368+
}
369+
370+
// The assembly directory every operation reads, which build synthesized into.
371+
function assemblyDirectory(directory: string): string {
372+
return join(directory, "example", "agentcore", "cdk", "cdk.out");
373+
}
374+
375+
// deploy() builds first, so the CDK app's node_modules must exist; create() with
376+
// skipInstall never produces them. Targets overwrite the empty list create()
377+
// scaffolds; null leaves that empty list in place, and a string is written verbatim
378+
// so a test can supply invalid JSON.
379+
async function scaffolded(
380+
subject: FsProjectManager,
381+
directory: string,
382+
targets: unknown = [{ name: "default", account: "111122223333", region: "us-east-1" }],
383+
): Promise<Project> {
384+
const { project } = await runCreate(subject, {
385+
name: "example",
386+
template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON,
387+
skipInstall: true,
388+
skipGit: true,
389+
});
390+
await mkdir(join(directory, "example", "agentcore", "cdk", "node_modules"), {
391+
recursive: true,
392+
});
393+
if (targets !== null) {
394+
await writeFile(
395+
join(directory, "example", "agentcore", "aws-targets.json"),
396+
typeof targets === "string" ? targets : JSON.stringify(targets),
397+
);
398+
}
399+
return project;
400+
}
401+
402+
async function drain(generator: AsyncGenerator<ProjectEvent, void>): Promise<ProjectEvent[]> {
403+
const events: ProjectEvent[] = [];
404+
for await (const event of generator) events.push(event);
405+
return events;
406+
}
407+
408+
test("synthesizes, bootstraps the target environment, then deploys every stack", async () => {
409+
const directory = await inTempDirectory();
410+
const { manager: subject, commands, runs } = deployManager();
411+
const project = await scaffolded(subject, directory);
412+
commands.length = 0; // discard create()'s commands
413+
const cdkDir = join(directory, "example", "agentcore", "cdk");
414+
const options = { assemblyDirectory: assemblyDirectory(directory), region: REGION };
415+
416+
const events = await drain(subject.deploy(project, { region: REGION, skipBootstrap: false }));
417+
418+
// Only synthesis shells out; everything that reaches AWS goes through the toolkit.
419+
expect(commands).toEqual([{ command: synthCommand(directory), cwd: cdkDir }]);
420+
expect(runs).toEqual([
421+
{ operation: { kind: "bootstrap", environments: ["aws://111122223333/us-east-1"] }, options },
422+
{ operation: { kind: "deploy" }, options },
423+
]);
424+
expect(events).toEqual([
425+
{ message: "Synthesizing CloudFormation templates" },
426+
{ message: "Bootstrapping aws://111122223333/us-east-1" },
427+
{ message: "Deploying stacks" },
428+
]);
429+
});
430+
431+
test("synthesizes into the same directory the toolkit deploys from", async () => {
432+
const directory = await inTempDirectory();
433+
const { manager: subject, commands, runs } = deployManager();
434+
const project = await scaffolded(subject, directory);
435+
commands.length = 0;
436+
437+
await drain(subject.deploy(project, { region: REGION, skipBootstrap: true }));
438+
439+
// The invariant behind passing --output at all: whatever synth was told to write
440+
// is exactly what the toolkit is handed. Left to cdk.json's `output`, synth could
441+
// write elsewhere and deploy would ship a stale assembly while reporting success.
442+
const assembly = assemblyDirectory(directory);
443+
expect(commands[0]?.command.at(-1)).toBe(assembly);
444+
expect(runs.map(({ options }) => options.assemblyDirectory)).toEqual([assembly]);
445+
});
446+
447+
test("bootstraps each distinct environment once, however many targets share it", async () => {
448+
const directory = await inTempDirectory();
449+
const { manager: subject, runs } = deployManager();
450+
const project = await scaffolded(subject, directory, [
451+
{ name: "alpha", account: "111122223333", region: "us-east-1" },
452+
{ name: "beta", account: "111122223333", region: "us-east-1" }, // same environment
453+
{ name: "gamma", account: "444455556666", region: "eu-west-1" },
454+
]);
455+
456+
await drain(subject.deploy(project, { region: REGION, skipBootstrap: false }));
457+
458+
expect(
459+
runs.flatMap(({ operation }) =>
460+
operation.kind === "bootstrap" ? operation.environments : [],
461+
),
462+
).toEqual(["aws://111122223333/us-east-1", "aws://444455556666/eu-west-1"]);
463+
});
464+
465+
test("skips bootstrapping when asked, and still deploys", async () => {
466+
const directory = await inTempDirectory();
467+
const { manager: subject, commands, runs } = deployManager();
468+
const project = await scaffolded(subject, directory);
469+
commands.length = 0;
470+
const cdkDir = join(directory, "example", "agentcore", "cdk");
471+
472+
const events = await drain(subject.deploy(project, { region: REGION, skipBootstrap: true }));
473+
474+
expect(commands).toEqual([{ command: synthCommand(directory), cwd: cdkDir }]);
475+
expect(runs.map(({ operation }) => operation)).toEqual([{ kind: "deploy" }]);
476+
expect(events).not.toContainEqual({ message: "Bootstrapping aws://111122223333/us-east-1" });
477+
});
478+
479+
test("names the file to fix when no deployment targets are configured", async () => {
480+
const directory = await inTempDirectory();
481+
const { manager: subject, commands, runs } = deployManager();
482+
// create() scaffolds an empty list, which is what a fresh project has.
483+
const project = await scaffolded(subject, directory, null);
484+
commands.length = 0;
485+
486+
await expect(
487+
drain(subject.deploy(project, { region: REGION, skipBootstrap: false })),
488+
).rejects.toThrow(/aws-targets\.json/);
489+
// Nothing ran at all: a deploy with nowhere to go does not even synthesize.
490+
expect(commands).toEqual([]);
491+
expect(runs).toEqual([]);
492+
});
493+
494+
test("reports a malformed aws-targets.json as an actionable error", async () => {
495+
const directory = await inTempDirectory();
496+
const { manager: subject } = deployManager();
497+
const project = await scaffolded(subject, directory, "{ not a target list");
498+
499+
await expect(
500+
drain(subject.deploy(project, { region: REGION, skipBootstrap: false })),
501+
).rejects.toThrow(/is not a valid list of deployment targets/);
502+
});
503+
504+
test("rejects targets that omit a required field", async () => {
505+
const directory = await inTempDirectory();
506+
const { manager: subject } = deployManager();
507+
const project = await scaffolded(subject, directory, [
508+
{ name: "default", region: "us-east-1" },
509+
]);
510+
511+
await expect(
512+
drain(subject.deploy(project, { region: REGION, skipBootstrap: false })),
513+
).rejects.toThrow(/is not a valid list of deployment targets/);
514+
});
515+
516+
test("streams the CDK toolkit's messages as they arrive", async () => {
517+
const directory = await inTempDirectory();
518+
const { manager: subject } = deployManager((operation, emit) => {
519+
if (operation.kind !== "deploy") return;
520+
emit({ level: "info", message: "example-stack: creating CloudFormation changeset..." });
521+
emit({ level: "result", message: "example-stack: deployed" });
522+
});
523+
const project = await scaffolded(subject, directory);
524+
525+
const events = await drain(subject.deploy(project, { region: REGION, skipBootstrap: true }));
526+
527+
expect(events.map((event) => event.output).filter(Boolean)).toEqual([
528+
"example-stack: creating CloudFormation changeset...",
529+
"example-stack: deployed",
530+
]);
531+
});
532+
533+
test("keeps the toolkit's debug and trace messages off screen", async () => {
534+
const directory = await inTempDirectory();
535+
const { manager: subject } = deployManager((operation, emit) => {
536+
if (operation.kind !== "deploy") return;
537+
emit({ level: "debug", message: "resolved 3 environments" });
538+
emit({ level: "trace", message: "sdk call: DescribeStacks" });
539+
emit({ level: "warn", message: "example-stack: no changes" });
540+
});
541+
const project = await scaffolded(subject, directory);
542+
543+
const events = await drain(subject.deploy(project, { region: REGION, skipBootstrap: true }));
544+
545+
// The suppressed ones are still in the debug log; only the warning is surfaced.
546+
expect(events.map((event) => event.output).filter(Boolean)).toEqual([
547+
"example-stack: no changes",
548+
]);
549+
});
550+
551+
test("yields the output that explains a failure before propagating it", async () => {
552+
const directory = await inTempDirectory();
553+
const { manager: subject } = deployManager((operation, emit) => {
554+
if (operation.kind !== "deploy") return;
555+
emit({ level: "error", message: "example-stack: CREATE_FAILED" });
556+
throw new Error("cdk deploy exploded");
557+
});
558+
const project = await scaffolded(subject, directory);
559+
560+
const events: ProjectEvent[] = [];
561+
const generator = subject.deploy(project, { region: REGION, skipBootstrap: true });
562+
await expect(
563+
(async () => {
564+
for await (const event of generator) events.push(event);
565+
})(),
566+
).rejects.toThrow("cdk deploy exploded");
567+
expect(events).toContainEqual({ output: "example-stack: CREATE_FAILED" });
568+
});
569+
570+
test("refuses a project managed by a backend it cannot deploy", async () => {
571+
const directory = await inTempDirectory();
572+
const { manager: subject, commands, runs } = deployManager();
573+
const project = await scaffolded(subject, directory);
574+
commands.length = 0;
575+
576+
// CDK is the only backend today; the cast stands in for a future one.
577+
const foreign = { ...project, managedBy: "Terraform" as Project["managedBy"] };
578+
await expect(
579+
drain(subject.deploy(foreign, { region: REGION, skipBootstrap: false })),
580+
).rejects.toThrow(/unsupported backend: Terraform/);
581+
expect(commands).toEqual([]);
582+
expect(runs).toEqual([]);
583+
});
584+
585+
test("fails before touching AWS when the CDK dependencies are missing", async () => {
586+
const directory = await inTempDirectory();
587+
const { manager: subject, commands, runs } = deployManager();
588+
const project = await scaffolded(subject, directory);
589+
await rm(join(directory, "example", "agentcore", "cdk", "node_modules"), { recursive: true });
590+
commands.length = 0;
591+
592+
await expect(
593+
drain(subject.deploy(project, { region: REGION, skipBootstrap: false })),
594+
).rejects.toThrow(/npm install/);
595+
expect(commands).toEqual([]);
596+
expect(runs).toEqual([]);
597+
});
598+
});
599+
304600
describe("FsProjectManager.resolve", () => {
305601
test("round-trips a project it just created", async () => {
306602
const root = await inTempDirectory();

0 commit comments

Comments
 (0)