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
38 changes: 37 additions & 1 deletion src/cli/commands/deploy/__tests__/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down
55 changes: 44 additions & 11 deletions src/cli/commands/deploy/actions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -387,21 +418,22 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
region: target.region,
});
toolkitWrapper = synthResult.toolkitWrapper;
const stackNames = synthResult.stackNames;
if (stackNames.length === 0) {
endStep('error', 'No stacks found');
// The assembly holds one stack per target in aws-targets.json. Select the deployed target's
// stack so every downstream step (deployability check, deploy, Persist/describe) acts on it
// rather than blindly on stackNames[0] — see selectTargetStack.
const stackSelection = selectTargetStack(synthResult.stackNames, context.projectSpec.name, target.name);
if (!stackSelection.success) {
endStep('error', stackSelection.error.message);
logger.finalize(false);
return {
success: false,
error: new ValidationError('No stacks found to deploy'),
error: stackSelection.error,
logPath: logger.getRelativeLogPath(),
};
}
const stackName = stackNames[0]!;
const stackName = stackSelection.stackName;
endStep('success');

const targetStackName = toStackName(context.projectSpec.name, target.name);

// Check if bootstrap needed
startStep('Check bootstrap status');
const bootstrapCheck = await checkBootstrapNeeded(context.awsTargets);
Expand All @@ -421,9 +453,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
}
endStep('success');

// Check stack deployability
// Check stack deployability — scope to the deployed target's stack only, not every
// synthesized target, so a sibling target's in-progress/failed stack can't block this deploy.
startStep('Check stack status');
const deployabilityCheck = await checkStackDeployability(target.region, stackNames);
const deployabilityCheck = await checkStackDeployability(target.region, [stackName]);
if (!deployabilityCheck.canDeploy) {
endStep('error', deployabilityCheck.message);
logger.finalize(false);
Expand Down Expand Up @@ -451,7 +484,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
// Diff mode: run cdk diff and exit without deploying
if (options.diff) {
startStep('Run CDK diff');
await runDiff(toolkitWrapper, targetStackName, switchableIoHost);
await runDiff(toolkitWrapper, stackName, switchableIoHost);
endStep('success');

logger.finalize(true);
Expand Down Expand Up @@ -491,7 +524,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
switchableIoHost.setVerbose(true);
}

await runDeploy(toolkitWrapper, targetStackName);
await runDeploy(toolkitWrapper, stackName);

// Disable verbose output
if (switchableIoHost) {
Expand Down
Loading