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;