Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f1d264c
feat(multi-environment-deploy): Add environment schemas to aws-target…
May 8, 2026
401e381
feat(multi-environment-deploy): Add cross-validation: env target refs…
May 8, 2026
b1c9b04
feat(multi-environment-deploy): Unit tests for environments schema
May 8, 2026
d2a56ff
feat(multi-environment-deploy): Implement environment resolution and …
May 8, 2026
8ce30cf
feat(multi-environment-deploy): Implement sequential multi-target dep…
May 8, 2026
25eb822
feat(multi-environment-deploy): Register --env option and DeployOptio…
May 8, 2026
37e11e4
feat(multi-environment-deploy): Wire --env resolution into deploy act…
May 8, 2026
af9dd92
test(multi-environment-deploy): cover --env / --target mutual exclusi…
May 8, 2026
3b8b09c
feat(multi-environment-deploy): Register --parallel and --continue-on…
May 8, 2026
e718910
test(multi-environment-deploy): cover --parallel/--continue-on-error …
May 8, 2026
6ffe1cf
feat(multi-environment-deploy): Add parallel + continue-on-error path…
May 8, 2026
81d0f93
feat(multi-environment-deploy): Add --env to status command with tabl…
May 8, 2026
a1f5472
test(multi-environment-deploy): inject loadConfig into handleEnvStatu…
May 8, 2026
8bc5c3e
feat(multi-environment-deploy): Expose environments via useAwsTargetC…
May 8, 2026
151cc6d
feat(multi-environment-deploy): Add environment picker to deploy TUI …
May 8, 2026
c370204
test(multi-environment-deploy): flush stdin between events in Environ…
May 8, 2026
482d15c
feat(multi-environment-deploy): Add environment setup step to create TUI
May 8, 2026
fdaa0fa
feat(multi-environment-deploy): Target-to-environment assignment + wr…
May 8, 2026
9f1dd89
test(multi-environment-deploy): relax cross-validation regex to match…
May 8, 2026
d3becdc
fix: address reviewer feedback
May 8, 2026
eb71658
fix: address reviewer feedback
May 8, 2026
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
72 changes: 72 additions & 0 deletions src/cli/commands/deploy/__tests__/handleEnvDeploy.flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { DeployToTargetsOptions } from '../../../operations/deploy/multi-target';
import { handleEnvDeploy } from '../actions';
import { describe, expect, it, vi } from 'vitest';

// Capture every call to deployToTargets so we can assert the orchestrator
// observes the parallel / continueOnError flags forwarded by handleEnvDeploy.
const deployToTargetsMock = vi.fn((_targets: unknown, _options: DeployToTargetsOptions, _fn: unknown) =>
Promise.resolve({ successes: [], failures: [] })
);

vi.mock('../../../operations/deploy/multi-target', () => ({
deployToTargets: (targets: unknown, options: DeployToTargetsOptions, fn: unknown) =>
deployToTargetsMock(targets, options, fn),
}));

// Stub heavy ConfigIO + project operations so we never touch a real project.
vi.mock('../../../../lib', () => ({
ConfigIO: class {
readAwsTargetsFull() {
return Promise.resolve({
targets: [{ name: 'dev-a', account: '111111111111', region: 'us-west-2' }],
environments: { dev: { targets: ['dev-a'] } },
});
}
resolveAWSDeploymentTargets() {
return Promise.resolve([{ name: 'dev-a', account: '111111111111', region: 'us-west-2' }]);
}
},
SecureCredentials: class {},
}));

vi.mock('../../../operations/deploy', () => ({
bootstrapEnvironment: vi.fn(),
buildCdkProject: vi.fn(),
checkBootstrapNeeded: vi.fn(),
checkStackDeployability: vi.fn(),
getAllCredentials: () => [],
hasIdentityApiProviders: () => false,
hasIdentityOAuthProviders: () => false,
performStackTeardown: vi.fn(),
setupApiKeyProviders: vi.fn(),
setupOAuth2Providers: vi.fn(),
setupTransactionSearch: vi.fn(),
synthesizeCdk: vi.fn(),
validateProject: vi.fn(),
}));

describe('handleEnvDeploy flag forwarding', () => {
it('forwards parallel + continueOnError to deployToTargets', async () => {
deployToTargetsMock.mockClear();
await handleEnvDeploy({
env: 'dev',
parallel: true,
continueOnError: true,
onLog: () => undefined,
});
expect(deployToTargetsMock).toHaveBeenCalledTimes(1);
const opts = deployToTargetsMock.mock.calls[0]![1];
expect(opts.environmentName).toBe('dev');
expect(opts.parallel).toBe(true);
expect(opts.continueOnError).toBe(true);
});

it('defaults parallel + continueOnError to undefined when not provided', async () => {
deployToTargetsMock.mockClear();
await handleEnvDeploy({ env: 'dev', onLog: () => undefined });
expect(deployToTargetsMock).toHaveBeenCalledTimes(1);
const opts = deployToTargetsMock.mock.calls[0]![1];
expect(opts.parallel).toBeUndefined();
expect(opts.continueOnError).toBeUndefined();
});
});
34 changes: 32 additions & 2 deletions src/cli/commands/deploy/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,45 @@ import { validateDeployOptions } from '../validate.js';
import { describe, expect, it } from 'vitest';

describe('validateDeployOptions', () => {
it('always returns valid', () => {
it('returns valid with no options', () => {
expect(validateDeployOptions({})).toEqual({ valid: true });
});

it('returns valid with all options set', () => {
it('returns valid with all non-conflicting options set', () => {
expect(validateDeployOptions({ target: 'prod', yes: true, verbose: true, json: true })).toEqual({ valid: true });
});

it('returns valid with target only', () => {
expect(validateDeployOptions({ target: 'default' })).toEqual({ valid: true });
});

it('returns valid with --env only', () => {
expect(validateDeployOptions({ env: 'dev' })).toEqual({ valid: true });
});

it('rejects --env and --target used together with a clear error', () => {
const result = validateDeployOptions({ env: 'dev', target: 'prod' });
expect(result.valid).toBe(false);
expect(result.error).toMatch(/--env.*--target/);
});

it('rejects --parallel without --env', () => {
const result = validateDeployOptions({ parallel: true });
expect(result.valid).toBe(false);
expect(result.error).toMatch(/--parallel.*--env/);
});

it('rejects --continue-on-error without --env', () => {
const result = validateDeployOptions({ continueOnError: true });
expect(result.valid).toBe(false);
expect(result.error).toMatch(/--continue-on-error.*--env/);
});

it('accepts --parallel together with --env', () => {
expect(validateDeployOptions({ env: 'dev', parallel: true })).toEqual({ valid: true });
});

it('accepts --continue-on-error together with --env', () => {
expect(validateDeployOptions({ env: 'dev', continueOnError: true })).toEqual({ valid: true });
});
});
126 changes: 125 additions & 1 deletion src/cli/commands/deploy/actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { ConfigIO, SecureCredentials } from '../../../lib';
import type { AgentCoreMcpSpec, DeployedState } from '../../../schema';
import type {
AgentCoreMcpSpec,
AwsDeploymentTarget,
DeployedState,
EnvironmentOverrides,
Environments,
} from '../../../schema';
import { applyTargetRegionToEnv } from '../../aws';
import { validateAwsCredentials } from '../../aws/account';
import { CdkToolkitWrapper, createSwitchableIoHost } from '../../cdk/toolkit-lib';
Expand Down Expand Up @@ -33,7 +39,9 @@ import {
synthesizeCdk,
validateProject,
} from '../../operations/deploy';
import { mergeOverrides, resolveEnvironment } from '../../operations/deploy/environment';
import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status';
import { deployToTargets } from '../../operations/deploy/multi-target';
import { deleteOrphanedABTests, setupABTests } from '../../operations/deploy/post-deploy-ab-tests';
import {
resolveConfigBundleComponentKeys,
Expand All @@ -51,6 +59,12 @@ export interface ValidatedDeployOptions {
verbose?: boolean;
plan?: boolean;
diff?: boolean;
/**
* Optional env-var overrides applied in-memory to every runtime in the
* loaded project spec just before CDK synth. Set by --env deploys; never
* persisted to disk.
*/
envVarOverrides?: EnvironmentOverrides;
onProgress?: (step: string, status: 'start' | 'success' | 'error') => void;
onResourceEvent?: (message: string) => void;
}
Expand Down Expand Up @@ -142,6 +156,13 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
// Preflight: validate project
startStep('Validate project');
const context = await validateProject();
// Apply per-environment envVar overrides (in-memory only) before synth.
if (options.envVarOverrides && context.projectSpec.runtimes) {
context.projectSpec = {
...context.projectSpec,
runtimes: context.projectSpec.runtimes.map(rt => mergeOverrides(rt, options.envVarOverrides)),
};
}
endStep('success');

// Teardown confirmation: if this is a teardown deploy, require --yes
Expand Down Expand Up @@ -705,3 +726,106 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
*/
// resolveConfigBundleComponentKeys and resolveComponentKey moved to
// src/cli/operations/deploy/post-deploy-config-bundles.ts

export interface EnvDeployOptions {
env: string;
autoConfirm?: boolean;
verbose?: boolean;
plan?: boolean;
diff?: boolean;
/** Run env targets concurrently via Promise.allSettled. */
parallel?: boolean;
/** Continue past per-target failures (sequential mode only). */
continueOnError?: boolean;
onProgress?: (step: string, status: 'start' | 'success' | 'error') => void;
onResourceEvent?: (message: string) => void;
/** Sink for orchestrator progress + summary lines. Defaults to console.log. */
onLog?: (line: string) => void;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

EnvDeployOptions is missing parallel and continueOnError, and handleEnvDeploy below never forwards them to deployToTargets. Combined with command.tsx not passing them through either, agentcore deploy --env dev --parallel and --continue-on-error are accepted by the validator but have zero runtime effect — every env deploy is sequential fail-fast regardless of flags.

Please:

  • Add parallel?: boolean and continueOnError?: boolean to EnvDeployOptions.
  • Forward them into the deployToTargets(..., { environmentName, parallel, continueOnError, log }, ...) call (line ~799).
  • Pass cliOptions.parallel / cliOptions.continueOnError from handleDeployCLI in command.tsx (line ~73).

A test that exercises handleEnvDeploy with parallel: true and asserts the orchestrator observes it (e.g. by spying on deployToTargets or by verifying concurrent execution) would prevent this from regressing.


export interface EnvDeployResult {
success: boolean;
envName: string;
results: DeployResult[];
/** Set when the env couldn't be resolved (no per-target results available). */
error?: string;
}

/**
* Read aws-targets.json supporting both the legacy array shape and the new
* `{ targets, environments }` object shape. Returns environments only when
* the file uses the object shape; otherwise environments is undefined.
*/
/**
* Read aws-targets.json with environments if present. Delegates to
* `ConfigIO.readAwsTargetsFull` and then applies region fallback so the same
* environment/profile precedence used by the rest of the CLI is honored.
*/
async function readAwsTargetsWithEnvironments(
configIO: ConfigIO
): Promise<{ targets: AwsDeploymentTarget[]; environments?: Environments }> {
const full = await configIO.readAwsTargetsFull();
// Apply the standard region fallback (env vars / profile config) by going
// through resolveAWSDeploymentTargets — its result preserves saved regions
// and fills in only blanks, so we can safely substitute it for the targets
// we just read while keeping the environments map from the object shape.
const resolved = await configIO.resolveAWSDeploymentTargets();
return full.environments ? { targets: resolved, environments: full.environments } : { targets: resolved };
}

/**
* Deploy a named environment: resolve the env to its targets, apply any
* env-level overrides per target, and run them through the multi-target
* orchestrator. Sequential / fail-fast for v1 (T9 adds parallel + continue-on-error).
*/
export async function handleEnvDeploy(options: EnvDeployOptions): Promise<EnvDeployResult> {
const configIO = new ConfigIO();

let resolved;
try {
const awsTargets = await readAwsTargetsWithEnvironments(configIO);
resolved = resolveEnvironment(options.env, awsTargets);
} catch (err: unknown) {
return {
success: false,
envName: options.env,
results: [],
error: getErrorMessage(err),
};
}

const results: DeployResult[] = [];
const aggregate = await deployToTargets(
resolved.targets,
{
environmentName: options.env,
parallel: options.parallel,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

--parallel has real concurrency bugs inside handleDeploy — running targets concurrently across different regions will produce incorrect / corrupt results.

The orchestrator in multi-target.ts is correct, but each concurrent handleDeploy call it fans out does three things that are not safe to run in parallel within the same process / project:

  1. Global process.env mutation. handleDeploy calls applyTargetRegionToEnv(target.region) (actions.ts:144), which sets process.env.AWS_REGION and AWS_DEFAULT_REGION globally and returns a restoreEnv called in finally. Two targets in different regions will race on these env vars — the later writer wins, so the SDK / CDK toolkit clients constructed afterwards (inside synthesizeCdk, checkBootstrapNeeded, the post-deploy CFN / identity calls, etc.) can pick up the other target's region. The interleaved restoreEnvs can also leave AWS_REGION pointing at a region that isn't the user's original.

  2. Shared cdk.out directory and CDK toolkit lock files (synthesizeCdkpath.join(cdkProject.projectDir, 'cdk.out'), plus cleanupStaleLockFiles). cleanupStaleLockFiles is designed to clean up after dead processes; for two live parallel synth / deploy calls on the same project, they will contend for the same directory and lock files — producing confusing ENOENT/EEXIST failures at best, and at worst one synth's assembly becoming visible to the other's deploy.

  3. Read-modify-write race on deployed-state.json. handleDeploy does readDeployedState() → mutate → writeDeployedState() in several places (actions.ts:283, 498, 574, 599, 630, 661). Two parallel targets both read the same pre-state, each writes back only their own slice, and whichever writes last overwrites the other target's updates. Subsequent agentcore status then shows stale or missing resources for the clobbered target.

Options to fix:

  1. Scope region per-deploy without mutating process.env. Thread target.region through handleDeploy so every SDK / CDK client that defaults from env vars receives an explicit region instead. Remove (or gate to sequential-only) applyTargetRegionToEnv. Combine with per-target cdk.out directories (e.g. cdk.out/<targetName>) and a ConfigIO.updateDeployedState(fn) that does atomic read-modify-write under a mutex keyed on the state file. This makes --parallel correct for any combination of regions.

  2. Serialize the unsafe sections inside handleDeploy with a process-scoped mutex around applyTargetRegionToEnv → synth → CFN calls → writeDeployedState. Simplest change, but only the CFN CREATE_IN_PROGRESS wait actually overlaps — so most of the point of --parallel is lost.

  3. Disable --parallel for now and land it as a follow-up once build(deps): bump diff and @aws-cdk/cloudformation-diff #1 is in place. validateDeployOptions can reject --parallel with an explanatory error. This avoids shipping a flag that silently corrupts state when used across regions.

Any of these is fine — option 1 is the long-term correct answer, option 3 is the lowest-risk for this PR. Whatever is chosen, please also add a test that fans out handleEnvDeploy with parallel: true across two distinct-region targets and asserts (a) each deploy's SDK calls use its own region and (b) the final deployed-state.json contains both targets' resources — the current multi-target.test.ts parallel test only exercises the orchestrator, not the full handleEnvDeployhandleDeploy stack.

continueOnError: options.continueOnError,
log: options.onLog,
},
async target => {
const result = await handleDeploy({
target: target.name,
autoConfirm: options.autoConfirm,
verbose: options.verbose,
plan: options.plan,
diff: options.diff,
envVarOverrides: resolved.overrides,
onProgress: options.onProgress,
onResourceEvent: options.onResourceEvent,
});
results.push(result);
if (!result.success) {
// Throw so the orchestrator records this as a failure (fail-fast).
throw new Error(result.error ?? `Deploy failed for target ${target.name}`);
}
return result;
}
);

return {
success: aggregate.failures.length === 0 && results.every(r => r.success),
envName: options.env,
results,
};
}
52 changes: 49 additions & 3 deletions src/cli/commands/deploy/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { getErrorMessage } from '../../errors';
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
import { requireProject, requireTTY } from '../../tui/guards';
import { DeployScreen } from '../../tui/screens/deploy/DeployScreen';
import { handleDeploy } from './actions';
import { handleDeploy, handleEnvDeploy } from './actions';
import type { DeployOptions } from './types';
import { validateDeployOptions } from './validate';
import type { Command } from '@commander-js/extra-typings';
Expand Down Expand Up @@ -69,6 +69,34 @@ async function handleDeployCLI(options: DeployOptions): Promise<void> {
}
: undefined;

if (options.env) {
const envResult = await handleEnvDeploy({
env: options.env,
autoConfirm: options.yes,
verbose: options.verbose ?? options.diff,
plan: options.plan,
diff: options.diff,
parallel: options.parallel,
continueOnError: options.continueOnError,
onProgress,
onResourceEvent,
onLog: line => console.log(line),
});

if (spinner) {
clearInterval(spinner);
process.stdout.write('\r\x1b[K');
}

if (options.json) {
console.log(JSON.stringify(envResult));
} else if (!envResult.success) {
if (envResult.error) console.error(envResult.error);
}

process.exit(envResult.success ? 0 : 1);
}

const result = await handleDeploy({
target: options.target!,
autoConfirm: options.yes,
Expand Down Expand Up @@ -141,6 +169,12 @@ export const registerDeploy = (program: Command) => {
.alias('dp')
.description(COMMAND_DESCRIPTIONS.deploy)
.option('--target <target>', 'Deployment target name (default: "default") [non-interactive]')
.option('--env <name>', 'Environment name to deploy (deploys all targets in the environment) [non-interactive]')
.option('--parallel', 'Deploy environment targets concurrently (requires --env) [non-interactive]')
.option(
'--continue-on-error',
'Continue deploying remaining targets after a failure (requires --env) [non-interactive]'
)
.option('-y, --yes', 'Auto-confirm prompts, read credentials from env [non-interactive]')
.option('-v, --verbose', 'Show resource-level deployment events [non-interactive]')
.option('--json', 'Output as JSON [non-interactive]')
Expand All @@ -149,6 +183,9 @@ export const registerDeploy = (program: Command) => {
.action(
async (cliOptions: {
target?: string;
env?: string;
parallel?: boolean;
continueOnError?: boolean;
yes?: boolean;
verbose?: boolean;
json?: boolean;
Expand All @@ -157,12 +194,21 @@ export const registerDeploy = (program: Command) => {
}) => {
try {
requireProject();
if (cliOptions.json || cliOptions.target || cliOptions.dryRun || cliOptions.yes || cliOptions.verbose) {
if (
cliOptions.json ||
cliOptions.target ||
cliOptions.env ||
cliOptions.parallel ||
cliOptions.continueOnError ||
cliOptions.dryRun ||
cliOptions.yes ||
cliOptions.verbose
) {
// CLI mode - any flag triggers non-interactive mode
const options = {
...cliOptions,
plan: cliOptions.dryRun,
target: cliOptions.target ?? 'default',
target: cliOptions.env ? cliOptions.target : (cliOptions.target ?? 'default'),
progress: !cliOptions.json,
};
await handleDeployCLI(options as DeployOptions);
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/deploy/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export interface DeployOptions {
target?: string;
env?: string;
parallel?: boolean;
continueOnError?: boolean;
yes?: boolean;
progress?: boolean;
verbose?: boolean;
Expand Down
Loading
Loading