From 67e1d37edfdcdfd63a48c8076c14d1309bb6a05a Mon Sep 17 00:00:00 2001 From: notgitika Date: Wed, 19 Aug 2026 21:11:56 -0400 Subject: [PATCH 1/5] feat(project): implement safe CDK deployment --- bun.lock | 2 + package.json | 2 + scripts/build.ts | 44 ++- src/core/project/backends/cdk.test.ts | 296 ++++++++++++++++-- src/core/project/backends/cdk.ts | 113 ++++++- .../project/backends/cdk/assembly.test.ts | 63 ++++ src/core/project/backends/cdk/assembly.ts | 58 ++++ .../project/backends/cdk/environment.test.ts | 103 ++++++ src/core/project/backends/cdk/environment.ts | 101 ++++++ src/core/project/backends/cdk/toolkit.test.ts | 30 +- src/core/project/backends/cdk/toolkit.ts | 39 +++ src/core/project/manager.test.ts | 150 ++++++++- src/core/project/manager.tsx | 12 +- src/handlers/project/project.test.ts | 27 +- 14 files changed, 975 insertions(+), 65 deletions(-) create mode 100644 src/core/project/backends/cdk/assembly.test.ts create mode 100644 src/core/project/backends/cdk/assembly.ts create mode 100644 src/core/project/backends/cdk/environment.test.ts create mode 100644 src/core/project/backends/cdk/environment.ts diff --git a/bun.lock b/bun.lock index 974fffc38..7345c9ef0 100644 --- a/bun.lock +++ b/bun.lock @@ -8,8 +8,10 @@ "@aws-cdk/toolkit-lib": "1.38.2", "@aws-sdk/client-bedrock-agentcore": "^3.1092.0", "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", + "@aws-sdk/client-cloudformation": "^3.1092.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", "@aws-sdk/client-iam": "^3.1080.0", + "@aws-sdk/client-sts": "^3.1092.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", "@opentelemetry/otlp-transformer": "0.213.0", diff --git a/package.json b/package.json index 0c03ca73c..ca2dbdbb5 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,10 @@ "@aws-cdk/toolkit-lib": "1.38.2", "@aws-sdk/client-bedrock-agentcore": "^3.1092.0", "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", + "@aws-sdk/client-cloudformation": "^3.1092.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", "@aws-sdk/client-iam": "^3.1080.0", + "@aws-sdk/client-sts": "^3.1092.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.221.0", "@opentelemetry/otlp-transformer": "0.213.0", diff --git a/scripts/build.ts b/scripts/build.ts index 71d711ccd..9b1412474 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { $ } from "bun"; +import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; import { runWithExitCode } from "../src/runnable"; @@ -23,6 +24,8 @@ const ASSET_NAMING = "agentcore-assets/[dir]/[name].[ext]"; // explicit copy of anything it would otherwise read from its package. const EXTERNAL = ["@aws-cdk/toolkit-lib"]; +const BOOTSTRAP_TEMPLATE = ["lib", "api", "bootstrap", "bootstrap-template.yaml"]; + // Shrink whitespace/syntax but keep identifiers: minified names make stack // traces unreadable and erase error names telemetry keys on. const MINIFY = { whitespace: true, syntax: true, identifiers: false } as const; @@ -38,14 +41,39 @@ function assetLoaderPlugin(): Bun.BunPlugin { return { name: "asset-file-loader", setup(build) { - build.onLoad({ filter: /src[/\\]assets[/\\]/ }, async ({ path }) => ({ - contents: await Bun.file(path).bytes(), - loader: "file", - })); + build.onLoad( + { filter: /src[/\\]assets[/\\]|bootstrap-template\.yaml$/ }, + async ({ path }) => ({ + contents: await Bun.file(path).bytes(), + loader: "file", + }), + ); }, }; } +function bootstrapTemplate(): string { + const manifest = Bun.resolveSync("@aws-cdk/toolkit-lib/package.json", REPO_ROOT); + const template = join(resolve(manifest, ".."), ...BOOTSTRAP_TEMPLATE); + if (!existsSync(template)) { + throw new Error( + `@aws-cdk/toolkit-lib no longer ships ${BOOTSTRAP_TEMPLATE.join("/")}; ` + + `looked in ${template}`, + ); + } + return template; +} + +async function assertTemplateIsEmbedded(outfile: string, template: string): Promise { + const [executable, contents] = await Promise.all([ + Bun.file(outfile).bytes(), + Bun.file(template).bytes(), + ]); + if (!Buffer.from(executable).includes(contents)) { + throw new Error(`${outfile} does not contain ${BOOTSTRAP_TEMPLATE.join("/")}`); + } +} + /** Fail loudly on a non-UTF-8 asset — the source reads every asset as text. */ async function assertAssetsAreText(assets: string[]): Promise { const decoder = new TextDecoder("utf-8", { fatal: true }); @@ -83,15 +111,19 @@ async function compile(target: string): Promise { const outfile = join(DIST, "bin", `agentcore-${target.replace(/^bun-/, "")}`); await $`mkdir -p ${join(DIST, "bin")}`; + const template = bootstrapTemplate(); await Bun.build({ - entrypoints: [ENTRYPOINT, ...assets], + entrypoints: [ENTRYPOINT, ...assets, template], compile: { target: target as Bun.Build.CompileTarget, outfile }, minify: MINIFY, root: REPO_ROOT, naming: { asset: ASSET_NAMING }, plugins: [assetLoaderPlugin()], }); - console.log(`Compiled ${target} → ${outfile} (${assets.length} assets embedded)`); + await assertTemplateIsEmbedded(outfile, template); + console.log( + `Compiled ${target} → ${outfile} (${assets.length} assets embedded, plus the bootstrap template)`, + ); } process.exit( diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 05e3f45ae..5432712a5 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -1,11 +1,19 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { createSilentLogger } from "../../../testing"; -import type { Project, ProjectEvent } from "../../../handlers/project/types"; import { CdkBackend } from "./cdk"; +import type { BootstrapState } from "./cdk/environment"; +import type { CdkOperation, CdkOutputs, CdkRunOptions } from "./cdk/toolkit"; + +const TARGET = { + name: "default", + account: "111122223333", + region: "us-east-1", +} as const; const tempDirectories: string[] = []; @@ -15,11 +23,25 @@ afterEach(async () => { ); }); +function cdkDirectory(project: Project): string { + return join(project.rootPath, "agentcore", "cdk"); +} + +function assemblyDirectory(project: Project): string { + return join(cdkDirectory(project), "cdk.out"); +} + +function synthCommand(project: Project): string[] { + return ["npm", "run", "cdk", "--", "synth", "--quiet", "--output", assemblyDirectory(project)]; +} + async function project(withDependencies = true): Promise { const rootPath = await mkdtemp(join(tmpdir(), "agentcore-cdk-backend-")); tempDirectories.push(rootPath); const cdkDir = join(rootPath, "agentcore", "cdk"); - await mkdir(withDependencies ? join(cdkDir, "node_modules") : cdkDir, { recursive: true }); + await mkdir(withDependencies ? join(cdkDir, "node_modules") : cdkDir, { + recursive: true, + }); return { name: "example", rootPath, @@ -27,58 +49,274 @@ async function project(withDependencies = true): Promise { }; } -async function drain(generator: AsyncGenerator): Promise { +async function writeAssembly(project: Project, targetNames: string[]): Promise { + const directory = assemblyDirectory(project); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "manifest.json"), + JSON.stringify({ + version: "36.0.0", + artifacts: { + Tree: { type: "cdk:tree" }, + ...Object.fromEntries( + targetNames.flatMap((target, index) => [ + [ + `AgentCore-example-${target}-${index}`, + { + type: "aws:cloudformation:stack", + properties: { tags: { "agentcore:target-name": target } }, + }, + ], + ]), + ), + }, + }), + ); +} + +type HarnessOptions = { + account?: string; + bootstrap?: BootstrapState; + outputs?: CdkOutputs; + template?: boolean; + failOperation?: CdkOperation["kind"]; + bootstrapError?: Error; +}; + +function harness(options: HarnessOptions = {}) { + const commands: { command: string[]; cwd: string }[] = []; + const runs: { operation: CdkOperation; options: CdkRunOptions }[] = []; + const accountRegions: string[] = []; + const bootstrapRegions: string[] = []; + let templateLoads = 0; + let templateCleanups = 0; + + const backend = new CdkBackend({ + logger: createSilentLogger(), + runner: async (command, { cwd }) => { + commands.push({ command, cwd }); + }, + checkTool: async () => {}, + resolveAccount: async (region) => { + accountRegions.push(region); + return options.account ?? TARGET.account; + }, + bootstrap: async (region) => { + bootstrapRegions.push(region); + if (options.bootstrapError) throw options.bootstrapError; + return options.bootstrap ?? { kind: "current", version: 30 }; + }, + cdk: async (operation, runOptions) => { + runs.push({ operation, options: runOptions }); + if (operation.kind === options.failOperation) { + throw new Error(`${operation.kind} failed`); + } + return operation.kind === "deploy" ? (options.outputs ?? {}) : {}; + }, + loadBootstrapTemplate: async () => { + templateLoads++; + if (!options.template) return undefined; + return { + path: "/tmp/bootstrap-template.yaml", + cleanup: async () => { + templateCleanups++; + }, + }; + }, + }); + + return { + accountRegions, + backend, + bootstrapRegions, + commands, + runs, + templateLoads: () => templateLoads, + templateCleanups: () => templateCleanups, + }; +} + +async function collect(generator: AsyncGenerator): Promise { const events: ProjectEvent[] = []; for await (const event of generator) events.push(event); return events; } +async function collectDeploy( + generator: AsyncGenerator, +): Promise<{ events: ProjectEvent[]; result: DeployResult }> { + const events: ProjectEvent[] = []; + while (true) { + const next = await generator.next(); + if (next.done) return { events, result: next.value }; + events.push(next.value as ProjectEvent); + } +} + describe("CdkBackend.build", () => { - test("compiles and synthesizes through the generated CDK script", async () => { - const commands: { command: string[]; cwd: string }[] = []; + test("synthesizes into the assembly directory deploy reads", async () => { + const input = await project(); + const subject = harness(); + + expect(await collect(subject.backend.build(input))).toEqual([ + { message: "Synthesizing CloudFormation templates" }, + ]); + expect(subject.commands).toEqual([{ command: synthCommand(input), cwd: cdkDirectory(input) }]); + }); + + test("fails actionably when CDK dependencies are missing", async () => { + const input = await project(false); + const subject = harness(); + + await expect(collect(subject.backend.build(input))).rejects.toThrow(/npm install/); + expect(subject.commands).toEqual([]); + }); + + test("propagates synthesis failures", async () => { + const input = await project(); const subject = new CdkBackend({ logger: createSilentLogger(), - runner: async (command, { cwd }) => { - commands.push({ command, cwd }); + runner: async () => { + throw new Error("cdk synth exploded"); }, checkTool: async () => {}, }); + + await expect(collect(subject.build(input))).rejects.toThrow("cdk synth exploded"); + }); +}); + +describe("CdkBackend.deploy", () => { + test("preflights, synthesizes, and deploys the selected stack", async () => { const input = await project(); + await writeAssembly(input, [TARGET.name]); + const subject = harness({ outputs: { RuntimeArn: "arn:runtime" } }); + + const deployed = await collectDeploy(subject.backend.deploy(input, { target: TARGET })); - expect(await drain(subject.build(input))).toEqual([ + expect(deployed.events).toEqual([ + { message: `Verifying AWS account ${TARGET.account}` }, { message: "Synthesizing CloudFormation templates" }, + { message: "Deploying AgentCore-example-default-0" }, ]); - expect(commands).toEqual([ + expect(deployed.result).toEqual({ outputs: { RuntimeArn: "arn:runtime" } }); + expect(subject.commands).toEqual([{ command: synthCommand(input), cwd: cdkDirectory(input) }]); + expect(subject.runs).toEqual([ { - command: ["npm", "run", "cdk", "--", "synth", "--quiet"], - cwd: join(input.rootPath, "agentcore", "cdk"), + operation: { + kind: "deploy", + stackName: "AgentCore-example-default-0", + }, + options: { + assemblyDirectory: assemblyDirectory(input), + region: TARGET.region, + }, }, ]); + expect(subject.accountRegions).toEqual([TARGET.region]); + expect(subject.bootstrapRegions).toEqual([TARGET.region]); + expect(subject.templateLoads()).toBe(0); }); - test("fails actionably when CDK dependencies are missing", async () => { - const commands: string[][] = []; - const subject = new CdkBackend({ - logger: createSilentLogger(), - runner: async (command) => { - commands.push(command); + test.each([ + ["absent", { kind: "absent" } as const], + ["outdated", { kind: "outdated", version: 29 } as const], + ])("bootstraps an %s environment before deploying", async (_label, bootstrap) => { + const input = await project(); + await writeAssembly(input, [TARGET.name]); + const subject = harness({ bootstrap }); + + const deployed = await collectDeploy(subject.backend.deploy(input, { target: TARGET })); + + expect(subject.runs.map(({ operation }) => operation)).toEqual([ + { + kind: "bootstrap", + environments: [`aws://${TARGET.account}/${TARGET.region}`], }, - checkTool: async () => {}, + { kind: "deploy", stackName: "AgentCore-example-default-0" }, + ]); + expect(deployed.events).toContainEqual({ + message: `Bootstrapping aws://${TARGET.account}/${TARGET.region}`, }); + }); - await expect(drain(subject.build(await project(false)))).rejects.toThrow(/npm install/); - expect(commands).toEqual([]); + test("rejects credentials for a different account before build or mutation", async () => { + const input = await project(); + const subject = harness({ account: "999900001111" }); + + await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toThrow( + /expects AWS account 111122223333.*999900001111/, + ); + expect(subject.commands).toEqual([]); + expect(subject.bootstrapRegions).toEqual([]); + expect(subject.runs).toEqual([]); }); - test("propagates synthesis failures", async () => { - const subject = new CdkBackend({ - logger: createSilentLogger(), - runner: async () => { - throw new Error("cdk synth exploded"); - }, - checkTool: async () => {}, + test("does not touch bootstrap or deploy when the assembly lacks the target", async () => { + const input = await project(); + await writeAssembly(input, ["other"]); + const subject = harness(); + + await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toThrow( + /no stack for deployment target 'default'/, + ); + expect(subject.bootstrapRegions).toEqual([]); + expect(subject.runs).toEqual([]); + }); + + test("rejects an ambiguous assembly before touching bootstrap or deploy", async () => { + const input = await project(); + await writeAssembly(input, [TARGET.name, TARGET.name]); + const subject = harness(); + + await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toThrow( + /2 stacks for deployment target 'default'/, + ); + expect(subject.bootstrapRegions).toEqual([]); + expect(subject.runs).toEqual([]); + }); + + test("propagates an unsafe bootstrap state without running the Toolkit", async () => { + const input = await project(); + await writeAssembly(input, [TARGET.name]); + const failure = new Error("CDKToolkit is UPDATE_IN_PROGRESS"); + const subject = harness({ bootstrapError: failure }); + + await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toBe( + failure, + ); + expect(subject.runs).toEqual([]); + }); + + test("uses and cleans up an embedded bootstrap template", async () => { + const input = await project(); + await writeAssembly(input, [TARGET.name]); + const subject = harness({ bootstrap: { kind: "absent" }, template: true }); + + await collectDeploy(subject.backend.deploy(input, { target: TARGET })); + + expect(subject.runs[0]?.operation).toEqual({ + kind: "bootstrap", + environments: [`aws://${TARGET.account}/${TARGET.region}`], + templateFile: "/tmp/bootstrap-template.yaml", + }); + expect(subject.templateLoads()).toBe(1); + expect(subject.templateCleanups()).toBe(1); + }); + + test("cleans up the embedded template when bootstrap fails", async () => { + const input = await project(); + await writeAssembly(input, [TARGET.name]); + const subject = harness({ + bootstrap: { kind: "absent" }, + template: true, + failOperation: "bootstrap", }); - await expect(drain(subject.build(await project()))).rejects.toThrow("cdk synth exploded"); + await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toThrow( + "bootstrap failed", + ); + expect(subject.templateCleanups()).toBe(1); + expect(subject.runs.map(({ operation }) => operation.kind)).toEqual(["bootstrap"]); }); }); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 1915ed70f..30d27ebb8 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -1,31 +1,65 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { NotImplementedError, ProjectStateError } from "../../../errors/errors"; +import { ProjectStateError } from "../../../errors/errors"; import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; -import { requireTool, runProcess, type ProcessRunner } from "../../../io"; +import { + FsReadWriteJson, + requireTool, + runProcess, + type ProcessRunner, + type ReadWriteJson, +} from "../../../io"; import type { Logger } from "../../../logging"; import type { DeployBackendInput, ProjectBackend } from "./types"; +import { stackForTarget } from "./cdk/assembly"; +import { + probeBootstrap, + resolveAwsAccount, + type AccountResolver, + type BootstrapProbe, +} from "./cdk/environment"; +import { + createCdkRunner, + loadBootstrapTemplate, + type BootstrapTemplateLoader, + type CdkRunner, +} from "./cdk/toolkit"; export type CdkBackendConfig = { logger: Logger; runner?: ProcessRunner; checkTool?: typeof requireTool; + json?: ReadWriteJson; + cdk?: CdkRunner; + bootstrap?: BootstrapProbe; + resolveAccount?: AccountResolver; + loadBootstrapTemplate?: BootstrapTemplateLoader; }; -/** Builds projects through the CDK app scaffolded by `agentcore project create`. */ +/** Builds and deploys projects through the scaffolded CDK app. */ export class CdkBackend implements ProjectBackend { private readonly logger: Logger; private readonly runner: ProcessRunner; private readonly checkTool: typeof requireTool; + private readonly json: ReadWriteJson; + private readonly cdk: CdkRunner; + private readonly bootstrap: BootstrapProbe; + private readonly resolveAccount: AccountResolver; + private readonly loadBootstrapTemplate: BootstrapTemplateLoader; constructor(config: CdkBackendConfig) { this.logger = config.logger; this.runner = config.runner ?? runProcess; this.checkTool = config.checkTool ?? requireTool; + this.json = config.json ?? new FsReadWriteJson({ logger: config.logger }); + this.cdk = config.cdk ?? createCdkRunner(config.logger); + this.bootstrap = config.bootstrap ?? probeBootstrap; + this.resolveAccount = config.resolveAccount ?? resolveAwsAccount; + this.loadBootstrapTemplate = config.loadBootstrapTemplate ?? loadBootstrapTemplate; } public async *build(project: Project): AsyncGenerator { - const cdkDir = join(project.rootPath, "agentcore", "cdk"); + const cdkDir = this.cdkDirectory(project); if (!existsSync(join(cdkDir, "node_modules"))) { throw new ProjectStateError( @@ -36,17 +70,72 @@ export class CdkBackend implements ProjectBackend { await this.checkTool("npm", "Install Node.js: https://nodejs.org/"); yield { message: "Synthesizing CloudFormation templates" }; - await this.runner(["npm", "run", "cdk", "--", "synth", "--quiet"], { - cwd: cdkDir, - onOutput: (chunk) => this.logger.debug(chunk), - }); + await this.runner( + ["npm", "run", "cdk", "--", "synth", "--quiet", "--output", this.assemblyDirectory(project)], + { + cwd: cdkDir, + onOutput: (chunk) => this.logger.debug(chunk), + }, + ); } public async *deploy( - _project: Project, - _input: DeployBackendInput, + project: Project, + input: DeployBackendInput, ): AsyncGenerator { - yield* []; - throw new NotImplementedError("CDK project deployment is not implemented yet"); + const { target } = input; + yield { message: `Verifying AWS account ${target.account}` }; + const account = await this.resolveAccount(target.region); + if (account !== target.account) { + throw new ProjectStateError( + `Deployment target '${target.name}' expects AWS account ${target.account}, ` + + `but the active credentials belong to ${account}.`, + ); + } + + yield* this.build(project); + const assemblyDirectory = this.assemblyDirectory(project); + const stackName = await stackForTarget(this.json, assemblyDirectory, target.name); + const options = { assemblyDirectory, region: target.region }; + + const bootstrap = await this.bootstrap(target.region); + this.logger + .child({ + account: target.account, + region: target.region, + bootstrapState: bootstrap.kind, + ...("version" in bootstrap && { bootstrapVersion: bootstrap.version }), + }) + .debug("checked CDK bootstrap stack"); + + if (bootstrap.kind !== "current") { + const environment = `aws://${target.account}/${target.region}`; + yield { message: `Bootstrapping ${environment}` }; + const template = await this.loadBootstrapTemplate(); + try { + await this.cdk( + { + kind: "bootstrap", + environments: [environment], + ...(template && { templateFile: template.path }), + }, + options, + ); + } finally { + await template?.cleanup(); + } + } + + yield { message: `Deploying ${stackName}` }; + const outputs = await this.cdk({ kind: "deploy", stackName }, options); + return { outputs }; + } + + private cdkDirectory(project: Project): string { + return join(project.rootPath, "agentcore", "cdk"); + } + + private assemblyDirectory(project: Project): string { + return join(this.cdkDirectory(project), "cdk.out"); } } diff --git a/src/core/project/backends/cdk/assembly.test.ts b/src/core/project/backends/cdk/assembly.test.ts new file mode 100644 index 000000000..32c73cc4d --- /dev/null +++ b/src/core/project/backends/cdk/assembly.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { FsReadWriteJson } from "../../../../io"; +import { createSilentLogger } from "../../../../testing"; +import { stackForTarget } from "./assembly"; + +const temporaryDirectories: string[] = []; +const json = new FsReadWriteJson({ logger: createSilentLogger() }); + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function assembly(artifacts: Record): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-assembly-")); + temporaryDirectories.push(directory); + await writeFile(join(directory, "manifest.json"), JSON.stringify({ artifacts })); + return directory; +} + +describe("stackForTarget", () => { + test("selects by the target tag instead of deriving a stack name", async () => { + const directory = await assembly({ + "nested/stack-id": { + type: "aws:cloudformation:stack", + properties: { + tags: { "agentcore:target-name": "prod" }, + }, + }, + }); + + expect(await stackForTarget(json, directory, "prod")).toBe("nested/stack-id"); + }); + + test("ignores non-stack artifacts", async () => { + const directory = await assembly({ + Tree: { + type: "cdk:tree", + properties: { + tags: { "agentcore:target-name": "prod" }, + }, + }, + }); + + await expect(stackForTarget(json, directory, "prod")).rejects.toThrow(/defines 0 stack/); + }); + + test("reports a missing manifest before attempting deployment", async () => { + const directory = await mkdtemp(join(tmpdir(), "agentcore-assembly-")); + temporaryDirectories.push(directory); + await mkdir(directory, { recursive: true }); + + await expect(stackForTarget(json, directory, "prod")).rejects.toThrow( + /No synthesized cloud assembly was found/, + ); + }); +}); diff --git a/src/core/project/backends/cdk/assembly.ts b/src/core/project/backends/cdk/assembly.ts new file mode 100644 index 000000000..e358262ad --- /dev/null +++ b/src/core/project/backends/cdk/assembly.ts @@ -0,0 +1,58 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { z } from "zod"; +import { ProjectStateError } from "../../../../errors/errors"; +import type { ReadWriteJson } from "../../../../io"; + +const TARGET_TAG = "agentcore:target-name"; +const STACK_ARTIFACT = "aws:cloudformation:stack"; + +const AssemblyManifestSchema = z.object({ + artifacts: z + .record( + z.string(), + z.object({ + type: z.string(), + properties: z + .object({ + tags: z.record(z.string(), z.string()).optional(), + }) + .optional(), + }), + ) + .default({}), +}); + +/** Finds the one synthesized stack tagged for the selected deployment target. */ +export async function stackForTarget( + json: ReadWriteJson, + assemblyDirectory: string, + target: string, +): Promise { + const manifestPath = join(assemblyDirectory, "manifest.json"); + if (!existsSync(manifestPath)) { + throw new ProjectStateError(`No synthesized cloud assembly was found at ${manifestPath}.`); + } + + const manifest = await json.read(manifestPath, AssemblyManifestSchema); + const stacks = Object.entries(manifest.artifacts).filter( + ([, artifact]) => artifact.type === STACK_ARTIFACT, + ); + const matches = stacks.filter( + ([, artifact]) => artifact.properties?.tags?.[TARGET_TAG] === target, + ); + + if (matches.length === 0) { + throw new ProjectStateError( + `The synthesized cloud assembly has no stack for deployment target '${target}'. ` + + `${manifestPath} defines ${stacks.length} stack(s), none tagged ${TARGET_TAG}='${target}'.`, + ); + } + if (matches.length > 1) { + throw new ProjectStateError( + `The synthesized cloud assembly has ${matches.length} stacks for deployment target ` + + `'${target}'. Exactly one stack must be tagged ${TARGET_TAG}='${target}'.`, + ); + } + return matches[0]![0]; +} diff --git a/src/core/project/backends/cdk/environment.test.ts b/src/core/project/backends/cdk/environment.test.ts new file mode 100644 index 000000000..5b7e727cc --- /dev/null +++ b/src/core/project/backends/cdk/environment.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import type { Stack } from "@aws-sdk/client-cloudformation"; +import { isBootstrapStackNotFound, probeBootstrap, readBootstrapState } from "./environment"; + +function stack(status: Stack["StackStatus"], version?: string): Stack { + return { + StackName: "CDKToolkit", + CreationTime: new Date(0), + StackStatus: status, + Outputs: version ? [{ OutputKey: "BootstrapVersion", OutputValue: version }] : [], + }; +} + +describe("readBootstrapState", () => { + test("accepts stable stacks at or above the minimum version", () => { + expect(readBootstrapState([stack("CREATE_COMPLETE", "30")])).toEqual({ + kind: "current", + version: 30, + }); + expect(readBootstrapState([stack("UPDATE_ROLLBACK_COMPLETE", "99")])).toEqual({ + kind: "current", + version: 99, + }); + }); + + test("marks stable legacy and old stacks for an upgrade", () => { + expect(readBootstrapState([stack("UPDATE_COMPLETE")])).toEqual({ + kind: "outdated", + version: 0, + }); + expect(readBootstrapState([stack("UPDATE_COMPLETE", "29")])).toEqual({ + kind: "outdated", + version: 29, + }); + }); + + test.each([ + "CREATE_IN_PROGRESS", + "UPDATE_IN_PROGRESS", + "DELETE_IN_PROGRESS", + "ROLLBACK_COMPLETE", + "ROLLBACK_FAILED", + "UPDATE_ROLLBACK_FAILED", + "DELETE_FAILED", + ] as const)("refuses to repair a stack in %s", (status) => { + expect(() => readBootstrapState([stack(status, "30")])).toThrow(new RegExp(status)); + }); + + test("rejects a malformed bootstrap version", () => { + expect(() => readBootstrapState([stack("CREATE_COMPLETE", "v30")])).toThrow( + /invalid BootstrapVersion/, + ); + }); + + test("rejects an empty successful response", () => { + expect(() => readBootstrapState([])).toThrow(/CloudFormation returned no stack/); + }); +}); + +describe("probeBootstrap", () => { + test("treats only CloudFormation's stack-not-found response as absent", async () => { + const notFound = Object.assign(new Error("Stack with id CDKToolkit does not exist"), { + name: "ValidationError", + }); + + expect(isBootstrapStackNotFound(notFound)).toBe(true); + expect( + await probeBootstrap("us-east-1", async () => { + throw notFound; + }), + ).toEqual({ kind: "absent" }); + }); + + test.each([ + Object.assign(new Error("User is not authorized"), { + name: "AccessDeniedException", + }), + Object.assign(new Error("Rate exceeded"), { + name: "ThrottlingException", + }), + Object.assign(new Error("request timed out"), { name: "TimeoutError" }), + Object.assign(new Error("Template format error"), { + name: "ValidationError", + }), + ])("propagates %s instead of guessing the stack is absent", async (failure) => { + await expect( + probeBootstrap("us-east-1", async () => { + throw failure; + }), + ).rejects.toBe(failure); + }); + + test("reads the target region", async () => { + const regions: string[] = []; + + await probeBootstrap("eu-west-1", async (region) => { + regions.push(region); + return [stack("CREATE_COMPLETE", "30")]; + }); + + expect(regions).toEqual(["eu-west-1"]); + }); +}); diff --git a/src/core/project/backends/cdk/environment.ts b/src/core/project/backends/cdk/environment.ts new file mode 100644 index 000000000..f969a0ef5 --- /dev/null +++ b/src/core/project/backends/cdk/environment.ts @@ -0,0 +1,101 @@ +import type { Stack } from "@aws-sdk/client-cloudformation"; +import { MalformedServiceResponseError, ProjectStateError } from "../../../../errors/errors"; + +const BOOTSTRAP_STACK_NAME = "CDKToolkit"; +const BOOTSTRAP_VERSION_OUTPUT = "BootstrapVersion"; +export const MINIMUM_BOOTSTRAP_VERSION = 30; + +const STABLE_BOOTSTRAP_STATUSES = new Set([ + "CREATE_COMPLETE", + "UPDATE_COMPLETE", + "UPDATE_ROLLBACK_COMPLETE", +]); + +export type BootstrapState = + { kind: "absent" } | { kind: "current"; version: number } | { kind: "outdated"; version: number }; + +export type BootstrapStackReader = (region: string) => Promise; +export type BootstrapProbe = (region: string) => Promise; +export type AccountResolver = (region: string) => Promise; + +export function readBootstrapState(stacks?: Stack[]): Exclude { + const stack = stacks?.[0]; + if (!stack) { + throw new MalformedServiceResponseError( + `CloudFormation returned no stack after describing ${BOOTSTRAP_STACK_NAME}`, + ); + } + + const status = stack.StackStatus; + if (!status || !STABLE_BOOTSTRAP_STATUSES.has(status)) { + throw new ProjectStateError( + `The shared ${BOOTSTRAP_STACK_NAME} stack is in '${status ?? "UNKNOWN"}'. ` + + `Resolve that stack manually before deploying this project.`, + ); + } + + const rawVersion = stack.Outputs?.find( + ({ OutputKey }) => OutputKey === BOOTSTRAP_VERSION_OUTPUT, + )?.OutputValue; + if (rawVersion !== undefined && !/^[0-9]+$/.test(rawVersion)) { + throw new ProjectStateError( + `The shared ${BOOTSTRAP_STACK_NAME} stack has an invalid ` + + `${BOOTSTRAP_VERSION_OUTPUT} output: '${rawVersion}'.`, + ); + } + + const version = rawVersion === undefined ? 0 : Number.parseInt(rawVersion, 10); + return version >= MINIMUM_BOOTSTRAP_VERSION + ? { kind: "current", version } + : { kind: "outdated", version }; +} + +export function isBootstrapStackNotFound(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { name?: unknown; message?: unknown }; + return ( + candidate.name === "ValidationError" && + typeof candidate.message === "string" && + /Stack with id .+ does not exist/i.test(candidate.message) + ); +} + +const describeBootstrapStack: BootstrapStackReader = async (region) => { + const { CloudFormationClient, DescribeStacksCommand } = + await import("@aws-sdk/client-cloudformation"); + const client = new CloudFormationClient({ region }); + try { + const response = await client.send( + new DescribeStacksCommand({ StackName: BOOTSTRAP_STACK_NAME }), + ); + return response.Stacks; + } finally { + client.destroy(); + } +}; + +export async function probeBootstrap( + region: string, + read: BootstrapStackReader = describeBootstrapStack, +): Promise { + try { + return readBootstrapState(await read(region)); + } catch (error) { + if (isBootstrapStackNotFound(error)) return { kind: "absent" }; + throw error; + } +} + +export const resolveAwsAccount: AccountResolver = async (region) => { + const { GetCallerIdentityCommand, STSClient } = await import("@aws-sdk/client-sts"); + const client = new STSClient({ region }); + try { + const { Account } = await client.send(new GetCallerIdentityCommand({})); + if (!Account) { + throw new MalformedServiceResponseError("STS GetCallerIdentity returned no AWS account ID"); + } + return Account; + } finally { + client.destroy(); + } +}; diff --git a/src/core/project/backends/cdk/toolkit.test.ts b/src/core/project/backends/cdk/toolkit.test.ts index 3d2072489..88a4ead4d 100644 --- a/src/core/project/backends/cdk/toolkit.test.ts +++ b/src/core/project/backends/cdk/toolkit.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { rm } from "node:fs/promises"; +import { dirname } from "node:path"; import type { IoMessage, IoRequest } from "@aws-cdk/toolkit-lib"; import * as toolkitLib from "@aws-cdk/toolkit-lib"; import { createSilentLogger } from "../../../../testing"; @@ -6,11 +8,20 @@ import { createCdkIoHost, createCdkRunner, loadCdkToolkit, + loadBootstrapTemplate, performCdkOperation, type CdkToolkit, type LoadedCdkToolkit, } from "./toolkit"; +const temporaryTemplates: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryTemplates.splice(0).map((path) => rm(dirname(path), { recursive: true, force: true })), + ); +}); + function message(text: string): IoMessage { return { time: new Date(0), @@ -197,3 +208,20 @@ describe("Toolkit loading", () => { expect(regions).toEqual(["eu-west-1"]); }); }); + +describe("bootstrap template loading", () => { + test("materializes and cleans up an embedded template", async () => { + const template = await loadBootstrapTemplate([ + new File(["Resources: {}"], "lib/api/bootstrap/bootstrap-template.yaml"), + ]); + temporaryTemplates.push(template!.path); + + expect(await Bun.file(template!.path).text()).toBe("Resources: {}"); + await template!.cleanup(); + expect(await Bun.file(template!.path).exists()).toBe(false); + }); + + test("uses the installed Toolkit template when no file is embedded", async () => { + expect(await loadBootstrapTemplate([])).toBeUndefined(); + }); +}); diff --git a/src/core/project/backends/cdk/toolkit.ts b/src/core/project/backends/cdk/toolkit.ts index ec8a7d19d..691746c54 100644 --- a/src/core/project/backends/cdk/toolkit.ts +++ b/src/core/project/backends/cdk/toolkit.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import type { IIoHost, IoMessage, Toolkit } from "@aws-cdk/toolkit-lib"; import { AgentCoreCLIError } from "../../../../errors"; import type { Logger } from "../../../../logging"; @@ -35,6 +38,42 @@ export type LoadedCdkToolkit = { export type CdkToolkitLoader = (ioHost: IIoHost, region: string) => Promise; +type NamedBlob = Blob & { readonly name: string }; + +export type LoadedBootstrapTemplate = { + path: string; + cleanup(): Promise; +}; + +export type BootstrapTemplateLoader = () => Promise; + +const BOOTSTRAP_TEMPLATE = "bootstrap-template.yaml"; + +function embeddedFiles(): readonly NamedBlob[] { + return typeof Bun === "undefined" ? [] : (Bun.embeddedFiles as readonly NamedBlob[]); +} + +/** Materializes the Toolkit template embedded in a standalone executable. */ +export async function loadBootstrapTemplate( + files: readonly NamedBlob[] = embeddedFiles(), +): Promise { + const template = files.find((file) => file.name.endsWith(BOOTSTRAP_TEMPLATE)); + if (!template) return undefined; + + const directory = await mkdtemp(join(tmpdir(), "agentcore-bootstrap-")); + const path = join(directory, BOOTSTRAP_TEMPLATE); + try { + await writeFile(path, await template.text()); + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } + return { + path, + cleanup: () => rm(directory, { recursive: true, force: true }), + }; +} + export function createCdkIoHost(logger: Logger): IIoHost { const toolkitLogger = logger.child({ component: "cdk-toolkit" }); const notify = async (message: IoMessage): Promise => { diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index d9254ca07..62c8190b0 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -3,14 +3,18 @@ import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; import { DeserializationError, ProjectStateError } from "../../errors/errors"; +import type { AwsDeploymentTarget } from "../../projectSchemas/aws-targets"; +import { ProjectSpecSchema } from "../../projectSchemas/project"; import { FsProjectManager } from "./manager"; import { PROJECT_TEMPLATES, type CreateProjectInput, + type DeployResult, type Project, type ProjectEvent, } from "../../handlers/project/types"; import { createSilentLogger } from "../../testing"; +import type { DeployBackendInput, ProjectBackend } from "./backends/types"; const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -256,7 +260,16 @@ describe("FsProjectManager.build", () => { expect(commands).toEqual([ { - command: ["npm", "run", "cdk", "--", "synth", "--quiet"], + command: [ + "npm", + "run", + "cdk", + "--", + "synth", + "--quiet", + "--output", + join(directory, "example", "agentcore", "cdk", "cdk.out"), + ], cwd: join(directory, "example", "agentcore", "cdk"), }, ]); @@ -304,6 +317,141 @@ describe("FsProjectManager.build", () => { }); }); +describe("FsProjectManager.deploy", () => { + type DeployCall = { project: Project; input: DeployBackendInput }; + + function deployManager() { + const calls: DeployCall[] = []; + const backend: ProjectBackend = { + async *build() {}, + async *deploy(project, input) { + calls.push({ project, input }); + yield { message: "Backend deployment started" }; + return { outputs: { RuntimeArn: "arn:runtime" } }; + }, + }; + return { + calls, + manager: new FsProjectManager({ + logger: createSilentLogger(), + backends: { CDK: backend }, + }), + }; + } + + async function projectWithTargets(rootPath: string, targets?: unknown): Promise { + await mkdir(join(rootPath, "agentcore"), { recursive: true }); + if (targets !== undefined) { + await writeFile( + join(rootPath, "agentcore", "aws-targets.json"), + typeof targets === "string" ? targets : JSON.stringify(targets), + ); + } + return { + name: "example", + rootPath, + spec: { + ...ProjectSpecSchema.parse({ name: "example", version: 1 }), + }, + }; + } + + async function deploy( + manager: FsProjectManager, + project: Project, + target: string, + ): Promise<{ events: ProjectEvent[]; result: DeployResult }> { + const generator = manager.deploy(project, { target }); + const events: ProjectEvent[] = []; + while (true) { + const next = await generator.next(); + if (next.done) return { events, result: next.value }; + events.push(next.value as ProjectEvent); + } + } + + const targets: AwsDeploymentTarget[] = [ + { + name: "staging", + account: "111122223333", + region: "us-east-1", + }, + { + name: "prod", + account: "444455556666", + region: "eu-west-1", + }, + ]; + + test("resolves one target and returns the backend result", async () => { + const root = await inTempDirectory(); + const subject = deployManager(); + const project = await projectWithTargets(root, targets); + + const deployed = await deploy(subject.manager, project, "prod"); + + expect(subject.calls).toEqual([{ project, input: { target: targets[1]! } }]); + expect(deployed.events).toEqual([{ message: "Backend deployment started" }]); + expect(deployed.result).toEqual({ + outputs: { RuntimeArn: "arn:runtime" }, + }); + }); + + test("rejects an unknown target before invoking the backend", async () => { + const root = await inTempDirectory(); + const subject = deployManager(); + const project = await projectWithTargets(root, targets); + + await expect(deploy(subject.manager, project, "prd")).rejects.toThrow( + /no deployment target named 'prd'.*staging, prod/s, + ); + expect(subject.calls).toEqual([]); + }); + + test.each([ + ["a missing file", undefined], + ["an empty list", []], + ])("rejects %s before invoking the backend", async (_label, configured) => { + const root = await inTempDirectory(); + const subject = deployManager(); + const project = await projectWithTargets(root, configured); + + await expect(deploy(subject.manager, project, "default")).rejects.toThrow( + /No deployment targets are configured/, + ); + expect(subject.calls).toEqual([]); + }); + + test.each([ + ["malformed JSON", "{ not-json"], + ["an invalid target", [{ name: "default", account: "not-an-account", region: "us-east-1" }]], + [ + "duplicate targets", + [ + { + name: "default", + account: "111122223333", + region: "us-east-1", + }, + { + name: "default", + account: "444455556666", + region: "eu-west-1", + }, + ], + ], + ])("rejects %s before invoking the backend", async (_label, configured) => { + const root = await inTempDirectory(); + const subject = deployManager(); + const project = await projectWithTargets(root, configured); + + await expect(deploy(subject.manager, project, "default")).rejects.toThrow( + /not a valid list of deployment targets/, + ); + expect(subject.calls).toEqual([]); + }); +}); + describe("FsProjectManager.resolve", () => { test("round-trips a project it just created", async () => { const root = await inTempDirectory(); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 13fa990e4..e424e187c 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -28,6 +28,7 @@ import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project" import { enclosingProjectRoot } from "./fsUtils"; import { AgentCoreCLIError, + DeserializationError, InputValidationError, NotImplementedError, ProjectStateError, @@ -71,6 +72,7 @@ export class FsProjectManager implements ProjectManager { logger: config.logger, runner: config.runner, checkTool: config.checkTool, + json: config.json, }), }; } @@ -329,7 +331,15 @@ export class FsProjectManager implements ProjectManager { ); } - const targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema); + let targets; + try { + targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema); + } catch (error) { + if (!(error instanceof DeserializationError)) throw error; + throw new ProjectStateError(`${targetsPath} is not a valid list of deployment targets.`, { + cause: error, + }); + } if (targets.length === 0) { throw new ProjectStateError( diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 8b73848a1..45a97dcea 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -1,6 +1,6 @@ import { afterEach, test, expect, describe } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createRootHandler } from "../index"; @@ -582,7 +582,16 @@ describe("project build", () => { expect(core.projectCommands).toEqual([ { - command: ["npm", "run", "cdk", "--", "synth", "--quiet"], + command: [ + "npm", + "run", + "cdk", + "--", + "synth", + "--quiet", + "--output", + join(projectRoot, "agentcore", "cdk", "cdk.out"), + ], cwd: join(projectRoot, "agentcore", "cdk"), }, ]); @@ -620,20 +629,8 @@ describe("project deploy", () => { await expect(run(["deploy"])).rejects.toThrow(/No AgentCore project found/); }); - test("reports that a freshly scaffolded project has no deployment targets", async () => { + test("rejects a project with no deployment targets", async () => { await inProject(); await expect(run(["deploy"])).rejects.toThrow(/No deployment targets are configured/); }); - - // Proves the manager reaches CdkBackend.deploy once a target resolves; the - // backend is what remains unimplemented until the CDK deployment PR. - test("remains nonfunctional until deployment support is implemented", async () => { - const projectRoot = await inProject(); - await writeFile( - join(projectRoot, "agentcore", "aws-targets.json"), - JSON.stringify([{ name: "default", account: "111122223333", region: "us-east-1" }]), - ); - - await expect(run(["deploy"])).rejects.toThrow(/not implemented/); - }); }); From e4b687e90b92127604915ab0a1ba30c60292426f Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 20 Aug 2026 00:18:38 -0400 Subject: [PATCH 2/5] fix(project): share deploy credentials across CDK clients --- src/core/project/backends/cdk.test.ts | 27 +++++++++-- src/core/project/backends/cdk.ts | 13 +++-- .../project/backends/cdk/environment.test.ts | 17 +++++-- src/core/project/backends/cdk/environment.ts | 27 +++++++---- src/core/project/backends/cdk/toolkit.test.ts | 36 +++++++++++--- src/core/project/backends/cdk/toolkit.ts | 47 +++++++++++++++++-- 6 files changed, 137 insertions(+), 30 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 5432712a5..c02fec88e 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -7,7 +7,7 @@ import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { createSilentLogger } from "../../../testing"; import { CdkBackend } from "./cdk"; import type { BootstrapState } from "./cdk/environment"; -import type { CdkOperation, CdkOutputs, CdkRunOptions } from "./cdk/toolkit"; +import type { CdkCredentialProvider, CdkOperation, CdkOutputs, CdkRunOptions } from "./cdk/toolkit"; const TARGET = { name: "default", @@ -86,10 +86,17 @@ type HarnessOptions = { function harness(options: HarnessOptions = {}) { const commands: { command: string[]; cwd: string }[] = []; const runs: { operation: CdkOperation; options: CdkRunOptions }[] = []; + const credentialRegions: string[] = []; + const accountCredentials: CdkCredentialProvider[] = []; + const bootstrapCredentials: CdkCredentialProvider[] = []; const accountRegions: string[] = []; const bootstrapRegions: string[] = []; let templateLoads = 0; let templateCleanups = 0; + const credentials: CdkCredentialProvider = async () => ({ + accessKeyId: "access-key", + secretAccessKey: "secret-key", + }); const backend = new CdkBackend({ logger: createSilentLogger(), @@ -97,12 +104,18 @@ function harness(options: HarnessOptions = {}) { commands.push({ command, cwd }); }, checkTool: async () => {}, - resolveAccount: async (region) => { + resolveCredentials: async (region) => { + credentialRegions.push(region); + return credentials; + }, + resolveAccount: async (region, provider) => { accountRegions.push(region); + accountCredentials.push(provider); return options.account ?? TARGET.account; }, - bootstrap: async (region) => { + bootstrap: async (region, provider) => { bootstrapRegions.push(region); + bootstrapCredentials.push(provider); if (options.bootstrapError) throw options.bootstrapError; return options.bootstrap ?? { kind: "current", version: 30 }; }, @@ -126,10 +139,14 @@ function harness(options: HarnessOptions = {}) { }); return { + accountCredentials, accountRegions, backend, + bootstrapCredentials, bootstrapRegions, commands, + credentialRegions, + credentials, runs, templateLoads: () => templateLoads, templateCleanups: () => templateCleanups, @@ -209,10 +226,14 @@ describe("CdkBackend.deploy", () => { }, options: { assemblyDirectory: assemblyDirectory(input), + credentials: subject.credentials, region: TARGET.region, }, }, ]); + expect(subject.credentialRegions).toEqual([TARGET.region]); + expect(subject.accountCredentials).toEqual([subject.credentials]); + expect(subject.bootstrapCredentials).toEqual([subject.credentials]); expect(subject.accountRegions).toEqual([TARGET.region]); expect(subject.bootstrapRegions).toEqual([TARGET.region]); expect(subject.templateLoads()).toBe(0); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 30d27ebb8..44b722c1c 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -19,9 +19,11 @@ import { type BootstrapProbe, } from "./cdk/environment"; import { + createCdkCredentialResolver, createCdkRunner, loadBootstrapTemplate, type BootstrapTemplateLoader, + type CdkCredentialResolver, type CdkRunner, } from "./cdk/toolkit"; @@ -31,6 +33,7 @@ export type CdkBackendConfig = { checkTool?: typeof requireTool; json?: ReadWriteJson; cdk?: CdkRunner; + resolveCredentials?: CdkCredentialResolver; bootstrap?: BootstrapProbe; resolveAccount?: AccountResolver; loadBootstrapTemplate?: BootstrapTemplateLoader; @@ -43,6 +46,7 @@ export class CdkBackend implements ProjectBackend { private readonly checkTool: typeof requireTool; private readonly json: ReadWriteJson; private readonly cdk: CdkRunner; + private readonly resolveCredentials: CdkCredentialResolver; private readonly bootstrap: BootstrapProbe; private readonly resolveAccount: AccountResolver; private readonly loadBootstrapTemplate: BootstrapTemplateLoader; @@ -53,6 +57,8 @@ export class CdkBackend implements ProjectBackend { this.checkTool = config.checkTool ?? requireTool; this.json = config.json ?? new FsReadWriteJson({ logger: config.logger }); this.cdk = config.cdk ?? createCdkRunner(config.logger); + this.resolveCredentials = + config.resolveCredentials ?? createCdkCredentialResolver(config.logger); this.bootstrap = config.bootstrap ?? probeBootstrap; this.resolveAccount = config.resolveAccount ?? resolveAwsAccount; this.loadBootstrapTemplate = config.loadBootstrapTemplate ?? loadBootstrapTemplate; @@ -85,7 +91,8 @@ export class CdkBackend implements ProjectBackend { ): AsyncGenerator { const { target } = input; yield { message: `Verifying AWS account ${target.account}` }; - const account = await this.resolveAccount(target.region); + const credentials = await this.resolveCredentials(target.region); + const account = await this.resolveAccount(target.region, credentials); if (account !== target.account) { throw new ProjectStateError( `Deployment target '${target.name}' expects AWS account ${target.account}, ` + @@ -96,9 +103,9 @@ export class CdkBackend implements ProjectBackend { yield* this.build(project); const assemblyDirectory = this.assemblyDirectory(project); const stackName = await stackForTarget(this.json, assemblyDirectory, target.name); - const options = { assemblyDirectory, region: target.region }; + const options = { assemblyDirectory, credentials, region: target.region }; - const bootstrap = await this.bootstrap(target.region); + const bootstrap = await this.bootstrap(target.region, credentials); this.logger .child({ account: target.account, diff --git a/src/core/project/backends/cdk/environment.test.ts b/src/core/project/backends/cdk/environment.test.ts index 5b7e727cc..64d2c83a9 100644 --- a/src/core/project/backends/cdk/environment.test.ts +++ b/src/core/project/backends/cdk/environment.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test"; import type { Stack } from "@aws-sdk/client-cloudformation"; import { isBootstrapStackNotFound, probeBootstrap, readBootstrapState } from "./environment"; +import type { CdkCredentialProvider } from "./toolkit"; + +const credentials: CdkCredentialProvider = async () => ({ + accessKeyId: "access-key", + secretAccessKey: "secret-key", +}); function stack(status: Stack["StackStatus"], version?: string): Stack { return { @@ -65,7 +71,7 @@ describe("probeBootstrap", () => { expect(isBootstrapStackNotFound(notFound)).toBe(true); expect( - await probeBootstrap("us-east-1", async () => { + await probeBootstrap("us-east-1", credentials, async () => { throw notFound; }), ).toEqual({ kind: "absent" }); @@ -84,20 +90,23 @@ describe("probeBootstrap", () => { }), ])("propagates %s instead of guessing the stack is absent", async (failure) => { await expect( - probeBootstrap("us-east-1", async () => { + probeBootstrap("us-east-1", credentials, async () => { throw failure; }), ).rejects.toBe(failure); }); - test("reads the target region", async () => { + test("reads the target region with the deployment credentials", async () => { const regions: string[] = []; + const providers: CdkCredentialProvider[] = []; - await probeBootstrap("eu-west-1", async (region) => { + await probeBootstrap("eu-west-1", credentials, async (region, provider) => { regions.push(region); + providers.push(provider); return [stack("CREATE_COMPLETE", "30")]; }); expect(regions).toEqual(["eu-west-1"]); + expect(providers).toEqual([credentials]); }); }); diff --git a/src/core/project/backends/cdk/environment.ts b/src/core/project/backends/cdk/environment.ts index f969a0ef5..8169e125c 100644 --- a/src/core/project/backends/cdk/environment.ts +++ b/src/core/project/backends/cdk/environment.ts @@ -1,5 +1,6 @@ import type { Stack } from "@aws-sdk/client-cloudformation"; import { MalformedServiceResponseError, ProjectStateError } from "../../../../errors/errors"; +import type { CdkCredentialProvider } from "./toolkit"; const BOOTSTRAP_STACK_NAME = "CDKToolkit"; const BOOTSTRAP_VERSION_OUTPUT = "BootstrapVersion"; @@ -14,9 +15,18 @@ const STABLE_BOOTSTRAP_STATUSES = new Set([ export type BootstrapState = { kind: "absent" } | { kind: "current"; version: number } | { kind: "outdated"; version: number }; -export type BootstrapStackReader = (region: string) => Promise; -export type BootstrapProbe = (region: string) => Promise; -export type AccountResolver = (region: string) => Promise; +export type BootstrapStackReader = ( + region: string, + credentials: CdkCredentialProvider, +) => Promise; +export type BootstrapProbe = ( + region: string, + credentials: CdkCredentialProvider, +) => Promise; +export type AccountResolver = ( + region: string, + credentials: CdkCredentialProvider, +) => Promise; export function readBootstrapState(stacks?: Stack[]): Exclude { const stack = stacks?.[0]; @@ -60,10 +70,10 @@ export function isBootstrapStackNotFound(error: unknown): boolean { ); } -const describeBootstrapStack: BootstrapStackReader = async (region) => { +const describeBootstrapStack: BootstrapStackReader = async (region, credentials) => { const { CloudFormationClient, DescribeStacksCommand } = await import("@aws-sdk/client-cloudformation"); - const client = new CloudFormationClient({ region }); + const client = new CloudFormationClient({ credentials, region }); try { const response = await client.send( new DescribeStacksCommand({ StackName: BOOTSTRAP_STACK_NAME }), @@ -76,19 +86,20 @@ const describeBootstrapStack: BootstrapStackReader = async (region) => { export async function probeBootstrap( region: string, + credentials: CdkCredentialProvider, read: BootstrapStackReader = describeBootstrapStack, ): Promise { try { - return readBootstrapState(await read(region)); + return readBootstrapState(await read(region, credentials)); } catch (error) { if (isBootstrapStackNotFound(error)) return { kind: "absent" }; throw error; } } -export const resolveAwsAccount: AccountResolver = async (region) => { +export const resolveAwsAccount: AccountResolver = async (region, credentials) => { const { GetCallerIdentityCommand, STSClient } = await import("@aws-sdk/client-sts"); - const client = new STSClient({ region }); + const client = new STSClient({ credentials, region }); try { const { Account } = await client.send(new GetCallerIdentityCommand({})); if (!Account) { diff --git a/src/core/project/backends/cdk/toolkit.test.ts b/src/core/project/backends/cdk/toolkit.test.ts index 88a4ead4d..922a5f3cb 100644 --- a/src/core/project/backends/cdk/toolkit.test.ts +++ b/src/core/project/backends/cdk/toolkit.test.ts @@ -10,11 +10,27 @@ import { loadCdkToolkit, loadBootstrapTemplate, performCdkOperation, + resolveCdkCredentials, + type CdkCredentialProvider, + type CdkRunOptions, type CdkToolkit, type LoadedCdkToolkit, } from "./toolkit"; const temporaryTemplates: string[] = []; +const credentials: CdkCredentialProvider = async () => ({ + accessKeyId: "access-key", + secretAccessKey: "secret-key", +}); + +function runOptions(options: Partial = {}): CdkRunOptions { + return { + assemblyDirectory: "/unused", + credentials, + region: "us-east-1", + ...options, + }; +} afterEach(async () => { await Promise.all( @@ -96,7 +112,7 @@ describe("performCdkOperation", () => { await performCdkOperation( loaded, { kind: "bootstrap", environments: ["aws://111122223333/us-east-1"] }, - { assemblyDirectory: "/unused", region: "us-east-1" }, + runOptions(), ), ).toEqual({}); @@ -126,7 +142,7 @@ describe("performCdkOperation", () => { environments: ["aws://111122223333/us-east-1"], templateFile: "/tmp/bootstrap-template.yaml", }, - { assemblyDirectory: "/unused", region: "us-east-1" }, + runOptions(), ); expect(calls[0]!.args[1]).toMatchObject({ @@ -140,7 +156,7 @@ describe("performCdkOperation", () => { const outputs = await performCdkOperation( loaded, { kind: "deploy", stackName: "AgentCore-orders-default" }, - { assemblyDirectory: "/workspace/agentcore/cdk/cdk.out", region: "us-east-1" }, + runOptions({ assemblyDirectory: "/workspace/agentcore/cdk/cdk.out" }), ); expect(outputs).toEqual({ RuntimeArn: "arn:runtime" }); @@ -186,26 +202,32 @@ describe("performCdkOperation", () => { describe("Toolkit loading", () => { test("constructs the real Toolkit without resolving credentials", async () => { - const loaded = await loadCdkToolkit(createCdkIoHost(createSilentLogger()), "us-west-2"); + const ioHost = createCdkIoHost(createSilentLogger()); + const provider = await resolveCdkCredentials(ioHost, "us-west-2"); + const loaded = await loadCdkToolkit(ioHost, "us-west-2", provider); + expect(typeof provider).toBe("function"); expect(typeof loaded.toolkit.bootstrap).toBe("function"); expect(typeof loaded.toolkit.deploy).toBe("function"); }); - test("loads the Toolkit with the target region for each operation", async () => { + test("loads the Toolkit with the target region and deployment credentials", async () => { const { loaded } = loadedToolkit(); const regions: string[] = []; - const runner = createCdkRunner(createSilentLogger(), async (_ioHost, region) => { + const providers: CdkCredentialProvider[] = []; + const runner = createCdkRunner(createSilentLogger(), async (_ioHost, region, provider) => { regions.push(region); + providers.push(provider); return loaded; }); await runner( { kind: "deploy", stackName: "AgentCore-orders-default" }, - { assemblyDirectory: "/workspace/cdk.out", region: "eu-west-1" }, + runOptions({ assemblyDirectory: "/workspace/cdk.out", region: "eu-west-1" }), ); expect(regions).toEqual(["eu-west-1"]); + expect(providers).toEqual([credentials]); }); }); diff --git a/src/core/project/backends/cdk/toolkit.ts b/src/core/project/backends/cdk/toolkit.ts index 691746c54..19f1c0ad9 100644 --- a/src/core/project/backends/cdk/toolkit.ts +++ b/src/core/project/backends/cdk/toolkit.ts @@ -1,7 +1,13 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import type { IIoHost, IoMessage, Toolkit } from "@aws-cdk/toolkit-lib"; +import type { + IActionAwareIoHost, + IIoHost, + IoMessage, + SdkBaseConfig, + Toolkit, +} from "@aws-cdk/toolkit-lib"; import { AgentCoreCLIError } from "../../../../errors"; import type { Logger } from "../../../../logging"; @@ -12,11 +18,15 @@ export type CdkOperation = export type CdkRunOptions = { /** Synthesized cloud assembly used by deploy operations. */ assemblyDirectory: string; + /** Credential provider shared with deployment preflight calls. */ + credentials: CdkCredentialProvider; /** Region used for the Toolkit's own AWS SDK calls. */ region: string; }; export type CdkOutputs = Record; +export type CdkCredentialProvider = SdkBaseConfig["credentialProvider"]; +export type CdkCredentialResolver = (region: string) => Promise; export type CdkRunner = (operation: CdkOperation, options: CdkRunOptions) => Promise; @@ -36,7 +46,11 @@ export type LoadedCdkToolkit = { toolkit: CdkToolkit; }; -export type CdkToolkitLoader = (ioHost: IIoHost, region: string) => Promise; +export type CdkToolkitLoader = ( + ioHost: IIoHost, + region: string, + credentials: CdkCredentialProvider, +) => Promise; type NamedBlob = Blob & { readonly name: string }; @@ -95,8 +109,31 @@ export function createCdkIoHost(logger: Logger): IIoHost { }; } +function forAction(ioHost: IIoHost, action: "deploy"): IActionAwareIoHost { + return { + notify: (message) => ioHost.notify({ ...message, action }), + requestResponse: (request) => ioHost.requestResponse({ ...request, action }), + }; +} + +export async function resolveCdkCredentials( + ioHost: IIoHost, + region: string, +): Promise { + const { BaseCredentials } = await import("@aws-cdk/toolkit-lib"); + const config = await BaseCredentials.awsCliCompatible({ + defaultRegion: region, + }).sdkBaseConfig(forAction(ioHost, "deploy"), {}); + return config.credentialProvider; +} + +export function createCdkCredentialResolver(logger: Logger): CdkCredentialResolver { + const ioHost = createCdkIoHost(logger); + return (region) => resolveCdkCredentials(ioHost, region); +} + /** Loads the Toolkit only when a deploy operation needs it. */ -export const loadCdkToolkit: CdkToolkitLoader = async (ioHost, region) => { +export const loadCdkToolkit: CdkToolkitLoader = async (ioHost, region, credentials) => { const lib = await import("@aws-cdk/toolkit-lib"); return { lib, @@ -105,7 +142,7 @@ export const loadCdkToolkit: CdkToolkitLoader = async (ioHost, region) => { color: false, emojis: false, sdkConfig: { - baseCredentials: lib.BaseCredentials.awsCliCompatible({ defaultRegion: region }), + baseCredentials: lib.BaseCredentials.custom({ provider: credentials, region }), }, }), }; @@ -159,7 +196,7 @@ export function createCdkRunner( ): CdkRunner { const ioHost = createCdkIoHost(logger); return async (operation, options) => { - const loaded = await load(ioHost, options.region); + const loaded = await load(ioHost, options.region, options.credentials); return performCdkOperation(loaded, operation, options); }; } From 769427bf48551bf5dd0a2c799faede1ea904e9d6 Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 20 Aug 2026 00:27:54 -0400 Subject: [PATCH 3/5] refactor(project): simplify deploy target resolution --- src/core/project/backends/cdk.test.ts | 4 ++-- src/core/project/backends/cdk.ts | 12 ++++++++---- src/core/project/backends/cdk/assembly.test.ts | 12 +++++++----- src/core/project/backends/cdk/assembly.ts | 4 ++-- src/core/project/backends/cdk/toolkit.test.ts | 12 ++++++------ src/core/project/backends/cdk/toolkit.ts | 6 +++--- src/core/project/manager.test.ts | 4 ++-- src/core/project/manager.tsx | 11 +---------- 8 files changed, 31 insertions(+), 34 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index c02fec88e..221d4b181 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -222,7 +222,7 @@ describe("CdkBackend.deploy", () => { { operation: { kind: "deploy", - stackName: "AgentCore-example-default-0", + stackArtifactId: "AgentCore-example-default-0", }, options: { assemblyDirectory: assemblyDirectory(input), @@ -254,7 +254,7 @@ describe("CdkBackend.deploy", () => { kind: "bootstrap", environments: [`aws://${TARGET.account}/${TARGET.region}`], }, - { kind: "deploy", stackName: "AgentCore-example-default-0" }, + { kind: "deploy", stackArtifactId: "AgentCore-example-default-0" }, ]); expect(deployed.events).toContainEqual({ message: `Bootstrapping aws://${TARGET.account}/${TARGET.region}`, diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 44b722c1c..334d3d373 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -11,7 +11,7 @@ import { } from "../../../io"; import type { Logger } from "../../../logging"; import type { DeployBackendInput, ProjectBackend } from "./types"; -import { stackForTarget } from "./cdk/assembly"; +import { stackArtifactIdForTarget } from "./cdk/assembly"; import { probeBootstrap, resolveAwsAccount, @@ -102,7 +102,11 @@ export class CdkBackend implements ProjectBackend { yield* this.build(project); const assemblyDirectory = this.assemblyDirectory(project); - const stackName = await stackForTarget(this.json, assemblyDirectory, target.name); + const stackArtifactId = await stackArtifactIdForTarget( + this.json, + assemblyDirectory, + target.name, + ); const options = { assemblyDirectory, credentials, region: target.region }; const bootstrap = await this.bootstrap(target.region, credentials); @@ -133,8 +137,8 @@ export class CdkBackend implements ProjectBackend { } } - yield { message: `Deploying ${stackName}` }; - const outputs = await this.cdk({ kind: "deploy", stackName }, options); + yield { message: `Deploying ${stackArtifactId}` }; + const outputs = await this.cdk({ kind: "deploy", stackArtifactId }, options); return { outputs }; } diff --git a/src/core/project/backends/cdk/assembly.test.ts b/src/core/project/backends/cdk/assembly.test.ts index 32c73cc4d..20180acee 100644 --- a/src/core/project/backends/cdk/assembly.test.ts +++ b/src/core/project/backends/cdk/assembly.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { FsReadWriteJson } from "../../../../io"; import { createSilentLogger } from "../../../../testing"; -import { stackForTarget } from "./assembly"; +import { stackArtifactIdForTarget } from "./assembly"; const temporaryDirectories: string[] = []; const json = new FsReadWriteJson({ logger: createSilentLogger() }); @@ -24,7 +24,7 @@ async function assembly(artifacts: Record): Promise { return directory; } -describe("stackForTarget", () => { +describe("stackArtifactIdForTarget", () => { test("selects by the target tag instead of deriving a stack name", async () => { const directory = await assembly({ "nested/stack-id": { @@ -35,7 +35,7 @@ describe("stackForTarget", () => { }, }); - expect(await stackForTarget(json, directory, "prod")).toBe("nested/stack-id"); + expect(await stackArtifactIdForTarget(json, directory, "prod")).toBe("nested/stack-id"); }); test("ignores non-stack artifacts", async () => { @@ -48,7 +48,9 @@ describe("stackForTarget", () => { }, }); - await expect(stackForTarget(json, directory, "prod")).rejects.toThrow(/defines 0 stack/); + await expect(stackArtifactIdForTarget(json, directory, "prod")).rejects.toThrow( + /defines 0 stack/, + ); }); test("reports a missing manifest before attempting deployment", async () => { @@ -56,7 +58,7 @@ describe("stackForTarget", () => { temporaryDirectories.push(directory); await mkdir(directory, { recursive: true }); - await expect(stackForTarget(json, directory, "prod")).rejects.toThrow( + await expect(stackArtifactIdForTarget(json, directory, "prod")).rejects.toThrow( /No synthesized cloud assembly was found/, ); }); diff --git a/src/core/project/backends/cdk/assembly.ts b/src/core/project/backends/cdk/assembly.ts index e358262ad..4077da573 100644 --- a/src/core/project/backends/cdk/assembly.ts +++ b/src/core/project/backends/cdk/assembly.ts @@ -23,8 +23,8 @@ const AssemblyManifestSchema = z.object({ .default({}), }); -/** Finds the one synthesized stack tagged for the selected deployment target. */ -export async function stackForTarget( +/** Finds the one synthesized stack artifact tagged for the selected deployment target. */ +export async function stackArtifactIdForTarget( json: ReadWriteJson, assemblyDirectory: string, target: string, diff --git a/src/core/project/backends/cdk/toolkit.test.ts b/src/core/project/backends/cdk/toolkit.test.ts index 922a5f3cb..c8211ed49 100644 --- a/src/core/project/backends/cdk/toolkit.test.ts +++ b/src/core/project/backends/cdk/toolkit.test.ts @@ -155,7 +155,7 @@ describe("performCdkOperation", () => { const outputs = await performCdkOperation( loaded, - { kind: "deploy", stackName: "AgentCore-orders-default" }, + { kind: "deploy", stackArtifactId: "AgentCore-orders-default" }, runOptions({ assemblyDirectory: "/workspace/agentcore/cdk/cdk.out" }), ); @@ -178,8 +178,8 @@ describe("performCdkOperation", () => { const deploying = performCdkOperation( loaded, - { kind: "deploy", stackName: "AgentCore-orders-default" }, - { assemblyDirectory: "/workspace/agentcore/cdk/cdk.out", region: "us-east-1" }, + { kind: "deploy", stackArtifactId: "AgentCore-orders-default" }, + runOptions({ assemblyDirectory: "/workspace/agentcore/cdk/cdk.out" }), ); await expect(deploying).rejects.toThrow( @@ -192,8 +192,8 @@ describe("performCdkOperation", () => { const outputs = await performCdkOperation( loaded, - { kind: "deploy", stackName: "AgentCore-orders-default" }, - { assemblyDirectory: "/workspace/agentcore/cdk/cdk.out", region: "us-east-1" }, + { kind: "deploy", stackArtifactId: "AgentCore-orders-default" }, + runOptions({ assemblyDirectory: "/workspace/agentcore/cdk/cdk.out" }), ); expect(outputs).toEqual({}); @@ -222,7 +222,7 @@ describe("Toolkit loading", () => { }); await runner( - { kind: "deploy", stackName: "AgentCore-orders-default" }, + { kind: "deploy", stackArtifactId: "AgentCore-orders-default" }, runOptions({ assemblyDirectory: "/workspace/cdk.out", region: "eu-west-1" }), ); diff --git a/src/core/project/backends/cdk/toolkit.ts b/src/core/project/backends/cdk/toolkit.ts index 19f1c0ad9..666b76fb2 100644 --- a/src/core/project/backends/cdk/toolkit.ts +++ b/src/core/project/backends/cdk/toolkit.ts @@ -13,7 +13,7 @@ import type { Logger } from "../../../../logging"; export type CdkOperation = | { kind: "bootstrap"; environments: string[]; templateFile?: string } - | { kind: "deploy"; stackName: string }; + | { kind: "deploy"; stackArtifactId: string }; export type CdkRunOptions = { /** Synthesized cloud assembly used by deploy operations. */ @@ -169,7 +169,7 @@ export async function performCdkOperation( const result = await toolkit.deploy(source, { stacks: { strategy: lib.StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE, - patterns: [operation.stackName], + patterns: [operation.stackArtifactId], }, }); @@ -180,7 +180,7 @@ export async function performCdkOperation( // deletion a successful deploy. if (result.stacks.length !== 1) { throw new AgentCoreCLIError( - `The CDK Toolkit deployed no stack for '${operation.stackName}'. ` + + `The CDK Toolkit deployed no stack for '${operation.stackArtifactId}'. ` + `This happens when the synthesized stack has no resources, in which case an ` + `existing stack of that name is deleted rather than updated.`, ); diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 62c8190b0..ffbcbbe3b 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -445,8 +445,8 @@ describe("FsProjectManager.deploy", () => { const subject = deployManager(); const project = await projectWithTargets(root, configured); - await expect(deploy(subject.manager, project, "default")).rejects.toThrow( - /not a valid list of deployment targets/, + await expect(deploy(subject.manager, project, "default")).rejects.toBeInstanceOf( + DeserializationError, ); expect(subject.calls).toEqual([]); }); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index e424e187c..54ed00c50 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -28,7 +28,6 @@ import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project" import { enclosingProjectRoot } from "./fsUtils"; import { AgentCoreCLIError, - DeserializationError, InputValidationError, NotImplementedError, ProjectStateError, @@ -331,15 +330,7 @@ export class FsProjectManager implements ProjectManager { ); } - let targets; - try { - targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema); - } catch (error) { - if (!(error instanceof DeserializationError)) throw error; - throw new ProjectStateError(`${targetsPath} is not a valid list of deployment targets.`, { - cause: error, - }); - } + const targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema); if (targets.length === 0) { throw new ProjectStateError( From 53633d32acdb158b1ceb0df245b5d8e231ffe53a Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 24 Aug 2026 11:12:28 -0400 Subject: [PATCH 4/5] fix(project): fail loudly when a binary ships without the bootstrap template loadBootstrapTemplate() returned undefined both when there is nothing embedded and when the embed is missing from a standalone binary. The first case is fine: scripts and the npm bundle have node_modules, so the Toolkit reads the template from its own package. The second is not. A binary has no node_modules, and the Toolkit resolves its package directory from a __dirname that Bun rewrote to the build machine's path, so the user sees "Unable to find package manifest" rather than anything about bootstrapping. Treat other embedded files as proof this is a binary and fail with a message that names the missing file. --- src/core/project/backends/cdk/toolkit.test.ts | 13 +++++++++++++ src/core/project/backends/cdk/toolkit.ts | 17 ++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/core/project/backends/cdk/toolkit.test.ts b/src/core/project/backends/cdk/toolkit.test.ts index c8211ed49..8519366fb 100644 --- a/src/core/project/backends/cdk/toolkit.test.ts +++ b/src/core/project/backends/cdk/toolkit.test.ts @@ -246,4 +246,17 @@ describe("bootstrap template loading", () => { test("uses the installed Toolkit template when no file is embedded", async () => { expect(await loadBootstrapTemplate([])).toBeUndefined(); }); + + // Other embedded files prove this is a standalone binary, where falling back to + // the installed Toolkit is not an option: it would resolve its own package from + // a build-time path and report a missing package manifest instead. + test("fails when a binary embeds assets but not the bootstrap template", async () => { + const loading = loadBootstrapTemplate([ + new File(["{}"], "agentcore-assets/src/assets/cdk/package.json"), + ]); + + await expect(loading).rejects.toThrow( + /missing its copy of bootstrap-template\.yaml.*Reinstall the CLI/s, + ); + }); }); diff --git a/src/core/project/backends/cdk/toolkit.ts b/src/core/project/backends/cdk/toolkit.ts index 666b76fb2..243427c14 100644 --- a/src/core/project/backends/cdk/toolkit.ts +++ b/src/core/project/backends/cdk/toolkit.ts @@ -72,7 +72,22 @@ export async function loadBootstrapTemplate( files: readonly NamedBlob[] = embeddedFiles(), ): Promise { const template = files.find((file) => file.name.endsWith(BOOTSTRAP_TEMPLATE)); - if (!template) return undefined; + if (!template) { + // No embedded files at all means a script or npm bundle, where node_modules + // exists and the Toolkit reads the template from its own package. Embedded + // files *without* the template means a standalone binary whose embed went + // missing; returning undefined there sends the Toolkit looking for a package + // directory relative to a build-time __dirname that does not exist on this + // machine, so the user gets "Unable to find package manifest" instead. + if (files.length > 0) { + throw new AgentCoreCLIError( + `This build of the CLI is missing its copy of ${BOOTSTRAP_TEMPLATE}, so it cannot ` + + `bootstrap an AWS environment. Reinstall the CLI, or report this at ` + + `https://github.com/aws/agentcore-cli/issues.`, + ); + } + return undefined; + } const directory = await mkdtemp(join(tmpdir(), "agentcore-bootstrap-")); const path = join(directory, BOOTSTRAP_TEMPLATE); From 04663bf5daef2c6e3dacbde44b1af0ce17bad56b Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 24 Aug 2026 13:40:48 -0400 Subject: [PATCH 5/5] fix(build): name Windows executables with the extension Bun emits Bun appends .exe to a Windows executable whose outfile carries no extension, so the emitted path was never the one we asked for. The embed assertion then read the extensionless path and failed with ENOENT, which is what turned the Windows build red. The CI smoke test already expects agentcore-windows-x64.exe, so build.ts was the only place disagreeing. Derived from the target rather than hardcoded, since `bun run compile` also builds windows-arm64, which CI does not smoke test. --- scripts/build.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/build.ts b/scripts/build.ts index 9b1412474..45256374e 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -108,7 +108,11 @@ async function compile(target: string): Promise { const assets = discoverAssets(); await assertAssetsAreText(assets); - const outfile = join(DIST, "bin", `agentcore-${target.replace(/^bun-/, "")}`); + // Bun appends .exe to a Windows executable whose outfile has no extension, so + // the emitted path is not the one we asked for. Name it in full instead: the + // embed assertion below and the CI smoke test both read this exact path. + const extension = target.includes("windows") ? ".exe" : ""; + const outfile = join(DIST, "bin", `agentcore-${target.replace(/^bun-/, "")}${extension}`); await $`mkdir -p ${join(DIST, "bin")}`; const template = bootstrapTemplate();