diff --git a/cdk/src/constructs/bedrock-models.ts b/cdk/src/constructs/bedrock-models.ts index c1c05203..42a5f1dc 100644 --- a/cdk/src/constructs/bedrock-models.ts +++ b/cdk/src/constructs/bedrock-models.ts @@ -17,6 +17,7 @@ * SOFTWARE. */ +import { CrossRegionInferenceProfileRegion } from '@aws-cdk/aws-bedrock-alpha'; import { Node } from 'constructs'; /** @@ -46,9 +47,10 @@ export const DEFAULT_BEDROCK_MODEL_IDS: readonly string[] = [ // turn 0 with AccessDenied. Bare ID by contract: Bedrock refuses the bare ID // for on-demand invocation ("ValidationException: … isn't supported. Retry // your request with the ID or ARN of an inference profile"), and both grant - // sites derive the `us.`-prefixed inference-profile ARN — the invocable one — - // from this entry. Opus 4.8 above stays granted: blueprints may pin it - // per-repo, so removing it would fail those repos at turn 0. + // sites derive the geo-prefixed inference-profile ARN — the invocable one — + // from this entry plus `bedrockGeoRegion` (default `us`). Opus 4.8 above + // stays granted: blueprints may pin it per-repo, so removing it would fail + // those repos at turn 0. 'anthropic.claude-opus-5', 'anthropic.claude-haiku-4-5-20251001-v1:0', ]; @@ -56,6 +58,76 @@ export const DEFAULT_BEDROCK_MODEL_IDS: readonly string[] = [ /** CDK context key whose value (a string array) overrides the model set. */ export const BEDROCK_MODELS_CONTEXT_KEY = 'bedrockModels'; +/** CDK context key selecting the cross-Region inference-profile geography. */ +export const BEDROCK_GEO_REGION_CONTEXT_KEY = 'bedrockGeoRegion'; + +/** + * Default inference-profile geography: the US cross-Region profiles + * (`us.anthropic.…`). Documented here rather than in `cdk.json` so a deploy that + * passes no context at all still resolves — `cdk.json` context is not present + * when the app is synthesized from a test or another CDK app. + */ +export const DEFAULT_BEDROCK_GEO_REGION = CrossRegionInferenceProfileRegion.US; + +/** + * The geographies `@aws-cdk/aws-bedrock-alpha` actually models — the single + * source of truth for both the {@link resolveBedrockGeoRegion} allow-list and + * the region-prefix rejection in {@link resolveBedrockModelIds}. Derived from + * the enum, so a future CDK release that adds a geography widens both at once + * instead of leaving one of them silently behind. + */ +export const BEDROCK_GEO_REGIONS: readonly CrossRegionInferenceProfileRegion[] = + Object.values(CrossRegionInferenceProfileRegion); + +/** + * `global|us-gov|apac|eu|us|jp|au` — an alternation over + * {@link BEDROCK_GEO_REGIONS}, sorted longest-first so the pattern reads + * unambiguously with `us-gov` ahead of `us`. Readability only: because + * {@link GEO_PREFIX_RE} anchors a literal `.` after the alternation, a `us`-first + * order would still reject `us-gov.…` correctly (the engine backtracks when the + * `.` fails against `-`). Sorting just means nobody has to reason about that. + */ +const GEO_ALTERNATION = [...BEDROCK_GEO_REGIONS] + .sort((a, b) => b.length - a.length) + .join('|'); + +/** Matches a leading `.` inference-profile prefix on a model ID. */ +const GEO_PREFIX_RE = new RegExp(`^(?:${GEO_ALTERNATION})\\.`); + +/** + * Resolves the cross-Region inference-profile geography: CDK context + * `bedrockGeoRegion` when provided, else {@link DEFAULT_BEDROCK_GEO_REGION} + * (`us`). Set via `cdk.json` `context` or `-c bedrockGeoRegion=global`, then + * redeploy, to move the deployment's inference profiles to another geography — + * no construct edits needed. + * + * Both grant sites derive their inference-profile ARNs from this one value (the + * AgentCore runtime in `stacks/agent.ts` via + * `CrossRegionInferenceProfile.fromConfig`, the ECS task role in + * `constructs/ecs-agent-cluster.ts` via the `.` ARN resource + * name), and the agent's auxiliary-model env var (`ANTHROPIC_DEFAULT_HAIKU_MODEL`) + * takes the same prefix — so the main and auxiliary models can never route + * through different geographies. + * + * Throws at synth on an unrecognized value: an invented geography would produce + * a syntactically valid but non-existent inference-profile ARN, and the grant + * would silently authorize nothing (the agent then fails at turn 0 with + * AccessDenied). A typo must fail the synth, not the deployment. + */ +export function resolveBedrockGeoRegion(node: Node): CrossRegionInferenceProfileRegion { + const override = node.tryGetContext(BEDROCK_GEO_REGION_CONTEXT_KEY); + if (override === undefined || override === null) { + return DEFAULT_BEDROCK_GEO_REGION; + } + if (typeof override !== 'string' || !BEDROCK_GEO_REGIONS.includes(override as CrossRegionInferenceProfileRegion)) { + throw new Error( + `Context '${BEDROCK_GEO_REGION_CONTEXT_KEY}' must be one of ` + + `${BEDROCK_GEO_REGIONS.map((g) => `'${g}'`).join(', ')}; got ${JSON.stringify(override)}.`, + ); + } + return override as CrossRegionInferenceProfileRegion; +} + /** * Resolves the invocable foundation-model IDs: CDK context `bedrockModels` * (an array of **bare foundation-model IDs**) when provided, else @@ -63,14 +135,18 @@ export const BEDROCK_MODELS_CONTEXT_KEY = 'bedrockModels'; * `-c bedrockModels='["anthropic.claude-opus-4-8", …]'`, then redeploy, to add * a model the runtime may invoke — no construct edits needed. * - * **Use the bare foundation-model ID (`anthropic.claude-…`), NOT the - * `us.`-prefixed inference-profile ID.** Both grant sites derive the US - * inference-profile ARN by prefixing `us.`, so passing `us.anthropic.…` here - * would produce an invalid `us.us.anthropic.…` ARN. The resolver rejects a - * `us.`/`eu.`/`apac.`-prefixed entry to catch that early. + * **Use the bare foundation-model ID (`anthropic.claude-…`), NOT a + * geo-prefixed inference-profile ID.** Both grant sites derive the + * inference-profile ARN by prefixing the geography from + * {@link resolveBedrockGeoRegion} (`bedrockGeoRegion`, default `us`), so passing + * `us.anthropic.…` here would produce an invalid `us.us.anthropic.…` ARN. The + * resolver rejects an entry carrying ANY modelled geo prefix + * ({@link BEDROCK_GEO_REGIONS}) to catch that early — including `global.`, + * which previously slipped through and silently yielded + * `us.global.anthropic.…`. * * Throws on a malformed override (non-array, non-string / empty entries, or a - * region-prefixed ID) so a typo fails synth loudly instead of silently + * geo-prefixed ID) so a typo fails synth loudly instead of silently * granting nothing or an invalid ARN. */ export function resolveBedrockModelIds(node: Node): readonly string[] { @@ -90,11 +166,12 @@ export function resolveBedrockModelIds(node: Node): readonly string[] { `Context '${BEDROCK_MODELS_CONTEXT_KEY}' entries must be non-empty strings; got ${JSON.stringify(id)}.`, ); } - if (/^(us|eu|apac)\./.test(id)) { + if (GEO_PREFIX_RE.test(id)) { throw new Error( - `Context '${BEDROCK_MODELS_CONTEXT_KEY}' expects bare foundation-model IDs, not region-prefixed ` - + `inference-profile IDs — got '${id}'. Use '${id.replace(/^(us|eu|apac)\./, '')}'; ` - + 'the US inference-profile ARN is derived automatically.', + `Context '${BEDROCK_MODELS_CONTEXT_KEY}' expects bare foundation-model IDs, not geo-prefixed ` + + `inference-profile IDs — got '${id}'. Use '${id.replace(GEO_PREFIX_RE, '')}'; ` + + `the inference-profile ARN is derived automatically from the '${BEDROCK_GEO_REGION_CONTEXT_KEY}' ` + + 'context key (default \'us\').', ); } } diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts index 60fa4a60..dae168ae 100644 --- a/cdk/src/constructs/ecs-agent-cluster.ts +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -30,7 +30,7 @@ import { NagSuppressions } from 'cdk-nag'; import { Construct, type Node } from 'constructs'; import { AgentMemory } from './agent-memory'; import { AgentSessionRole } from './agent-session-role'; -import { resolveBedrockModelIds } from './bedrock-models'; +import { resolveBedrockGeoRegion, resolveBedrockModelIds } from './bedrock-models'; import { buildAppId } from './solution-ua-aspect'; import { ToolGateway } from './tool-gateway'; @@ -585,10 +585,12 @@ export class EcsAgentCluster extends Construct { // Bedrock model invocation — scoped to explicit foundation-model and // cross-region inference-profile ARNs (parity with the AgentCore runtime - // grants in agent.ts), NOT a Resource: '*' wildcard. The model set is the - // shared, context-overridable list (constructs/bedrock-models.ts) so the - // ECS and AgentCore backends can't drift. + // grants in agent.ts), NOT a Resource: '*' wildcard. The model set and the + // inference-profile geography are both the shared, context-overridable + // values (constructs/bedrock-models.ts: `bedrockModels`, `bedrockGeoRegion`) + // so the ECS and AgentCore backends can't drift. const stack = Stack.of(this); + const bedrockGeoRegion = resolveBedrockGeoRegion(this.node); const bedrockResources: string[] = []; for (const modelId of resolveBedrockModelIds(this.node)) { bedrockResources.push( @@ -603,7 +605,10 @@ export class EcsAgentCluster extends Construct { stack.formatArn({ service: 'bedrock', resource: 'inference-profile', - resourceName: `us.${modelId}`, + // Same `.` shape CrossRegionInferenceProfile.fromConfig + // builds for the AgentCore grant — regional + account-qualified for + // every geography, `global.` included. + resourceName: `${bedrockGeoRegion}.${modelId}`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME, }), ); diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 73b88954..45dac2d0 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -36,7 +36,7 @@ import { AgentVpc } from '../constructs/agent-vpc'; import { ApiKeyTable } from '../constructs/api-key-table'; import { ApprovalMetricsPublisherConsumer } from '../constructs/approval-metrics-publisher-consumer'; import { AttachmentsBucket } from '../constructs/attachments-bucket'; -import { resolveBedrockModelIds } from '../constructs/bedrock-models'; +import { resolveBedrockGeoRegion, resolveBedrockModelIds } from '../constructs/bedrock-models'; import { Blueprint } from '../constructs/blueprint'; import { CedarWasmLayer } from '../constructs/cedar-wasm-layer'; import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; @@ -446,16 +446,26 @@ export class AgentStack extends Stack { this.node.tryGetContext('sdkUaAppId') as string | undefined, ); + // Cross-Region inference-profile geography (`bedrockGeoRegion`, default + // `us`). Resolved once and used for BOTH the auxiliary-model env var below + // and the Bedrock grants further down, so a deployment can never grant one + // geography's profiles while telling the agent to call another's. + const bedrockGeoRegion = resolveBedrockGeoRegion(this.node); + const runtimeEnvironmentVariables = { GITHUB_TOKEN_SECRET_ARN: githubTokenSecret.secretArn, AWS_REGION: process.env.AWS_REGION ?? 'us-east-1', CLAUDE_CODE_USE_BEDROCK: '1', ANTHROPIC_LOG: 'debug', - // Cross-region inference-profile id (``us.`` prefix), NOT the bare - // foundation-model id: Claude 4.x can't be invoked on-demand by bare id - // (400 "on-demand throughput isn't supported"). Must match a granted - // profile (see bedrock-models.ts). runner.py re-sets this at spawn time. - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + // Cross-region inference-profile id (geo prefix, `us.` by default), NOT + // the bare foundation-model id: Claude 4.x can't be invoked on-demand by + // bare id (400 "on-demand throughput isn't supported"). The prefix is + // derived from `bedrockGeoRegion` rather than hardcoded so this auxiliary + // model routes through the same geography as the granted profiles (see + // bedrock-models.ts) — a second hardcode here would silently split the + // two on any non-`us` deploy. runner.py re-sets this at spawn time. + ANTHROPIC_DEFAULT_HAIKU_MODEL: + `${bedrockGeoRegion}.anthropic.claude-haiku-4-5-20251001-v1:0`, TASK_TABLE_NAME: taskTable.table.tableName, TASK_EVENTS_TABLE_NAME: taskEventsTable.table.tableName, NUDGES_TABLE_NAME: taskNudgesTable.table.tableName, @@ -607,9 +617,10 @@ export class AgentStack extends Stack { // EcsAgentCluster prop below (substrate parity). toolGateway?.grantInvoke(runtime); - // Grant the runtime invoke on each configured foundation model + its US - // cross-Region inference profile. The model set is a single source of truth - // (constructs/bedrock-models.ts), shared with the ECS task role and + // Grant the runtime invoke on each configured foundation model + its + // cross-Region inference profile in the configured geography + // (`bedrockGeoRegion`, default `us`). The model set is a single source of + // truth (constructs/bedrock-models.ts), shared with the ECS task role and // overridable via the `bedrockModels` CDK context. Each invokable is also // collected so the same set is granted to the SessionRole below (for cost // attribution) — the two grants derive from one list and can't drift. @@ -622,7 +633,7 @@ export class AgentStack extends Stack { supportsCrossRegion: true, }); const crossRegionProfile = bedrock.CrossRegionInferenceProfile.fromConfig({ - geoRegion: bedrock.CrossRegionInferenceProfileRegion.US, + geoRegion: bedrockGeoRegion, model: foundationModel, }); foundationModel.grantInvoke(runtime); diff --git a/cdk/test/constructs/bedrock-models.test.ts b/cdk/test/constructs/bedrock-models.test.ts index f0c3b84c..477fbca8 100644 --- a/cdk/test/constructs/bedrock-models.test.ts +++ b/cdk/test/constructs/bedrock-models.test.ts @@ -19,10 +19,15 @@ import * as fs from 'fs'; import * as path from 'path'; +import { CrossRegionInferenceProfileRegion } from '@aws-cdk/aws-bedrock-alpha'; import { App, Stack } from 'aws-cdk-lib'; import { + BEDROCK_GEO_REGION_CONTEXT_KEY, + BEDROCK_GEO_REGIONS, BEDROCK_MODELS_CONTEXT_KEY, + DEFAULT_BEDROCK_GEO_REGION, DEFAULT_BEDROCK_MODEL_IDS, + resolveBedrockGeoRegion, resolveBedrockModelIds, } from '../../src/constructs/bedrock-models'; @@ -61,9 +66,9 @@ describe('resolveBedrockModelIds', () => { ).toThrow(/non-empty strings/); }); - it('throws on a region-prefixed (us./eu./apac.) inference-profile ID', () => { + it('throws on a geo-prefixed inference-profile ID', () => { // Guards the us.us.… double-prefix footgun: both grant sites derive the - // inference-profile ARN by prefixing `us.`, so the context wants the bare id. + // inference-profile ARN by prefixing the geo, so the context wants the bare id. expect(() => resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['us.anthropic.claude-opus-4-8'] })), ).toThrow(/bare foundation-model IDs/); @@ -71,6 +76,74 @@ describe('resolveBedrockModelIds', () => { resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['eu.anthropic.claude-sonnet-4-6'] })), ).toThrow(/bare foundation-model IDs/); }); + + // The guard used to test only /^(us|eu|apac)\./, so a `global.`-, `us-gov.`-, + // `jp.`- or `au.`-prefixed entry sailed through and produced a syntactically + // valid but non-existent `us.global.anthropic.…` inference-profile ARN — the + // grant then authorized nothing and the agent failed at turn 0 with + // AccessDenied, with nothing at synth to say why. Every geography the CDK enum + // models must be rejected, so the hole cannot reopen when a geography is added. + it.each([...BEDROCK_GEO_REGIONS])('throws on a %s-prefixed entry (no silent double-prefix)', (geo) => { + expect(() => + resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: [`${geo}.anthropic.claude-opus-5`] })), + ).toThrow(/bare foundation-model IDs/); + }); + + it('names the bare id and the geo context key in the rejection message', () => { + // The error is the only place the bare-ids-only contract is stated at the + // moment an operator gets it wrong, so it must carry the fix, not just the + // complaint: strip the geo the operator actually typed (`us-gov`, not `us`, + // for a `us-gov.` entry) and point at where geo really belongs. + expect(() => + resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['us-gov.anthropic.claude-opus-5'] })), + ).toThrow(/Use 'anthropic\.claude-opus-5'/); + expect(() => + resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['global.anthropic.claude-opus-5'] })), + ).toThrow(new RegExp(BEDROCK_GEO_REGION_CONTEXT_KEY)); + }); + + it('still accepts a bare id whose name merely starts with a geo word', () => { + // The rejection keys on the `.` separator, not a bare prefix match, so a + // hypothetical `august.…`/`european.…` model id is not collateral damage. + expect(resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['august-labs.model-1'] }))) + .toEqual(['august-labs.model-1']); + }); +}); + +describe('resolveBedrockGeoRegion', () => { + it('defaults to the US geography so an existing deploy is unchanged', () => { + expect(resolveBedrockGeoRegion(nodeWithContext())).toBe(CrossRegionInferenceProfileRegion.US); + expect(DEFAULT_BEDROCK_GEO_REGION).toBe(CrossRegionInferenceProfileRegion.US); + }); + + it.each([...BEDROCK_GEO_REGIONS])('accepts the %s geography the CDK enum models', (geo) => { + expect(resolveBedrockGeoRegion(nodeWithContext({ [BEDROCK_GEO_REGION_CONTEXT_KEY]: geo }))).toBe(geo); + }); + + it('covers exactly the geographies @aws-cdk/aws-bedrock-alpha models', () => { + // Derived from the enum rather than hand-listed: a CDK release that adds a + // geography must widen the allow-list automatically, and one that REMOVES a + // geography must not leave us granting an ARN the SDK no longer builds. + expect([...BEDROCK_GEO_REGIONS].sort()) + .toEqual(['apac', 'au', 'eu', 'global', 'jp', 'us', 'us-gov']); + }); + + it('throws at synth on an unknown geography rather than granting an invalid ARN', () => { + // A typo'd geo yields a well-formed but non-existent inference-profile ARN. + // The grant would be accepted by IAM and authorize nothing, so the failure + // would surface as a turn-0 AccessDenied on a deployed stack instead of here. + expect(() => resolveBedrockGeoRegion(nodeWithContext({ [BEDROCK_GEO_REGION_CONTEXT_KEY]: 'usa' }))) + .toThrow(/must be one of/); + expect(() => resolveBedrockGeoRegion(nodeWithContext({ [BEDROCK_GEO_REGION_CONTEXT_KEY]: 'US' }))) + .toThrow(/must be one of/); + expect(() => resolveBedrockGeoRegion(nodeWithContext({ [BEDROCK_GEO_REGION_CONTEXT_KEY]: 'us-east-1' }))) + .toThrow(/must be one of/); + }); + + it('throws on a non-string value', () => { + expect(() => resolveBedrockGeoRegion(nodeWithContext({ [BEDROCK_GEO_REGION_CONTEXT_KEY]: ['us'] }))) + .toThrow(/must be one of/); + }); }); /** @@ -90,10 +163,24 @@ describe('DEFAULT_BEDROCK_MODEL_IDS covers the agent runtime default', () => { expect(match).not.toBeNull(); const agentDefault = match![1]; - // The agent names the US inference profile (`us.anthropic.…`); the grant list - // holds bare foundation-model IDs and both grant sites add the `us.` prefix. - expect(agentDefault).toMatch(/^us\./); - const bare = agentDefault.replace(/^us\./, ''); + // The agent names a cross-Region inference profile (`.anthropic.…`); the + // grant list holds bare foundation-model IDs and both grant sites add the geo + // prefix from `bedrockGeoRegion`. Accept any geography the CDK enum models — + // deliberately NOT `.*`: the assertion's teeth are that a BARE id here is a + // bug, because Bedrock refuses a bare Claude 4.x/5 id for on-demand + // invocation ("ValidationException: … on-demand throughput isn't supported"). + // Widening to `.*` would let that un-invokable default land unnoticed. + const geoPrefix = agentDefault.match(new RegExp(`^(${[...BEDROCK_GEO_REGIONS].join('|')})\\.`)); + expect(geoPrefix).not.toBeNull(); + const bare = agentDefault.slice(geoPrefix![0].length); expect(DEFAULT_BEDROCK_MODEL_IDS).toContain(bare); }); + + it('rejects a bare foundation-model id as the agent default', () => { + // Mutation-proof for the assertion above: if someone "simplifies" the geo + // regex to `.*`, or drops it, this test is what still fails. Exercises the + // same matcher against the shape the guard exists to catch. + const bareDefault = 'anthropic.claude-opus-4-8'; + expect(bareDefault.match(new RegExp(`^(${[...BEDROCK_GEO_REGIONS].join('|')})\\.`))).toBeNull(); + }); }); diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index e6c1ecfd..6a7912ac 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -28,11 +28,13 @@ import * as s3 from 'aws-cdk-lib/aws-s3'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import { AgentMemory } from '../../src/constructs/agent-memory'; import { AgentSessionRole } from '../../src/constructs/agent-session-role'; +import { DEFAULT_BEDROCK_MODEL_IDS } from '../../src/constructs/bedrock-models'; import { EcsAgentCluster, resolveEcsTaskSizing } from '../../src/constructs/ecs-agent-cluster'; function createStack(overrides?: { memoryId?: string; bedrockModels?: string[]; + bedrockGeoRegion?: string; withMemory?: boolean; taskSizing?: { buildTaskCpu?: number; @@ -44,7 +46,10 @@ function createStack(overrides?: { }; }): { stack: Stack; template: Template } { const app = new App({ - context: overrides?.bedrockModels ? { bedrockModels: overrides.bedrockModels } : undefined, + context: { + ...(overrides?.bedrockModels && { bedrockModels: overrides.bedrockModels }), + ...(overrides?.bedrockGeoRegion && { bedrockGeoRegion: overrides.bedrockGeoRegion }), + }, }); const stack = new Stack(app, 'TestStack'); @@ -426,6 +431,73 @@ describe('EcsAgentCluster construct', () => { expect(serialized).toContain('inference-profile/us.anthropic.claude-opus-4-8'); }); + /** + * TEMPLATE IDENTITY (#746), ECS half. The `us.` literal that used to be + * string-concatenated into this ARN is now the resolved `bedrockGeoRegion` + * (default `us`), so the default-context grant must be UNCHANGED. Exact set + * equality against the list captured from a pre-change `origin/main` synth + * (`fb1e007b`) — the AgentCore half is asserted the same way in + * `test/stacks/agent.test.ts`, and the two lists being identical is itself the + * substrate-parity invariant this construct exists to keep. + */ + test('default-context Bedrock grants are byte-identical to the pre-#746 template', () => { + const PRE_CHANGE_BEDROCK_RESOURCE_NAMES = [ + 'foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0', + 'foundation-model/anthropic.claude-opus-4-20250514-v1:0', + 'foundation-model/anthropic.claude-opus-4-8', + 'foundation-model/anthropic.claude-opus-5', + 'foundation-model/anthropic.claude-sonnet-4-6', + 'inference-profile/us.anthropic.claude-haiku-4-5-20251001-v1:0', + 'inference-profile/us.anthropic.claude-opus-4-20250514-v1:0', + 'inference-profile/us.anthropic.claude-opus-4-8', + 'inference-profile/us.anthropic.claude-opus-5', + 'inference-profile/us.anthropic.claude-sonnet-4-6', + ]; + const serialized = JSON.stringify(baseTemplate.findResources('AWS::IAM::Policy')); + const found = [...new Set( + serialized.match(/(?:foundation-model|inference-profile)\/[^"]+/g) ?? [], + )].sort(); + expect(found).toEqual(PRE_CHANGE_BEDROCK_RESOURCE_NAMES); + expect(found).toHaveLength(DEFAULT_BEDROCK_MODEL_IDS.length * 2); + }); + + /** + * ECS/AgentCore parity for the geo knob. The two substrates build their + * inference-profile ARNs by different routes — AgentCore through + * `CrossRegionInferenceProfile.fromConfig`, ECS through `Stack.formatArn` — + * so a geo threaded into only one of them would grant a repo running on ECS a + * different profile than the same repo on AgentCore. Both reduce to + * `.`; that equivalence is what this asserts. + */ + test.each(['global', 'eu'])('bedrockGeoRegion=%s re-prefixes the task role inference-profile ARNs', (geo) => { + const template = createStack({ bedrockGeoRegion: geo }).template; + const policies = template.findResources('AWS::IAM::Policy'); + let bedrockStatement: { Resource: unknown } | undefined; + for (const policy of Object.values(policies)) { + for (const s of policy.Properties.PolicyDocument.Statement) { + const actions = Array.isArray(s.Action) ? s.Action : [s.Action]; + if (actions.includes('bedrock:InvokeModel')) bedrockStatement = s; + } + } + expect(bedrockStatement).toBeDefined(); + const serialized = JSON.stringify(bedrockStatement!.Resource); + for (const modelId of DEFAULT_BEDROCK_MODEL_IDS) { + expect(serialized).toContain(`inference-profile/${geo}.${modelId}`); + // The default geo must be replaced, not appended: a stale `us.` grant + // alongside a `global.` call is the AccessDenied this guards. + expect(serialized).not.toContain(`inference-profile/us.${modelId}`); + // Foundation-model half is geo-agnostic (region '*') and stays bare. + expect(serialized).toContain(`foundation-model/${modelId}`); + } + // Never widened to a wildcard by the geo knob. + expect(bedrockStatement!.Resource).not.toEqual('*'); + expect(serialized).not.toContain('"*"'); + }); + + test('an unknown bedrockGeoRegion fails at synth', () => { + expect(() => createStack({ bedrockGeoRegion: 'usa' })).toThrow(/must be one of/); + }); + test('task role can DescribeAvailabilityZones so a CDK target repo can `cdk synth` on a fresh clone (ECS-parity)', () => { // REGRESSION: `mise run build` on a CDK-based target repo runs `cdk synth`, // and a stack wired to a concrete env does a synth-time AZ context lookup diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index f7acc8a3..11ea39f4 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -21,6 +21,11 @@ import * as fs from 'fs'; import * as path from 'path'; import { App, AspectPriority, Aspects } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; +import { + BEDROCK_GEO_REGION_CONTEXT_KEY, + DEFAULT_BEDROCK_GEO_REGION, + DEFAULT_BEDROCK_MODEL_IDS, +} from '../../src/constructs/bedrock-models'; import * as lambdaMicrovmCompute from '../../src/constructs/lambda-microvm-compute'; import { buildAppId, SolutionUaAspect } from '../../src/constructs/solution-ua-aspect'; import { AgentStack } from '../../src/stacks/agent'; @@ -147,16 +152,22 @@ describe('AgentStack', () => { } }); - test('default Haiku model env var is the cross-region inference profile (us.), not the bare model id', () => { + test('default Haiku model env var is the cross-region inference profile, not the bare model id', () => { // Claude 4.x on Bedrock cannot be invoked on-demand by bare foundation-model // id (400 "on-demand throughput isn't supported"); WebFetch's Haiku sub-calls - // hit this. The env var must be the granted us.* inference profile. + // hit this. The env var must be the granted inference profile. + // + // The expectation is DERIVED from the default geography rather than hardcoded + // `us.` (#746): the prefix is now a function of `bedrockGeoRegion`, so a + // hardcoded literal here would assert the default's value twice and go stale + // the moment the default moves — while the thing worth guarding (main and + // auxiliary models routing through the SAME geography) went unchecked. const runtimes = template.findResources('AWS::BedrockAgentCore::Runtime'); for (const rt of Object.values(runtimes)) { const envVars = (rt as { Properties?: { EnvironmentVariables?: Record } }) .Properties?.EnvironmentVariables ?? {}; expect(envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL) - .toBe('us.anthropic.claude-haiku-4-5-20251001-v1:0'); + .toBe(`${DEFAULT_BEDROCK_GEO_REGION}.anthropic.claude-haiku-4-5-20251001-v1:0`); } }); @@ -261,6 +272,11 @@ describe('AgentStack', () => { // Default (no bedrockModels context): the runtime execution role must hold // bedrock:InvokeModel on every default foundation model + its US // inference profile, scoped (never Resource: '*'). + // + // The `us.` literals below are deliberate, not stale (#746): this test pins + // the DEFAULT deploy, and the default geography is still `us`. The + // geo-parameterized behaviour is covered separately; template identity under + // default context is covered by the exact-set test further down. const serialized = JSON.stringify(template.findResources('AWS::IAM::Policy')); expect(serialized).toContain('foundation-model/anthropic.claude-sonnet-4-6'); expect(serialized).toContain('inference-profile/us.anthropic.claude-sonnet-4-6'); @@ -335,6 +351,136 @@ describe('AgentStack', () => { expect(serialized).not.toContain('"*"'); }); + /** + * TEMPLATE IDENTITY (#746). Making the inference-profile geography + * configurable must not MOVE it: the default is still `us`, so a stack + * deployed before this change and re-synthesized after it must produce the + * same Bedrock IAM resources. That is the whole safety argument for shipping + * the refactor on its own, ahead of the geo flip (#747) — so it is asserted, + * not asserted-in-a-PR-description. + * + * The expected set below is the literal, exhaustive list captured from a + * pre-change `origin/main` synth of this same stack (`fb1e007b`, before the + * `bedrockGeoRegion` key existed) — every `foundation-model/…` and + * `inference-profile/…` resource name appearing in any `bedrock:` IAM + * statement of the default-context template. Asserted as EXACT set equality, + * so the refactor can neither add, drop, nor re-prefix a grant unnoticed. A + * `toContain`-style check would pass on a template that also granted + * something new. + * + * (The full 25k-line template was also diffed pre/post out-of-band and is + * identical modulo CDK's own local synth non-determinism — asset hashes, + * custom-resource timestamps, the InputGuardrail version logical id — which + * differ between two synths of the SAME tree and so cannot be asserted here. + * The Bedrock resources are the change's entire blast radius.) + */ + test('default-context Bedrock grants are byte-identical to the pre-#746 template', () => { + const PRE_CHANGE_BEDROCK_RESOURCE_NAMES = [ + 'foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0', + 'foundation-model/anthropic.claude-opus-4-20250514-v1:0', + 'foundation-model/anthropic.claude-opus-4-8', + 'foundation-model/anthropic.claude-opus-5', + 'foundation-model/anthropic.claude-sonnet-4-6', + 'inference-profile/us.anthropic.claude-haiku-4-5-20251001-v1:0', + 'inference-profile/us.anthropic.claude-opus-4-20250514-v1:0', + 'inference-profile/us.anthropic.claude-opus-4-8', + 'inference-profile/us.anthropic.claude-opus-5', + 'inference-profile/us.anthropic.claude-sonnet-4-6', + ]; + + const serialized = JSON.stringify(template.findResources('AWS::IAM::Policy')); + const found = [...new Set( + serialized.match(/(?:foundation-model|inference-profile)\/[^"]+/g) ?? [], + )].sort(); + expect(found).toEqual(PRE_CHANGE_BEDROCK_RESOURCE_NAMES); + + // Sanity: the set is derived from the shared model list, so a model added to + // DEFAULT_BEDROCK_MODEL_IDS without updating this baseline fails loudly here + // rather than silently widening the "identical" claim. + expect(found).toHaveLength(DEFAULT_BEDROCK_MODEL_IDS.length * 2); + + // And the auxiliary-model env var is still the `us.` profile it always was. + const runtimes = template.findResources('AWS::BedrockAgentCore::Runtime'); + const envVars = (Object.values(runtimes)[0] as { + Properties?: { EnvironmentVariables?: Record }; + }).Properties?.EnvironmentVariables ?? {}; + expect(envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL) + .toBe('us.anthropic.claude-haiku-4-5-20251001-v1:0'); + expect(DEFAULT_BEDROCK_GEO_REGION).toBe('us'); + }); + + /** + * The point of the key: a non-`us` deploy must be reachable from context + * alone, with no construct edit. Parameterized over the geographies with a + * distinct ARN shape from the default, `global` included — `global.` was the + * specific case verified live (its profile ARN is regional + account-qualified, + * identical in shape to `us.`), and is what #747 will flip the default to. + */ + describe.each(['global', 'eu', 'apac'])('bedrockGeoRegion=%s', (geo) => { + let geoTemplate: Template; + + beforeAll(() => { + const app = new App({ context: { [BEDROCK_GEO_REGION_CONTEXT_KEY]: geo } }); + const stack = new AgentStack(app, `GeoAgentStack${geo.replace('-', '')}`, { + env: { account: '123456789012', region: 'us-east-1' }, + }); + geoTemplate = Template.fromStack(stack); + }); + + test('re-prefixes every inference-profile ARN and drops the us. ones', () => { + const serialized = JSON.stringify(geoTemplate.findResources('AWS::IAM::Policy')); + for (const modelId of DEFAULT_BEDROCK_MODEL_IDS) { + expect(serialized).toContain(`inference-profile/${geo}.${modelId}`); + // The default geography must be GONE, not merely joined — a grant left + // on `us.` while the agent calls `global.` is an AccessDenied at turn 0. + expect(serialized).not.toContain(`inference-profile/us.${modelId}`); + // The foundation-model half is already geo-agnostic (region: '*'), so it + // is unchanged — and must NOT pick up a geo prefix. + expect(serialized).toContain(`foundation-model/${modelId}`); + expect(serialized).not.toContain(`foundation-model/${geo}.${modelId}`); + } + // Still per-model scoped; the geo knob must never become a wildcard. + // Scoped to the bedrock:InvokeModel* statements — the stack legitimately + // holds Resource:'*' elsewhere (ec2/route53resolver describes have no + // resource-level scoping), so a blanket scan would assert nothing here. + const bedrockStatements: unknown[] = []; + for (const p of Object.values(geoTemplate.findResources('AWS::IAM::Policy'))) { + for (const s of (p.Properties?.PolicyDocument?.Statement ?? []) as Array<{ Action?: unknown; Resource?: unknown }>) { + const actions = Array.isArray(s.Action) ? s.Action : [s.Action]; + if (actions.some((a) => typeof a === 'string' && a.startsWith('bedrock:InvokeModel'))) { + bedrockStatements.push(s.Resource); + } + } + } + expect(bedrockStatements.length).toBeGreaterThan(0); + expect(bedrockStatements).not.toContain('*'); + expect(JSON.stringify(bedrockStatements)).not.toContain('"*"'); + }); + + test('derives the auxiliary Haiku model prefix from the same key', () => { + // Without this the main model routes through `geo` while WebFetch's Haiku + // sub-calls still ask for `us.` — a grant/env split that only shows up as a + // mid-task failure on the auxiliary path. + const runtimes = geoTemplate.findResources('AWS::BedrockAgentCore::Runtime'); + for (const rt of Object.values(runtimes)) { + const envVars = (rt as { Properties?: { EnvironmentVariables?: Record } }) + .Properties?.EnvironmentVariables ?? {}; + expect(envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL) + .toBe(`${geo}.anthropic.claude-haiku-4-5-20251001-v1:0`); + } + }); + }); + + test('an unknown bedrockGeoRegion fails at synth, not at turn 0', () => { + // Synth-time because the value feeds grantInvoke's ARN construction: a + // CloudFormation parameter would resolve after synth and force the grant back + // to Resource: '*'. A typo must therefore fail here, loudly. + const app = new App({ context: { [BEDROCK_GEO_REGION_CONTEXT_KEY]: 'usa' } }); + expect(() => new AgentStack(app, 'BadGeoAgentStack', { + env: { account: '123456789012', region: 'us-east-1' }, + })).toThrow(/must be one of/); + }); + test('outputs ApiUrl', () => { template.hasOutput('ApiUrl', { Description: 'URL of the Task API',