From 9303322cdef4c2d5a05b33c3f9c007a3340ea267 Mon Sep 17 00:00:00 2001 From: notgitika Date: Wed, 19 Aug 2026 21:11:56 -0400 Subject: [PATCH 1/3] 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 6ac8385fb..7c7697ffc 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/resources": "^2.10.0", diff --git a/package.json b/package.json index ad5409055..59af691e2 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/resources": "^2.10.0", diff --git a/scripts/build.ts b/scripts/build.ts index 14d303a76..c167253a6 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"; @@ -15,6 +16,8 @@ const ASSET_NAMING = "agentcore-assets/[dir]/[name].[ext]"; // that package intact when producing the npm bundle. 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; @@ -30,14 +33,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 }); @@ -75,15 +103,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 83c1014bc..fe18f334e 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), @@ -165,3 +176,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 bbc1ffd56..29af81c62 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 type { Logger } from "../../../../logging"; @@ -34,6 +37,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 4645dab98..009ce7074 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 45e16cbcc..4ca76c478 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -27,6 +27,7 @@ import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project" import { enclosingProjectRoot } from "./fsUtils"; import { AgentCoreCLIError, + DeserializationError, InputValidationError, NotImplementedError, ProjectStateError, @@ -70,6 +71,7 @@ export class FsProjectManager implements ProjectManager { logger: config.logger, runner: config.runner, checkTool: config.checkTool, + json: config.json, }), }; } @@ -301,7 +303,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 7d0036e85..704ee09d9 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"; @@ -315,7 +315,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"), }, ]); @@ -353,20 +362,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 bddfaf3afa29659dba8f95c18206cf1b611d2452 Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 20 Aug 2026 00:18:38 -0400 Subject: [PATCH 2/3] 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 fe18f334e..65533b2b2 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( @@ -93,7 +109,7 @@ describe("performCdkOperation", () => { await performCdkOperation( loaded, { kind: "bootstrap", environments: ["aws://111122223333/us-east-1"] }, - { assemblyDirectory: "/unused", region: "us-east-1" }, + runOptions(), ), ).toEqual({}); @@ -123,7 +139,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({ @@ -137,7 +153,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" }); @@ -154,26 +170,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 29af81c62..00913c2e1 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 type { Logger } from "../../../../logging"; export type CdkOperation = @@ -11,11 +17,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; @@ -35,7 +45,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 }; @@ -94,8 +108,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, @@ -104,7 +141,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 }), }, }), }; @@ -143,7 +180,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 9d9e7f64a14c3d021f4970c422966313c4e09172 Mon Sep 17 00:00:00 2001 From: notgitika Date: Thu, 20 Aug 2026 00:27:54 -0400 Subject: [PATCH 3/3] 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 | 4 ++-- src/core/project/backends/cdk/toolkit.ts | 4 ++-- src/core/project/manager.test.ts | 4 ++-- src/core/project/manager.tsx | 11 +---------- 8 files changed, 26 insertions(+), 29 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 65533b2b2..8a0bf45f4 100644 --- a/src/core/project/backends/cdk/toolkit.test.ts +++ b/src/core/project/backends/cdk/toolkit.test.ts @@ -152,7 +152,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" }), ); @@ -190,7 +190,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 00913c2e1..3a452e1af 100644 --- a/src/core/project/backends/cdk/toolkit.ts +++ b/src/core/project/backends/cdk/toolkit.ts @@ -12,7 +12,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. */ @@ -168,7 +168,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], }, }); return result.stacks[0]?.outputs ?? {}; diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 009ce7074..b713bfeaa 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 4ca76c478..97a8b4e52 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -27,7 +27,6 @@ import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project" import { enclosingProjectRoot } from "./fsUtils"; import { AgentCoreCLIError, - DeserializationError, InputValidationError, NotImplementedError, ProjectStateError, @@ -303,15 +302,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(