From e3e239bd00cc0f1b47182e0a2dc1bc7b9af92925 Mon Sep 17 00:00:00 2001 From: Restebance Date: Thu, 8 Oct 2026 22:21:18 -0500 Subject: [PATCH] feat(deploy): support multiple targets in one account and region Deployed resource names are prefixed with the project name only, so two targets in the same account and region collided on physical names (runtime, memory, gateway, ...). Add an optional per-target `resourceNameSuffix` in aws-targets.json that is appended to the project name in deployed resource names (e.g. myappDev_agent). - Vended CDK app applies the suffix per target by passing the suffixed project name to AgentCoreStack; stack names and the project tag are unchanged, and targets without a suffix synthesize identical templates. - Preflight validates name length with the suffix, fails on same account+region collisions with deployed targets (warns otherwise), blocks payments in shared accounts, and detects vended CDK apps that predate the option. - Insights, batch evaluation, A/B tests, config bundle lookup and import rebuild physical names with the target's suffix. - Telemetry: uses_resource_name_suffix deploy attribute. - Docs: aws-targets.json section with a dev/prod example and limits. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/configuration.md | 81 ++++++++++- .../assets.snapshot.test.ts.snap | 25 +++- src/assets/cdk/bin/cdk.ts | 25 +++- src/cli/commands/deploy/actions.ts | 7 + src/cli/commands/deploy/command.tsx | 3 + src/cli/commands/deploy/types.ts | 2 + src/cli/commands/deploy/utils.ts | 9 +- src/cli/commands/import/actions.ts | 5 +- src/cli/commands/import/import-gateway.ts | 3 +- src/cli/commands/import/import-online-eval.ts | 3 +- src/cli/commands/import/resource-import.ts | 3 +- .../__tests__/resource-naming.test.ts | 55 ++++++++ .../__tests__/bundle-name-variants.test.ts | 5 + .../config-bundle/bundle-name-variants.ts | 10 +- .../config-bundle/resolve-bundle.ts | 18 ++- .../deploy/__tests__/preflight.test.ts | 133 +++++++++++++++++- src/cli/operations/deploy/preflight.ts | 123 ++++++++++++++-- src/cli/operations/insights/run-insights.ts | 23 ++- src/cli/operations/jobs/ab-test/handler.ts | 12 +- .../jobs/batch-evaluation/handler.ts | 3 +- src/cli/operations/jobs/insights/handler.ts | 8 +- .../__tests__/resolve-agent-state.test.ts | 26 ++++ .../jobs/shared/resolve-agent-state.ts | 6 +- src/cli/operations/resource-naming.ts | 50 +++++++ src/cli/telemetry/schemas/command-run.ts | 2 + src/cli/tui/hooks/useCdkPreflight.ts | 6 +- .../config-bundle-hub/useConfigBundleHub.ts | 6 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 9 +- src/schema/llm-compacted/aws-targets.ts | 1 + .../schemas/__tests__/aws-targets.test.ts | 17 +++ src/schema/schemas/aws-targets.ts | 12 ++ 31 files changed, 630 insertions(+), 61 deletions(-) create mode 100644 src/cli/operations/__tests__/resource-naming.test.ts create mode 100644 src/cli/operations/jobs/shared/__tests__/resolve-agent-state.test.ts create mode 100644 src/cli/operations/resource-naming.ts diff --git a/docs/configuration.md b/docs/configuration.md index f6c4e0391a..956de3407d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -607,12 +607,81 @@ Deployment target ] ``` -| Field | Required | Description | -| ------------- | -------- | --------------------------------------- | -| `name` | Yes | Target name (used with `--target` flag) | -| `description` | No | Target description | -| `account` | Yes | AWS account ID (12 digits) | -| `region` | Yes | AWS region | +| Field | Required | Description | +| -------------------- | -------- | ----------------------------------------------------------------------------------- | +| `name` | Yes | Target name (used with `--target` flag) | +| `description` | No | Target description | +| `account` | Yes | AWS account ID (12 digits) | +| `region` | Yes | AWS region | +| `resourceNameSuffix` | No | 1-8 alphanumeric characters appended to the project name in deployed resource names | + +### Multiple targets in one account and region + +Each target deploys its own CloudFormation stack (`AgentCore--`), but deployed resources are named +after the project, not the target (for example the runtime `myapp_agent`). Two targets in the same account and region +therefore collide unless you give them different `resourceNameSuffix` values: + +```json +[ + { "name": "dev", "account": "123456789012", "region": "us-east-1", "resourceNameSuffix": "Dev" }, + { "name": "prod", "account": "123456789012", "region": "us-east-1", "resourceNameSuffix": "Prod" } +] +``` + +```bash +agentcore deploy --target dev -y # runtime myappDev_agent, memory myappDev_..., gateway myappDev-... +agentcore deploy --target prod -y # runtime myappProd_agent, ... +``` + +- **The suffix counts toward the project name limit.** Project name plus suffix must stay within 23 characters. +- **Only set a suffix on new targets.** Adding or changing `resourceNameSuffix` on an already-deployed target renames + its resources, so CloudFormation replaces them. Replaced memories lose their stored data. +- **Credential providers are shared.** API key and OAuth credential providers are named after the credential, not the + project, so targets in the same account and region use the same providers. All targets read their secrets from the + same `.env.local`. +- **Payments are not supported** in this setup. Payment managers and connectors aren't prefixed with the project name, + so deploy fails if two targets in the same account and region both deploy them. +- **Some commands ignore `--target`.** `logs`, `traces` and `dev` don't take `--target`; they use the first target in + `aws-targets.json`. + +Deploy fails with a clear error when a target would collide with another target that is already deployed (or deployed in +the same run). If the colliding target hasn't been deployed yet, deploy only warns. + +#### Projects created before `resourceNameSuffix` + +The CDK app in `agentcore/cdk/bin/cdk.ts` is copied once, at `agentcore create`. In older projects it ignores +`resourceNameSuffix`, so deploy stops with an error until you update it: + +1. Add this helper next to `toStackName`: + + ```ts + function readResourceNameSuffixes(configRoot: string): Record { + const raw = JSON.parse(fs.readFileSync(path.join(configRoot, 'aws-targets.json'), 'utf8')) as { + name?: unknown; + resourceNameSuffix?: unknown; + }[]; + const suffixes: Record = {}; + for (const entry of raw) { + if (typeof entry.name === 'string' && typeof entry.resourceNameSuffix === 'string') { + suffixes[entry.name] = entry.resourceNameSuffix; + } + } + return suffixes; + } + ``` + +2. In `main()`, after the targets are read, add `const resourceNameSuffixes = readResourceNameSuffixes(configRoot);`. +3. Inside `for (const target of targets)`, pass the suffixed name to the stack instead of `spec`: + + ```ts + const physicalProjectName = `${spec.name}${resourceNameSuffixes[target.name] ?? ''}`; + new AgentCoreStack(app, stackName, { + spec: physicalProjectName === spec.name ? spec : { ...spec, name: physicalProjectName }, + // ...other props unchanged + }); + ``` + +Keep `toStackName(spec.name, target.name)` and the `agentcore:project-name` tag on the plain project name. ### Supported Regions diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 4446b1e966..1f8238a5f2 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -65,6 +65,20 @@ function toStackName(projectName: string, targetName: string): string { return \`AgentCore-\${sanitize(projectName)}-\${sanitize(targetName)}\`; } +function readResourceNameSuffixes(configRoot: string): Record { + const raw = JSON.parse(fs.readFileSync(path.join(configRoot, 'aws-targets.json'), 'utf8')) as { + name?: unknown; + resourceNameSuffix?: unknown; + }[]; + const suffixes: Record = {}; + for (const entry of raw) { + if (typeof entry.name === 'string' && typeof entry.resourceNameSuffix === 'string') { + suffixes[entry.name] = entry.resourceNameSuffix; + } + } + return suffixes; +} + async function main() { // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/. const configRoot = path.resolve(process.cwd(), '..'); @@ -101,6 +115,11 @@ async function main() { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } + // Optional per-target resourceNameSuffix, appended to the project name in deployed resource + // names so several targets can share one account+region. Read from the raw file because the + // published @aws/agentcore-cdk target schema may not know the field yet and strips it. + const resourceNameSuffixes = readResourceNameSuffixes(configRoot); + // Read harness configs: the full validated spec drives the CFN resource; the // role-scoped fields drive the IAM role + container build. const projectRoot = path.resolve(configRoot, '..'); @@ -236,8 +255,12 @@ async function main() { ) : undefined; + // Physical resource names derive from spec.name (e.g. runtime \`\${spec.name}_\${agent}\`), so the + // suffix is applied there. Stack name and project tag keep the plain project name. + const physicalProjectName = \`\${spec.name}\${resourceNameSuffixes[target.name] ?? ''}\`; + new AgentCoreStack(app, stackName, { - spec, + spec: physicalProjectName === spec.name ? spec : { ...spec, name: physicalProjectName }, mcpSpec, credentials, connectorParametersByFile, diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 4349182faa..b660177344 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -20,6 +20,20 @@ function toStackName(projectName: string, targetName: string): string { return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`; } +function readResourceNameSuffixes(configRoot: string): Record { + const raw = JSON.parse(fs.readFileSync(path.join(configRoot, 'aws-targets.json'), 'utf8')) as { + name?: unknown; + resourceNameSuffix?: unknown; + }[]; + const suffixes: Record = {}; + for (const entry of raw) { + if (typeof entry.name === 'string' && typeof entry.resourceNameSuffix === 'string') { + suffixes[entry.name] = entry.resourceNameSuffix; + } + } + return suffixes; +} + async function main() { // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/. const configRoot = path.resolve(process.cwd(), '..'); @@ -56,6 +70,11 @@ async function main() { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } + // Optional per-target resourceNameSuffix, appended to the project name in deployed resource + // names so several targets can share one account+region. Read from the raw file because the + // published @aws/agentcore-cdk target schema may not know the field yet and strips it. + const resourceNameSuffixes = readResourceNameSuffixes(configRoot); + // Read harness configs: the full validated spec drives the CFN resource; the // role-scoped fields drive the IAM role + container build. const projectRoot = path.resolve(configRoot, '..'); @@ -191,8 +210,12 @@ async function main() { ) : undefined; + // Physical resource names derive from spec.name (e.g. runtime `${spec.name}_${agent}`), so the + // suffix is applied there. Stack name and project tag keep the plain project name. + const physicalProjectName = `${spec.name}${resourceNameSuffixes[target.name] ?? ''}`; + new AgentCoreStack(app, stackName, { - spec, + spec: physicalProjectName === spec.name ? spec : { ...spec, name: physicalProjectName }, mcpSpec, credentials, connectorParametersByFile, diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 1219bb02c2..7ef34402a2 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -188,6 +188,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { currentStepName = name; @@ -214,6 +215,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? allWarnings : undefined, dependencySyncResult, + usesResourceNameSuffix, }; } catch (err: unknown) { logger.log(getErrorMessage(err), 'error'); diff --git a/src/cli/commands/deploy/command.tsx b/src/cli/commands/deploy/command.tsx index b6c3fd5252..15832b5727 100644 --- a/src/cli/commands/deploy/command.tsx +++ b/src/cli/commands/deploy/command.tsx @@ -52,6 +52,9 @@ async function handleDeployCLI(options: DeployOptions): Promise { if (result.dependencySyncResult) { recorder.set(toDepSyncAttrs(result.dependencySyncResult)); } + if (result.usesResourceNameSuffix !== undefined) { + recorder.set({ uses_resource_name_suffix: result.usesResourceNameSuffix }); + } if (!result.success) { return { success: false as const, error: result.error, deployResult: result }; } diff --git a/src/cli/commands/deploy/types.ts b/src/cli/commands/deploy/types.ts index d0df81a5ee..fb4767918b 100644 --- a/src/cli/commands/deploy/types.ts +++ b/src/cli/commands/deploy/types.ts @@ -22,6 +22,8 @@ export type DeployResult = Result<{ logPath?: string; /** Sync outcome rides on BOTH branches so dep_sync_* telemetry survives a failed deploy. */ dependencySyncResult?: DependencySyncResult; + /** Whether the deployed target sets resourceNameSuffix (telemetry). Undefined before the target loads. */ + usesResourceNameSuffix?: boolean; }; export type PreflightResult = Result<{ diff --git a/src/cli/commands/deploy/utils.ts b/src/cli/commands/deploy/utils.ts index d6362e114d..88ba35b81e 100644 --- a/src/cli/commands/deploy/utils.ts +++ b/src/cli/commands/deploy/utils.ts @@ -1,4 +1,4 @@ -import type { AgentCoreProjectSpec } from '../../../schema'; +import type { AgentCoreProjectSpec, AwsDeploymentTarget } from '../../../schema'; import type { DeployMode } from '../../telemetry/schemas/common-shapes'; export const DEFAULT_DEPLOY_ATTRS = { @@ -15,7 +15,11 @@ export const DEFAULT_DEPLOY_ATTRS = { deploy_mode: 'deploy' as DeployMode, }; -export function computeDeployAttrs(projectSpec: Partial, mode: DeployMode) { +export function computeDeployAttrs( + projectSpec: Partial, + mode: DeployMode, + targets?: readonly AwsDeploymentTarget[] +) { const gateways = projectSpec.agentCoreGateways ?? []; const policyEngines = projectSpec.policyEngines ?? []; return { @@ -30,5 +34,6 @@ export function computeDeployAttrs(projectSpec: Partial, m policy_engine_count: policyEngines.length, policy_count: policyEngines.reduce((sum, pe) => sum + (pe.policies ?? []).length, 0), deploy_mode: mode, + ...(targets?.length && { uses_resource_name_suffix: targets.some(t => Boolean(t.resourceNameSuffix)) }), }; } diff --git a/src/cli/commands/import/actions.ts b/src/cli/commands/import/actions.ts index 7da60ed98e..0088ccfec9 100644 --- a/src/cli/commands/import/actions.ts +++ b/src/cli/commands/import/actions.ts @@ -13,6 +13,7 @@ import { arnPrefix } from '../../aws/partition'; import { ANSI } from '../../constants'; import { ExecLogger } from '../../logging'; import { setupPythonProject } from '../../operations/python/setup'; +import { getPhysicalProjectName } from '../../operations/resource-naming'; import { copyAndRenderDir } from '../../templates/render'; import { getTemplatePath } from '../../templates/templateRoot'; import { resolveVpcIdFromSubnets } from '../shared/vpc-utils'; @@ -562,7 +563,7 @@ export async function handleImport(options: ImportOptions): Promise( ); if (!logicalId) { - const prefixedName = `${ctx.projectName}_${localName}`; + const prefixedName = `${getPhysicalProjectName(ctx.projectName, target)}_${localName}`; logicalId = findLogicalIdByProperty( synthTemplate, descriptor.cfnResourceType, diff --git a/src/cli/operations/__tests__/resource-naming.test.ts b/src/cli/operations/__tests__/resource-naming.test.ts new file mode 100644 index 0000000000..0179da0a84 --- /dev/null +++ b/src/cli/operations/__tests__/resource-naming.test.ts @@ -0,0 +1,55 @@ +import type { AwsDeploymentTarget } from '../../../schema'; +import { + getPhysicalProjectName, + getPhysicalProjectNameForTarget, + validatePhysicalProjectName, +} from '../resource-naming'; +import { describe, expect, it } from 'vitest'; + +const target = (overrides: Partial = {}): AwsDeploymentTarget => ({ + name: 'dev', + account: '123456789012', + region: 'us-east-1', + ...overrides, +}); + +describe('getPhysicalProjectName', () => { + it('returns the plain project name without a target or suffix', () => { + expect(getPhysicalProjectName('myapp')).toBe('myapp'); + expect(getPhysicalProjectName('myapp', target())).toBe('myapp'); + }); + + it('appends the target resourceNameSuffix', () => { + expect(getPhysicalProjectName('myapp', target({ resourceNameSuffix: 'Dev' }))).toBe('myappDev'); + }); +}); + +describe('getPhysicalProjectNameForTarget', () => { + const targets = [target({ resourceNameSuffix: 'Dev' }), target({ name: 'prod' })]; + + it('looks the target up by name', () => { + expect(getPhysicalProjectNameForTarget('myapp', targets, 'dev')).toBe('myappDev'); + expect(getPhysicalProjectNameForTarget('myapp', targets, 'prod')).toBe('myapp'); + }); + + it('falls back to the plain project name for unknown or missing targets', () => { + expect(getPhysicalProjectNameForTarget('myapp', targets, 'qa')).toBe('myapp'); + expect(getPhysicalProjectNameForTarget('myapp', targets, undefined)).toBe('myapp'); + }); +}); + +describe('validatePhysicalProjectName', () => { + it('accepts a suffixed name within the 23-char project name limit', () => { + expect(() => validatePhysicalProjectName('a'.repeat(15), target({ resourceNameSuffix: 'Staging1' }))).not.toThrow(); + }); + + it('rejects a suffixed name over 23 chars', () => { + expect(() => validatePhysicalProjectName('a'.repeat(16), target({ resourceNameSuffix: 'Staging1' }))).toThrow( + 'Use a shorter resourceNameSuffix' + ); + }); + + it('skips targets without a suffix', () => { + expect(() => validatePhysicalProjectName('a'.repeat(30), target())).not.toThrow(); + }); +}); diff --git a/src/cli/operations/config-bundle/__tests__/bundle-name-variants.test.ts b/src/cli/operations/config-bundle/__tests__/bundle-name-variants.test.ts index 5c753cb528..a540e14389 100644 --- a/src/cli/operations/config-bundle/__tests__/bundle-name-variants.test.ts +++ b/src/cli/operations/config-bundle/__tests__/bundle-name-variants.test.ts @@ -15,6 +15,11 @@ describe('getBundleNameVariants', () => { expect(variants).toEqual(['MyBundle', 'testevoMyBundle', 'testevo_MyBundle']); }); + it('adds variants for every distinct project name prefix', () => { + const variants = getBundleNameVariants('MyBundle', ['projDev', 'proj', 'proj']); + expect(variants).toEqual(['MyBundle', 'projDevMyBundle', 'projDev_MyBundle', 'projMyBundle', 'proj_MyBundle']); + }); + it('filters out empty bundle name', () => { const variants = getBundleNameVariants('', 'proj'); expect(variants).toEqual(['proj', 'proj_']); diff --git a/src/cli/operations/config-bundle/bundle-name-variants.ts b/src/cli/operations/config-bundle/bundle-name-variants.ts index a282b9ad33..24019ba447 100644 --- a/src/cli/operations/config-bundle/bundle-name-variants.ts +++ b/src/cli/operations/config-bundle/bundle-name-variants.ts @@ -1,11 +1,15 @@ /** * Returns all possible API-side names for a config bundle. * The API stores bundles with a project-name prefix, but users reference them by local name. + * Pass several project names to also match targets that use a resourceNameSuffix + * (prefix `${projectName}${suffix}`). */ -export function getBundleNameVariants(bundleName: string, projectName?: string): string[] { +export function getBundleNameVariants(bundleName: string, projectNames?: string | readonly string[]): string[] { + const prefixes = [...new Set(typeof projectNames === 'string' ? [projectNames] : (projectNames ?? []))].filter( + Boolean + ); return [ bundleName, - projectName ? `${projectName}${bundleName}` : undefined, - projectName ? `${projectName}_${bundleName}` : undefined, + ...prefixes.flatMap(projectName => [`${projectName}${bundleName}`, `${projectName}_${bundleName}`]), ].filter((x): x is string => Boolean(x)); } diff --git a/src/cli/operations/config-bundle/resolve-bundle.ts b/src/cli/operations/config-bundle/resolve-bundle.ts index 964c705c1c..f9a5ac779a 100644 --- a/src/cli/operations/config-bundle/resolve-bundle.ts +++ b/src/cli/operations/config-bundle/resolve-bundle.ts @@ -6,6 +6,7 @@ */ import { ConfigIO } from '../../../lib'; import { listConfigurationBundleVersions, listConfigurationBundles } from '../../aws/agentcore-config-bundles'; +import { getPhysicalProjectName } from '../resource-naming'; import { getBundleNameVariants } from './bundle-name-variants'; export interface ResolvedBundle { @@ -52,16 +53,25 @@ export async function resolveBundleByName( } // Fallback: search via API - // The API stores bundles with a prefixed name: {projectName}{bundleName} - let projectName: string | undefined; + // The API stores bundles with a prefixed name: {projectName}{bundleName}, where projectName + // carries the resourceNameSuffix of the target the bundle was deployed to. + const projectNames: string[] = []; try { const projectSpec = await configIO.readProjectSpec(); - projectName = projectSpec.name; + try { + const targets = await configIO.readAWSDeploymentTargets(); + for (const target of targets.filter(t => t.region === region)) { + projectNames.push(getPhysicalProjectName(projectSpec.name, target)); + } + } catch { + // Targets may not be available — fall back to the plain project name + } + projectNames.push(projectSpec.name); } catch { // Project spec may not be available } - const nameVariants = getBundleNameVariants(bundleName, projectName); + const nameVariants = getBundleNameVariants(bundleName, projectNames); let nextToken: string | undefined; let match: { bundleId: string; bundleArn: string; bundleName: string } | undefined; do { diff --git a/src/cli/operations/deploy/__tests__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts index 894e4eb30e..88d861f1b8 100644 --- a/src/cli/operations/deploy/__tests__/preflight.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight.test.ts @@ -1,5 +1,12 @@ import { StaleCdkConstructError } from '../../../../lib/errors/types.js'; -import { extractUnknownKeys, formatError, rewriteIfStaleCdkConstruct, validateProject } from '../preflight.js'; +import type { AgentCoreProjectSpec } from '../../../../schema/index.js'; +import { + extractUnknownKeys, + formatError, + rewriteIfStaleCdkConstruct, + validateProject, + validateTargetIsolation, +} from '../preflight.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; const { mockReadProjectSpec, mockReadAWSDeploymentTargets, mockReadDeployedState, mockConfigExists } = vi.hoisted( @@ -350,6 +357,130 @@ describe('validateProject', () => { const result = await validateProject(); expect(result.projectSpec.name).toBe('ZeroTrustTodayACProj'); }); + + it('checks runtime name length against the project name plus the target resourceNameSuffix', async () => { + const selectedTarget = { + name: 'dev', + account: '111111111111', + region: 'us-east-1', + resourceNameSuffix: 'Dev', + } as const; + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + // "proj" (4) + "Dev" (3) + "_" (1) + 41-char name = 49 > 48, but fits without the suffix + mockReadProjectSpec.mockResolvedValue({ name: 'proj', runtimes: [{ name: 'a'.repeat(41) }] }); + mockReadAWSDeploymentTargets.mockResolvedValue([selectedTarget]); + + await expect(validateProject(selectedTarget)).rejects.toThrow('Runtime name too long: "projDev_'); + }); + + it('rejects a resourceNameSuffix that pushes the project name past 23 chars', async () => { + const selectedTarget = { + name: 'dev', + account: '111111111111', + region: 'us-east-1', + resourceNameSuffix: 'Staging1', + } as const; + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ name: 'a'.repeat(16), runtimes: [{ name: 'agent' }] }); + mockReadAWSDeploymentTargets.mockResolvedValue([selectedTarget]); + + await expect(validateProject(selectedTarget)).rejects.toThrow('Use a shorter resourceNameSuffix'); + }); + + it('rejects a resourceNameSuffix when the vended CDK app predates it', async () => { + const selectedTarget = { + name: 'dev', + account: '111111111111', + region: 'us-east-1', + resourceNameSuffix: 'Dev', + } as const; + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ name: 'proj', runtimes: [{ name: 'agent' }] }); + mockReadAWSDeploymentTargets.mockResolvedValue([selectedTarget]); + + // node:fs readFileSync is mocked to return "{}" — a cdk.ts without resourceNameSuffix support + await expect(validateProject(selectedTarget)).rejects.toThrow('predates that option'); + }); + + it('fails when a deployed target in the same account and region uses the same resource names', async () => { + const dev = { name: 'dev', account: '111111111111', region: 'us-east-1' } as const; + const prod = { name: 'prod', account: '111111111111', region: 'us-east-1' } as const; + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ name: 'proj', runtimes: [{ name: 'agent' }] }); + mockReadAWSDeploymentTargets.mockResolvedValue([dev, prod]); + mockReadDeployedState.mockResolvedValue({ targets: { dev: { resources: {} } } }); + + await expect(validateProject(prod)).rejects.toThrow( + 'Targets "prod" and "dev" deploy to the same account (111111111111) and region (us-east-1)' + ); + }); +}); + +describe('validateTargetIsolation', () => { + const spec = { name: 'proj', runtimes: [{ name: 'agent' }] } as unknown as AgentCoreProjectSpec; + const dev = { name: 'dev', account: '111111111111', region: 'us-east-1' } as const; + const prod = { name: 'prod', account: '111111111111', region: 'us-east-1' } as const; + + afterEach(() => vi.restoreAllMocks()); + + it('allows same-account targets with different resourceNameSuffix values', () => { + expect(() => + validateTargetIsolation( + spec, + [ + { ...dev, resourceNameSuffix: 'Dev' }, + { ...prod, resourceNameSuffix: 'Prod' }, + ], + [{ ...prod, resourceNameSuffix: 'Prod' }], + ['dev'] + ) + ).not.toThrow(); + }); + + it('allows a suffix on only one of the targets', () => { + expect(() => + validateTargetIsolation(spec, [dev, { ...prod, resourceNameSuffix: 'Prod' }], [dev], ['prod']) + ).not.toThrow(); + }); + + it('ignores targets in a different region or account', () => { + const other = [ + { ...prod, region: 'us-west-2' }, + { ...prod, name: 'other', account: '222222222222' }, + ] as const; + expect(() => validateTargetIsolation(spec, [dev, ...other], [dev], ['prod', 'other'])).not.toThrow(); + }); + + it('only warns when the colliding target is neither deployed nor being deployed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + expect(() => validateTargetIsolation(spec, [dev, prod], [dev], [])).not.toThrow(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Deploying "prod" as well will fail.')); + }); + + it('fails when two colliding targets are deployed in the same run', () => { + expect(() => validateTargetIsolation(spec, [dev, prod], [dev, prod], [])).toThrow( + 'Set a different "resourceNameSuffix"' + ); + }); + + it('fails for payment managers even when suffixes differ', () => { + const withPayments = { ...spec, payments: [{ name: 'pm' }] } as unknown as AgentCoreProjectSpec; + expect(() => + validateTargetIsolation( + withPayments, + [ + { ...dev, resourceNameSuffix: 'Dev' }, + { ...prod, resourceNameSuffix: 'Prod' }, + ], + [{ ...dev, resourceNameSuffix: 'Dev' }], + ['prod'] + ) + ).toThrow('payment managers'); + }); }); describe('formatError', () => { diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 61cd8a196c..eb241e5ac4 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -11,8 +11,9 @@ import { validateAwsCredentials } from '../../aws/account'; import { LocalCdkProject } from '../../cdk/local-cdk-project'; import { CdkToolkitWrapper, createCdkToolkitWrapper, silentIoHost } from '../../cdk/toolkit-lib'; import { checkBootstrapStatus, checkStacksStatus, formatCdkEnvironment } from '../../cloudformation'; -import { MAX_GATEWAY_NAME_LENGTH, MAX_RUNTIME_NAME_LENGTH } from '../../constants'; +import { CDK_PROJECT_DIR, MAX_GATEWAY_NAME_LENGTH, MAX_RUNTIME_NAME_LENGTH } from '../../constants'; import { cleanupStaleLockFiles } from '../../tui/utils'; +import { getPhysicalProjectName, validatePhysicalProjectName } from '../resource-naming'; import type { IIoHost } from '@aws-cdk/toolkit-lib'; import { existsSync, readFileSync } from 'node:fs'; import * as path from 'node:path'; @@ -108,8 +109,15 @@ export function hasDeployableResources(spec: AgentCoreProjectSpec): boolean { * Validates the CDK project and loads configuration. * Also validates AWS credentials are configured before proceeding. * Returns the project context needed for subsequent steps. + * + * @param selectedTarget Target whose credentials are validated (defaults to the first target). + * @param deployingTargets Every target this deploy will touch, when more than one is selected + * (TUI multi-select). Defaults to `[selectedTarget]`, or all targets when none is selected. */ -export async function validateProject(selectedTarget?: AwsDeploymentTarget): Promise { +export async function validateProject( + selectedTarget?: AwsDeploymentTarget, + deployingTargets?: AwsDeploymentTarget[] +): Promise { // Find the agentcore config directory, walking up from cwd if needed const configRoot = requireConfigRoot(); // Project root is the parent of the agentcore directory @@ -128,9 +136,11 @@ export async function validateProject(selectedTarget?: AwsDeploymentTarget): Pro // deployed-state.json is written by the CLI after every successful deploy, so it is a // reliable indicator of whether a CloudFormation stack exists for this project. let hasExistingStack = false; + let deployedTargetNames: string[] = []; try { const deployedState = await configIO.readDeployedState(); - hasExistingStack = Object.keys(deployedState.targets).length > 0; + deployedTargetNames = Object.keys(deployedState.targets); + hasExistingStack = deployedTargetNames.length > 0; } catch { // No deployed state file — no existing stack } @@ -148,11 +158,30 @@ export async function validateProject(selectedTarget?: AwsDeploymentTarget): Pro isTeardownDeploy = true; } - // Validate runtime names don't exceed AWS limits - validateRuntimeNames(projectSpec); + const targetsToDeploy = deployingTargets ?? (selectedTarget ? [selectedTarget] : awsTargets); + + // Validate per-target resource name prefixes (project name + optional resourceNameSuffix) + for (const target of targetsToDeploy) { + validatePhysicalProjectName(projectSpec.name, target); + } + const physicalProjectNames = targetsToDeploy.length + ? [...new Set(targetsToDeploy.map(t => getPhysicalProjectName(projectSpec.name, t)))] + : [projectSpec.name]; + + for (const physicalProjectName of physicalProjectNames) { + // Validate runtime names don't exceed AWS limits + validateRuntimeNames(projectSpec, physicalProjectName); - // Validate gateway names don't exceed AWS limits - validateGatewayNames(projectSpec); + // Validate gateway names don't exceed AWS limits + validateGatewayNames(projectSpec, physicalProjectName); + } + + // Validate targets sharing an account+region won't collide on physical resource names. + // A teardown deploy creates nothing, so it can't collide. + if (!isTeardownDeploy) { + validateTargetIsolation(projectSpec, awsTargets, targetsToDeploy, deployedTargetNames); + validateCdkAppSupportsSuffix(targetsToDeploy, configRoot); + } // Validate Container agents have Dockerfiles validateContainerAgents(projectSpec, configRoot); @@ -177,8 +206,7 @@ export async function validateProject(selectedTarget?: AwsDeploymentTarget): Pro /** * Validates that combined runtime names (projectName_agentName) don't exceed AWS limits. */ -function validateRuntimeNames(projectSpec: AgentCoreProjectSpec): void { - const projectName = projectSpec.name; +function validateRuntimeNames(projectSpec: AgentCoreProjectSpec, projectName: string): void { for (const agent of projectSpec.runtimes || []) { const agentName = agent.name; if (agentName) { @@ -199,8 +227,7 @@ function validateRuntimeNames(projectSpec: AgentCoreProjectSpec): void { * The deployed gateway resource name is `${projectName}-${gatewayName}` and AWS rejects names * over 48 chars at CreateGateway — surface that here instead of as an opaque CREATE_FAILED. */ -export function validateGatewayNames(projectSpec: AgentCoreProjectSpec): void { - const projectName = projectSpec.name; +export function validateGatewayNames(projectSpec: AgentCoreProjectSpec, projectName: string = projectSpec.name): void { for (const gateway of projectSpec.agentCoreGateways || []) { // Imported gateways carry an explicit resourceName that AWS already accepted; skip those. if (gateway.resourceName) continue; @@ -215,6 +242,80 @@ export function validateGatewayNames(projectSpec: AgentCoreProjectSpec): void { } } +/** + * Validates that targets sharing an account+region won't collide on physical resource names. + * + * Deployed resource names are prefixed with the project name (plus the target's optional + * resourceNameSuffix), not the target name, so two such targets need different suffixes. + * Payment managers/connectors aren't prefixed at all and can never be isolated this way. + * + * Fails only when the other target is already deployed or deployed in the same run; otherwise + * warns, so configs that deploy fine today (only one of the targets ever deployed) keep working. + */ +export function validateTargetIsolation( + projectSpec: AgentCoreProjectSpec, + awsTargets: readonly AwsDeploymentTarget[], + targetsToDeploy: readonly AwsDeploymentTarget[], + deployedTargetNames: readonly string[] +): void { + const hasPayments = (projectSpec.payments?.length ?? 0) > 0; + const blocking = new Set([...deployedTargetNames, ...targetsToDeploy.map(t => t.name)]); + const reported = new Set(); + + for (const target of targetsToDeploy) { + const physicalName = getPhysicalProjectName(projectSpec.name, target); + for (const other of awsTargets) { + if (other.name === target.name || other.account !== target.account || other.region !== target.region) { + continue; + } + const sameNames = getPhysicalProjectName(projectSpec.name, other) === physicalName; + if (!sameNames && !hasPayments) continue; + + const pairKey = [target.name, other.name].sort().join('\0'); + if (reported.has(pairKey)) continue; + reported.add(pairKey); + + const reason = sameNames + ? `both prefix their resource names with "${physicalName}". ` + + `Set a different "resourceNameSuffix" on one of them in aws-targets.json.` + : `the project has payment managers, which are not prefixed with the project name and can't be ` + + `isolated per target. Deploy one of these targets to a different account or region.`; + const message = + `Targets "${target.name}" and "${other.name}" deploy to the same account (${target.account}) ` + + `and region (${target.region}), and ${reason}`; + + if (blocking.has(other.name)) { + throw new ValidationError(message); + } + console.warn(`Warning: ${message} Deploying "${other.name}" as well will fail.`); + } + } +} + +/** + * The vended CDK app is copied once at `agentcore create`, so projects created before + * resourceNameSuffix existed would silently ignore it and collide anyway. Fail early instead. + */ +function validateCdkAppSupportsSuffix(targetsToDeploy: readonly AwsDeploymentTarget[], configRoot: string): void { + const suffixed = targetsToDeploy.find(t => t.resourceNameSuffix); + if (!suffixed) return; + + const cdkAppPath = path.join(configRoot, CDK_PROJECT_DIR, 'bin', 'cdk.ts'); + let source: string; + try { + source = readFileSync(cdkAppPath, 'utf-8'); + } catch { + return; // Missing/unreadable CDK app is reported by the CDK project validation/build steps. + } + if (!source.includes('resourceNameSuffix')) { + throw new ValidationError( + `Target "${suffixed.name}" sets resourceNameSuffix, but this project's CDK app (${cdkAppPath}) ` + + `predates that option and would ignore it. Update agentcore/cdk/bin/cdk.ts as described in ` + + `docs/configuration.md ("Multiple targets in one account and region").` + ); + } +} + /** * Validates that Container agents have required Dockerfiles. */ diff --git a/src/cli/operations/insights/run-insights.ts b/src/cli/operations/insights/run-insights.ts index 4b443e0d29..f869100afe 100644 --- a/src/cli/operations/insights/run-insights.ts +++ b/src/cli/operations/insights/run-insights.ts @@ -7,12 +7,13 @@ * 5. Return results */ import { ConfigIO, ResourceNotFoundError, toError } from '../../../lib'; -import type { DeployedState } from '../../../schema'; import type { CloudWatchFilterConfig, InsightConfig } from '../../aws/agentcore-batch-evaluation'; import { generateClientToken, getBatchEvaluation, startBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; import { resolveEndpointName, runtimeLogGroup } from '../../aws/cloudwatch'; import { getRegion } from '../../commands/shared/region-utils'; import { ExecLogger } from '../../logging/exec-logger'; +import { resolveAgentState } from '../jobs/shared/resolve-agent-state'; +import { getPhysicalProjectNameForTarget } from '../resource-naming'; import { saveInsightsRun, updateInsightsRun } from './insights-storage'; import type { InsightsRunRecord, RunInsightsOptions, RunInsightsResult } from './types'; @@ -40,7 +41,11 @@ export async function runInsightsCommand(options: RunInsightsOptions): Promise 0) { return { sessionIds: options.sessionIds }; diff --git a/src/cli/operations/jobs/ab-test/handler.ts b/src/cli/operations/jobs/ab-test/handler.ts index 3792267819..283aad4e15 100644 --- a/src/cli/operations/jobs/ab-test/handler.ts +++ b/src/cli/operations/jobs/ab-test/handler.ts @@ -20,6 +20,7 @@ import { getGatewayDetail, getOnlineEvaluationConfig } from '../../../aws/agentc import { detectRegion } from '../../../aws/region'; import { getErrorMessage } from '../../../errors'; import { ExecLogger } from '../../../logging/exec-logger'; +import { getPhysicalProjectNameForTarget } from '../../resource-naming'; import { NOT_FOUND_STATUS } from '../shared/constants'; import { regionFromArn, resolveJobRegion } from '../shared/region'; import type { ABTestHandler, ABTestJobRecord, DebugCheckResult, StartABTestJobOptions } from '../shared/types'; @@ -124,6 +125,13 @@ export const abTestHandler: ABTestHandler = { logger?.log(`Gateway ARN: ${gatewayArn}`); logger?.endStep('success'); + // Name the test and its role after the target that owns the gateway, so targets sharing an + // account (distinct resourceNameSuffix) don't collide on the account-wide names. + const gatewayTargetName = Object.entries(deployedState.targets).find( + ([, target]) => resolveGatewayArn(opts.gateway, target.resources) === gatewayArn + )?.[0]; + const physicalProjectName = getPhysicalProjectNameForTarget(projectSpec.name, awsTargets, gatewayTargetName); + // Build variants + eval config (throws ValidationError on missing mode inputs). const built = buildABTestRequest(opts, deployedResources); @@ -135,7 +143,7 @@ export const abTestHandler: ABTestHandler = { opts.onProgress?.('role', 'Creating execution role (waiting for IAM propagation)...'); roleArn = await getOrCreateABTestRole({ region, - projectName: projectSpec.name, + projectName: physicalProjectName, testName: opts.name, gatewayArn, }); @@ -149,7 +157,7 @@ export const abTestHandler: ABTestHandler = { opts.onProgress?.('starting', `Creating A/B test "${opts.name}"...`); const createOptions = { region, - name: `${projectSpec.name}_${opts.name}`, + name: `${physicalProjectName}_${opts.name}`, description: opts.description, gatewayArn, roleArn, diff --git a/src/cli/operations/jobs/batch-evaluation/handler.ts b/src/cli/operations/jobs/batch-evaluation/handler.ts index 754ef2f80b..2a5341efe7 100644 --- a/src/cli/operations/jobs/batch-evaluation/handler.ts +++ b/src/cli/operations/jobs/batch-evaluation/handler.ts @@ -22,6 +22,7 @@ import { import type { BatchEvaluationResultEntry } from '../../../aws/agentcore-batch-evaluation'; import { detectRegion } from '../../../aws/region'; import { ExecLogger } from '../../../logging/exec-logger'; +import { getPhysicalProjectNameForTarget } from '../../resource-naming'; import { NOT_FOUND_STATUS } from '../shared/constants'; import { regionFromArn, resolveJobRegion } from '../shared/region'; import { resolveAgentState } from '../shared/resolve-agent-state'; @@ -95,7 +96,7 @@ export const batchEvaluationHandler: BatchEvaluationHandler = { return { success: false, error: err }; } const { serviceName, logGroupName } = buildCloudWatchSource( - projectSpec.name, + getPhysicalProjectNameForTarget(projectSpec.name, awsTargets, agentState.targetName), opts.agent, agentState.runtimeId, opts.endpoint diff --git a/src/cli/operations/jobs/insights/handler.ts b/src/cli/operations/jobs/insights/handler.ts index ac2f41dc46..108205bd79 100644 --- a/src/cli/operations/jobs/insights/handler.ts +++ b/src/cli/operations/jobs/insights/handler.ts @@ -21,6 +21,7 @@ import type { CloudWatchFilterConfig, DataSourceConfig } from '../../../aws/agen import { resolveEndpointName, runtimeLogGroup } from '../../../aws/cloudwatch'; import { detectRegion } from '../../../aws/region'; import { ExecLogger } from '../../../logging/exec-logger'; +import { getPhysicalProjectNameForTarget } from '../../resource-naming'; import { resolveBatchEvaluatorIds } from '../batch-evaluation/build-source'; import { NOT_FOUND_STATUS } from '../shared/constants'; import { regionFromArn, resolveJobRegion } from '../shared/region'; @@ -108,7 +109,12 @@ export const insightsHandler: InsightsHandler = { } const endpointName = resolveEndpointName(opts.endpoint); - const serviceName = `${projectSpec.name}_${agentName}.${endpointName}`; + const physicalProjectName = getPhysicalProjectNameForTarget( + projectSpec.name, + awsTargets, + agentState.targetName + ); + const serviceName = `${physicalProjectName}_${agentName}.${endpointName}`; const logGroupName = runtimeLogGroup(agentState.runtimeId, opts.endpoint); logger?.log(`Service name: ${serviceName}`); logger?.log(`Log group: ${logGroupName}`); diff --git a/src/cli/operations/jobs/shared/__tests__/resolve-agent-state.test.ts b/src/cli/operations/jobs/shared/__tests__/resolve-agent-state.test.ts new file mode 100644 index 0000000000..7f364bea80 --- /dev/null +++ b/src/cli/operations/jobs/shared/__tests__/resolve-agent-state.test.ts @@ -0,0 +1,26 @@ +import type { DeployedState } from '../../../../../schema'; +import { resolveAgentState } from '../resolve-agent-state'; +import { describe, expect, it } from 'vitest'; + +const runtime = (id: string) => ({ + runtimeId: id, + runtimeArn: `arn:aws:bedrock-agentcore:::runtime/${id}`, + roleArn: 'r', +}); + +describe('resolveAgentState', () => { + const deployedState = { + targets: { + dev: { resources: { runtimes: { other: runtime('other-1') } } }, + prod: { resources: { runtimes: { agent: runtime('agent-1') } } }, + }, + } as unknown as DeployedState; + + it('returns the runtime with the target it was found in', () => { + expect(resolveAgentState(deployedState, 'agent')).toEqual({ ...runtime('agent-1'), targetName: 'prod' }); + }); + + it('returns undefined when the agent is not deployed', () => { + expect(resolveAgentState(deployedState, 'missing')).toBeUndefined(); + }); +}); diff --git a/src/cli/operations/jobs/shared/resolve-agent-state.ts b/src/cli/operations/jobs/shared/resolve-agent-state.ts index 8335664b64..2332622334 100644 --- a/src/cli/operations/jobs/shared/resolve-agent-state.ts +++ b/src/cli/operations/jobs/shared/resolve-agent-state.ts @@ -9,13 +9,15 @@ export interface ResolvedAgentState { runtimeId: string; runtimeArn: string; roleArn?: string; + /** Deployment target the runtime was found in. */ + targetName: string; } /** Find the agent runtime across all deployment targets; undefined if not deployed. */ export function resolveAgentState(deployedState: DeployedState, agentName: string): ResolvedAgentState | undefined { - for (const target of Object.values(deployedState.targets)) { + for (const [targetName, target] of Object.entries(deployedState.targets)) { const agent = target.resources?.runtimes?.[agentName]; - if (agent) return agent; + if (agent) return { ...agent, targetName }; } return undefined; } diff --git a/src/cli/operations/resource-naming.ts b/src/cli/operations/resource-naming.ts new file mode 100644 index 0000000000..4a9592a4ac --- /dev/null +++ b/src/cli/operations/resource-naming.ts @@ -0,0 +1,50 @@ +import { ValidationError } from '../../lib/errors/types'; +import { type AwsDeploymentTarget, ProjectNameSchema } from '../../schema'; + +type TargetNaming = Pick; + +/** + * The project name used as the prefix of deployed resource names for a target + * (e.g. runtime `${physicalProjectName}_${agentName}`). + * + * A target's optional `resourceNameSuffix` is appended so several targets can share one + * account+region without physical-name collisions. Without a suffix this is the plain project + * name, so existing deployments keep their resource names. The vended CDK app + * (src/assets/cdk/bin/cdk.ts) applies the same rule — keep the two in sync. + */ +export function getPhysicalProjectName(projectName: string, target?: TargetNaming): string { + return `${projectName}${target?.resourceNameSuffix ?? ''}`; +} + +/** + * Like {@link getPhysicalProjectName}, but looks the target up by name. Unknown target names fall + * back to the plain project name. + */ +export function getPhysicalProjectNameForTarget( + projectName: string, + awsTargets: readonly AwsDeploymentTarget[], + targetName: string | undefined +): string { + return getPhysicalProjectName( + projectName, + awsTargets.find(t => t.name === targetName) + ); +} + +/** + * Ensures project name + suffix is still a valid project name. The suffixed name replaces the + * project name everywhere physical names are built, so it must respect the same 23-char budget + * that keeps `${projectName}_${resourceName}` within the 48-char AWS limits. + */ +export function validatePhysicalProjectName(projectName: string, target: AwsDeploymentTarget): void { + if (!target.resourceNameSuffix) return; + const physicalName = getPhysicalProjectName(projectName, target); + const result = ProjectNameSchema.safeParse(physicalName); + if (!result.success) { + throw new ValidationError( + `Target "${target.name}": project name plus resourceNameSuffix ("${physicalName}") is not a valid ` + + `resource name prefix: ${result.error.issues[0]?.message ?? 'invalid'}. ` + + `Use a shorter resourceNameSuffix in aws-targets.json.` + ); + } +} diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index bca7a0e9aa..d897ba01a0 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -152,6 +152,8 @@ const DeployAttrs = safeSchema({ dep_sync_opted_out: z.boolean().optional(), dep_sync_skew_warning: z.boolean().optional(), dep_sync_reinstalled: z.boolean().optional(), + /** Whether a deployed target sets resourceNameSuffix (several targets in one account+region). */ + uses_resource_name_suffix: z.boolean().optional(), }); const DevAttrs = safeSchema({ diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index c23bd434fa..cd543203e9 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -148,6 +148,8 @@ export interface PreflightOptions { skipIdentityCheck?: boolean; /** Target selected by the TUI. Falls back to the first configured target when omitted. */ selectedTarget?: AwsDeploymentTarget; + /** Every target selected for this deploy (multi-select), used for cross-target name collision checks. */ + selectedTargets?: AwsDeploymentTarget[]; /** * Preview mode (diff): the managed-dependency sync runs check-only, computing the plan and a * future-tense notice without writing package.json or running npm install. Previews must never @@ -226,6 +228,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { isInteractive = false, skipIdentityCheck = false, selectedTarget, + selectedTargets, dependencySyncCheckOnly = false, } = options; @@ -425,7 +428,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { let preflightContext: PreflightContext; let target: AwsDeploymentTarget | undefined; try { - preflightContext = await validateProject(selectedTarget); + preflightContext = await validateProject(selectedTarget, selectedTargets); target = selectedTarget ?? preflightContext.awsTargets[0]; setContext(preflightContext); // Make aws-targets.json region authoritative for downstream SDK / CDK @@ -713,6 +716,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { teardownConfirmed, restoreRegionEnv, selectedTarget, + selectedTargets, ]); // Handle identity-setup phase (after user provides credentials) diff --git a/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts index 5cf7ca89bd..a82c1d8825 100644 --- a/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts +++ b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts @@ -9,6 +9,7 @@ import { } from '../../../../cli/aws/agentcore-config-bundles'; import { ConfigIO } from '../../../../lib'; import { getBundleNameVariants } from '../../../operations/config-bundle/bundle-name-variants'; +import { getPhysicalProjectName } from '../../../operations/resource-naming'; import { useEffect, useRef, useState } from 'react'; export interface BundleWithMeta { @@ -58,6 +59,7 @@ export function useConfigBundleHub(): ConfigBundleHubState { return; } const resolvedRegion = targets[0]!.region; + const bundleProjectNames = [getPhysicalProjectName(projectSpec.name, targets[0]), projectSpec.name]; if (mountedRef.current) setRegion(resolvedRegion); // Get config bundles from project config (agentcore.json) @@ -86,7 +88,7 @@ export function useConfigBundleHub(): ConfigBundleHubState { const enriched = await Promise.all( projectBundles.map(async (bundleSpec): Promise => { const deployed = deployedBundles[bundleSpec.name]; - const nameVariants = getBundleNameVariants(bundleSpec.name, projectSpec.name); + const nameVariants = getBundleNameVariants(bundleSpec.name, bundleProjectNames); const listMatch = allBundlesList?.bundles.find(b => nameVariants.includes(b.bundleName)); if (!deployed) { // Not yet deployed — show from project config only @@ -133,7 +135,7 @@ export function useConfigBundleHub(): ConfigBundleHubState { try { const allBundles = allBundlesList ?? (await listConfigurationBundles({ region: resolvedRegion, maxResults: 100 })); - const nameVariants = getBundleNameVariants(bundleSpec.name, projectSpec.name); + const nameVariants = getBundleNameVariants(bundleSpec.name, bundleProjectNames); const match = allBundles.bundles.find(b => nameVariants.includes(b.bundleName)); if (match) { effectiveBundleId = match.bundleId; diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 26c0ed00f4..99b1e4e8ac 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -170,6 +170,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState logger, isInteractive, selectedTarget: selectedTargets?.[0], + selectedTargets, dependencySyncCheckOnly: diffMode, }); @@ -778,7 +779,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (preflight.phase === 'error') { const error = preflight.lastError ?? new Error('Preflight failed'); const attrs = withDepSyncAttrs( - context ? computeDeployAttrs(context.projectSpec, 'deploy') : { ...DEFAULT_DEPLOY_ATTRS }, + context ? computeDeployAttrs(context.projectSpec, 'deploy', selectedTargets) : { ...DEFAULT_DEPLOY_ATTRS }, preflight.dependencySyncResult ); withCommandRunTelemetry('deploy', attrs, () => ({ success: false as const, error })).catch(() => { @@ -791,7 +792,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (!cdkToolkitWrapper) return; const attrs = withDepSyncAttrs( - context ? computeDeployAttrs(context.projectSpec, 'deploy') : { ...DEFAULT_DEPLOY_ATTRS }, + context ? computeDeployAttrs(context.projectSpec, 'deploy', selectedTargets) : { ...DEFAULT_DEPLOY_ATTRS }, preflight.dependencySyncResult ); @@ -1082,7 +1083,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const error = preflight.lastError ?? new Error('Preflight failed'); const attrs = withDepSyncAttrs( context - ? computeDeployAttrs(context.projectSpec, 'diff') + ? computeDeployAttrs(context.projectSpec, 'diff', selectedTargets) : { ...DEFAULT_DEPLOY_ATTRS, deploy_mode: 'diff' as const }, preflight.dependencySyncResult ); @@ -1097,7 +1098,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const attrs = withDepSyncAttrs( context - ? computeDeployAttrs(context.projectSpec, 'diff') + ? computeDeployAttrs(context.projectSpec, 'diff', selectedTargets) : { ...DEFAULT_DEPLOY_ATTRS, deploy_mode: 'diff' as const }, preflight.dependencySyncResult ); diff --git a/src/schema/llm-compacted/aws-targets.ts b/src/schema/llm-compacted/aws-targets.ts index 87e311c7e5..ba1104dcea 100644 --- a/src/schema/llm-compacted/aws-targets.ts +++ b/src/schema/llm-compacted/aws-targets.ts @@ -19,6 +19,7 @@ interface AwsDeploymentTarget { description?: string; // @max 256 account: string; // @regex ^[0-9]{12}$ - AWS account ID (exactly 12 digits) region: AgentCoreRegion; + resourceNameSuffix?: string; // @regex ^[A-Za-z0-9]{1,8}$ - appended to the project name in deployed resource names (e.g. "Dev" -> myappDev_agent); lets several targets share one account+region } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/schema/schemas/__tests__/aws-targets.test.ts b/src/schema/schemas/__tests__/aws-targets.test.ts index 1c4597f673..389d7c3642 100644 --- a/src/schema/schemas/__tests__/aws-targets.test.ts +++ b/src/schema/schemas/__tests__/aws-targets.test.ts @@ -136,6 +136,23 @@ describe('AwsDeploymentTargetSchema', () => { expect(AwsDeploymentTargetSchema.safeParse({ name: 'prod' }).success).toBe(false); expect(AwsDeploymentTargetSchema.safeParse({ account: '123456789012' }).success).toBe(false); }); + + it('accepts target with alphanumeric resourceNameSuffix', () => { + const result = AwsDeploymentTargetSchema.safeParse({ ...validTarget, resourceNameSuffix: 'Prod1' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.resourceNameSuffix).toBe('Prod1'); + } + }); + + it('rejects resourceNameSuffix with separators or over 8 chars', () => { + expect(AwsDeploymentTargetSchema.safeParse({ ...validTarget, resourceNameSuffix: 'dev-1' }).success).toBe(false); + expect(AwsDeploymentTargetSchema.safeParse({ ...validTarget, resourceNameSuffix: 'dev_1' }).success).toBe(false); + expect(AwsDeploymentTargetSchema.safeParse({ ...validTarget, resourceNameSuffix: '' }).success).toBe(false); + expect(AwsDeploymentTargetSchema.safeParse({ ...validTarget, resourceNameSuffix: 'a'.repeat(9) }).success).toBe( + false + ); + }); }); describe('AwsDeploymentTargetsSchema', () => { diff --git a/src/schema/schemas/aws-targets.ts b/src/schema/schemas/aws-targets.ts index b82e88b766..1707769c78 100644 --- a/src/schema/schemas/aws-targets.ts +++ b/src/schema/schemas/aws-targets.ts @@ -55,6 +55,17 @@ export const AwsAccountIdSchema = z .regex(/^[0-9]{12}$/, 'AWS account ID must be exactly 12 digits') .describe('AWS account ID'); +// ============================================================================ +// Resource Name Suffix +// ============================================================================ + +export const ResourceNameSuffixSchema = z + .string() + .regex(/^[A-Za-z0-9]{1,8}$/, 'Resource name suffix must be 1-8 alphanumeric characters') + .describe( + 'Appended to the project name when naming deployed resources, so several targets can share one account and region' + ); + // ============================================================================ // AWS Deployment Target // ============================================================================ @@ -64,6 +75,7 @@ export const AwsDeploymentTargetSchema = z.object({ description: z.string().max(256).optional(), account: AwsAccountIdSchema, region: AgentCoreRegionSchema, + resourceNameSuffix: ResourceNameSuffixSchema.optional(), }); export type AwsDeploymentTarget = z.infer;