From bef4616b5fd3aa0badda3336c8c5a0a351d92dda Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 14:53:37 +0000 Subject: [PATCH] fix: select deployed target's stack at Persist instead of stackNames[0] The vended CDK app synthesizes one stack per target in aws-targets.json, so synthResult.stackNames contains every target's stack. The deploy flow used stackNames[0] for the deployability check and the Persist step (getStackOutputs / buildDeployedState), while deploy/diff correctly used toStackName(project, target). When --target was not first in aws-targets.json, Persist described the wrong (often undeployed) stack and failed with "Stack does not exist", leaving AWS resources live with an empty deployed-state.json. Add a pure selectTargetStack(stackNames, project, target) that matches the deterministic toStackName, making selection ordering-independent, and make that stack name authoritative for the deployability check (now scoped to the single stack), diff, deploy, and Persist. Remove the redundant targetStackName. Verified end-to-end on CloudFormation: deploy --target qa (qa second in aws-targets.json) failed at Persist before the fix and succeeds after, recording deployed-state under qa. --- .../commands/deploy/__tests__/deploy.test.ts | 38 ++++++++++++- src/cli/commands/deploy/actions.ts | 55 +++++++++++++++---- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/cli/commands/deploy/__tests__/deploy.test.ts b/src/cli/commands/deploy/__tests__/deploy.test.ts index f11fa93b7..0df7905e2 100644 --- a/src/cli/commands/deploy/__tests__/deploy.test.ts +++ b/src/cli/commands/deploy/__tests__/deploy.test.ts @@ -1,5 +1,5 @@ import { runCLI } from '../../../../test-utils/index.js'; -import { runDeploy, runDiff } from '../actions.js'; +import { runDeploy, runDiff, selectTargetStack } from '../actions.js'; import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; import { randomUUID } from 'node:crypto'; import { mkdir, rm, writeFile } from 'node:fs/promises'; @@ -59,6 +59,42 @@ describe('deploy without agents', () => { }); }); +describe('selectTargetStack', () => { + // Multi-target projects synth one stack per target in aws-targets.json. The deploy flow + // must persist/describe the stack for the *deployed* target — not blindly stackNames[0]. + // Regression guard for: `deploy --target qa` failing at Persist because the CLI described + // the first target's stack (e.g. AgentCore-myapp-default) instead of the qa stack. + it('selects the deployed target stack, not the first synthesized stack', () => { + const result = selectTargetStack(['AgentCore-myapp-default', 'AgentCore-myapp-qa'], 'myapp', 'qa'); + expect(result).toEqual({ success: true, stackName: 'AgentCore-myapp-qa' }); + }); + + it('selects the target stack regardless of ordering in the assembly', () => { + const result = selectTargetStack(['AgentCore-myapp-qa', 'AgentCore-myapp-default'], 'myapp', 'default'); + expect(result).toEqual({ success: true, stackName: 'AgentCore-myapp-default' }); + }); + + it('handles single-target projects', () => { + const result = selectTargetStack(['AgentCore-myapp-default'], 'myapp', 'default'); + expect(result).toEqual({ success: true, stackName: 'AgentCore-myapp-default' }); + }); + + it('normalizes underscores in project and target names to match synthesized names', () => { + const result = selectTargetStack(['AgentCore-my-app-qa-east'], 'my_app', 'qa_east'); + expect(result).toEqual({ success: true, stackName: 'AgentCore-my-app-qa-east' }); + }); + + it('fails when no stacks were synthesized', () => { + const result = selectTargetStack([], 'myapp', 'qa'); + expect(result.success).toBe(false); + }); + + it('fails when the deployed target has no matching synthesized stack', () => { + const result = selectTargetStack(['AgentCore-myapp-default'], 'myapp', 'qa'); + expect(result.success).toBe(false); + }); +}); + describe('runDiff', () => { it('passes stack selection pattern to toolkit wrapper diff', async () => { let captured: unknown; diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index b3e349f69..a7b2376b5 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,4 +1,5 @@ import { ConfigIO, ResourceNotFoundError, SecureCredentials, ValidationError, toError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import type { AgentCoreMcpSpec, DeployedState } from '../../../schema'; import { applyTargetRegionToEnv } from '../../aws'; import { validateAwsCredentials } from '../../aws/account'; @@ -94,6 +95,36 @@ export function computeHarnessVersionDrift( return notes; } +/** + * Pick the synthesized stack for the target being deployed. + * + * The vended CDK app synthesizes one stack per target in aws-targets.json, so a multi-target + * project's assembly contains every target's stack. Selecting `stackNames[0]` would describe/persist + * the *first* target's stack rather than the deployed one — for `deploy --target qa` that meant the + * Persist step ran `DescribeStacks` on a different (often undeployed) stack and failed. Matching on the + * deterministic `toStackName(project, target)` keeps the choice correct regardless of target ordering. + */ +export function selectTargetStack( + stackNames: string[], + projectName: string, + targetName: string +): Result<{ stackName: string }, ValidationError> { + if (stackNames.length === 0) { + return { success: false, error: new ValidationError('No stacks found to deploy') }; + } + const expected = toStackName(projectName, targetName); + if (!stackNames.includes(expected)) { + return { + success: false, + error: new ValidationError( + `Synthesized stacks [${stackNames.join(', ')}] do not include the stack for target ` + + `"${targetName}" (expected "${expected}").` + ), + }; + } + return { success: true, stackName: expected }; +} + export async function runDiff( toolkitWrapper: CdkToolkitWrapper, stackName: string, @@ -387,21 +418,22 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise