Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/cli/aws/arn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* ARN parsing helpers.
*
* Kept separate from region.ts: that module *detects* the ambient region from the environment and
* shared config files, while these are pure string functions over an ARN the caller already holds.
*/

/**
* Parse the region out of a service ARN.
* ARN format: arn:{partition}:{service}:{region}:{account}:{resource} → field index 3 is the region.
* Splitting on ':' rather than matching a partition keeps this correct for GovCloud and China ARNs.
* Returns undefined for a malformed or region-less ARN so callers can fall back.
*/
export function regionFromArn(arn: string): string | undefined {
const region = arn.split(':')[3];
return region && region.length > 0 ? region : undefined;
}
68 changes: 61 additions & 7 deletions src/cli/commands/exec/__tests__/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,13 +767,55 @@ describe('loadExecContext --runtime as ARN or name', () => {
).not.toHaveBeenCalled();
});

it('resolves region from config when --runtime is a full ARN but --region is omitted', async () => {
// A full ARN already carries its region, so exec must not need a project to recover it. Reading
// config here used to fail outright with "AWS Targets config file not found" for anyone deploying
// runtimes outside this CLI (CDK, pipelines, personal stacks) — the ARN was enough all along.
it('takes the region from the ARN when --region is omitted, without reading config', async () => {
// Config reads throw, standing in for "no project / no aws-targets.json in cwd".
const noProject = {
readAWSDeploymentTargets: vi.fn().mockRejectedValue(new Error('AWS Targets config file not found')),
readDeployedState: vi.fn().mockRejectedValue(new Error('State config file not found')),
} as unknown as ConfigIO;

const ctx = await loadExecContext({ runtimeArn: 'arn:aws:bedrock-agentcore:eu-west-2:123:runtime/X' }, noProject);

expect(ctx.region).toBe('eu-west-2'); // from the ARN, not config
expect(ctx.runtimeArn).toBe('arn:aws:bedrock-agentcore:eu-west-2:123:runtime/X');
expect(
(noProject as unknown as { readAWSDeploymentTargets: ReturnType<typeof vi.fn> }).readAWSDeploymentTargets
).not.toHaveBeenCalled();
expect(
(noProject as unknown as { readDeployedState: ReturnType<typeof vi.fn> }).readDeployedState
).not.toHaveBeenCalled();
});

it('lets an explicit --region override the region in the ARN', async () => {
const ctx = await loadExecContext(
{ runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/X' },
{ runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/X', region: 'us-west-2' },
TWO_AGENT_CONFIG
);
expect(ctx.region).toBe('us-west-2');
});

// Region is field 3 regardless of partition, so GovCloud and China ARNs resolve the same way.
it.each([
['arn:aws-us-gov:bedrock-agentcore:us-gov-west-1:123:runtime/X', 'us-gov-west-1'],
['arn:aws-cn:bedrock-agentcore:cn-north-1:123:runtime/X', 'cn-north-1'],
])('parses the region out of a non-commercial partition ARN (%s)', async (arn, expected) => {
const throwing = {
readAWSDeploymentTargets: vi.fn().mockRejectedValue(new Error('should not be read')),
readDeployedState: vi.fn().mockRejectedValue(new Error('should not be read')),
} as unknown as ConfigIO;

const ctx = await loadExecContext({ runtimeArn: arn }, throwing);
expect(ctx.region).toBe(expected);
});

// An ARN with an empty region field carries no region to use, so config remains the fallback.
it('falls back to config when the ARN has no region field', async () => {
const ctx = await loadExecContext({ runtimeArn: 'arn:aws:bedrock-agentcore::123:runtime/X' }, TWO_AGENT_CONFIG);
expect(ctx.region).toBe('us-east-1'); // from config
expect(ctx.runtimeArn).toBe('arn:aws:bedrock-agentcore:us-east-1:123:runtime/X');
expect(ctx.runtimeArn).toBe('arn:aws:bedrock-agentcore::123:runtime/X');
});

it('resolves runtimeArn when --runtime is an agent name', async () => {
Expand Down Expand Up @@ -858,10 +900,22 @@ describe('loadExecContext with harnesses', () => {
).not.toHaveBeenCalled();
});

it('resolves region from config for --harness <arn> when --region is omitted', async () => {
const ctx = await loadExecContext({ harnessName: HARNESS_ARN }, HARNESS_ONLY_CONFIG);
expect(ctx.runtimeArn).toBe(HARNESS_ARN);
expect(ctx.region).toBe('us-east-1'); // from config target
it('takes the region from a --harness <arn> when --region is omitted, without reading config', async () => {
const noProject = {
readAWSDeploymentTargets: vi.fn().mockRejectedValue(new Error('AWS Targets config file not found')),
readDeployedState: vi.fn().mockRejectedValue(new Error('State config file not found')),
} as unknown as ConfigIO;

const ctx = await loadExecContext(
{ harnessName: 'arn:aws:bedrock-agentcore:eu-west-2:123:harness/h1-abc' },
noProject
);

expect(ctx.runtimeArn).toBe('arn:aws:bedrock-agentcore:eu-west-2:123:harness/h1-abc');
expect(ctx.region).toBe('eu-west-2'); // from the ARN, not config
expect(
(noProject as unknown as { readAWSDeploymentTargets: ReturnType<typeof vi.fn> }).readAWSDeploymentTargets
).not.toHaveBeenCalled();
});

it('rejects a runtime ARN passed to --harness (with --region)', async () => {
Expand Down
27 changes: 20 additions & 7 deletions src/cli/commands/exec/action.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ConfigIO } from '../../../lib';
import { executeBashCommand } from '../../aws/agentcore';
import { regionFromArn } from '../../aws/arn';
import { connectShell, startKeepalive } from '../../aws/connect-shell';
import { ShellChannel, ShellFramer, parseStatusFrame } from '../../aws/shell-framer';
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
Expand Down Expand Up @@ -38,6 +39,7 @@ function assertInteractiveHarnessUnsupported(options: ExecOptions, ctx: ExecCont

/** Resolve region + runtimeArn from options and/or agentcore.json deployed state.
* --runtime accepts either a full ARN (arn:...) or an agent name from deployed state.
* A full ARN resolves with no project and no config on disk; only a *name* needs deployed state.
*/
export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = new ConfigIO()): Promise<ExecContext> {
// Mutual exclusion: --runtime and --harness cannot both be set. Checked first so it applies to
Expand All @@ -46,19 +48,29 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO =
throw new Error('Cannot specify both --runtime and --harness.');
}

// Short-circuit: explicit ARN + region — no need to read deployed state
if (options.runtimeArn?.startsWith('arn:') && options.region) {
return assertInteractiveHarnessUnsupported(options, { region: options.region, runtimeArn: options.runtimeArn });
// Short-circuit: an explicit ARN already carries its region in field 3, so --region is optional.
// Reading config here would demand a project, an aws-targets.json and a completed deploy purely to
// recover a value the caller already supplied — which blocks `exec` for anyone who deploys their
// runtimes outside this CLI (CDK, pipelines, personal stacks).
// A region-less/malformed ARN still falls through so config can supply the region.
if (options.runtimeArn?.startsWith('arn:')) {
const region = options.region ?? regionFromArn(options.runtimeArn);
if (region) {
return assertInteractiveHarnessUnsupported(options, { region, runtimeArn: options.runtimeArn });
}
}

// Same short-circuit for --harness <arn> + region. Validate it's a harness ARN (not a runtime ARN).
if (options.harnessName?.startsWith('arn:') && options.region) {
// Same short-circuit for --harness <arn>. Validate it's a harness ARN (not a runtime ARN).
if (options.harnessName?.startsWith('arn:')) {
if (!isHarnessArn(options.harnessName)) {
throw new Error(
`--harness expects a harness ARN (…:harness/…), got '${options.harnessName}'. Use --runtime for a runtime ARN.`
);
}
return assertInteractiveHarnessUnsupported(options, { region: options.region, runtimeArn: options.harnessName });
const region = options.region ?? regionFromArn(options.harnessName);
if (region) {
return assertInteractiveHarnessUnsupported(options, { region, runtimeArn: options.harnessName });
}
}

const awsTargets = await configIO.readAWSDeploymentTargets();
Expand All @@ -85,7 +97,8 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO =
const runtimeKeys = Object.keys(targetState?.resources?.runtimes ?? {});
const harnessKeys = Object.keys(targetState?.resources?.harnesses ?? {});

// --runtime <arn> with no --region: ARN provided but region must come from config
// --runtime <arn> whose region field is empty or malformed: only reachable when the short-circuit
// above could not derive a region, so config is the last resort.
if (options.runtimeArn?.startsWith('arn:')) {
return assertInteractiveHarnessUnsupported(options, {
region: options.region ?? targetConfig.region,
Expand Down
15 changes: 5 additions & 10 deletions src/cli/operations/jobs/shared/region.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
* no regression to either legacy path) and baked into the stored ARN; refresh/stop/archive
* parse it back out of the ARN rather than storing a separate field.
*/
import { regionFromArn } from '../../../aws/arn';
import { detectRegion } from '../../../aws/region';

// regionFromArn is shared with exec's target resolution, so it lives in cli/aws/arn.
// Re-exported here to keep the jobs-facing import path stable.
export { regionFromArn };

/** AWS targets carry a per-target region; we only need that field here. */
interface RegionTarget {
region: string;
Expand All @@ -24,13 +29,3 @@ export async function resolveJobRegion(optsRegion: string | undefined, awsTarget
const { region } = await detectRegion();
return region;
}

/**
* Parse the region out of a service ARN.
* ARN format: arn:{partition}:{service}:{region}:{account}:{resource} → field index 3 is the region.
* Engine-created ARNs are always well-formed; returns undefined for a malformed/region-less ARN.
*/
export function regionFromArn(arn: string): string | undefined {
const region = arn.split(':')[3];
return region && region.length > 0 ? region : undefined;
}
Loading