Skip to content
Open
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
30 changes: 30 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
171 changes: 171 additions & 0 deletions src/assets/__tests__/cdk-target-selection.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, typeof credential> }) {
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');
});
});
20 changes: 19 additions & 1 deletion src/assets/cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
46 changes: 46 additions & 0 deletions src/cli/cdk/toolkit-lib/__tests__/wrapper.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
2 changes: 2 additions & 0 deletions src/cli/cdk/toolkit-lib/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions src/cli/cdk/toolkit-lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
4 changes: 4 additions & 0 deletions src/cli/cdk/toolkit-lib/wrapper.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 }),
},
});
Expand Down
5 changes: 3 additions & 2 deletions src/cli/commands/deploy/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,10 +487,11 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
const synthResult = await synthesizeCdk(context.cdkProject, {
...(switchableIoHost && { ioHost: switchableIoHost.ioHost }),
region: target.region,
targetNames: [target.name],
});
toolkitWrapper = synthResult.toolkitWrapper;
// 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
// Select the deployed target's stack explicitly (older vended apps may still synthesize all
// configured targets) 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) {
Expand Down
38 changes: 38 additions & 0 deletions src/cli/operations/deploy/__tests__/synthesis-targets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { LocalCdkProject } from '../../../cdk/local-cdk-project';
import { synthesizeCdk } from '../preflight';
import { afterEach, describe, expect, it, vi } from 'vitest';

const { createWrapper, synth, cleanup } = vi.hoisted(() => ({
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 }));
});
});
Loading
Loading