diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index da5b313d7..cb48c8f0c 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -872,7 +872,43 @@ describe("CdkBackend.resolveDeployedResources", () => { expect(subject.stackReads).toHaveLength(1); }); - test("fails without reading AWS when the target has no deployed stack ARN", async () => { + test("resolves resources from a legacy stack name when the target has no stack ARN", async () => { + const input = await project(); + input.spec = ProjectSpecSchema.parse({ + ...input.spec, + harnesses: [{ name: "support", path: "app/support" }], + }); + await updateTargetState(json, input.rootPath, TARGET.name, { + resources: { stackName: "AgentCore-example-default" }, + }); + const subject = harness({ + describedStack: { + StackName: "AgentCore-example-default", + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [ + { + ExportName: "AgentCore-example-default-Harness-support-Id", + OutputValue: "support-AbCdEf1234", + }, + ], + }, + }); + + await expect( + subject.backend.resolveDeployedResources(input, { target: TARGET }), + ).resolves.toEqual([ + { + resourceType: "harness", + name: "support", + id: "support-AbCdEf1234", + target: TARGET, + }, + ]); + expect(subject.stackReads[0]?.stackName).toBe("AgentCore-example-default"); + }); + + test("fails without reading AWS when the target has no recorded stack", async () => { const input = await project(); const subject = harness({ describedStack: null }); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 3d0f4d51d..d7bb9bda0 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -42,7 +42,12 @@ import { stackArtifactForTarget, type StackArtifact, } from "./cdk/assembly"; -import { readDeployedState, removeTargetState, updateTargetState } from "./cdk/deployedState"; +import { + readDeployedState, + removeTargetState, + stackReferenceOf, + updateTargetState, +} from "./cdk/deployedState"; import { bootstrapStackReader, createCloudFormationStackReader, @@ -402,8 +407,9 @@ export class CdkBackend implements ProjectBackend { ): Promise { const { target } = input; const deployedState = await readDeployedState(this.json, project.rootPath); - const stackArn = deployedState.targets[target.name]?.stackArn; - if (!stackArn) { + const recorded = deployedState.targets[target.name]; + const stackReference = stackReferenceOf(recorded); + if (!stackReference) { throw new ProjectStateError( `Project '${project.name}' is not deployed to target '${target.name}'. ` + `Run 'agentcore project deploy --target ${target.name}' first.`, @@ -411,7 +417,7 @@ export class CdkBackend implements ProjectBackend { } const credentials = await this.credentialsForTarget(target); - const stack = await this.describeStack(target.region, credentials, stackArn); + const stack = await this.describeStack(target.region, credentials, stackReference); if (!stack) { throw new ProjectStateError( `Project '${project.name}' is not deployed to target '${target.name}'. ` + @@ -437,14 +443,15 @@ export class CdkBackend implements ProjectBackend { const { spec } = project; const deployedState = await readDeployedState(this.json, project.rootPath); const recorded = deployedState.targets[target.name]; + const stackReference = stackReferenceOf(recorded); // No recorded stack means nothing was ever deployed to this target, which // every resource below reports as local-only. - const stack = recorded?.stackArn + const stack = stackReference ? await this.describeStack( target.region, await this.credentialsForTarget(target), - recorded.stackArn, + stackReference, ) : undefined; diff --git a/src/core/project/backends/cdk/deployedState.test.ts b/src/core/project/backends/cdk/deployedState.test.ts index 6f4630140..91bfc6ff7 100644 --- a/src/core/project/backends/cdk/deployedState.test.ts +++ b/src/core/project/backends/cdk/deployedState.test.ts @@ -9,6 +9,7 @@ import { DEPLOYED_STATE_RELATIVE_PATH, readDeployedState, removeTargetState, + stackReferenceOf, updateTargetState, } from "./deployedState"; @@ -58,6 +59,30 @@ describe("readDeployedState", () => { }); }); +describe("stackReferenceOf", () => { + test("prefers the exact stack ARN over the legacy stack name", () => { + expect( + stackReferenceOf({ + stackArn: "arn:stack:default", + resources: { stackName: "AgentCore-example-default" }, + }), + ).toBe("arn:stack:default"); + }); + + test("falls back to the legacy stack name", () => { + expect( + stackReferenceOf({ + resources: { stackName: "AgentCore-example-default" }, + }), + ).toBe("AgentCore-example-default"); + }); + + test("returns undefined when no stack was recorded", () => { + expect(stackReferenceOf(undefined)).toBeUndefined(); + expect(stackReferenceOf({})).toBeUndefined(); + }); +}); + describe("updateTargetState", () => { test("creates the file with the target entry", async () => { const root = await projectRoot(); diff --git a/src/core/project/backends/cdk/deployedState.ts b/src/core/project/backends/cdk/deployedState.ts index c1f079be8..48797804f 100644 --- a/src/core/project/backends/cdk/deployedState.ts +++ b/src/core/project/backends/cdk/deployedState.ts @@ -39,6 +39,10 @@ const CredentialStateSchema = z const ResourceStateSchema = z .object({ credentials: z.record(z.string(), CredentialStateSchema).optional(), + // The legacy deployer recorded the CloudFormation stack name + // here. New deploys record the stack ARN instead. Keep this + // field so projects can be correctly inspected after upgrading. + stackName: z.string().optional(), }) .passthrough(); @@ -61,6 +65,15 @@ export const DeployedStateSchema = z export type DeployedState = z.infer; export type TargetState = z.infer; +/** + * Returns the CloudFormation reference recorded for a target. New deploys bind + * to the exact stack ARN; legacy deploys recorded only the stack name under + * resources, which CloudFormation also accepts when describing the stack. + */ +export function stackReferenceOf(state: TargetState | undefined): string | undefined { + return state?.stackArn ?? state?.resources?.stackName; +} + function statePathFor(projectRoot: string): string { return join(projectRoot, DEPLOYED_STATE_RELATIVE_PATH); } diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts index d71d4702f..548cd3c51 100644 --- a/src/handlers/project/status/index.test.ts +++ b/src/handlers/project/status/index.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createRootHandler } from "../../index"; @@ -7,11 +7,12 @@ import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, + TestIdentityClient, testIO, ttyTestIO, waitFor, } from "../../../testing"; -import type { ProjectBackend } from "../../../core/project"; +import { CdkBackend, type ProjectBackend } from "../../../core/project"; import { ProjectStateError } from "../../../errors"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; import type { ResolvedProjectResource } from "../types"; @@ -48,16 +49,14 @@ function fakeBackend(deployed: ResolvedProjectResource[]) { return { targets, backend }; } -function testStatusCommand(deployed: ResolvedProjectResource[] = [], io = testIO()) { - const fake = fakeBackend(deployed); - const root = createRootHandler(new TestCoreClient({ backends: { CDK: fake.backend } }), { +function statusCommand(backend: ProjectBackend, io = testIO()) { + const root = createRootHandler(new TestCoreClient({ backends: { CDK: backend } }), { io: io.io, globalConfigAccessor: new TestGlobalConfigAccessor(), logger: createSilentLogger(), }); return { - ...fake, io, json: () => JSON.parse(io.stdout()), run: (args: string[] = []) => root.route(["node", "agentcore", "project", "status", ...args]), @@ -65,6 +64,11 @@ function testStatusCommand(deployed: ResolvedProjectResource[] = [], io = testIO }; } +function testStatusCommand(deployed: ResolvedProjectResource[] = [], io = testIO()) { + const fake = fakeBackend(deployed); + return { ...fake, ...statusCommand(fake.backend, io) }; +} + const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -88,10 +92,10 @@ afterEach(() => { }); async function inProject( - subject: ReturnType, + subject: ReturnType, spec: Record = {}, targets: AwsDeploymentTarget[] = TARGETS, -): Promise { +): Promise { const directory = await mkdtemp(join(tmpdir(), "agentcore-status-")); tempDirectories.push(directory); process.chdir(directory); @@ -102,6 +106,7 @@ async function inProject( const current = JSON.parse(await Bun.file(specPath).text()); await writeFile(specPath, JSON.stringify({ ...current, ...spec })); process.chdir(projectRoot); + return projectRoot; } const deployed = ( @@ -133,6 +138,63 @@ const HARNESS_ROW = localOnly("harness", "orders"); const memory = (name: string) => ({ name, eventExpiryDuration: 30 }); const policy = (name: string) => ({ name, statement: "permit(principal, action, resource);" }); describe("project status handler", () => { + test("reports resources deployed by a legacy CLI using resources.stackName", async () => { + const stackName = "AgentCore-orders-default"; + const backend = new CdkBackend({ + logger: createSilentLogger(), + identity: new TestIdentityClient(), + resolveCredentials: async () => async () => ({ + accessKeyId: "access-key", + secretAccessKey: "secret-key", + }), + resolveAccount: async () => DEFAULT_TARGET.account, + describeStack: async (_region, _credentials, reference) => + reference === stackName + ? { + StackName: stackName, + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [ + { + ExportName: `${stackName}-Harness-orders-Arn`, + OutputValue: `${ARN}:harness/orders-1`, + }, + ], + } + : undefined, + }); + const subject = statusCommand(backend); + const projectRoot = await inProject(subject); + const stateDirectory = join(projectRoot, "agentcore", ".cli"); + await mkdir(stateDirectory, { recursive: true }); + await Bun.write( + join(stateDirectory, "deployed-state.json"), + JSON.stringify({ + targets: { + default: { + resources: { stackName }, + }, + }, + }), + ); + + await subject.run(["--json"]); + + expect(subject.json()).toEqual({ + projectName: "orders", + target: "default", + region: DEFAULT_TARGET.region, + resources: [ + { + resourceType: "harness", + name: "orders", + deploymentState: "deployed", + id: `${ARN}:harness/orders-1`, + }, + ], + }); + }); + test("reports deployed resources by ARN, nesting children under their owner", async () => { const subject = testStatusCommand([ HARNESS_ROW,