diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 83e54bb4a..701339bce 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -124,12 +124,16 @@ async function main() { const connectorParametersByFile = resolveConnectorParametersByFile(specAny, projectRoot); const harnessConfigs = resolveHarnessConfigs(specAny, projectRoot); - // Read deployed state for credential ARNs (populated by pre-deploy identity setup) + // Read deployed state for credential ARNs (populated by pre-deploy identity setup). + // Under agentcore/.cli/ to match the released CLI's location. let deployedState: Record | undefined; try { deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); - } catch { - // Deployed state may not exist on first deploy + } catch (err) { + // A missing file is the normal first-deploy case. A malformed one is not: + // surface it rather than silently synthesizing without the credential ARNs + // it holds (which would drop them from the stack). + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } const app = new App(); diff --git a/src/assets/templates/shared/gitignore.template b/src/assets/templates/shared/gitignore.template index d00650afc..f50fc8b44 100644 --- a/src/assets/templates/shared/gitignore.template +++ b/src/assets/templates/shared/gitignore.template @@ -10,8 +10,10 @@ __pycache__/ # Node node_modules/ -# AgentCore CLI state -agentcore/.cli/ +# AgentCore CLI state (ignore local scratch like traces/logs, but commit the +# deployed-state binding so a target's stack + credential ARNs are shared) +agentcore/.cli/* +!agentcore/.cli/deployed-state.json # CDK agentcore/cdk/cdk.out/ diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 221d4b181..288bb2199 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, 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 { CdkBackend } from "./cdk"; +import { DEPLOYED_STATE_RELATIVE_PATH } from "./cdk/deployedState"; import type { BootstrapState } from "./cdk/environment"; import type { CdkCredentialProvider, CdkOperation, CdkOutputs, CdkRunOptions } from "./cdk/toolkit"; @@ -78,6 +80,8 @@ type HarnessOptions = { account?: string; bootstrap?: BootstrapState; outputs?: CdkOutputs; + stackArn?: string; + omitStackArn?: boolean; template?: boolean; failOperation?: CdkOperation["kind"]; bootstrapError?: Error; @@ -124,7 +128,19 @@ function harness(options: HarnessOptions = {}) { if (operation.kind === options.failOperation) { throw new Error(`${operation.kind} failed`); } - return operation.kind === "deploy" ? (options.outputs ?? {}) : {}; + if (operation.kind !== "deploy") return { outputs: {} }; + return { + outputs: options.outputs ?? {}, + // A real deploy always carries a stack ARN; default one so tests exercise + // the persistence path, and use `omitStackArn` to test its absence. + ...(options.omitStackArn + ? {} + : { + stackArn: + options.stackArn ?? + "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/deployed", + }), + }; }, loadBootstrapTemplate: async () => { templateLoads++; @@ -239,6 +255,53 @@ describe("CdkBackend.deploy", () => { expect(subject.templateLoads()).toBe(0); }); + test("persists the deployed stack ARN under the target", async () => { + const input = await project(); + await writeAssembly(input, [TARGET.name]); + const subject = harness({ + outputs: { RuntimeArn: "arn:runtime" }, + stackArn: "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc", + }); + + await collectDeploy(subject.backend.deploy(input, { target: TARGET })); + + const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH); + expect(JSON.parse(await Bun.file(statePath).text())).toEqual({ + targets: { + default: { + stackArn: + "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc", + }, + }, + }); + }); + + test("fails a deploy whose result carries no stack ARN, recording nothing", async () => { + const input = await project(); + await writeAssembly(input, [TARGET.name]); + const subject = harness({ outputs: { RuntimeArn: "arn:runtime" }, omitStackArn: true }); + + await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toThrow( + /without a stack ARN/, + ); + expect(existsSync(join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH))).toBe(false); + }); + + test("fails before touching AWS when the existing state file is malformed", async () => { + const input = await project(); + const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH); + await mkdir(dirname(statePath), { recursive: true }); + await writeFile(statePath, "{ not valid json"); + const subject = harness({ outputs: { RuntimeArn: "arn:runtime" } }); + + await expect( + collectDeploy(subject.backend.deploy(input, { target: TARGET })), + ).rejects.toThrow(); + // Validated before synth/bootstrap/deploy, so nothing ran against AWS. + expect(subject.commands).toEqual([]); + expect(subject.runs).toEqual([]); + }); + test.each([ ["absent", { kind: "absent" } as const], ["outdated", { kind: "outdated", version: 29 } as const], diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 334d3d373..ee160263e 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { ProjectStateError } from "../../../errors/errors"; +import { MalformedServiceResponseError, ProjectStateError } from "../../../errors/errors"; import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; import { FsReadWriteJson, @@ -12,6 +12,7 @@ import { import type { Logger } from "../../../logging"; import type { DeployBackendInput, ProjectBackend } from "./types"; import { stackArtifactIdForTarget } from "./cdk/assembly"; +import { readDeployedState, updateTargetState } from "./cdk/deployedState"; import { probeBootstrap, resolveAwsAccount, @@ -100,6 +101,11 @@ export class CdkBackend implements ProjectBackend { ); } + // Validate any existing deployed state before mutating AWS. A malformed file + // must fail here — not after bootstrap/deploy — so we never leave AWS changed + // with the new stack ARN unrecorded because the post-deploy write can't parse it. + await readDeployedState(this.json, project.rootPath); + yield* this.build(project); const assemblyDirectory = this.assemblyDirectory(project); const stackArtifactId = await stackArtifactIdForTarget( @@ -138,7 +144,22 @@ export class CdkBackend implements ProjectBackend { } yield { message: `Deploying ${stackArtifactId}` }; - const outputs = await this.cdk({ kind: "deploy", stackArtifactId }, options); + const { outputs, stackArn } = await this.cdk({ kind: "deploy", stackArtifactId }, options); + + // A successful deploy always has a stack ARN (CDK's DeployedStack requires + // it). Its absence means a malformed result; fail loudly rather than return + // success without recording the binding later commands need. + if (!stackArn) { + throw new MalformedServiceResponseError( + `The CDK Toolkit reported a successful deploy of '${stackArtifactId}' without a stack ARN.`, + ); + } + + // Persist the deployed stack's ARN so later commands read live resource state + // from CloudFormation. Merged per target, so deploying one target never drops + // another's recorded state. + await updateTargetState(this.json, project.rootPath, target.name, { stackArn }); + return { outputs }; } diff --git a/src/core/project/backends/cdk/deployedState.test.ts b/src/core/project/backends/cdk/deployedState.test.ts new file mode 100644 index 000000000..32bac0a33 --- /dev/null +++ b/src/core/project/backends/cdk/deployedState.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FsReadWriteJson } from "../../../../io"; +import { createSilentLogger } from "../../../../testing"; +import { + DEPLOYED_STATE_RELATIVE_PATH, + readDeployedState, + updateTargetState, +} from "./deployedState"; + +const json = new FsReadWriteJson({ logger: createSilentLogger() }); + +const tempDirectories: string[] = []; +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function projectRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "agentcore-deployed-state-")); + tempDirectories.push(root); + return root; +} + +function statePath(root: string): string { + return join(root, DEPLOYED_STATE_RELATIVE_PATH); +} + +async function readRaw(root: string): Promise { + return JSON.parse(await Bun.file(statePath(root)).text()); +} + +describe("readDeployedState", () => { + test("returns an empty state when the file does not exist", async () => { + const root = await projectRoot(); + + expect(await readDeployedState(json, root)).toEqual({ targets: {} }); + expect(existsSync(statePath(root))).toBe(false); + }); + + test("round-trips a previously written state", async () => { + const root = await projectRoot(); + + await updateTargetState(json, root, "default", { stackArn: "arn:stack:default" }); + + expect(await readDeployedState(json, root)).toEqual({ + targets: { default: { stackArn: "arn:stack:default" } }, + }); + }); + + test("resolves the file under agentcore/.cli/", () => { + expect(DEPLOYED_STATE_RELATIVE_PATH).toBe(join("agentcore", ".cli", "deployed-state.json")); + }); +}); + +describe("updateTargetState", () => { + test("creates the file with the target entry", async () => { + const root = await projectRoot(); + + await updateTargetState(json, root, "default", { stackArn: "arn:stack:default" }); + + expect(await readRaw(root)).toEqual({ + targets: { default: { stackArn: "arn:stack:default" } }, + }); + }); + + test("preserves other targets", async () => { + const root = await projectRoot(); + + await updateTargetState(json, root, "default", { stackArn: "arn:stack:default" }); + await updateTargetState(json, root, "prod", { stackArn: "arn:stack:prod" }); + + expect(await readRaw(root)).toEqual({ + targets: { + default: { stackArn: "arn:stack:default" }, + prod: { stackArn: "arn:stack:prod" }, + }, + }); + }); + + test("merges resources without dropping the stack ARN", async () => { + const root = await projectRoot(); + const credentials = { "openai-key": { credentialProviderArn: "arn:apikey:openai-key" } }; + + await updateTargetState(json, root, "default", { stackArn: "arn:stack:default" }); + await updateTargetState(json, root, "default", { resources: { credentials } }); + + expect(await readRaw(root)).toEqual({ + targets: { default: { stackArn: "arn:stack:default", resources: { credentials } } }, + }); + }); + + test("replaces a resource kind's map wholesale but keeps other kinds", async () => { + const root = await projectRoot(); + + await updateTargetState(json, root, "default", { + resources: { + credentials: { old: { credentialProviderArn: "arn:apikey:old" } }, + // A resource kind the CLI does not own must survive the merge. + runtimes: { main: { runtimeArn: "arn:runtime:main" } }, + }, + }); + await updateTargetState(json, root, "default", { + resources: { credentials: { fresh: { credentialProviderArn: "arn:apikey:fresh" } } }, + }); + + expect(await readRaw(root)).toEqual({ + targets: { + default: { + resources: { + credentials: { fresh: { credentialProviderArn: "arn:apikey:fresh" } }, + runtimes: { main: { runtimeArn: "arn:runtime:main" } }, + }, + }, + }, + }); + }); + + test("preserves unknown fields inside a credential entry across an update", async () => { + const root = await projectRoot(); + // A field a newer CLI (or the CDK app) records that this code doesn't model. + await Bun.write( + statePath(root), + JSON.stringify({ + targets: { + default: { + resources: { + credentials: { k: { credentialProviderArn: "arn:a", futureField: "keep" } }, + }, + }, + }, + }), + ); + + await updateTargetState(json, root, "default", { stackArn: "arn:stack:default" }); + + expect(await readRaw(root)).toEqual({ + targets: { + default: { + stackArn: "arn:stack:default", + resources: { + credentials: { k: { credentialProviderArn: "arn:a", futureField: "keep" } }, + }, + }, + }, + }); + }); +}); diff --git a/src/core/project/backends/cdk/deployedState.ts b/src/core/project/backends/cdk/deployedState.ts new file mode 100644 index 000000000..b1edc215a --- /dev/null +++ b/src/core/project/backends/cdk/deployedState.ts @@ -0,0 +1,119 @@ +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { z } from "zod"; +import { atomicWrite, type ReadWriteJson } from "../../../../io"; + +/** + * Project-relative path of the state file the synthesized CDK app reads. + * + * Under `agentcore/.cli/` to match the released CLI's location, so a project + * created by an older CLI keeps reading the same path after upgrading. It holds + * a target's stack binding and its imperatively created credential ARNs; the + * scaffolded `.gitignore` keeps this one file committed while ignoring the rest + * of `.cli/`. + */ +export const DEPLOYED_STATE_RELATIVE_PATH = join("agentcore", ".cli", "deployed-state.json"); + +// Passthrough like the levels above it: a stack-ARN-only update reads and +// rewrites the whole file, so stripping unknown keys here would drop fields a +// newer CLI (or the CDK app) records inside a credential entry. +const CredentialStateSchema = z + .object({ + credentialProviderArn: z.string(), + clientSecretArn: z.string().optional(), + }) + .passthrough(); + +// Only the branches this CLI owns are modelled. Every other key the CDK app or +// the published @aws/agentcore-cdk DeployedStateSchema records under a target — +// runtimes, memories, and the rest — passes through untouched so a merge never +// drops state this code does not own. +const ResourceStateSchema = z + .object({ + credentials: z.record(z.string(), CredentialStateSchema).optional(), + }) + .passthrough(); + +const TargetStateSchema = z + .object({ + // The deployed CloudFormation stack's ARN, captured after a successful + // deploy. It embeds account + region + a unique id, so it both binds the + // target to an exact deployment and lets us detect a delete-and-recreate. + stackArn: z.string().optional(), + resources: ResourceStateSchema.optional(), + }) + .passthrough(); + +export const DeployedStateSchema = z + .object({ + targets: z.record(z.string(), TargetStateSchema).default({}), + }) + .passthrough(); + +export type DeployedState = z.infer; +export type TargetState = z.infer; + +function statePathFor(projectRoot: string): string { + return join(projectRoot, DEPLOYED_STATE_RELATIVE_PATH); +} + +/** + * Reads the deployed state for a project, returning an empty state when the + * file does not exist yet (the common case before the first deploy). Callers + * get a fully-shaped object either way, so they never special-case absence. + */ +export async function readDeployedState( + json: ReadWriteJson, + projectRoot: string, +): Promise { + const statePath = statePathFor(projectRoot); + if (!existsSync(statePath)) return { targets: {} }; + return json.read(statePath, DeployedStateSchema); +} + +/** + * Merges a patch into a single target's entry and writes the whole file back, + * preserving every other target and every resource kind this code does not own. + * + * The merge is shallow except for `resources`, which is merged one level deep so + * updating one resource kind (e.g. `credentials`) leaves the others in place. + * A resource map provided in the patch replaces the previous map for that kind + * wholesale, so a credential dropped from the spec stops being advertised. + * + * This read-modify-write is safe for sequential updates (one deploy at a time), + * which is the only supported case — concurrent deploys of the same project can + * still lose an update, since each reads the file before the other writes. + */ +export async function updateTargetState( + json: ReadWriteJson, + projectRoot: string, + targetName: string, + patch: Partial, +): Promise { + const statePath = statePathFor(projectRoot); + const state = await readDeployedState(json, projectRoot); + const previous = state.targets[targetName] ?? {}; + + const resources = + previous.resources || patch.resources + ? { ...previous.resources, ...patch.resources } + : undefined; + + const merged: TargetState = { + ...previous, + ...patch, + ...(resources && { resources }), + }; + + const next: DeployedState = { + ...state, + targets: { ...state.targets, [targetName]: merged }, + }; + + // Written atomically (temp file + rename) so an interruption or disk failure + // can't leave a half-written, unparseable state file that blocks later deploys. + await mkdir(dirname(statePath), { recursive: true }); + await atomicWrite(statePath, JSON.stringify(next, undefined, 2)); + return next; +} diff --git a/src/core/project/backends/cdk/toolkit.test.ts b/src/core/project/backends/cdk/toolkit.test.ts index 8519366fb..171e07fc3 100644 --- a/src/core/project/backends/cdk/toolkit.test.ts +++ b/src/core/project/backends/cdk/toolkit.test.ts @@ -114,7 +114,7 @@ describe("performCdkOperation", () => { { kind: "bootstrap", environments: ["aws://111122223333/us-east-1"] }, runOptions(), ), - ).toEqual({}); + ).toEqual({ outputs: {} }); expect(calls.map(({ method }) => method)).toEqual(["bootstrap"]); const [environments, options] = calls[0]!.args as [ @@ -153,13 +153,16 @@ describe("performCdkOperation", () => { test("deploys exactly one named stack from the synthesized assembly", async () => { const { calls, loaded } = loadedToolkit(); - const outputs = await performCdkOperation( + const result = await performCdkOperation( loaded, { kind: "deploy", stackArtifactId: "AgentCore-orders-default" }, runOptions({ assemblyDirectory: "/workspace/agentcore/cdk/cdk.out" }), ); - expect(outputs).toEqual({ RuntimeArn: "arn:runtime" }); + expect(result).toEqual({ + outputs: { RuntimeArn: "arn:runtime" }, + stackArn: DEPLOYED_STACK.stackArn, + }); expect(calls.map(({ method }) => method)).toEqual(["fromAssemblyDirectory", "deploy"]); expect(calls[0]!.args).toEqual(["/workspace/agentcore/cdk/cdk.out"]); expect(calls[1]!.args[1]).toMatchObject({ @@ -190,13 +193,13 @@ describe("performCdkOperation", () => { test("accepts a deployed stack that declares no outputs", async () => { const { loaded } = loadedToolkit([{ ...DEPLOYED_STACK, outputs: {} }]); - const outputs = await performCdkOperation( + const result = await performCdkOperation( loaded, { kind: "deploy", stackArtifactId: "AgentCore-orders-default" }, runOptions({ assemblyDirectory: "/workspace/agentcore/cdk/cdk.out" }), ); - expect(outputs).toEqual({}); + expect(result).toEqual({ outputs: {}, stackArn: DEPLOYED_STACK.stackArn }); }); }); diff --git a/src/core/project/backends/cdk/toolkit.ts b/src/core/project/backends/cdk/toolkit.ts index 243427c14..5bce6f503 100644 --- a/src/core/project/backends/cdk/toolkit.ts +++ b/src/core/project/backends/cdk/toolkit.ts @@ -28,7 +28,13 @@ export type CdkOutputs = Record; export type CdkCredentialProvider = SdkBaseConfig["credentialProvider"]; export type CdkCredentialResolver = (region: string) => Promise; -export type CdkRunner = (operation: CdkOperation, options: CdkRunOptions) => Promise; +/** + * Result of a CDK operation. `stackArn` is the ARN of the deployed stack (only + * a deploy produces one); bootstrap leaves it undefined. + */ +export type CdkRunResult = { outputs: CdkOutputs; stackArn?: string }; + +export type CdkRunner = (operation: CdkOperation, options: CdkRunOptions) => Promise; export type CdkToolkit = Pick; @@ -167,7 +173,7 @@ export async function performCdkOperation( { lib, toolkit }: LoadedCdkToolkit, operation: CdkOperation, options: CdkRunOptions, -): Promise { +): Promise { if (operation.kind === "bootstrap") { await toolkit.bootstrap(lib.BootstrapEnvironments.fromList(operation.environments), { parameters: lib.BootstrapStackParameters.withExisting({ @@ -177,7 +183,7 @@ export async function performCdkOperation( source: lib.BootstrapSource.customTemplate(operation.templateFile), }), }); - return {}; + return { outputs: {} }; } const source = await toolkit.fromAssemblyDirectory(options.assemblyDirectory); @@ -201,8 +207,9 @@ export async function performCdkOperation( ); } - // A stack that deployed but declares no outputs is legitimate. - return result.stacks[0]?.outputs ?? {}; + // A stack that deployed but declares no outputs is legitimate. The stack ARN + // is what the CLI persists to bind the target to this exact deployment. + return { outputs: result.stacks[0]?.outputs ?? {}, stackArn: result.stacks[0]?.stackArn }; } export function createCdkRunner(