From bf250aea16ba1e83d6917d82d5199b4cd2ad99cb Mon Sep 17 00:00:00 2001 From: Vidit Shah Date: Wed, 14 Oct 2026 02:43:37 +0530 Subject: [PATCH] fix(deploy): synthesize only selected deployment targets --- docs/configuration.md | 30 +++ .../assets.snapshot.test.ts.snap | 20 +- .../__tests__/cdk-target-selection.test.ts | 171 ++++++++++++++++++ src/assets/cdk/bin/cdk.ts | 20 +- .../cdk/toolkit-lib/__tests__/wrapper.test.ts | 46 +++++ src/cli/cdk/toolkit-lib/constants.ts | 2 + src/cli/cdk/toolkit-lib/types.ts | 3 + src/cli/cdk/toolkit-lib/wrapper.ts | 4 + src/cli/commands/deploy/actions.ts | 5 +- .../__tests__/synthesis-targets.test.ts | 38 ++++ src/cli/operations/deploy/preflight.ts | 3 + src/cli/tui/hooks/useCdkPreflight.ts | 18 +- .../__tests__/useDeployFlow.targets.test.tsx | 4 + src/cli/tui/screens/deploy/useDeployFlow.ts | 2 + 14 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 src/assets/__tests__/cdk-target-selection.test.ts create mode 100644 src/cli/cdk/toolkit-lib/__tests__/wrapper.test.ts create mode 100644 src/cli/cdk/toolkit-lib/constants.ts create mode 100644 src/cli/operations/deploy/__tests__/synthesis-targets.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index f6c4e0391a..995535e2be 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -614,6 +614,36 @@ Deployment target | `account` | Yes | AWS account ID (12 digits) | | `region` | Yes | AWS region | +### Deploying one target + +`agentcore deploy --target dev` synthesizes only the `dev` stack. Other targets do not need credential providers in +`.cli/deployed-state.json` before `dev` can deploy. Running CDK directly without a target selection still synthesizes +all configured targets. + +Existing projects retain their generated CDK source when the CLI is upgraded. To enable target-scoped synthesis in an +existing project, update `agentcore/cdk/bin/cdk.ts`: immediately before the stack-construction loop, read and validate +the target passed by the CLI: + +```typescript +const selectedTargets = process.env.AGENTCORE_SYNTH_TARGETS; +let synthTargets = targets; +if (selectedTargets !== undefined) { + const targetNames: unknown = JSON.parse(selectedTargets); + if (!Array.isArray(targetNames) || targetNames.length === 0 || targetNames.some(name => typeof name !== 'string')) { + throw new Error('AGENTCORE_SYNTH_TARGETS must be a non-empty JSON array of target names'); + } + for (const name of targetNames) { + if (!targets.some(target => target.name === name)) { + throw new Error(`Deployment target "${name}" not found in agentcore/aws-targets.json`); + } + } + synthTargets = targets.filter(target => targetNames.includes(target.name)); +} +``` + +Then change `for (const target of targets)` to `for (const target of synthTargets)`. The CLI rebuilds the CDK project +before deploying. Keep any custom logic in your CDK app intact. + ### Supported Regions See [AgentCore Regions](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-regions.html) for the diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 4446b1e966..9198f40000 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -160,7 +160,25 @@ async function main() { const app = new App(); - for (const target of targets) { + // Deployment provisions credentials only for its selected target. Filter before + // constructing stacks so an unselected target cannot require undeployed credentials. + // A standalone CDK invocation without this variable still synthesizes every target. + const selectedTargets = process.env.AGENTCORE_SYNTH_TARGETS; + let synthTargets = targets; + if (selectedTargets !== undefined) { + const targetNames: unknown = JSON.parse(selectedTargets); + if (!Array.isArray(targetNames) || targetNames.length === 0 || targetNames.some(name => typeof name !== 'string')) { + throw new Error('AGENTCORE_SYNTH_TARGETS must be a non-empty JSON array of target names'); + } + for (const name of targetNames) { + if (!targets.some(target => target.name === name)) { + throw new Error(\`Deployment target "\${name}" not found in agentcore/aws-targets.json\`); + } + } + synthTargets = targets.filter(target => targetNames.includes(target.name)); + } + + for (const target of synthTargets) { const env = toEnvironment(target); const stackName = toStackName(spec.name, target.name); diff --git a/src/assets/__tests__/cdk-target-selection.test.ts b/src/assets/__tests__/cdk-target-selection.test.ts new file mode 100644 index 0000000000..e922a1a323 --- /dev/null +++ b/src/assets/__tests__/cdk-target-selection.test.ts @@ -0,0 +1,171 @@ +import { App, CfnResource, Stack, type StackProps } from 'aws-cdk-lib'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { Script } from 'node:vm'; +import { ModuleKind, transpileModule } from 'typescript'; +import { afterEach, describe, expect, it } from 'vitest'; + +// Execute the vended entry point, with the external construct boundary replaced by +// a stack that requires the same per-target credentials. No AWS account is needed. +const source = transpileModule(readFileSync(resolve(__dirname, '../cdk/bin/cdk.ts'), 'utf8'), { + compilerOptions: { module: ModuleKind.CommonJS }, +}).outputText; +const directories: string[] = []; +const targets = ['prod', 'dev', 'int'].map(name => ({ name, account: '123456789012', region: 'us-east-1' })); +const credential = { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/test', +}; + +async function synthesize( + selectedTarget: string | string[] | undefined, + provisioned: string[], + configuredTargets = targets, + constructed: string[] = [] +) { + const root = mkdtempSync(join(tmpdir(), 'agentcore-target-test-')); + directories.push(root); + const configRoot = join(root, 'agentcore'); + const cdkRoot = join(configRoot, 'cdk'); + mkdirSync(join(configRoot, '.cli'), { recursive: true }); + mkdirSync(cdkRoot); + writeFileSync( + join(configRoot, '.cli/deployed-state.json'), + JSON.stringify({ + targets: Object.fromEntries( + provisioned.map(name => [name, { resources: { credentials: { 'api-key': credential } } }]) + ), + }) + ); + let synthesized: string[] | undefined; + const script = new Script(source, { filename: 'cdk.js' }); + await script.runInNewContext({ + exports: {}, + require: (name: string) => { + if (name === '@aws/agentcore-cdk') + return { + ConfigIO: class { + async readProjectSpec() { + return { name: 'test', agentCoreGateways: [{ name: 'gateway' }] }; + } + async readAWSDeploymentTargets() { + return configuredTargets; + } + }, + }; + if (name === 'aws-cdk-lib') + return { + App: class extends App { + constructor() { + super({ outdir: join(root, 'cdk.out') }); + } + override synth() { + const assembly = super.synth(); + synthesized = assembly.stacks.map(stack => stack.stackName); + return assembly; + } + }, + }; + if (name === '../lib/cdk-stack') + return { + AgentCoreStack: class extends Stack { + constructor(app: App, id: string, props: StackProps & { credentials?: Record }) { + super(app, id, props); + constructed.push(id); + if (!props.credentials?.['api-key']) throw new Error('Credential "api-key" not found in deployed state'); + new CfnResource(this, 'Credential', { + type: 'AWS::SSM::Parameter', + properties: { Type: 'String', Value: props.credentials['api-key'].credentialProviderArn }, + }); + } + }, + }; + if (name === 'path') return require('node:path'); + if (name === 'fs') return require('node:fs'); + throw new Error(`Unexpected import: ${name}`); + }, + process: { + cwd: () => cdkRoot, + env: + selectedTarget === undefined + ? {} + : { + AGENTCORE_SYNTH_TARGETS: JSON.stringify( + Array.isArray(selectedTarget) ? selectedTarget : [selectedTarget] + ), + }, + exit: () => { + throw new Error('CDK app failed'); + }, + }, + console: { + error: (_prefix: string, message: string) => { + throw new Error(message); + }, + }, + }); + return { constructed, synthesized }; +} + +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +describe('vended CDK app target selection', () => { + it('constructs only the selected stack when sibling credentials do not exist', async () => { + expect(await synthesize('dev', ['dev'])).toEqual({ + constructed: ['AgentCore-test-dev'], + synthesized: ['AgentCore-test-dev'], + }); + }); + + it.each(['prod', 'int'])('selects %s regardless of its position in aws-targets.json', async target => { + expect((await synthesize(target, [target])).synthesized).toEqual([`AgentCore-test-${target}`]); + }); + + it('preserves standalone all-target synthesis when every target is provisioned', async () => { + expect((await synthesize(undefined, ['prod', 'dev', 'int'])).synthesized).toEqual( + targets.map(target => `AgentCore-test-${target.name}`).sort() + ); + }); + + it('constructs every requested target without constructing an unselected sibling', async () => { + expect((await synthesize(['prod', 'dev'], ['prod', 'dev'])).synthesized).toEqual([ + 'AgentCore-test-dev', + 'AgentCore-test-prod', + ]); + }); + + it('rejects an empty target selection', async () => { + await expect(synthesize([], ['prod', 'dev', 'int'])).rejects.toThrow('must be a non-empty JSON array'); + }); + + it('preserves single-target synthesis without a selection', async () => { + expect((await synthesize(undefined, ['dev'], [targets[1]!])).synthesized).toEqual(['AgentCore-test-dev']); + }); + + it('still rejects missing credentials on the selected target', async () => { + await expect(synthesize('dev', ['prod'])).rejects.toThrow('Credential "api-key" not found in deployed state'); + }); + + it.each(['missing', ''])('rejects an unknown selection (%j) instead of synthesizing another target', async target => { + await expect(synthesize(target, ['prod', 'dev', 'int'])).rejects.toThrow('not found in agentcore/aws-targets.json'); + }); + + it('validates the entire selection before constructing any stack', async () => { + const constructed: string[] = []; + await expect(synthesize(['dev', 'missing'], ['dev'], targets, constructed)).rejects.toThrow( + 'Deployment target "missing" not found' + ); + expect(constructed).toEqual([]); + }); + + it('constructs a target only once if the selection contains duplicates', async () => { + expect((await synthesize(['dev', 'dev'], ['dev'])).synthesized).toEqual(['AgentCore-test-dev']); + }); + + it('retains the error for an empty target configuration', async () => { + await expect(synthesize('dev', [], [])).rejects.toThrow('No deployment targets configured'); + }); +}); diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 4349182faa..0fe8acc36b 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -115,7 +115,25 @@ async function main() { const app = new App(); - for (const target of targets) { + // Deployment provisions credentials only for its selected target. Filter before + // constructing stacks so an unselected target cannot require undeployed credentials. + // A standalone CDK invocation without this variable still synthesizes every target. + const selectedTargets = process.env.AGENTCORE_SYNTH_TARGETS; + let synthTargets = targets; + if (selectedTargets !== undefined) { + const targetNames: unknown = JSON.parse(selectedTargets); + if (!Array.isArray(targetNames) || targetNames.length === 0 || targetNames.some(name => typeof name !== 'string')) { + throw new Error('AGENTCORE_SYNTH_TARGETS must be a non-empty JSON array of target names'); + } + for (const name of targetNames) { + if (!targets.some(target => target.name === name)) { + throw new Error(`Deployment target "${name}" not found in agentcore/aws-targets.json`); + } + } + synthTargets = targets.filter(target => targetNames.includes(target.name)); + } + + for (const target of synthTargets) { const env = toEnvironment(target); const stackName = toStackName(spec.name, target.name); diff --git a/src/cli/cdk/toolkit-lib/__tests__/wrapper.test.ts b/src/cli/cdk/toolkit-lib/__tests__/wrapper.test.ts new file mode 100644 index 0000000000..c8ea6e5567 --- /dev/null +++ b/src/cli/cdk/toolkit-lib/__tests__/wrapper.test.ts @@ -0,0 +1,46 @@ +import { SYNTH_TARGET_ENV_VAR } from '../constants'; +import { CdkToolkitWrapper } from '../wrapper'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { fromCdkApp, awsCliCompatible } = vi.hoisted(() => ({ + fromCdkApp: vi.fn().mockResolvedValue({}), + awsCliCompatible: vi.fn(), +})); + +vi.mock('@aws-cdk/toolkit-lib', () => ({ + Toolkit: class { + fromCdkApp = fromCdkApp; + }, + BaseCredentials: { awsCliCompatible }, +})); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('CDK app target selection', () => { + it('passes the deployment target and region to the child app without changing the parent environment', async () => { + vi.stubEnv(SYNTH_TARGET_ENV_VAR, 'inherited'); + const wrapper = new CdkToolkitWrapper({ + projectDir: '/project/agentcore/cdk', + targetNames: ['prod'], + region: 'eu-west-1', + }); + await wrapper.initialize(); + + expect(fromCdkApp).toHaveBeenCalledWith('node dist/bin/cdk.js', { + workingDirectory: '/project/agentcore/cdk', + env: { AGENTCORE_SYNTH_TARGETS: '["prod"]', AWS_REGION: 'eu-west-1', AWS_DEFAULT_REGION: 'eu-west-1' }, + }); + expect(process.env[SYNTH_TARGET_ENV_VAR]).toBe('inherited'); + }); + + it('clears an inherited target for unscoped operations', async () => { + vi.stubEnv(SYNTH_TARGET_ENV_VAR, 'prod'); + await new CdkToolkitWrapper().initialize(); + + expect(fromCdkApp.mock.calls[0]?.[1].env).toHaveProperty(SYNTH_TARGET_ENV_VAR, undefined); + expect(process.env[SYNTH_TARGET_ENV_VAR]).toBe('prod'); + }); +}); diff --git a/src/cli/cdk/toolkit-lib/constants.ts b/src/cli/cdk/toolkit-lib/constants.ts new file mode 100644 index 0000000000..3aba7ea9c0 --- /dev/null +++ b/src/cli/cdk/toolkit-lib/constants.ts @@ -0,0 +1,2 @@ +/** Private CLI-to-CDK-app contract; also consumed by the vended CDK entry point. */ +export const SYNTH_TARGET_ENV_VAR = 'AGENTCORE_SYNTH_TARGETS'; diff --git a/src/cli/cdk/toolkit-lib/types.ts b/src/cli/cdk/toolkit-lib/types.ts index 261e01712b..c50c3ec221 100644 --- a/src/cli/cdk/toolkit-lib/types.ts +++ b/src/cli/cdk/toolkit-lib/types.ts @@ -163,6 +163,9 @@ export interface CdkToolkitWrapperOptions { */ projectDir?: string; + /** Deployment targets to synthesize. Omit to synthesize all configured targets. */ + targetNames?: string[]; + /** * Optional IO host for customizing message/request handling. */ diff --git a/src/cli/cdk/toolkit-lib/wrapper.ts b/src/cli/cdk/toolkit-lib/wrapper.ts index e3b9158a95..f51937c38e 100644 --- a/src/cli/cdk/toolkit-lib/wrapper.ts +++ b/src/cli/cdk/toolkit-lib/wrapper.ts @@ -1,6 +1,7 @@ import { CONFIG_DIR } from '../../../lib'; import { CDK_APP_ENTRY, CDK_PROJECT_DIR } from '../../constants'; import { isChangesetInProgressError } from '../../errors'; +import { SYNTH_TARGET_ENV_VAR } from './constants'; import type { CdkToolkitWrapperOptions, DeployOptions, DestroyOptions, DiffOptions, ListOptions } from './types'; import { BaseCredentials, @@ -113,6 +114,9 @@ export class CdkToolkitWrapper { this.cloudAssemblySource = await this.toolkit.fromCdkApp(this.getCdkAppCommand(), { workingDirectory: this.projectDir, env: { + // Explicitly unset an inherited selection for unscoped operations. + [SYNTH_TARGET_ENV_VAR]: + this.options.targetNames === undefined ? undefined : JSON.stringify(this.options.targetNames), ...(region && { AWS_REGION: region, AWS_DEFAULT_REGION: region }), }, }); diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 1219bb02c2..8670487dbe 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -487,10 +487,11 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise ({ + createWrapper: vi.fn(), + synth: vi.fn().mockResolvedValue({ stackNames: ['AgentCore-test-dev'], assemblyDirectory: '/project/cdk.out' }), + cleanup: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../cdk/toolkit-lib', () => ({ + CdkToolkitWrapper: class {}, + createCdkToolkitWrapper: createWrapper, + silentIoHost: {}, +})); +vi.mock('../../../tui/utils', () => ({ cleanupStaleLockFiles: cleanup })); + +afterEach(() => vi.clearAllMocks()); + +describe('deployment synthesis scope', () => { + it('forwards the requested targets through the shared preflight operation', async () => { + createWrapper.mockResolvedValue({ synth }); + const result = await synthesizeCdk(LocalCdkProject.fromPath('/project/agentcore/cdk'), { + targetNames: ['dev', 'int'], + region: 'eu-west-1', + }); + expect(createWrapper).toHaveBeenCalledWith( + expect.objectContaining({ targetNames: ['dev', 'int'], region: 'eu-west-1' }) + ); + expect(result.stackNames).toEqual(['AgentCore-test-dev']); + }); + + it('leaves unscoped synthesis unfiltered', async () => { + createWrapper.mockResolvedValue({ synth }); + await synthesizeCdk(LocalCdkProject.fromPath('/project/agentcore/cdk')); + expect(createWrapper).toHaveBeenCalledWith(expect.objectContaining({ targetNames: undefined })); + }); +}); diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 61cd8a196c..af96651a15 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -308,6 +308,8 @@ export async function buildCdkProject(cdkProject: LocalCdkProject): Promise { diff --git a/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx b/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx index f9bc8bd1b8..38acc409bd 100644 --- a/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx +++ b/src/cli/tui/screens/deploy/__tests__/useDeployFlow.targets.test.tsx @@ -169,6 +169,7 @@ describe('useDeployFlow target scoping (issue #1267)', () => { await flush(); unmount(); + expect(useCdkPreflightSpy).toHaveBeenCalledWith(expect.objectContaining({ targetNames: [TARGET_B.name] })); expect(deploySpy).toHaveBeenCalledTimes(1); const arg = deploySpy.mock.calls[0]![0]; @@ -261,6 +262,9 @@ describe('useDeployFlow target scoping (issue #1267)', () => { await flush(); unmount(); + expect(useCdkPreflightSpy).toHaveBeenCalledWith( + expect.objectContaining({ targetNames: [TARGET_A.name, TARGET_B.name] }) + ); expect(deploySpy).toHaveBeenCalledTimes(1); const arg = deploySpy.mock.calls[0]![0]; expect(arg.stacks.strategy).toBe(StackSelectionStrategy.PATTERN_MUST_MATCH); diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 26c0ed00f4..ebe6c18642 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -166,10 +166,12 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // Always call the hook (React rules), but we won't use it when preSynthesized is provided. // Diff mode is a preview: the managed-dependency sync runs check-only so the working tree // is never mutated by `agentcore deploy --diff`. + const targetNames = useMemo(() => selectedTargets?.map(target => target.name), [selectedTargets]); const preflight = useCdkPreflight({ logger, isInteractive, selectedTarget: selectedTargets?.[0], + targetNames, dependencySyncCheckOnly: diffMode, });