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