Skip to content

Commit 30a3a12

Browse files
committed
feat(project): deploy one target per invocation
deploy shipped every stack in the assembly, so a project with a staging and a prod target reached both at once. It now takes --target, defaulting to "default", and bootstraps and deploys only that target. The target is resolved from aws-targets.json before synthesizing, so a misspelled --target costs no build and the error lists the configured names. Which stack belongs to the target comes from the synthesized manifest, matched on the agentcore:target-name tag the generated CDK app writes, rather than from the CLI reproducing that app's naming convention. The lookup runs before bootstrap so a mismatch fails in seconds, and the toolkit selects with PATTERN_MUST_MATCH so a name the assembly does not contain fails loudly instead of deploying nothing.
1 parent bd5f9f0 commit 30a3a12

7 files changed

Lines changed: 294 additions & 47 deletions

File tree

src/core/project/assembly.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Reads the cloud assembly `build` synthesized, so deploy can find the stack that
2+
// belongs to a deployment target.
3+
import { existsSync } from "node:fs";
4+
import { join } from "node:path";
5+
import z from "zod";
6+
import { ProjectStateError } from "../../errors/errors";
7+
import type { ReadWriteJson } from "../../io";
8+
9+
// The tag the generated CDK app puts on every stack, naming the deployment target
10+
// the stack was synthesized for. Selecting on it means the CLI never has to
11+
// reproduce the app's stack-naming convention, so a project that renames its
12+
// stacks still deploys.
13+
const TARGET_TAG = "agentcore:target-name";
14+
15+
const STACK_ARTIFACT = "aws:cloudformation:stack";
16+
17+
// Only the parts of the manifest deploy reads. Artifacts are keyed by their
18+
// hierarchical id, which is what the CDK toolkit matches stack patterns against.
19+
const AssemblyManifestSchema = z.object({
20+
artifacts: z
21+
.record(
22+
z.string(),
23+
z.object({
24+
type: z.string(),
25+
properties: z.object({ tags: z.record(z.string(), z.string()).optional() }).optional(),
26+
}),
27+
)
28+
.default({}),
29+
});
30+
31+
/**
32+
* The name of the stack in the synthesized assembly that belongs to `target`.
33+
*
34+
* The generated CDK app synthesizes one stack per deployment target and tags each
35+
* with the target's name, so deploy asks the assembly which stack to ship rather
36+
* than deriving the name itself and hoping the two agree.
37+
*/
38+
export async function stackForTarget(
39+
json: ReadWriteJson,
40+
assemblyDirectory: string,
41+
target: string,
42+
): Promise<string> {
43+
const path = join(assemblyDirectory, "manifest.json");
44+
// deploy synthesizes immediately before this, so a missing manifest means synth
45+
// wrote somewhere else entirely rather than that the user skipped a step.
46+
if (!existsSync(path)) {
47+
throw new ProjectStateError(`No synthesized cloud assembly was found at ${path}.`);
48+
}
49+
50+
const manifest = await json.read(path, AssemblyManifestSchema);
51+
const stacks = Object.entries(manifest.artifacts).filter(
52+
([, artifact]) => artifact.type === STACK_ARTIFACT,
53+
);
54+
const match = stacks.find(([, artifact]) => artifact.properties?.tags?.[TARGET_TAG] === target);
55+
if (!match) {
56+
throw new ProjectStateError(
57+
`The synthesized cloud assembly has no stack for deployment target '${target}'. ` +
58+
`${path} defines ${stacks.length} stack(s), none tagged ${TARGET_TAG}='${target}'.`,
59+
);
60+
}
61+
return match[0];
62+
}

src/core/project/manager.test.ts

Lines changed: 131 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -372,10 +372,43 @@ describe("FsProjectManager.deploy", () => {
372372
return join(directory, "example", "agentcore", "cdk", "cdk.out");
373373
}
374374

375+
// How the generated CDK app names the stack it synthesizes for a target.
376+
function stackName(target: string): string {
377+
return `AgentCore-example-${target}`;
378+
}
379+
380+
// Stands in for what synth leaves behind: a manifest with one stack per target,
381+
// each tagged with the target it belongs to. deploy reads it to find the stack to
382+
// ship, and the stubbed runner never writes one.
383+
async function synthesized(directory: string, targetNames: string[]): Promise<void> {
384+
const assembly = assemblyDirectory(directory);
385+
await mkdir(assembly, { recursive: true });
386+
await writeFile(
387+
join(assembly, "manifest.json"),
388+
JSON.stringify({
389+
version: "36.0.0",
390+
artifacts: {
391+
// A non-stack artifact, as a real assembly has: only stacks are candidates.
392+
Tree: { type: "cdk:tree" },
393+
...Object.fromEntries(
394+
targetNames.map((target) => [
395+
stackName(target),
396+
{
397+
type: "aws:cloudformation:stack",
398+
properties: { tags: { "agentcore:target-name": target } },
399+
},
400+
]),
401+
),
402+
},
403+
}),
404+
);
405+
}
406+
375407
// deploy() builds first, so the CDK app's node_modules must exist; create() with
376408
// skipInstall never produces them. Targets overwrite the empty list create()
377409
// scaffolds; null leaves that empty list in place, and a string is written verbatim
378-
// so a test can supply invalid JSON.
410+
// so a test can supply invalid JSON. A well-formed list also gets the assembly
411+
// synth would have produced for it.
379412
async function scaffolded(
380413
subject: FsProjectManager,
381414
directory: string,
@@ -396,6 +429,12 @@ describe("FsProjectManager.deploy", () => {
396429
typeof targets === "string" ? targets : JSON.stringify(targets),
397430
);
398431
}
432+
if (Array.isArray(targets)) {
433+
await synthesized(
434+
directory,
435+
(targets as { name?: string }[]).flatMap(({ name }) => (name ? [name] : [])),
436+
);
437+
}
399438
return project;
400439
}
401440

@@ -405,26 +444,28 @@ describe("FsProjectManager.deploy", () => {
405444
return events;
406445
}
407446

408-
test("synthesizes, bootstraps the target environment, then deploys every stack", async () => {
447+
test("synthesizes, bootstraps the target environment, then deploys its stack", async () => {
409448
const directory = await inTempDirectory();
410449
const { manager: subject, commands, runs } = deployManager();
411450
const project = await scaffolded(subject, directory);
412451
commands.length = 0; // discard create()'s commands
413452
const cdkDir = join(directory, "example", "agentcore", "cdk");
414453
const options = { assemblyDirectory: assemblyDirectory(directory), region: REGION };
415454

416-
const events = await drain(subject.deploy(project, { region: REGION, skipBootstrap: false }));
455+
const events = await drain(
456+
subject.deploy(project, { region: REGION, skipBootstrap: false, target: "default" }),
457+
);
417458

418459
// Only synthesis shells out; everything that reaches AWS goes through the toolkit.
419460
expect(commands).toEqual([{ command: synthCommand(directory), cwd: cdkDir }]);
420461
expect(runs).toEqual([
421462
{ operation: { kind: "bootstrap", environments: ["aws://111122223333/us-east-1"] }, options },
422-
{ operation: { kind: "deploy" }, options },
463+
{ operation: { kind: "deploy", stackName: stackName("default") }, options },
423464
]);
424465
expect(events).toEqual([
425466
{ message: "Synthesizing CloudFormation templates" },
426467
{ message: "Bootstrapping aws://111122223333/us-east-1" },
427-
{ message: "Deploying stacks" },
468+
{ message: `Deploying ${stackName("default")}` },
428469
]);
429470
});
430471

@@ -434,7 +475,9 @@ describe("FsProjectManager.deploy", () => {
434475
const project = await scaffolded(subject, directory);
435476
commands.length = 0;
436477

437-
await drain(subject.deploy(project, { region: REGION, skipBootstrap: true }));
478+
await drain(
479+
subject.deploy(project, { region: REGION, skipBootstrap: true, target: "default" }),
480+
);
438481

439482
// The invariant behind passing --output at all: whatever synth was told to write
440483
// is exactly what the toolkit is handed. Left to cdk.json's `output`, synth could
@@ -444,22 +487,71 @@ describe("FsProjectManager.deploy", () => {
444487
expect(runs.map(({ options }) => options.assemblyDirectory)).toEqual([assembly]);
445488
});
446489

447-
test("bootstraps each distinct environment once, however many targets share it", async () => {
490+
// The three-target project the target-selection tests share.
491+
const TARGETS = [
492+
{ name: "staging", account: "111122223333", region: "us-east-1" },
493+
{ name: "prod", account: "444455556666", region: "eu-west-1" },
494+
{ name: "default", account: "777788889999", region: "us-west-2" },
495+
];
496+
497+
test("deploys only the requested target's stack, into only its environment", async () => {
448498
const directory = await inTempDirectory();
449499
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" },
500+
const project = await scaffolded(subject, directory, TARGETS);
501+
502+
await drain(subject.deploy(project, { region: REGION, skipBootstrap: false, target: "prod" }));
503+
504+
// A project with a staging and a prod target cannot reach the others by
505+
// accident: one deploy bootstraps one environment and ships one stack.
506+
expect(runs.map(({ operation }) => operation)).toEqual([
507+
{ kind: "bootstrap", environments: ["aws://444455556666/eu-west-1"] },
508+
{ kind: "deploy", stackName: stackName("prod") },
509+
]);
510+
});
511+
512+
test("deploys the target named 'default' when none is requested", async () => {
513+
const directory = await inTempDirectory();
514+
const { manager: subject, runs } = deployManager();
515+
const project = await scaffolded(subject, directory, TARGETS);
516+
517+
// What the handler passes when --target is omitted, and the name the example in
518+
// the empty-targets error uses.
519+
await drain(
520+
subject.deploy(project, { region: REGION, skipBootstrap: true, target: "default" }),
521+
);
522+
523+
expect(runs.map(({ operation }) => operation)).toEqual([
524+
{ kind: "deploy", stackName: stackName("default") },
454525
]);
526+
});
527+
528+
test("names the configured targets when the requested one is not among them", async () => {
529+
const directory = await inTempDirectory();
530+
const { manager: subject, commands, runs } = deployManager();
531+
const project = await scaffolded(subject, directory, TARGETS);
532+
commands.length = 0;
533+
534+
await expect(
535+
drain(subject.deploy(project, { region: REGION, skipBootstrap: false, target: "prd" })),
536+
).rejects.toThrow(/no deployment target named 'prd'.*staging, prod, default/s);
537+
// Resolved before synthesizing, so a misspelled --target costs no build.
538+
expect(commands).toEqual([]);
539+
expect(runs).toEqual([]);
540+
});
455541

456-
await drain(subject.deploy(project, { region: REGION, skipBootstrap: false }));
542+
test("fails when the synthesized assembly has no stack for the target", async () => {
543+
const directory = await inTempDirectory();
544+
const { manager: subject, runs } = deployManager();
545+
const project = await scaffolded(subject, directory);
546+
// An assembly synthesized from a different target list than the one on disk —
547+
// what a hand-edited CDK app that stops tagging its stacks would leave behind.
548+
await synthesized(directory, ["other"]);
457549

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"]);
550+
await expect(
551+
drain(subject.deploy(project, { region: REGION, skipBootstrap: false, target: "default" })),
552+
).rejects.toThrow(/no stack for deployment target 'default'/);
553+
// Resolved before bootstrapping, so nothing reached AWS.
554+
expect(runs).toEqual([]);
463555
});
464556

465557
test("skips bootstrapping when asked, and still deploys", async () => {
@@ -469,10 +561,14 @@ describe("FsProjectManager.deploy", () => {
469561
commands.length = 0;
470562
const cdkDir = join(directory, "example", "agentcore", "cdk");
471563

472-
const events = await drain(subject.deploy(project, { region: REGION, skipBootstrap: true }));
564+
const events = await drain(
565+
subject.deploy(project, { region: REGION, skipBootstrap: true, target: "default" }),
566+
);
473567

474568
expect(commands).toEqual([{ command: synthCommand(directory), cwd: cdkDir }]);
475-
expect(runs.map(({ operation }) => operation)).toEqual([{ kind: "deploy" }]);
569+
expect(runs.map(({ operation }) => operation)).toEqual([
570+
{ kind: "deploy", stackName: stackName("default") },
571+
]);
476572
expect(events).not.toContainEqual({ message: "Bootstrapping aws://111122223333/us-east-1" });
477573
});
478574

@@ -484,7 +580,7 @@ describe("FsProjectManager.deploy", () => {
484580
commands.length = 0;
485581

486582
await expect(
487-
drain(subject.deploy(project, { region: REGION, skipBootstrap: false })),
583+
drain(subject.deploy(project, { region: REGION, skipBootstrap: false, target: "default" })),
488584
).rejects.toThrow(/aws-targets\.json/);
489585
// Nothing ran at all: a deploy with nowhere to go does not even synthesize.
490586
expect(commands).toEqual([]);
@@ -497,7 +593,7 @@ describe("FsProjectManager.deploy", () => {
497593
const project = await scaffolded(subject, directory, "{ not a target list");
498594

499595
await expect(
500-
drain(subject.deploy(project, { region: REGION, skipBootstrap: false })),
596+
drain(subject.deploy(project, { region: REGION, skipBootstrap: false, target: "default" })),
501597
).rejects.toThrow(/is not a valid list of deployment targets/);
502598
});
503599

@@ -509,7 +605,7 @@ describe("FsProjectManager.deploy", () => {
509605
]);
510606

511607
await expect(
512-
drain(subject.deploy(project, { region: REGION, skipBootstrap: false })),
608+
drain(subject.deploy(project, { region: REGION, skipBootstrap: false, target: "default" })),
513609
).rejects.toThrow(/is not a valid list of deployment targets/);
514610
});
515611

@@ -522,7 +618,9 @@ describe("FsProjectManager.deploy", () => {
522618
});
523619
const project = await scaffolded(subject, directory);
524620

525-
const events = await drain(subject.deploy(project, { region: REGION, skipBootstrap: true }));
621+
const events = await drain(
622+
subject.deploy(project, { region: REGION, skipBootstrap: true, target: "default" }),
623+
);
526624

527625
expect(events.map((event) => event.output).filter(Boolean)).toEqual([
528626
"example-stack: creating CloudFormation changeset...",
@@ -540,7 +638,9 @@ describe("FsProjectManager.deploy", () => {
540638
});
541639
const project = await scaffolded(subject, directory);
542640

543-
const events = await drain(subject.deploy(project, { region: REGION, skipBootstrap: true }));
641+
const events = await drain(
642+
subject.deploy(project, { region: REGION, skipBootstrap: true, target: "default" }),
643+
);
544644

545645
// The suppressed ones are still in the debug log; only the warning is surfaced.
546646
expect(events.map((event) => event.output).filter(Boolean)).toEqual([
@@ -558,7 +658,11 @@ describe("FsProjectManager.deploy", () => {
558658
const project = await scaffolded(subject, directory);
559659

560660
const events: ProjectEvent[] = [];
561-
const generator = subject.deploy(project, { region: REGION, skipBootstrap: true });
661+
const generator = subject.deploy(project, {
662+
region: REGION,
663+
skipBootstrap: true,
664+
target: "default",
665+
});
562666
await expect(
563667
(async () => {
564668
for await (const event of generator) events.push(event);
@@ -576,7 +680,7 @@ describe("FsProjectManager.deploy", () => {
576680
// CDK is the only backend today; the cast stands in for a future one.
577681
const foreign = { ...project, managedBy: "Terraform" as Project["managedBy"] };
578682
await expect(
579-
drain(subject.deploy(foreign, { region: REGION, skipBootstrap: false })),
683+
drain(subject.deploy(foreign, { region: REGION, skipBootstrap: false, target: "default" })),
580684
).rejects.toThrow(/unsupported backend: Terraform/);
581685
expect(commands).toEqual([]);
582686
expect(runs).toEqual([]);
@@ -590,7 +694,7 @@ describe("FsProjectManager.deploy", () => {
590694
commands.length = 0;
591695

592696
await expect(
593-
drain(subject.deploy(project, { region: REGION, skipBootstrap: false })),
697+
drain(subject.deploy(project, { region: REGION, skipBootstrap: false, target: "default" })),
594698
).rejects.toThrow(/npm install/);
595699
expect(commands).toEqual([]);
596700
expect(runs).toEqual([]);

0 commit comments

Comments
 (0)