Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/assets/cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined;
try {
deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8'));
Comment thread
notgitika marked this conversation as resolved.
} 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();
Expand Down
6 changes: 4 additions & 2 deletions src/assets/templates/shared/gitignore.template
Original file line number Diff line number Diff line change
Expand Up @@ -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/
67 changes: 65 additions & 2 deletions src/core/project/backends/cdk.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -78,6 +80,8 @@ type HarnessOptions = {
account?: string;
bootstrap?: BootstrapState;
outputs?: CdkOutputs;
stackArn?: string;
omitStackArn?: boolean;
template?: boolean;
failOperation?: CdkOperation["kind"];
bootstrapError?: Error;
Expand Down Expand Up @@ -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++;
Expand Down Expand Up @@ -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],
Expand Down
25 changes: 23 additions & 2 deletions src/core/project/backends/cdk.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 };
}

Expand Down
152 changes: 152 additions & 0 deletions src/core/project/backends/cdk/deployedState.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<unknown> {
return JSON.parse(await Bun.file(statePath(root)).text());
}

describe("readDeployedState", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way to test this behavior through the handlers? The advantage being that implementation details here could change, but we still observe the same behavior e2e.

If that isn't really possible, than maybe one level higher in cdk?

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" } },
},
},
},
});
});
});
Loading
Loading