From d82f49c26e60c74b4465ec75ab74b67f034bbfad Mon Sep 17 00:00:00 2001 From: Xin Xu Date: Wed, 19 Aug 2026 20:32:07 +0000 Subject: [PATCH 1/2] Revert "revert: remove capacity-provider Journey 1 (#2045)" This reverts commit 0cd32ba1f9d8a52f4309c1418baafa1b2b4ee0d9. --- AGENTS.md | 5 +- README.md | 12 +- docs/commands.md | 52 +++ .../add-remove-capacity-provider.test.ts | 210 +++++++++++++ .../assets.snapshot.test.ts.snap | 4 +- src/assets/agents/AGENTS.md | 4 +- .../outputs-capacity-provider.test.ts | 51 +++ src/cli/cloudformation/outputs.ts | 39 +++ src/cli/commands/deploy/actions.ts | 6 + src/cli/commands/remove/command.tsx | 2 + src/cli/commands/remove/types.ts | 3 +- .../commands/status/__tests__/action.test.ts | 54 ++++ src/cli/commands/status/action.ts | 13 +- src/cli/commands/status/command.tsx | 1 + src/cli/logging/remove-logger.ts | 3 +- .../agent/generate/write-agent-to-project.ts | 1 + .../deploy/__tests__/preflight.test.ts | 70 +++++ src/cli/operations/deploy/preflight.ts | 66 ++-- .../primitives/CapacityProviderPrimitive.ts | 274 ++++++++++++++++ .../CapacityProviderPrimitive.test.ts | 296 ++++++++++++++++++ src/cli/primitives/index.ts | 3 + src/cli/primitives/registry.ts | 3 + src/cli/project.ts | 1 + src/cli/telemetry/schemas/command-run.ts | 12 + src/cli/telemetry/schemas/common-shapes.ts | 2 + src/cli/tui/components/ResourceGraph.tsx | 25 ++ src/cli/tui/hooks/useRemove.ts | 20 ++ src/cli/tui/screens/add/AddFlow.tsx | 20 ++ src/cli/tui/screens/add/AddScreen.tsx | 8 +- .../screens/add/__tests__/AddScreen.test.tsx | 6 + .../AddCapacityProviderFlow.tsx | 106 +++++++ .../AddCapacityProviderScreen.tsx | 262 ++++++++++++++++ .../tui/screens/capacity-provider/index.ts | 3 + .../remove/RemoveCapacityProviderScreen.tsx | 30 ++ src/cli/tui/screens/remove/RemoveFlow.tsx | 114 ++++++- src/cli/tui/screens/remove/RemoveScreen.tsx | 12 + .../remove/__tests__/RemoveScreen.test.tsx | 59 ++++ src/cli/tui/screens/remove/useRemoveFlow.ts | 5 + src/schema/llm-compacted/agentcore.ts | 52 +++ src/schema/schemas/agentcore-project.ts | 21 ++ src/schema/schemas/deployed-state.ts | 12 + .../__tests__/capacity-provider.test.ts | 98 ++++++ .../schemas/primitives/capacity-provider.ts | 213 +++++++++++++ src/schema/schemas/primitives/index.ts | 29 ++ 44 files changed, 2240 insertions(+), 42 deletions(-) create mode 100644 integ-tests/add-remove-capacity-provider.test.ts create mode 100644 src/cli/cloudformation/__tests__/outputs-capacity-provider.test.ts create mode 100644 src/cli/primitives/CapacityProviderPrimitive.ts create mode 100644 src/cli/primitives/__tests__/CapacityProviderPrimitive.test.ts create mode 100644 src/cli/tui/screens/capacity-provider/AddCapacityProviderFlow.tsx create mode 100644 src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx create mode 100644 src/cli/tui/screens/capacity-provider/index.ts create mode 100644 src/cli/tui/screens/remove/RemoveCapacityProviderScreen.tsx create mode 100644 src/schema/schemas/primitives/__tests__/capacity-provider.test.ts create mode 100644 src/schema/schemas/primitives/capacity-provider.ts diff --git a/AGENTS.md b/AGENTS.md index 4d4627610..6f8e92036 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,9 +32,9 @@ These options are available on all commands: - `create` - Create new AgentCore project - `add` - Add resources (agent, memory, credential, evaluator, online-eval, gateway, gateway-target, policy-engine, - policy, payment-manager, payment-connector) + policy, payment-manager, payment-connector, capacity-provider) - `remove` - Remove resources (agent, memory, credential, evaluator, online-eval, gateway, gateway-target, - policy-engine, policy, payment-manager, payment-connector, all) + policy-engine, policy, payment-manager, payment-connector, capacity-provider, all) - `deploy` - Deploy infrastructure to AWS - `status` - Check deployment status - `dev` - Local development server (CodeZip: uvicorn with hot-reload; Container: Docker build + run with volume mount) @@ -90,6 +90,7 @@ Current primitives: - `PolicyPrimitive` — Cedar policy creation/removal within policy engines - `PaymentManagerPrimitive` — payment manager creation/removal with agent code wiring - `PaymentConnectorPrimitive` — payment connector creation/removal with credential management +- `CapacityProviderPrimitive` — capacity provider creation/removal (customer-managed EC2 compute pool for runtimes) Singletons are created in `registry.ts` and wired into CLI commands via `cli.ts`. See `src/cli/AGENTS.md` for details on adding new primitives. diff --git a/README.md b/README.md index b257fd1cf..a6184931b 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,10 @@ agentcore invoke ### Resource Management -| Command | Description | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `add` | Add harnesses, agents, memory, credentials, gateways and gateway-targets, evaluators, online evals, online insights, knowledge bases, config bundles, datasets, policy engines and policies, payment managers and payment connectors, runtime endpoints | -| `remove` | Remove any of the above resources from the project | +| Command | Description | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `add` | Add harnesses, agents, memory, credentials, gateways and gateway-targets, evaluators, online evals, online insights, knowledge bases, config bundles, datasets, policy engines and policies, payment managers and payment connectors, capacity providers, runtime endpoints | +| `remove` | Remove any of the above resources from the project | > **Note**: Run `agentcore deploy` after `add` or `remove` to update resources in AWS. @@ -264,8 +264,8 @@ my-project/ Projects use JSON schema files in the `agentcore/` directory: - `agentcore.json` - Project resources (agents, memory, credentials, gateways, evaluators, online evals/insights, - knowledge bases, harnesses, policy engines and policies, payment managers and connectors, config bundles, datasets, - runtime endpoints) + knowledge bases, harnesses, policy engines and policies, payment managers and connectors, capacity providers, config + bundles, datasets, runtime endpoints) - `deployed-state.json` - Runtime state in agentcore/.cli/ (auto-managed) - `aws-targets.json` - Deployment targets (account, region) diff --git a/docs/commands.md b/docs/commands.md index 0b12e95e3..f1ad57055 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -778,6 +778,57 @@ agentcore add config-bundle \ | `--commit-message ` | Commit message for this version | | `--json` | JSON output | +### add capacity-provider + +Add a capacity provider — a customer-managed pool of AWS-managed EC2 compute that agent runtimes can run on instead of +the default managed fleet. Everything except the description and tags is immutable after creation. + +**Operator role.** AgentCore assumes an IAM _operator role_ to create and manage the EC2 compute on your behalf. Omit +`--operator-role-arn` and the CLI provisions one for you at deploy time — a role that trusts +`bedrock-agentcore.amazonaws.com` (scoped to your account and region) and carries the AWS managed policy +`BedrockAgentCoreRuntimeInstancesOperatorRolePolicy` (EC2/Auto Scaling/fleet management plus the +`agentcore-lifecycle-events-*` EventBridge permissions the service needs). Pass `--operator-role-arn` only when you want +to bring your own role; it must grant those same permissions, or capacity provider creation fails asynchronously +(surfaced by CloudFormation as `NotStabilized`). + +```bash +# Minimal — operator role is created automatically +agentcore add capacity-provider \ + --name MyCapacityProvider \ + --subnets subnet-0123456789abcdef0 \ + --security-groups sg-0123456789abcdef0 \ + --instance-types c6a.large + +# With a named EBS volume, lifecycle limits, and ARM64 (and a bring-your-own operator role) +agentcore add capacity-provider \ + --name MyCapacityProvider \ + --operator-role-arn arn:aws:iam::123456789012:role/MyOperatorRole \ + --subnets subnet-0123456789abcdef0,subnet-0fedcba9876543210 \ + --security-groups sg-0123456789abcdef0 \ + --os LINUX_ARM64 \ + --instance-types c7g.large,c7g.xlarge \ + --volume data:20 --volume-encrypted \ + --idle-instance-timeout 3600 \ + --max-lifetime 28800 +``` + +| Flag | Description | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `--name ` | Capacity provider name (required); immutable after creation | +| `--operator-role-arn ` | IAM role ARN AgentCore assumes to manage the capacity provider (optional — auto-created if omitted); immutable | +| `--description ` | Description (the only mutable field besides tags) | +| `--subnets ` | Comma-separated subnet IDs, 1–16 (required) | +| `--security-groups ` | Comma-separated security group IDs, 1–16 (required) | +| `--os ` | `LINUX_X86_64` (default) or `LINUX_ARM64` | +| `--instance-types ` | Comma-separated allowed EC2 instance types, 1–30 (required) | +| `--volume ` | Named EBS volume as `name:sizeGiB` (repeatable, max 5) | +| `--volume-encrypted` | Encrypt EBS volumes | +| `--volume-kms-key ` | KMS key ARN for EBS volume encryption | +| `--instance-profile-arn ` | IAM instance profile ARN for launched instances | +| `--idle-instance-timeout ` | Idle instance timeout in seconds (60–1209600) | +| `--max-lifetime ` | Maximum instance lifetime in seconds (60–1209600) | +| `--json` | JSON output | + ### remove Remove resources from project. @@ -797,6 +848,7 @@ agentcore remove dataset --name MyDataset agentcore remove config-bundle --name MyBundle agentcore remove payment-manager --name MyManager -y agentcore remove payment-connector --name MyCDPConnector --manager MyManager -y +agentcore remove capacity-provider --name MyCapacityProvider -y # Reset everything agentcore remove all -y diff --git a/integ-tests/add-remove-capacity-provider.test.ts b/integ-tests/add-remove-capacity-provider.test.ts new file mode 100644 index 000000000..df07a429f --- /dev/null +++ b/integ-tests/add-remove-capacity-provider.test.ts @@ -0,0 +1,210 @@ +import { createTestProject, readProjectConfig, runCLI } from '../src/test-utils/index.js'; +import type { TestProject } from '../src/test-utils/index.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const OPERATOR_ROLE_ARN = 'arn:aws:iam::123456789012:role/MyOperatorRole'; + +describe('integration: add and remove capacity providers', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + describe('capacity provider lifecycle', () => { + const cpName = `IntegCp${Date.now().toString().slice(-6)}`; + + it('adds a capacity provider', async () => { + const result = await runCLI( + [ + 'add', + 'capacity-provider', + '--name', + cpName, + '--operator-role-arn', + OPERATOR_ROLE_ARN, + '--subnets', + 'subnet-0123456789abcdef0', + '--security-groups', + 'sg-0123456789abcdef0', + '--os', + 'LINUX_X86_64', + '--instance-types', + 'c6a.large', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.capacityProviderName).toBe(cpName); + + const config = await readProjectConfig(project.projectPath); + const cp = config.capacityProviders?.find((c: Record) => c.name === cpName); + expect(cp, `Capacity provider "${cpName}" should be in config`).toBeTruthy(); + expect(cp!.operatorRoleArn).toBe(OPERATOR_ROLE_ARN); + const ec2 = (cp as any).computeConfiguration.ec2Configuration; + expect(ec2.launchTemplateSource.launchParameters.operatingSystem).toBe('LINUX_X86_64'); + expect(ec2.launchTemplateSource.launchParameters.instanceRequirements.allowedInstanceTypes).toEqual([ + 'c6a.large', + ]); + expect(ec2.vpcConfiguration.subnets).toEqual(['subnet-0123456789abcdef0']); + expect(ec2.vpcConfiguration.securityGroups).toEqual(['sg-0123456789abcdef0']); + }); + + it('adds a capacity provider with volumes, lifecycle, and description', async () => { + const richName = `${cpName}Rich`; + const result = await runCLI( + [ + 'add', + 'capacity-provider', + '--name', + richName, + '--operator-role-arn', + OPERATOR_ROLE_ARN, + '--description', + 'my rich capacity provider', + '--subnets', + 'subnet-0123456789abcdef0,subnet-0fedcba9876543210', + '--security-groups', + 'sg-0123456789abcdef0', + '--os', + 'LINUX_ARM64', + '--instance-types', + 'c7g.large,c7g.xlarge', + '--volume', + 'data:20', + '--volume-encrypted', + '--idle-instance-timeout', + '3600', + '--max-lifetime', + '28800', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + expect(JSON.parse(result.stdout).success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const cp = config.capacityProviders?.find((c: Record) => c.name === richName); + expect(cp).toBeTruthy(); + expect(cp!.description).toBe('my rich capacity provider'); + const ec2 = (cp as any).computeConfiguration.ec2Configuration; + expect(ec2.launchTemplateSource.launchParameters.operatingSystem).toBe('LINUX_ARM64'); + expect(ec2.vpcConfiguration.subnets).toHaveLength(2); + expect(ec2.volumes).toEqual([{ ebsConfiguration: { name: 'data', sizeGiB: 20, encrypted: true } }]); + expect(ec2.lifecycleConfiguration).toEqual({ idleInstanceTimeout: 3600, maxLifetime: 28800 }); + + await runCLI(['remove', 'capacity-provider', '--name', richName, '--yes'], project.projectPath); + }); + + it('rejects a duplicate capacity provider name', async () => { + const result = await runCLI( + [ + 'add', + 'capacity-provider', + '--name', + cpName, + '--operator-role-arn', + OPERATOR_ROLE_ARN, + '--subnets', + 'subnet-0123456789abcdef0', + '--security-groups', + 'sg-0123456789abcdef0', + '--instance-types', + 'c6a.large', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(json.error).toContain('already exists'); + }); + + it('removes the capacity provider', async () => { + const result = await runCLI( + ['remove', 'capacity-provider', '--name', cpName, '--yes', '--json'], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const found = config.capacityProviders?.some((c: Record) => c.name === cpName); + expect(found, `Capacity provider "${cpName}" should be removed`).toBeFalsy(); + }); + }); + + describe('validation', () => { + it('rejects a missing required option', async () => { + const result = await runCLI(['add', 'capacity-provider', '--name', 'noRole', '--json'], project.projectPath); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + }); + + it('rejects an unsupported operating system', async () => { + const result = await runCLI( + [ + 'add', + 'capacity-provider', + '--name', + 'badOs', + '--operator-role-arn', + OPERATOR_ROLE_ARN, + '--subnets', + 'subnet-0123456789abcdef0', + '--security-groups', + 'sg-0123456789abcdef0', + '--os', + 'WINDOWS_X86_64', + '--instance-types', + 'c6a.large', + '--json', + ], + project.projectPath + ); + expect(result.exitCode).toBe(1); + }); + + it('rejects a malformed operator role ARN', async () => { + const result = await runCLI( + [ + 'add', + 'capacity-provider', + '--name', + 'badArn', + '--operator-role-arn', + 'not-an-arn', + '--subnets', + 'subnet-0123456789abcdef0', + '--security-groups', + 'sg-0123456789abcdef0', + '--instance-types', + 'c6a.large', + '--json', + ], + project.projectPath + ); + expect(result.exitCode).toBe(1); + }); + + it('passes agentcore validate after add/remove lifecycle', async () => { + const result = await runCLI(['validate'], project.projectPath); + expect(result.exitCode).toBe(0); + }); + }); +}); diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 755b17266..a50303b57 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -7511,7 +7511,7 @@ file maps to a JSON config file and includes validation constraints as comments ### Key Types -- **AgentCoreProjectSpec**: Root config with runtimes, memories, knowledge bases, credentials, evaluators, online evals and insights, gateways, policy engines, config bundles, A/B tests, harness registrations, datasets, and payment managers +- **AgentCoreProjectSpec**: Root config with runtimes, memories, knowledge bases, credentials, evaluators, online evals and insights, gateways, policy engines, config bundles, A/B tests, harness registrations, datasets, payment managers, and capacity providers - **AgentEnvSpec**: Agent configuration (build type, entrypoint, code location, runtime version, network mode) - **Memory**: Memory resource with strategies (SEMANTIC, SUMMARIZATION, USER_PREFERENCE, EPISODIC) and expiry - **Credential**: API key or OAuth credential provider @@ -7622,7 +7622,7 @@ Run \`agentcore --help\` or \`agentcore --help\` for full flags. Commo | Command | Description | | --- | --- | -| \`agentcore add \` | Add agent, memory, credential, gateway, gateway-target, evaluator, online-eval, online-insights, knowledge-base, harness, policy-engine, policy, payment-manager, payment-connector, config-bundle, dataset, runtime-endpoint | +| \`agentcore add \` | Add agent, memory, credential, gateway, gateway-target, evaluator, online-eval, online-insights, knowledge-base, harness, policy-engine, policy, payment-manager, payment-connector, capacity-provider, config-bundle, dataset, runtime-endpoint | | \`agentcore remove \` | Remove any resource | | \`agentcore export harness\` | Export a harness to a Strands runtime agent under \`app//\` | diff --git a/src/assets/agents/AGENTS.md b/src/assets/agents/AGENTS.md index f81046d71..8a020757e 100644 --- a/src/assets/agents/AGENTS.md +++ b/src/assets/agents/AGENTS.md @@ -56,7 +56,7 @@ file maps to a JSON config file and includes validation constraints as comments ### Key Types -- **AgentCoreProjectSpec**: Root config with runtimes, memories, knowledge bases, credentials, evaluators, online evals and insights, gateways, policy engines, config bundles, A/B tests, harness registrations, datasets, and payment managers +- **AgentCoreProjectSpec**: Root config with runtimes, memories, knowledge bases, credentials, evaluators, online evals and insights, gateways, policy engines, config bundles, A/B tests, harness registrations, datasets, payment managers, and capacity providers - **AgentEnvSpec**: Agent configuration (build type, entrypoint, code location, runtime version, network mode) - **Memory**: Memory resource with strategies (SEMANTIC, SUMMARIZATION, USER_PREFERENCE, EPISODIC) and expiry - **Credential**: API key or OAuth credential provider @@ -167,7 +167,7 @@ Run `agentcore --help` or `agentcore --help` for full flags. Commonly | Command | Description | | --- | --- | -| `agentcore add ` | Add agent, memory, credential, gateway, gateway-target, evaluator, online-eval, online-insights, knowledge-base, harness, policy-engine, policy, payment-manager, payment-connector, config-bundle, dataset, runtime-endpoint | +| `agentcore add ` | Add agent, memory, credential, gateway, gateway-target, evaluator, online-eval, online-insights, knowledge-base, harness, policy-engine, policy, payment-manager, payment-connector, capacity-provider, config-bundle, dataset, runtime-endpoint | | `agentcore remove ` | Remove any resource | | `agentcore export harness` | Export a harness to a Strands runtime agent under `app//` | diff --git a/src/cli/cloudformation/__tests__/outputs-capacity-provider.test.ts b/src/cli/cloudformation/__tests__/outputs-capacity-provider.test.ts new file mode 100644 index 000000000..c4b932015 --- /dev/null +++ b/src/cli/cloudformation/__tests__/outputs-capacity-provider.test.ts @@ -0,0 +1,51 @@ +import { parseCapacityProviderOutputs } from '../outputs'; +import { describe, expect, it } from 'vitest'; + +describe('parseCapacityProviderOutputs', () => { + it('parses Id and Arn from stack outputs', () => { + const outputs = { + ApplicationCapacityProviderMyCpIdOutputABC123: 'MyCp-abc1234567', + ApplicationCapacityProviderMyCpArnOutputDEF456: + 'arn:aws:bedrock-agentcore:us-west-2:123456789012:capacity-provider/MyCp-abc1234567', + }; + + const result = parseCapacityProviderOutputs(outputs, ['MyCp']); + + expect(result).toEqual({ + MyCp: { + capacityProviderId: 'MyCp-abc1234567', + capacityProviderArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:capacity-provider/MyCp-abc1234567', + }, + }); + }); + + it('parses multiple capacity providers', () => { + const outputs = { + ApplicationCapacityProviderFirstIdOutputAAA: 'first-id', + ApplicationCapacityProviderFirstArnOutputBBB: 'arn:first', + ApplicationCapacityProviderSecondIdOutputCCC: 'second-id', + ApplicationCapacityProviderSecondArnOutputDDD: 'arn:second', + }; + + const result = parseCapacityProviderOutputs(outputs, ['First', 'Second']); + + expect(Object.keys(result)).toHaveLength(2); + expect(result.First!.capacityProviderId).toBe('first-id'); + expect(result.Second!.capacityProviderArn).toBe('arn:second'); + }); + + it('skips a capacity provider when the Id output is missing', () => { + const outputs = { + ApplicationCapacityProviderMyCpArnOutputDEF: 'arn:test', + }; + + const result = parseCapacityProviderOutputs(outputs, ['MyCp']); + + expect(result).toEqual({}); + }); + + it('returns empty for no names', () => { + const result = parseCapacityProviderOutputs({ ApplicationCapacityProviderMyCpIdOutputX: 'x' }, []); + expect(result).toEqual({}); + }); +}); diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index d8463a023..6db7ec064 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -1,5 +1,6 @@ import type { AgentCoreDeployedState, + CapacityProviderDeployedState, ConfigBundleDeployedState, DatasetDeployedState, DeployedState, @@ -537,6 +538,37 @@ export function parseDatasetOutputs( return datasets; } +/** + * Parse stack outputs into deployed state for capacity providers. + * + * Output key pattern: ApplicationCapacityProvider{PascalName}(Id|Arn)Output{Hash} + */ +export function parseCapacityProviderOutputs( + outputs: StackOutputs, + capacityProviderNames: string[] +): Record { + const capacityProviders: Record = {}; + const outputKeys = Object.keys(outputs); + + for (const capacityProviderName of capacityProviderNames) { + const pascal = toPascalId('CapacityProvider', capacityProviderName); + const idPrefix = `Application${pascal}IdOutput`; + const arnPrefix = `Application${pascal}ArnOutput`; + + const idKey = outputKeys.find(k => k.startsWith(idPrefix)); + const arnKey = outputKeys.find(k => k.startsWith(arnPrefix)); + + if (idKey && arnKey) { + capacityProviders[capacityProviderName] = { + capacityProviderId: outputs[idKey]!, + capacityProviderArn: outputs[arnKey]!, + }; + } + } + + return capacityProviders; +} + /** * Parse CDK stack outputs for CFN-deployed harnesses into deployed-state records. * @@ -724,6 +756,7 @@ export interface BuildDeployedStateOptions { configBundles?: Record; knowledgeBases?: Record; payments?: Record; + capacityProviders?: Record; /** * Names of A/B tests currently declared in the project spec. AB test state is managed * post-deploy (not via CFN outputs) and carried forward across deploys; passing the @@ -758,6 +791,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta configBundles, knowledgeBases, payments, + capacityProviders, abTestNames, } = opts; const targetState: TargetDeployedState = { @@ -860,6 +894,11 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta targetState.resources!.payments = payments; } + // Add capacity provider state from CFN outputs + if (capacityProviders && Object.keys(capacityProviders).length > 0) { + targetState.resources!.capacityProviders = capacityProviders; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 437f26898..484beaf1d 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -17,6 +17,7 @@ import { buildDeployedState, getStackOutputs, parseAgentOutputs, + parseCapacityProviderOutputs, parseConfigBundleOutputs, parseDatasetOutputs, parseEvaluatorOutputs, @@ -707,6 +708,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise d.name); const datasets = parseDatasetOutputs(outputs, datasetNames); + // Parse capacity provider outputs + const capacityProviderNames = (context.projectSpec.capacityProviders ?? []).map(cp => cp.name); + const capacityProviders = parseCapacityProviderOutputs(outputs, capacityProviderNames); + // Parse config bundle outputs const configBundleNames = (context.projectSpec.configBundles ?? []).map(b => b.name); const configBundles = parseConfigBundleOutputs(outputs, configBundleNames); @@ -790,6 +795,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise t.name), }); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index b5e298047..803f45bc3 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -27,6 +27,7 @@ async function handleRemoveAll(options: RemoveAllOptions): Promise for (const e of current.evaluators ?? []) items.push(`evaluator: ${e.name}`); for (const g of current.agentCoreGateways ?? []) items.push(`gateway: ${g.name}`); for (const pe of current.policyEngines ?? []) items.push(`policy-engine: ${pe.name}`); + for (const cp of current.capacityProviders ?? []) items.push(`capacity-provider: ${cp.name}`); return { success: true, message: items.length > 0 ? `Would remove: ${items.join(', ')}` : 'Nothing to remove', @@ -84,6 +85,7 @@ async function handleRemoveAll(options: RemoveAllOptions): Promise harnesses: [], datasets: [], payments: [], + capacityProviders: [], }); // Preserve aws-targets.json and deployed-state.json so that diff --git a/src/cli/commands/remove/types.ts b/src/cli/commands/remove/types.ts index 2e3ad6346..0ec4140d4 100644 --- a/src/cli/commands/remove/types.ts +++ b/src/cli/commands/remove/types.ts @@ -17,7 +17,8 @@ export type ResourceType = | 'dataset' | 'knowledge-base' | 'payment-manager' - | 'payment-connector'; + | 'payment-connector' + | 'capacity-provider'; export interface RemoveOptions { resourceType: ResourceType; diff --git a/src/cli/commands/status/__tests__/action.test.ts b/src/cli/commands/status/__tests__/action.test.ts index faceb2812..32979cc78 100644 --- a/src/cli/commands/status/__tests__/action.test.ts +++ b/src/cli/commands/status/__tests__/action.test.ts @@ -1338,4 +1338,58 @@ describe('handleProjectStatus — invocation URL enrichment', () => { expect(agentEntry!.deploymentState).toBe('pending-removal'); expect(agentEntry!.invocationUrl).toBeUndefined(); }); + + it('marks capacity provider deployed / local-only / pending-removal correctly', () => { + const project = { + ...baseProject, + capacityProviders: [ + { + name: 'my-cp', + operatorRoleArn: 'arn:aws:iam::123456789012:role/Op', + computeConfiguration: { + ec2Configuration: { + launchTemplateSource: { + launchParameters: { + operatingSystem: 'LINUX_X86_64', + instanceRequirements: { allowedInstanceTypes: ['c6a.large'] }, + }, + }, + vpcConfiguration: { subnets: ['subnet-0123456789abcdef0'], securityGroups: ['sg-0123456789abcdef0'] }, + }, + }, + }, + ], + } as unknown as AgentCoreProjectSpec; + + // Deployed + const deployed = computeResourceStatuses(project, { + capacityProviders: { + 'my-cp': { + capacityProviderId: 'my-cp-abc1234567', + capacityProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:capacity-provider/my-cp-abc1234567', + }, + }, + }); + const deployedEntry = deployed.find(r => r.resourceType === 'capacity-provider' && r.name === 'my-cp'); + expect(deployedEntry).toBeDefined(); + expect(deployedEntry!.deploymentState).toBe('deployed'); + expect(deployedEntry!.identifier).toContain('capacity-provider/my-cp-abc1234567'); + + // Local-only + const local = computeResourceStatuses(project, undefined); + const localEntry = local.find(r => r.resourceType === 'capacity-provider' && r.name === 'my-cp'); + expect(localEntry!.deploymentState).toBe('local-only'); + + // Pending removal: deployed but not in local spec + const pending = computeResourceStatuses(baseProject, { + capacityProviders: { + 'gone-cp': { + capacityProviderId: 'gone-cp-abc1234567', + capacityProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:capacity-provider/gone-cp-abc1234567', + }, + }, + }); + const pendingEntry = pending.find(r => r.resourceType === 'capacity-provider' && r.name === 'gone-cp'); + expect(pendingEntry!.deploymentState).toBe('pending-removal'); + }); }); diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 12bccd24d..91624aeaf 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -33,7 +33,8 @@ export interface ResourceStatusEntry { | 'harness' | 'runtime-endpoint' | 'knowledge-base' - | 'payment'; + | 'payment' + | 'capacity-provider'; name: string; deploymentState: ResourceDeploymentState; identifier?: string; @@ -352,6 +353,15 @@ export function computeResourceStatuses( `${item.authorizerType} — auto-pay ${item.autoPayment ? 'on' : 'off'} (${item.connectors.length} connector(s))`, }); + const capacityProviders = diffResourceSet({ + resourceType: 'capacity-provider', + localItems: project.capacityProviders ?? [], + deployedRecord: resources?.capacityProviders ?? {}, + getIdentifier: deployed => deployed.capacityProviderArn, + getLocalDetail: item => + item.computeConfiguration.ec2Configuration.launchTemplateSource.launchParameters.operatingSystem, + }); + return [ ...agents, ...runtimeEndpoints, @@ -367,6 +377,7 @@ export function computeResourceStatuses( ...configBundles, ...harnesses, ...payments, + ...capacityProviders, ]; } diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index bafccb023..cadc18b96 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -27,6 +27,7 @@ const VALID_RESOURCE_TYPES = [ 'dataset', 'knowledge-base', 'harness', + 'capacity-provider', ] as const; const VALID_STATES = ['deployed', 'local-only', 'pending-removal'] as const; diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index 30cc5642b..428d175b5 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -24,7 +24,8 @@ export interface RemoveLoggerOptions { | 'dataset' | 'knowledge-base' | 'payment-manager' - | 'payment-connector'; + | 'payment-connector' + | 'capacity-provider'; /** Name of the resource being removed */ resourceName: string; } diff --git a/src/cli/operations/agent/generate/write-agent-to-project.ts b/src/cli/operations/agent/generate/write-agent-to-project.ts index 8163494f6..a60f4404c 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -77,6 +77,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ harnesses: [], datasets: [], payments: [], + capacityProviders: [], }; await configIO.writeProjectSpec(project); diff --git a/src/cli/operations/deploy/__tests__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts index f8e9a16b0..894e4eb30 100644 --- a/src/cli/operations/deploy/__tests__/preflight.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight.test.ts @@ -162,6 +162,76 @@ describe('validateProject', () => { expect(result.isTeardownDeploy).toBe(false); }); + it('allows deploy when only config bundles are defined (regression: previously misclassified as empty)', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + runtimes: [], + agentCoreGateways: [], + configBundles: [{ name: 'bundle1' }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + + expect(result.projectSpec.name).toBe('test-project'); + expect(result.isTeardownDeploy).toBe(false); + }); + + it('allows deploy when only online eval configs are defined (regression: previously misclassified as empty)', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + runtimes: [], + agentCoreGateways: [], + onlineEvalConfigs: [{ name: 'oec1' }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + + expect(result.projectSpec.name).toBe('test-project'); + expect(result.isTeardownDeploy).toBe(false); + }); + + it('allows deploy when only capacity providers are defined', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + runtimes: [], + agentCoreGateways: [], + capacityProviders: [{ name: 'cp1' }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + + expect(result.projectSpec.name).toBe('test-project'); + expect(result.isTeardownDeploy).toBe(false); + }); + + it('treats an empty project as teardown when a deployed stack exists', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + runtimes: [], + agentCoreGateways: [], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockReadDeployedState.mockResolvedValue({ targets: { default: {} } }); + + const result = await validateProject(); + + expect(result.isTeardownDeploy).toBe(true); + }); + it('allows deploy when both agents and gateways exist', async () => { mockRequireConfigRoot.mockReturnValue('/project/agentcore'); mockValidate.mockReturnValue(undefined); diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 75c34b41a..61cd8a196 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -67,6 +67,43 @@ export function formatError(err: unknown): string { return String(err); } +/** + * Spec arrays whose presence means the project has something to deploy to CloudFormation. + * Keep in sync with the resource types that emit CFN outputs (see cloudformation/outputs.ts + * parse*Outputs). A project with none of these is empty: deploy either errors ("No resources + * defined") or, when a stack already exists, tears it down. + * + * This is the single source of truth for "is the project deployable" — a new deployable primitive + * adds its key here and every consumer stays correct. Previously this was a hand-maintained boolean + * chain that silently drifted (configBundles and onlineEvalConfigs were both missing from it, so a + * project containing only those was misclassified as empty). The `satisfies` clause makes a typo'd + * or renamed key a compile error. + */ +export const DEPLOYABLE_RESOURCE_KEYS = [ + 'runtimes', + 'agentCoreGateways', + 'memories', + 'knowledgeBases', + 'evaluators', + 'onlineEvalConfigs', + 'policyEngines', + 'configBundles', + 'datasets', + 'capacityProviders', + 'harnesses', + 'payments', +] as const satisfies readonly (keyof AgentCoreProjectSpec)[]; + +/** + * True when the project defines at least one resource that deploys to CloudFormation. + */ +export function hasDeployableResources(spec: AgentCoreProjectSpec): boolean { + return DEPLOYABLE_RESOURCE_KEYS.some(key => { + const value = spec[key]; + return Array.isArray(value) && value.length > 0; + }); +} + /** * Validates the CDK project and loads configuration. * Also validates AWS credentials are configured before proceeding. @@ -98,32 +135,11 @@ export async function validateProject(selectedTarget?: AwsDeploymentTarget): Pro // No deployed state file — no existing stack } - // Teardown detection: when agents is empty but deployed-state.json records existing - // targets, the user has run `remove all` and wants to tear down AWS resources via deploy. + // Teardown detection: when no deployable resources remain but deployed-state.json records + // existing targets, the user has run `remove all` and wants to tear down AWS resources via deploy. let isTeardownDeploy = false; - const hasAgents = projectSpec.runtimes && projectSpec.runtimes.length > 0; - const hasMemories = projectSpec.memories && projectSpec.memories.length > 0; - const hasKnowledgeBases = projectSpec.knowledgeBases && projectSpec.knowledgeBases.length > 0; - const hasEvaluators = projectSpec.evaluators && projectSpec.evaluators.length > 0; - const hasPolicyEngines = projectSpec.policyEngines && projectSpec.policyEngines.length > 0; - const hasHarnesses = projectSpec.harnesses && projectSpec.harnesses.length > 0; - const hasDatasets = projectSpec.datasets && projectSpec.datasets.length > 0; - - // Check for gateways in agentcore.json - const hasGateways = projectSpec.agentCoreGateways && projectSpec.agentCoreGateways.length > 0; - const hasPayments = projectSpec.payments && projectSpec.payments.length > 0; - - if ( - !hasAgents && - !hasGateways && - !hasMemories && - !hasKnowledgeBases && - !hasEvaluators && - !hasPolicyEngines && - !hasHarnesses && - !hasDatasets && - !hasPayments - ) { + + if (!hasDeployableResources(projectSpec)) { if (!hasExistingStack) { throw new ValidationError( 'No resources defined in project. Add at least one resource (agent, memory, knowledge base, evaluator, or gateway) before deploying.' diff --git a/src/cli/primitives/CapacityProviderPrimitive.ts b/src/cli/primitives/CapacityProviderPrimitive.ts new file mode 100644 index 000000000..d823d94f6 --- /dev/null +++ b/src/cli/primitives/CapacityProviderPrimitive.ts @@ -0,0 +1,274 @@ +import { ResourceNotFoundError, ValidationError, findConfigRoot, serializeResult, toError } from '../../lib'; +import type { Result } from '../../lib/result'; +import type { CapacityProvider } from '../../schema'; +import { CapacityProviderSchema } from '../../schema'; +import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; +import { runCliCommand } from '../telemetry/cli-command-run.js'; +import { OperatingSystem, standardize } from '../telemetry/schemas/common-shapes.js'; +import { BasePrimitive } from './BasePrimitive'; +import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +/** + * Options for adding a capacity provider resource (CLI-level). + */ +export interface AddCapacityProviderOptions { + name: string; + operatorRoleArn?: string; + description?: string; + subnets: string; + securityGroups: string; + os?: string; + instanceTypes: string; + volume?: string[]; + volumeEncrypted?: boolean; + volumeKmsKey?: string; + instanceProfileArn?: string; + idleInstanceTimeout?: string; + maxLifetime?: string; +} + +/** Split a comma-separated CLI value into a trimmed, non-empty string array. */ +function splitList(value: string): string[] { + return value + .split(',') + .map(s => s.trim()) + .filter(Boolean); +} + +/** + * CapacityProviderPrimitive handles capacity provider add/remove operations. + * + * A capacity provider is a declarative resource stored in agentcore.json and + * synthesized to an `AWS::BedrockAgentCore::CapacityProvider` CFN resource by + * the vended CDK project. Everything except Description/Tags is immutable after + * creation. + */ +export class CapacityProviderPrimitive extends BasePrimitive { + readonly kind = 'capacity-provider'; + readonly label = 'Capacity Provider'; + readonly primitiveSchema = CapacityProviderSchema; + + async add(options: AddCapacityProviderOptions): Promise> { + try { + const capacityProvider = this.buildCapacityProvider(options); + + const project = await this.readProjectSpec(); + this.checkDuplicate(project.capacityProviders ?? [], capacityProvider.name); + + project.capacityProviders = [...(project.capacityProviders ?? []), capacityProvider]; + await this.writeProjectSpec(project); + + return { success: true, capacityProviderName: capacityProvider.name }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + async remove(name: string): Promise { + try { + const project = await this.readProjectSpec(); + const existing = project.capacityProviders ?? []; + + if (!existing.some(cp => cp.name === name)) { + return { success: false, error: new ResourceNotFoundError(`Capacity provider "${name}" not found.`) }; + } + + const remaining = existing.filter(cp => cp.name !== name); + await this.writeProjectSpec({ + ...project, + capacityProviders: remaining.length > 0 ? remaining : undefined, + }); + + return { success: true }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + async previewRemove(name: string): Promise { + const project = await this.readProjectSpec(); + const existing = project.capacityProviders ?? []; + + if (!existing.some(cp => cp.name === name)) { + throw new Error(`Capacity provider "${name}" not found.`); + } + + const remaining = existing.filter(cp => cp.name !== name); + const schemaChanges: SchemaChange[] = [ + { + file: 'agentcore/agentcore.json', + before: project, + after: { ...project, capacityProviders: remaining.length > 0 ? remaining : undefined }, + }, + ]; + + return { + summary: [`Removing capacity provider: ${name}`], + directoriesToDelete: [], + schemaChanges, + }; + } + + async getRemovable(): Promise { + try { + const project = await this.readProjectSpec(); + return (project.capacityProviders ?? []).map(cp => ({ name: cp.name })); + } catch { + return []; + } + } + + /** Names of all capacity providers in the project (for duplicate checks in the TUI). */ + async getAllNames(): Promise { + try { + const project = await this.readProjectSpec(); + return (project.capacityProviders ?? []).map(cp => cp.name); + } catch { + return []; + } + } + + registerCommands(addCmd: Command, removeCmd: Command): void { + addCmd + .command('capacity-provider') + .description('Add a capacity provider (customer-managed EC2 compute pool for agent runtimes)') + .option('--name ', 'Capacity provider name [non-interactive]') + .option( + '--operator-role-arn ', + 'IAM role ARN AgentCore assumes to manage the capacity provider. Optional — omit to have one created automatically [non-interactive]' + ) + .option('--description ', 'Capacity provider description [non-interactive]') + .option('--subnets ', 'Comma-separated subnet IDs (1-16) [non-interactive]') + .option('--security-groups ', 'Comma-separated security group IDs (1-16) [non-interactive]') + .option('--os ', 'Operating system: LINUX_X86_64 or LINUX_ARM64 (default: LINUX_X86_64) [non-interactive]') + .option('--instance-types ', 'Comma-separated allowed EC2 instance types (1-30) [non-interactive]') + .option( + '--volume ', + 'Named EBS volume as name:sizeGiB (repeatable, max 5) [non-interactive]', + (val: string, prev: string[] = []) => [...prev, val] + ) + .option('--volume-encrypted', 'Encrypt EBS volumes [non-interactive]') + .option('--volume-kms-key ', 'KMS key ARN for EBS volume encryption [non-interactive]') + .option('--instance-profile-arn ', 'IAM instance profile ARN for launched instances [non-interactive]') + .option('--idle-instance-timeout ', 'Idle instance timeout in seconds (60-1209600) [non-interactive]') + .option('--max-lifetime ', 'Maximum instance lifetime in seconds (60-1209600) [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .action(async (rawOptions: Record) => { + const cliOptions = rawOptions as unknown as AddCapacityProviderOptions & { json?: boolean }; + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + await runCliCommand('add.capacity-provider', !!cliOptions.json, async () => { + this.validateRequiredOptions(cliOptions); + + const result = await this.add(cliOptions); + if (!result.success) { + throw result.error; + } + + if (cliOptions.json) { + console.log(JSON.stringify(serializeResult(result))); + } else { + console.log(`Added capacity provider '${result.capacityProviderName}'`); + } + + const built = this.buildCapacityProvider(cliOptions); + const ec2 = built.computeConfiguration.ec2Configuration; + return { + operating_system: standardize(OperatingSystem, ec2.launchTemplateSource.launchParameters.operatingSystem), + instance_type_count: + ec2.launchTemplateSource.launchParameters.instanceRequirements.allowedInstanceTypes.length, + subnet_count: ec2.vpcConfiguration.subnets.length, + security_group_count: ec2.vpcConfiguration.securityGroups.length, + volume_count: ec2.volumes?.length ?? 0, + has_description: !!built.description, + }; + }); + }); + + this.registerRemoveSubcommand(removeCmd); + } + + addScreen(): AddScreenComponent { + return null; + } + + /** + * Validate that all required CLI flags are present, throwing ValidationError + * with an actionable message when they are not. + */ + private validateRequiredOptions(options: AddCapacityProviderOptions): void { + const missing: string[] = []; + if (!options.name) missing.push('--name'); + if (!options.subnets) missing.push('--subnets'); + if (!options.securityGroups) missing.push('--security-groups'); + if (!options.instanceTypes) missing.push('--instance-types'); + if (missing.length > 0) { + throw new ValidationError(`Missing required option(s): ${missing.join(', ')}`); + } + } + + /** + * Build a validated CapacityProvider config from CLI options. + * Zod validation (via CapacityProviderSchema.parse) rejects bad input here, + * at `add` time, rather than late at deploy/CFN time. + */ + private buildCapacityProvider(options: AddCapacityProviderOptions): CapacityProvider { + const volumes = (options.volume ?? []).map(entry => { + // Require exactly `name:sizeGiB`. Splitting without a segment count lets `data:20:gp3` + // silently drop the trailing segment, and Number() accepts hex/exponent (`0x14`, `2e1`) + // as 20 — so validate the size as literal digits instead of trusting Number(). + const segments = entry.split(':'); + const [volName, sizeRaw] = segments; + if (segments.length !== 2 || !volName || !sizeRaw || !/^[0-9]+$/.test(sizeRaw)) { + throw new ValidationError(`Invalid --volume "${entry}". Expected format name:sizeGiB (e.g. data:20).`); + } + const sizeGiB = Number(sizeRaw); + return { + ebsConfiguration: { + name: volName, + sizeGiB, + ...(options.volumeEncrypted !== undefined && { encrypted: options.volumeEncrypted }), + ...(options.volumeKmsKey && { kmsKeyId: options.volumeKmsKey }), + }, + }; + }); + + const lifecycle: { idleInstanceTimeout?: number; maxLifetime?: number } = {}; + if (options.idleInstanceTimeout !== undefined) { + lifecycle.idleInstanceTimeout = Number(options.idleInstanceTimeout); + } + if (options.maxLifetime !== undefined) { + lifecycle.maxLifetime = Number(options.maxLifetime); + } + + const candidate = { + name: options.name, + ...(options.description && { description: options.description }), + ...(options.operatorRoleArn && { operatorRoleArn: options.operatorRoleArn }), + computeConfiguration: { + ec2Configuration: { + launchTemplateSource: { + launchParameters: { + operatingSystem: options.os ?? 'LINUX_X86_64', + instanceRequirements: { + allowedInstanceTypes: splitList(options.instanceTypes), + }, + ...(options.instanceProfileArn && { instanceProfileArn: options.instanceProfileArn }), + }, + }, + vpcConfiguration: { + subnets: splitList(options.subnets), + securityGroups: splitList(options.securityGroups), + }, + ...(volumes.length > 0 && { volumes }), + ...(Object.keys(lifecycle).length > 0 && { lifecycleConfiguration: lifecycle }), + }, + }, + }; + + return CapacityProviderSchema.parse(candidate); + } +} diff --git a/src/cli/primitives/__tests__/CapacityProviderPrimitive.test.ts b/src/cli/primitives/__tests__/CapacityProviderPrimitive.test.ts new file mode 100644 index 000000000..09fcfb629 --- /dev/null +++ b/src/cli/primitives/__tests__/CapacityProviderPrimitive.test.ts @@ -0,0 +1,296 @@ +import type { AgentCoreProjectSpec, CapacityProvider } from '../../../schema'; +import type { AddCapacityProviderOptions } from '../CapacityProviderPrimitive'; +import { CapacityProviderPrimitive } from '../CapacityProviderPrimitive'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); + +vi.mock('../../../lib', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + }, + findConfigRoot: vi.fn().mockReturnValue(null), + }; +}); + +function makeProject(overrides: Partial = {}): AgentCoreProjectSpec { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + knowledgeBases: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], + harnesses: [], + datasets: [], + payments: [], + ...overrides, + }; +} + +const OPERATOR_ROLE_ARN = 'arn:aws:iam::123456789012:role/MyOperatorRole'; + +function baseOptions(overrides: Partial = {}): AddCapacityProviderOptions { + return { + name: 'myCp', + operatorRoleArn: OPERATOR_ROLE_ARN, + subnets: 'subnet-0123456789abcdef0', + securityGroups: 'sg-0123456789abcdef0', + instanceTypes: 'c6a.large', + ...overrides, + }; +} + +function makeCapacityProvider(name: string): CapacityProvider { + return { + name, + operatorRoleArn: OPERATOR_ROLE_ARN, + computeConfiguration: { + ec2Configuration: { + launchTemplateSource: { + launchParameters: { + operatingSystem: 'LINUX_X86_64', + instanceRequirements: { allowedInstanceTypes: ['c6a.large'] }, + }, + }, + vpcConfiguration: { subnets: ['subnet-0123456789abcdef0'], securityGroups: ['sg-0123456789abcdef0'] }, + }, + }, + }; +} + +const primitive = new CapacityProviderPrimitive(); + +describe('CapacityProviderPrimitive', () => { + afterEach(() => vi.clearAllMocks()); + + describe('add()', () => { + it('happy path — adds a capacity provider to spec and returns success', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.add(baseOptions()); + + expect(result.success).toBe(true); + expect(result).toHaveProperty('capacityProviderName', 'myCp'); + + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.capacityProviders).toHaveLength(1); + const cp = written.capacityProviders![0]!; + expect(cp.name).toBe('myCp'); + expect(cp.operatorRoleArn).toBe(OPERATOR_ROLE_ARN); + const ec2 = cp.computeConfiguration.ec2Configuration; + expect(ec2.launchTemplateSource.launchParameters.operatingSystem).toBe('LINUX_X86_64'); + expect(ec2.launchTemplateSource.launchParameters.instanceRequirements.allowedInstanceTypes).toEqual([ + 'c6a.large', + ]); + expect(ec2.vpcConfiguration.subnets).toEqual(['subnet-0123456789abcdef0']); + expect(ec2.vpcConfiguration.securityGroups).toEqual(['sg-0123456789abcdef0']); + }); + + it('omitting the operator role ARN succeeds — the role is created at deploy time', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.add(baseOptions({ operatorRoleArn: undefined })); + + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + const cp = written.capacityProviders![0]!; + // The field is omitted entirely (not written as undefined) so the construct auto-creates the role. + expect(cp).not.toHaveProperty('operatorRoleArn'); + }); + + it('parses multi-value flags, volumes, and lifecycle', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + await primitive.add( + baseOptions({ + os: 'LINUX_ARM64', + description: 'my cp', + instanceTypes: 'c7g.large, c7g.xlarge', + subnets: 'subnet-0123456789abcdef0,subnet-0fedcba9876543210', + securityGroups: 'sg-0123456789abcdef0', + volume: ['data:20'], + volumeEncrypted: true, + idleInstanceTimeout: '3600', + maxLifetime: '28800', + }) + ); + + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + const ec2 = written.capacityProviders![0]!.computeConfiguration.ec2Configuration; + expect(ec2.launchTemplateSource.launchParameters.operatingSystem).toBe('LINUX_ARM64'); + expect(ec2.launchTemplateSource.launchParameters.instanceRequirements.allowedInstanceTypes).toEqual([ + 'c7g.large', + 'c7g.xlarge', + ]); + expect(ec2.vpcConfiguration.subnets).toHaveLength(2); + expect(ec2.volumes).toEqual([{ ebsConfiguration: { name: 'data', sizeGiB: 20, encrypted: true } }]); + expect(ec2.lifecycleConfiguration).toEqual({ idleInstanceTimeout: 3600, maxLifetime: 28800 }); + expect(written.capacityProviders![0]!.description).toBe('my cp'); + }); + + it('duplicate name — returns error without writing', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject({ capacityProviders: [makeCapacityProvider('myCp')] })); + + const result = await primitive.add(baseOptions()); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('myCp'); + expect(result.error.message).toContain('already exists'); + } + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('rejects an operator role ARN with a malformed shape', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + const result = await primitive.add(baseOptions({ operatorRoleArn: 'not-an-arn' })); + + expect(result.success).toBe(false); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('accepts an operator role ARN without an account id (account segment optional)', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.add(baseOptions({ operatorRoleArn: 'arn:aws:iam:::role/MyRole' })); + + expect(result.success).toBe(true); + }); + + it('rejects an unsupported operating system value', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + const result = await primitive.add(baseOptions({ os: 'WINDOWS_X86_64' })); + + expect(result.success).toBe(false); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('rejects a malformed --volume value', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + const result = await primitive.add(baseOptions({ volume: ['data-no-size'] })); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('name:sizeGiB'); + } + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + // A plain split(':') + Number() silently accepted all of these before the fix: + // extra segments were dropped, and hex/exponent notation coerced to a number. + it.each([ + ['extra segment', 'data:20:gp3'], + ['hex size', 'data:0x14'], + ['exponent size', 'data:2e1'], + ['decimal size', 'data:20.5'], + ['empty size', 'data:'], + ])('rejects a --volume with %s (%s)', async (_label, value) => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + const result = await primitive.add(baseOptions({ volume: [value] })); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('name:sizeGiB'); + } + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + }); + + describe('remove()', () => { + it('removes a capacity provider from spec', async () => { + const project = makeProject({ + capacityProviders: [makeCapacityProvider('cpA'), makeCapacityProvider('cpB')], + }); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('cpA'); + + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.capacityProviders).toHaveLength(1); + expect(written.capacityProviders![0]!.name).toBe('cpB'); + }); + + it('drops the array to undefined when removing the last capacity provider', async () => { + const project = makeProject({ capacityProviders: [makeCapacityProvider('only')] }); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + await primitive.remove('only'); + + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.capacityProviders).toBeUndefined(); + }); + + it('non-existent name — returns error without writing', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + const result = await primitive.remove('missing'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('missing'); + expect(result.error.message).toContain('not found'); + } + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + }); + + describe('getRemovable()', () => { + it('returns capacity provider names from spec', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ capacityProviders: [makeCapacityProvider('alpha'), makeCapacityProvider('beta')] }) + ); + + expect(await primitive.getRemovable()).toEqual([{ name: 'alpha' }, { name: 'beta' }]); + }); + + it('returns empty array when none exist', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + expect(await primitive.getRemovable()).toEqual([]); + }); + }); + + describe('previewRemove()', () => { + it('returns summary and schema changes', async () => { + const project = makeProject({ capacityProviders: [makeCapacityProvider('previewCp')] }); + mockReadProjectSpec.mockResolvedValue(project); + + const preview = await primitive.previewRemove('previewCp'); + + expect(preview.summary[0]).toContain('previewCp'); + expect(preview.schemaChanges).toHaveLength(1); + expect(preview.schemaChanges[0]!.file).toBe('agentcore/agentcore.json'); + const after = preview.schemaChanges[0]!.after as AgentCoreProjectSpec; + expect(after.capacityProviders).toBeUndefined(); + }); + + it('throws when not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + await expect(primitive.previewRemove('missing')).rejects.toThrow('not found'); + }); + }); +}); diff --git a/src/cli/primitives/index.ts b/src/cli/primitives/index.ts index 7e711ec6c..105d40d8a 100644 --- a/src/cli/primitives/index.ts +++ b/src/cli/primitives/index.ts @@ -10,6 +10,8 @@ export { EvaluatorPrimitive } from './EvaluatorPrimitive'; export { OnlineEvalConfigPrimitive } from './OnlineEvalConfigPrimitive'; export { GatewayPrimitive } from './GatewayPrimitive'; export { GatewayTargetPrimitive } from './GatewayTargetPrimitive'; +export { CapacityProviderPrimitive } from './CapacityProviderPrimitive'; +export type { AddCapacityProviderOptions } from './CapacityProviderPrimitive'; export { RuntimeEndpointPrimitive } from './RuntimeEndpointPrimitive'; export type { AddRuntimeEndpointOptions, RemovableRuntimeEndpoint } from './RuntimeEndpointPrimitive'; export { @@ -23,6 +25,7 @@ export { onlineEvalConfigPrimitive, gatewayPrimitive, gatewayTargetPrimitive, + capacityProviderPrimitive, configBundlePrimitive, runtimeEndpointPrimitive, getPrimitive, diff --git a/src/cli/primitives/registry.ts b/src/cli/primitives/registry.ts index d578c4b6f..c6e26ea78 100644 --- a/src/cli/primitives/registry.ts +++ b/src/cli/primitives/registry.ts @@ -1,5 +1,6 @@ import { AgentPrimitive } from './AgentPrimitive'; import type { BasePrimitive } from './BasePrimitive'; +import { CapacityProviderPrimitive } from './CapacityProviderPrimitive'; import { ConfigBundlePrimitive } from './ConfigBundlePrimitive'; import { CredentialPrimitive } from './CredentialPrimitive'; import { DatasetPrimitive } from './DatasetPrimitive'; @@ -38,6 +39,7 @@ export const configBundlePrimitive = new ConfigBundlePrimitive(); export const runtimeEndpointPrimitive = new RuntimeEndpointPrimitive(); export const paymentManagerPrimitive = new PaymentManagerPrimitive(); export const paymentConnectorPrimitive = new PaymentConnectorPrimitive(); +export const capacityProviderPrimitive = new CapacityProviderPrimitive(); /** * All primitives in display order. @@ -60,6 +62,7 @@ export const ALL_PRIMITIVES: BasePrimitive[] = [ runtimeEndpointPrimitive, paymentManagerPrimitive, paymentConnectorPrimitive, + capacityProviderPrimitive, ]; /** diff --git a/src/cli/project.ts b/src/cli/project.ts index 3399042ab..d5886ab66 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -24,6 +24,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS abTests: [], datasets: [], payments: [], + capacityProviders: [], tags: { 'agentcore:created-by': 'agentcore-cli', 'agentcore:project-name': projectName, diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index 9e91b8357..ab091bd27 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -25,6 +25,7 @@ import { Mode, ModelProvider, NetworkMode, + OperatingSystem, OutboundAuthType, PolicyAttrSourceType, PolicyEngineMode, @@ -113,6 +114,15 @@ const AddPolicyAttrs = safeSchema({ const AddSkillAttrs = safeSchema({ skill_source_type: SkillSourceType }); +const AddCapacityProviderAttrs = safeSchema({ + operating_system: OperatingSystem, + instance_type_count: Count, + subnet_count: Count, + security_group_count: Count, + volume_count: Count, + has_description: z.boolean(), +}); + const DeployAttrs = safeSchema({ runtime_count: Count, harness_count: Count, @@ -244,6 +254,7 @@ export const COMMAND_SCHEMAS = { 'add.knowledge-base': AddKnowledgeBaseAttrs, 'add.payment-manager': NoAttrs, 'add.payment-connector': NoAttrs, + 'add.capacity-provider': AddCapacityProviderAttrs, 'add.skill': AddSkillAttrs, deploy: DeployAttrs, @@ -307,6 +318,7 @@ export const COMMAND_SCHEMAS = { 'dataset.remove-version': NoAttrs, 'remove.payment-manager': NoAttrs, 'remove.payment-connector': NoAttrs, + 'remove.capacity-provider': NoAttrs, 'remove.skill': NoAttrs, 'telemetry.disable': NoAttrs, 'telemetry.enable': NoAttrs, diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index 071df8956..2c4064390 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -55,6 +55,7 @@ export const FilterType = z.enum([ 'config-bundle', 'dataset', 'harness', + 'capacity-provider', 'none', ]); export const AgentEnvironment = z.enum(['harness', 'runtime']); @@ -89,6 +90,7 @@ export const MemoryType = z.enum(['none', 'shortterm', 'longandshortterm']); export const Mode = z.enum(['cli', 'tui']); export const ModelProvider = z.enum(['bedrock', 'anthropic', 'openai', 'gemini', 'lite_llm']); export const NetworkMode = z.enum(['public', 'vpc']); +export const OperatingSystem = z.enum(['linux_x86_64', 'linux_arm64']); export const OutboundAuthType = z.enum(['oauth', 'api-key', 'none']); export const PolicyEngineMode = z.enum(['log_only', 'enforce']); export const AgentProtocol = z.enum(['http', 'mcp', 'a2a', 'agui']); diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index f7f748035..5e4bd80f8 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -26,6 +26,7 @@ const ICONS = { 'runtime-endpoint': '◉', 'knowledge-base': '✚', payment: '₿', + 'capacity-provider': '▦', } as const; interface ResourceGraphProps { @@ -138,6 +139,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res const configBundles = project.configBundles ?? []; const datasets = project.datasets ?? []; const payments = project.payments ?? []; + const capacityProviders = project.capacityProviders ?? []; const harnesses = project.harnesses ?? []; // Build lookup map and collect pending-removal resources in a single pass @@ -437,6 +439,29 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res )} + {/* Capacity Providers */} + {capacityProviders.length > 0 && ( + + Capacity Providers + {capacityProviders.map(cp => { + const rsEntry = statusMap.get(`capacity-provider:${cp.name}`); + const localDetail = + cp.computeConfiguration.ec2Configuration.launchTemplateSource.launchParameters.operatingSystem; + return ( + + ); + })} + + )} + {/* Removed locally — still deployed in AWS, will be torn down on next deploy */} {pendingRemovals.length > 0 && ( diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index 48eaca8b8..1ccaf9182 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -9,6 +9,7 @@ import type { RemovablePolicyResource } from '../../primitives/PolicyPrimitive'; import type { RemovableRuntimeEndpoint } from '../../primitives/RuntimeEndpointPrimitive'; import { agentPrimitive, + capacityProviderPrimitive, configBundlePrimitive, credentialPrimitive, datasetPrimitive, @@ -162,6 +163,11 @@ export function useRemovableDatasets() { return { datasets, ...rest }; } +export function useRemovableCapacityProviders() { + const { items: capacityProviders, ...rest } = useRemovableResources(() => capacityProviderPrimitive.getRemovable()); + return { capacityProviders, ...rest }; +} + export function useRemovableKnowledgeBases() { const { items: knowledgeBases, ...rest } = useRemovableResources(() => knowledgeBasePrimitive.getRemovable()); return { knowledgeBases, ...rest }; @@ -297,6 +303,11 @@ export function useRemovalPreview() { [loadPreview] ); + const loadCapacityProviderPreview = useCallback( + (name: string) => loadPreview(n => capacityProviderPrimitive.previewRemove(n), name), + [loadPreview] + ); + const reset = useCallback(() => { setState({ isLoading: false, preview: null, error: null }); }, []); @@ -317,6 +328,7 @@ export function useRemovalPreview() { loadPolicyPreview, loadConfigBundlePreview, loadRuntimeEndpointPreview, + loadCapacityProviderPreview, reset, }; } @@ -396,6 +408,14 @@ export function useRemoveDataset() { ); } +export function useRemoveCapacityProvider() { + return useRemoveResource( + (name: string) => capacityProviderPrimitive.remove(name), + 'capacity-provider', + name => name + ); +} + export function useRemoveKnowledgeBase() { return useRemoveResource( (name: string) => knowledgeBasePrimitive.remove(name), diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index dab2f1433..c7b837f8e 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -7,6 +7,7 @@ import { AddAgentFlow } from '../agent/AddAgentFlow'; import type { AddAgentConfig } from '../agent/types'; import { FRAMEWORK_OPTIONS } from '../agent/types'; import { useAddAgent } from '../agent/useAddAgent'; +import { AddCapacityProviderFlow } from '../capacity-provider'; import { AddConfigBundleFlow } from '../config-bundle'; import { AddDatasetFlow } from '../dataset'; import { AddEvaluatorFlow } from '../evaluator'; @@ -45,6 +46,7 @@ type FlowState = | { name: 'runtime-endpoint-wizard' } | { name: 'payment-manager-wizard' } | { name: 'payment-connector-wizard' } + | { name: 'capacity-provider-wizard' } | { name: 'agent-create-success'; agentName: string; @@ -220,6 +222,8 @@ function getInitialFlowState(resource?: AddResourceType): FlowState { return { name: 'payment-manager-wizard' }; case 'payment-connector': return { name: 'payment-connector-wizard' }; + case 'capacity-provider': + return { name: 'capacity-provider-wizard' }; default: return { name: 'select' }; } @@ -296,6 +300,9 @@ export function AddFlow(props: AddFlowProps) { case 'payment-connector': setFlow({ name: 'payment-connector-wizard' }); break; + case 'capacity-provider': + setFlow({ name: 'capacity-provider-wizard' }); + break; } }, []); @@ -640,6 +647,19 @@ export function AddFlow(props: AddFlowProps) { ); } + // Capacity provider wizard + if (flow.name === 'capacity-provider-wizard') { + return ( + setFlow({ name: 'select' })} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + return ( { expect(lastFrame()).toContain('Payment Manager'); expect(lastFrame()).toContain('Payment Connector'); }); + + it('capacity provider is a top-level option', () => { + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Capacity Provider'); + }); }); diff --git a/src/cli/tui/screens/capacity-provider/AddCapacityProviderFlow.tsx b/src/cli/tui/screens/capacity-provider/AddCapacityProviderFlow.tsx new file mode 100644 index 000000000..2fc056e79 --- /dev/null +++ b/src/cli/tui/screens/capacity-provider/AddCapacityProviderFlow.tsx @@ -0,0 +1,106 @@ +import { capacityProviderPrimitive } from '../../../primitives/registry'; +import { ErrorPrompt } from '../../components'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import type { AddCapacityProviderConfig } from './AddCapacityProviderScreen'; +import { AddCapacityProviderScreen } from './AddCapacityProviderScreen'; +import { Box, Text } from 'ink'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'create-wizard' } + | { name: 'create-success'; capacityProviderName: string; os: string; instanceTypes: string; description?: string } + | { name: 'error'; message: string }; + +interface AddCapacityProviderFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function AddCapacityProviderFlow({ + isInteractive = true, + onExit, + onBack, + onDev, + onDeploy, +}: AddCapacityProviderFlowProps) { + const [flow, setFlow] = useState({ name: 'create-wizard' }); + const [existingNames, setExistingNames] = useState([]); + + useEffect(() => { + void capacityProviderPrimitive.getAllNames().then(setExistingNames); + }, []); + + // In non-interactive mode, exit after success + useEffect(() => { + if (!isInteractive && flow.name === 'create-success') { + onExit(); + } + }, [isInteractive, flow.name, onExit]); + + const handleCreateComplete = useCallback((config: AddCapacityProviderConfig) => { + void capacityProviderPrimitive + .add({ + name: config.name, + operatorRoleArn: config.operatorRoleArn, + description: config.description, + subnets: config.subnets, + securityGroups: config.securityGroups, + os: config.os, + instanceTypes: config.instanceTypes, + }) + .then(result => { + if (result.success) { + setFlow({ + name: 'create-success', + capacityProviderName: result.capacityProviderName, + os: config.os, + instanceTypes: config.instanceTypes, + description: config.description, + }); + return; + } + setFlow({ name: 'error', message: result.error.message }); + }); + }, []); + + if (flow.name === 'create-wizard') { + return ( + + ); + } + + if (flow.name === 'create-success') { + return ( + + OS: {flow.os} + Instance types: {flow.instanceTypes} + {flow.description && Desc: {flow.description}} + + } + onAddAnother={onBack} + onDev={onDev} + onDeploy={onDeploy} + onExit={onExit} + /> + ); + } + + return ( + { + setFlow({ name: 'create-wizard' }); + }} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx b/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx new file mode 100644 index 000000000..4b238ae5f --- /dev/null +++ b/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx @@ -0,0 +1,262 @@ +import type { OperatingSystem } from '../../../../schema'; +import { CapacityProviderNameSchema, isValidOperatorRoleArn } from '../../../../schema'; +import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { generateUniqueName } from '../../utils'; +import React, { useMemo, useState } from 'react'; + +const OS_OPTIONS: SelectableItem[] = [ + { id: 'LINUX_X86_64', title: 'Linux x86_64', description: 'Intel/AMD 64-bit Linux instances' }, + { id: 'LINUX_ARM64', title: 'Linux ARM64', description: 'Graviton/ARM 64-bit Linux instances' }, +]; + +export interface AddCapacityProviderConfig { + name: string; + operatorRoleArn?: string; + description?: string; + subnets: string; + securityGroups: string; + os: OperatingSystem; + instanceTypes: string; +} + +type Step = + | 'name' + | 'operator-role' + | 'subnets' + | 'security-groups' + | 'os' + | 'instance-types' + | 'description' + | 'confirm'; + +const STEP_LABELS: Record = { + name: 'Name', + 'operator-role': 'Operator Role', + subnets: 'Subnets', + 'security-groups': 'Security Groups', + os: 'OS', + 'instance-types': 'Instance Types', + description: 'Description', + confirm: 'Confirm', +}; + +const STEPS: Step[] = [ + 'name', + 'operator-role', + 'subnets', + 'security-groups', + 'os', + 'instance-types', + 'description', + 'confirm', +]; + +const SUBNET_PATTERN = /^subnet-[0-9a-zA-Z]{8,17}$/; +const SECURITY_GROUP_PATTERN = /^sg-[0-9a-zA-Z]{8,17}$/; + +function splitList(value: string): string[] { + return value + .split(',') + .map(s => s.trim()) + .filter(Boolean); +} + +interface AddCapacityProviderScreenProps { + onComplete: (config: AddCapacityProviderConfig) => void; + onExit: () => void; + existingNames: string[]; +} + +export function AddCapacityProviderScreen({ onComplete, onExit, existingNames }: AddCapacityProviderScreenProps) { + const [step, setStep] = useState('name'); + const [name, setName] = useState(''); + const [operatorRoleArn, setOperatorRoleArn] = useState(''); + const [subnets, setSubnets] = useState(''); + const [securityGroups, setSecurityGroups] = useState(''); + const [os, setOs] = useState('LINUX_X86_64'); + const [instanceTypes, setInstanceTypes] = useState(''); + const [description, setDescription] = useState(''); + + const isNameStep = step === 'name'; + const isOperatorRoleStep = step === 'operator-role'; + const isSubnetsStep = step === 'subnets'; + const isSecurityGroupsStep = step === 'security-groups'; + const isOsStep = step === 'os'; + const isInstanceTypesStep = step === 'instance-types'; + const isDescriptionStep = step === 'description'; + const isConfirmStep = step === 'confirm'; + + const osNav = useListNavigation({ + items: OS_OPTIONS, + isActive: isOsStep, + onSelect: (item: SelectableItem) => { + setOs(item.id as OperatingSystem); + setStep('instance-types'); + }, + onExit: () => setStep('security-groups'), + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => + onComplete({ + name, + operatorRoleArn: operatorRoleArn || undefined, + subnets, + securityGroups, + os, + instanceTypes, + description: description || undefined, + }), + onExit: () => setStep('description'), + isActive: isConfirmStep, + }); + + const helpText = isOsStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ; + + const confirmFields = useMemo( + () => [ + { label: 'Name', value: name }, + { label: 'Operator Role ARN', value: operatorRoleArn || '(auto-created)' }, + { label: 'Subnets', value: splitList(subnets).join(', ') }, + { label: 'Security Groups', value: splitList(securityGroups).join(', ') }, + { label: 'OS', value: os }, + { label: 'Instance Types', value: splitList(instanceTypes).join(', ') }, + ...(description ? [{ label: 'Description', value: description }] : []), + ], + [name, operatorRoleArn, subnets, securityGroups, os, instanceTypes, description] + ); + + return ( + + + {isNameStep && ( + { + setName(value); + setStep('operator-role'); + }} + onCancel={onExit} + schema={CapacityProviderNameSchema} + customValidation={value => !existingNames.includes(value) || 'Capacity provider name already exists'} + /> + )} + + {isOperatorRoleStep && ( + { + setOperatorRoleArn(value); + setStep('subnets'); + }} + onCancel={() => setStep('name')} + allowEmpty + customValidation={value => + value.trim() === '' || isValidOperatorRoleArn(value) || 'Must be a valid IAM role ARN' + } + /> + )} + + {isSubnetsStep && ( + { + setSubnets(value); + setStep('security-groups'); + }} + onCancel={() => setStep('operator-role')} + customValidation={value => { + const ids = splitList(value); + if (ids.length < 1 || ids.length > 16) return 'Provide 1-16 subnet IDs'; + return ids.every(id => SUBNET_PATTERN.test(id)) || 'Each must be a valid subnet ID (subnet-...)'; + }} + /> + )} + + {isSecurityGroupsStep && ( + { + setSecurityGroups(value); + setStep('os'); + }} + onCancel={() => setStep('subnets')} + customValidation={value => { + const ids = splitList(value); + if (ids.length < 1 || ids.length > 16) return 'Provide 1-16 security group IDs'; + return ( + ids.every(id => SECURITY_GROUP_PATTERN.test(id)) || 'Each must be a valid security group ID (sg-...)' + ); + }} + /> + )} + + {isOsStep && ( + + )} + + {isInstanceTypesStep && ( + { + setInstanceTypes(value); + setStep('description'); + }} + onCancel={() => setStep('os')} + customValidation={value => { + const types = splitList(value); + return (types.length >= 1 && types.length <= 30) || 'Provide 1-30 instance types'; + }} + /> + )} + + {isDescriptionStep && ( + { + setDescription(value); + setStep('confirm'); + }} + onCancel={() => setStep('instance-types')} + allowEmpty + /> + )} + + {isConfirmStep && } + + + ); +} diff --git a/src/cli/tui/screens/capacity-provider/index.ts b/src/cli/tui/screens/capacity-provider/index.ts new file mode 100644 index 000000000..f9c391a36 --- /dev/null +++ b/src/cli/tui/screens/capacity-provider/index.ts @@ -0,0 +1,3 @@ +export { AddCapacityProviderFlow } from './AddCapacityProviderFlow'; +export { AddCapacityProviderScreen } from './AddCapacityProviderScreen'; +export type { AddCapacityProviderConfig } from './AddCapacityProviderScreen'; diff --git a/src/cli/tui/screens/remove/RemoveCapacityProviderScreen.tsx b/src/cli/tui/screens/remove/RemoveCapacityProviderScreen.tsx new file mode 100644 index 000000000..dabb0d2f2 --- /dev/null +++ b/src/cli/tui/screens/remove/RemoveCapacityProviderScreen.tsx @@ -0,0 +1,30 @@ +import type { RemovableResource } from '../../../primitives/types'; +import { SelectScreen } from '../../components'; +import React from 'react'; + +interface RemoveCapacityProviderScreenProps { + capacityProviders: RemovableResource[]; + onSelect: (capacityProviderName: string) => void; + onExit: () => void; +} + +export function RemoveCapacityProviderScreen({ + capacityProviders, + onSelect, + onExit, +}: RemoveCapacityProviderScreenProps) { + const items = capacityProviders.map(cp => ({ + id: cp.name, + title: cp.name, + description: 'Capacity Provider', + })); + + return ( + onSelect(item.id)} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index 036bf6cb4..cfa48e439 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -4,6 +4,7 @@ import { harnessPrimitive, paymentManagerPrimitive } from '../../../primitives/r import { ErrorPrompt, Panel, Screen, SelectScreen } from '../../components'; import { useRemovableAgents, + useRemovableCapacityProviders, useRemovableConfigBundles, useRemovableDatasets, useRemovableEvaluators, @@ -20,6 +21,7 @@ import { useRemovableRuntimeEndpoints, useRemovalPreview, useRemoveAgent, + useRemoveCapacityProvider, useRemoveConfigBundle, useRemoveDataset, useRemoveEvaluator, @@ -36,6 +38,7 @@ import { } from '../../hooks/useRemove'; import { RemoveAgentScreen } from './RemoveAgentScreen'; import { RemoveAllScreen } from './RemoveAllScreen'; +import { RemoveCapacityProviderScreen } from './RemoveCapacityProviderScreen'; import { RemoveConfigBundleScreen } from './RemoveConfigBundleScreen'; import { RemoveConfirmScreen } from './RemoveConfirmScreen'; import { RemoveDatasetScreen } from './RemoveDatasetScreen'; @@ -75,6 +78,7 @@ type FlowState = | { name: 'select-config-bundle' } | { name: 'select-runtime-endpoint' } | { name: 'select-payment' } + | { name: 'select-capacity-provider' } | { name: 'confirm-agent'; agentName: string; preview: RemovalPreview } | { name: 'confirm-gateway'; gatewayName: string; preview: RemovalPreview } | { name: 'confirm-gateway-target'; tool: RemovableGatewayTarget; preview: RemovalPreview } @@ -89,6 +93,7 @@ type FlowState = | { name: 'confirm-config-bundle'; bundleName: string; preview: RemovalPreview } | { name: 'confirm-runtime-endpoint'; endpointName: string; preview: RemovalPreview } | { name: 'confirm-payment'; managerName: string; preview: RemovalPreview } + | { name: 'confirm-capacity-provider'; capacityProviderName: string; preview: RemovalPreview } | { name: 'loading'; message: string } | { name: 'harness-success'; harnessName: string; logFilePath?: string } | { name: 'agent-success'; agentName: string; logFilePath?: string } @@ -105,6 +110,7 @@ type FlowState = | { name: 'config-bundle-success'; bundleName: string; logFilePath?: string } | { name: 'runtime-endpoint-success'; endpointName: string; logFilePath?: string } | { name: 'payment-success'; managerName: string } + | { name: 'capacity-provider-success'; capacityProviderName: string; logFilePath?: string } | { name: 'remove-all' } | { name: 'error'; message: string }; @@ -136,6 +142,7 @@ interface RemoveFlowProps { | 'payment' | 'payment-manager' | 'payment-connector' + | 'capacity-provider' | 'all'; /** Initial resource name to auto-select (for CLI --name flag) */ initialResourceName?: string; @@ -186,6 +193,8 @@ export function RemoveFlow({ case 'payment-manager': case 'payment-connector': return { name: 'select-payment' }; + case 'capacity-provider': + return { name: 'select-capacity-provider' }; case 'all': return { name: 'remove-all' }; default: @@ -230,6 +239,11 @@ export function RemoveFlow({ refresh: refreshRuntimeEndpoints, } = useRemovableRuntimeEndpoints(); const { paymentManagers, isLoading: isLoadingPayments, refresh: refreshPayments } = useRemovablePaymentManagers(); + const { + capacityProviders, + isLoading: isLoadingCapacityProviders, + refresh: refreshCapacityProviders, + } = useRemovableCapacityProviders(); // Check if any data is still loading const isLoading = @@ -247,7 +261,8 @@ export function RemoveFlow({ isLoadingPolicies || isLoadingConfigBundles || isLoadingRuntimeEndpoints || - isLoadingPayments; + isLoadingPayments || + isLoadingCapacityProviders; // Preview hook const { @@ -265,6 +280,7 @@ export function RemoveFlow({ loadPolicyPreview, loadConfigBundlePreview, loadRuntimeEndpointPreview, + loadCapacityProviderPreview, reset: resetPreview, } = useRemovalPreview(); @@ -283,6 +299,7 @@ export function RemoveFlow({ const { remove: removePolicyOp, reset: resetRemovePolicy } = useRemovePolicy(); const { remove: removeConfigBundleOp, reset: resetRemoveConfigBundle } = useRemoveConfigBundle(); const { remove: removeRuntimeEndpointOp, reset: resetRemoveRuntimeEndpoint } = useRemoveRuntimeEndpoint(); + const { remove: removeCapacityProviderOp, reset: resetRemoveCapacityProvider } = useRemoveCapacityProvider(); // Track pending result state const pendingResultRef = useRef(null); @@ -319,6 +336,7 @@ export function RemoveFlow({ 'config-bundle-success', 'runtime-endpoint-success', 'payment-success', + 'capacity-provider-success', ]; if (successStates.includes(flow.name)) { onExit(); @@ -376,6 +394,9 @@ export function RemoveFlow({ case 'payment': setFlow({ name: 'select-payment' }); break; + case 'capacity-provider': + setFlow({ name: 'select-capacity-provider' }); + break; case 'all': setFlow({ name: 'remove-all' }); break; @@ -578,6 +599,28 @@ export function RemoveFlow({ [loadDatasetPreview, force, removeDatasetOp] ); + const handleSelectCapacityProvider = useCallback( + async (capacityProviderName: string) => { + const result = await loadCapacityProviderPreview(capacityProviderName); + if (result.ok) { + if (force) { + setFlow({ name: 'loading', message: `Removing capacity provider ${capacityProviderName}...` }); + const removeResult = await removeCapacityProviderOp(capacityProviderName, result.preview); + if (removeResult.success) { + setFlow({ name: 'capacity-provider-success', capacityProviderName }); + } else { + setFlow({ name: 'error', message: removeResult.error.message }); + } + } else { + setFlow({ name: 'confirm-capacity-provider', capacityProviderName, preview: result.preview }); + } + } else { + setFlow({ name: 'error', message: result.error }); + } + }, + [loadCapacityProviderPreview, force, removeCapacityProviderOp] + ); + const handleSelectKnowledgeBase = useCallback( async (knowledgeBaseName: string) => { const result = await loadKnowledgeBasePreview(knowledgeBaseName); @@ -790,6 +833,9 @@ export function RemoveFlow({ case 'payment-manager': void handleSelectPaymentManager(initialResourceName); break; + case 'capacity-provider': + void handleSelectCapacityProvider(initialResourceName); + break; } }, 0); }, [ @@ -809,6 +855,7 @@ export function RemoveFlow({ handleSelectConfigBundle, handleSelectRuntimeEndpoint, handleSelectPaymentManager, + handleSelectCapacityProvider, ]); // Confirm handlers - pass preview for logging @@ -940,6 +987,26 @@ export function RemoveFlow({ [removeDatasetOp] ); + const handleConfirmCapacityProvider = useCallback( + async (capacityProviderName: string, preview: RemovalPreview) => { + pendingResultRef.current = null; + setResultReady(false); + setFlow({ name: 'loading', message: `Removing capacity provider ${capacityProviderName}...` }); + const result = await removeCapacityProviderOp(capacityProviderName, preview); + if (result.success) { + pendingResultRef.current = { + name: 'capacity-provider-success', + capacityProviderName, + logFilePath: result.logFilePath, + }; + } else { + pendingResultRef.current = { name: 'error', message: result.error.message }; + } + setResultReady(true); + }, + [removeCapacityProviderOp] + ); + const handleConfirmKnowledgeBase = useCallback( async (knowledgeBaseName: string, preview: RemovalPreview) => { pendingResultRef.current = null; @@ -1056,6 +1123,7 @@ export function RemoveFlow({ resetRemovePolicy(); resetRemoveConfigBundle(); resetRemoveRuntimeEndpoint(); + resetRemoveCapacityProvider(); }, [ resetPreview, resetRemoveAgent, @@ -1072,6 +1140,7 @@ export function RemoveFlow({ resetRemovePolicy, resetRemoveConfigBundle, resetRemoveRuntimeEndpoint, + resetRemoveCapacityProvider, ]); const refreshAll = useCallback(async () => { @@ -1091,6 +1160,7 @@ export function RemoveFlow({ refreshConfigBundles(), refreshRuntimeEndpoints(), refreshPayments(), + refreshCapacityProviders(), ]); }, [ refreshAgents, @@ -1108,6 +1178,7 @@ export function RemoveFlow({ refreshConfigBundles, refreshRuntimeEndpoints, refreshPayments, + refreshCapacityProviders, ]); // Select screen - wait for data to load to avoid arrow position issues @@ -1134,6 +1205,7 @@ export function RemoveFlow({ datasetCount={datasets.length} knowledgeBaseCount={knowledgeBases.length} paymentCount={paymentManagers.length} + capacityProviderCount={capacityProviders.length} /> ); } @@ -1255,6 +1327,19 @@ export function RemoveFlow({ ); } + if (flow.name === 'select-capacity-provider') { + if (initialResourceName && isLoading) { + return null; + } + return ( + void handleSelectCapacityProvider(name)} + onExit={() => setFlow({ name: 'select' })} + /> + ); + } + if (flow.name === 'select-knowledge-base') { if (initialResourceName && isLoading) { return null; @@ -1480,6 +1565,17 @@ export function RemoveFlow({ ); } + if (flow.name === 'confirm-capacity-provider') { + return ( + void handleConfirmCapacityProvider(flow.capacityProviderName, flow.preview)} + onCancel={() => setFlow({ name: 'select-capacity-provider' })} + /> + ); + } + if (flow.name === 'confirm-knowledge-base') { return ( { + resetAll(); + void refreshAll().then(() => setFlow({ name: 'select' })); + }} + onExit={onExit} + /> + ); + } + if (flow.name === 'knowledge-base-success') { return ( { return REMOVE_RESOURCES.map(r => { @@ -194,6 +199,12 @@ export function RemoveScreen({ description = 'No payment managers to remove'; } break; + case 'capacity-provider': + if (capacityProviderCount === 0) { + disabled = true; + description = 'No capacity providers to remove'; + } + break; case 'all': // 'all' is always available break; @@ -217,6 +228,7 @@ export function RemoveScreen({ datasetCount, knowledgeBaseCount, paymentCount, + capacityProviderCount, ]); const isDisabled = (item: SelectableItem) => item.disabled ?? false; diff --git a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx index a849ab45e..0db65700b 100644 --- a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -27,6 +27,7 @@ describe('RemoveScreen', () => { datasetCount={0} knowledgeBaseCount={0} paymentCount={1} + capacityProviderCount={0} /> ); @@ -63,6 +64,7 @@ describe('RemoveScreen', () => { datasetCount={0} knowledgeBaseCount={0} paymentCount={0} + capacityProviderCount={0} /> ); @@ -95,6 +97,7 @@ describe('RemoveScreen', () => { datasetCount={0} knowledgeBaseCount={3} paymentCount={0} + capacityProviderCount={0} /> ); @@ -125,9 +128,65 @@ describe('RemoveScreen', () => { datasetCount={0} knowledgeBaseCount={0} paymentCount={0} + capacityProviderCount={0} /> ); expect(lastFrame()).toContain('No knowledge bases to remove'); }); + + it('Capacity Provider option enabled when capacityProviderCount > 0', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('Capacity Provider'); + expect(lastFrame()).not.toContain('No capacity providers to remove'); + }); + + it('Capacity Provider option disabled when capacityProviderCount = 0', () => { + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('No capacity providers to remove'); + }); }); diff --git a/src/cli/tui/screens/remove/useRemoveFlow.ts b/src/cli/tui/screens/remove/useRemoveFlow.ts index 11ecb5774..393040db4 100644 --- a/src/cli/tui/screens/remove/useRemoveFlow.ts +++ b/src/cli/tui/screens/remove/useRemoveFlow.ts @@ -96,6 +96,11 @@ export function useRemoveFlow({ force, dryRun }: RemoveFlowOptions): RemoveFlowS items.push(`${totalConnectors} payment connector${totalConnectors > 1 ? 's' : ''}`); } } + if (projectSpec.capacityProviders && projectSpec.capacityProviders.length > 0) { + items.push( + `${projectSpec.capacityProviders.length} capacity provider${projectSpec.capacityProviders.length > 1 ? 's' : ''}` + ); + } } catch { // Project exists but has issues - still allow reset items.push('AgentCore project (corrupted or incomplete)'); diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index c2915ea8d..0d259a9c0 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -26,6 +26,7 @@ interface AgentCoreProjectSpec { abTests: ABTest[]; // default [], unique by name harnesses: HarnessRegistryEntry[]; // default [], unique by name datasets?: Dataset[]; // unique by name + capacityProviders?: CapacityProvider[]; // unique by name payments?: PaymentManager[]; // unique by name } @@ -581,6 +582,57 @@ interface Dataset { kmsKeyArn?: string; } +// CAPACITY PROVIDER + +interface CapacityProvider { + name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ @min 1 @max 48; immutable after creation + description?: string; // @min 1 @max 4096; mutable (the only mutable field besides tags) + operatorRoleArn: string; // IAM role ARN @regex ^arn:aws(-[^:]+)?:iam::([0-9]{12})?:role/.+$ @max 2048; immutable + computeConfiguration: ComputeConfiguration; // immutable after creation + tags?: Tags; +} + +interface ComputeConfiguration { + ec2Configuration: Ec2Configuration; +} + +interface Ec2Configuration { + launchTemplateSource: { launchParameters: LaunchParameters }; + vpcConfiguration: CapacityProviderVpcConfiguration; + volumes?: VolumeConfiguration[]; // @max 5 + lifecycleConfiguration?: InstanceLifecycleConfiguration; + // long tail (rootVolume, etc.) accepted via passthrough and validated by CFN +} + +interface LaunchParameters { + operatingSystem: 'LINUX_X86_64' | 'LINUX_ARM64'; + instanceRequirements: { allowedInstanceTypes: string[] }; // @min 1 @max 30; each @min 1 @max 255 + instanceProfileArn?: string; // @regex ^arn:aws(-[^:]+)?:iam::[0-9]{12}:instance-profile/.+$ + // long tail (sshKeyName, monitoring, licenseSpecifications, capacityReservationSpecification, + // ephemeralVolumes, propagatedTags) accepted via passthrough and validated by CFN +} + +interface CapacityProviderVpcConfiguration { + subnets: string[]; // @min 1 @max 16; each @regex ^subnet-[0-9a-zA-Z]{8,17}$ + securityGroups: string[]; // @min 1 @max 16; each @regex ^sg-[0-9a-zA-Z]{8,17}$ +} + +interface VolumeConfiguration { + ebsConfiguration: { + name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_-]{0,47}$ @min 1 @max 48 + sizeGiB: number; // integer @min 1 @max 65536 + volumeType?: 'standard' | 'io1' | 'io2' | 'gp2' | 'sc1' | 'st1' | 'gp3'; + encrypted?: boolean; + kmsKeyId?: string; // KMS key ARN + // long tail (iops, throughput, snapshotId) accepted via passthrough + }; +} + +interface InstanceLifecycleConfiguration { + idleInstanceTimeout?: number; // integer seconds @min 60 @max 1209600 + maxLifetime?: number; // integer seconds @min 60 @max 1209600 +} + // PAYMENTS type PaymentProvider = 'CoinbaseCDP' | 'StripePrivy'; diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index c1eaa46fd..26539b063 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -10,6 +10,7 @@ import { isReservedProjectName } from '../constants'; import { AgentEnvSpecSchema } from './agent-env'; import { AgentCoreGatewaySchema, AgentCoreGatewayTargetSchema, AgentCoreMcpRuntimeToolSchema } from './mcp'; import { ABTestSchema } from './primitives/ab-test'; +import { CapacityProviderSchema } from './primitives/capacity-provider'; import { ConfigBundleSchema } from './primitives/config-bundle'; import { DatasetSchema } from './primitives/dataset'; import { @@ -91,6 +92,15 @@ export type { Tags } from './primitives/tags'; export { DatasetSchema }; export { DatasetNameSchema, DatasetSchemaTypeSchema } from './primitives/dataset'; export type { Dataset, DatasetSchemaType } from './primitives/dataset'; +export { CapacityProviderSchema }; +export { + CapacityProviderNameSchema, + CAPACITY_PROVIDER_OPERATOR_ROLE_ARN_PATTERN, + isValidOperatorRoleArn, + OperatingSystemSchema, + OperatorRoleArnSchema, +} from './primitives/capacity-provider'; +export type { CapacityProvider, OperatingSystem } from './primitives/capacity-provider'; export type { ABTestMode, TargetRef, GatewayFilter, PerVariantOnlineEvaluationConfig } from './primitives/ab-test'; export { ABTestModeSchema, TargetRefSchema, GatewayFilterSchema } from './primitives/ab-test'; export type { @@ -542,6 +552,17 @@ export const AgentCoreProjectSpecSchema = z } }), + capacityProviders: z + .array(CapacityProviderSchema) + .optional() + .superRefine((items, ctx) => { + if (!items) return; + uniqueBy( + (cp: { name: string }) => cp.name, + (name: string) => `Duplicate capacity provider name: ${name}` + )(items, ctx); + }), + httpGateways: z .array(z.unknown()) .max( diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 49d369da2..74b6a9e63 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -314,6 +314,17 @@ export const PaymentDeployedStateSchema = z.object({ export type PaymentDeployedState = z.infer; +// ============================================================================ +// Capacity Provider Deployed State +// ============================================================================ + +export const CapacityProviderDeployedStateSchema = z.object({ + capacityProviderId: z.string().min(1), + capacityProviderArn: z.string().min(1), +}); + +export type CapacityProviderDeployedState = z.infer; + // ============================================================================ // Deployed Resource State // ============================================================================ @@ -336,6 +347,7 @@ export const DeployedResourceStateSchema = z.object({ harnesses: z.record(z.string(), HarnessDeployedStateSchema).optional(), runtimeEndpoints: z.record(z.string(), RuntimeEndpointDeployedStateSchema).optional(), payments: z.record(z.string(), PaymentDeployedStateSchema).optional(), + capacityProviders: z.record(z.string(), CapacityProviderDeployedStateSchema).optional(), stackName: z.string().optional(), identityKmsKeyArn: z.string().optional(), deployHash: z.string().optional(), diff --git a/src/schema/schemas/primitives/__tests__/capacity-provider.test.ts b/src/schema/schemas/primitives/__tests__/capacity-provider.test.ts new file mode 100644 index 000000000..951d48939 --- /dev/null +++ b/src/schema/schemas/primitives/__tests__/capacity-provider.test.ts @@ -0,0 +1,98 @@ +import { CapacityProviderSchema, isValidOperatorRoleArn } from '../capacity-provider'; +import { describe, expect, it } from 'vitest'; + +const validCp = { + name: 'myCp', + operatorRoleArn: 'arn:aws:iam::123456789012:role/MyOperatorRole', + computeConfiguration: { + ec2Configuration: { + launchTemplateSource: { + launchParameters: { + operatingSystem: 'LINUX_X86_64', + instanceRequirements: { allowedInstanceTypes: ['c6a.large'] }, + }, + }, + vpcConfiguration: { subnets: ['subnet-0123456789abcdef0'], securityGroups: ['sg-0123456789abcdef0'] }, + }, + }, +}; + +describe('CapacityProviderSchema', () => { + it('accepts a minimal valid capacity provider', () => { + expect(CapacityProviderSchema.safeParse(validCp).success).toBe(true); + }); + + it('rejects a name that does not start with a letter', () => { + const result = CapacityProviderSchema.safeParse({ ...validCp, name: '1bad' }); + expect(result.success).toBe(false); + }); + + it('rejects a description over 4096 characters', () => { + const result = CapacityProviderSchema.safeParse({ ...validCp, description: 'x'.repeat(4097) }); + expect(result.success).toBe(false); + }); + + it('accepts a 4096-character description', () => { + const result = CapacityProviderSchema.safeParse({ ...validCp, description: 'x'.repeat(4096) }); + expect(result.success).toBe(true); + }); + + it('only accepts the two Linux operating systems', () => { + for (const os of ['LINUX_X86_64', 'LINUX_ARM64']) { + const cp = structuredClone(validCp); + cp.computeConfiguration.ec2Configuration.launchTemplateSource.launchParameters.operatingSystem = os; + expect(CapacityProviderSchema.safeParse(cp).success).toBe(true); + } + for (const os of ['MAC_ARM64', 'WINDOWS_X86_64']) { + const cp = structuredClone(validCp); + cp.computeConfiguration.ec2Configuration.launchTemplateSource.launchParameters.operatingSystem = os; + expect(CapacityProviderSchema.safeParse(cp).success).toBe(false); + } + }); + + it('requires 1-30 instance types', () => { + const empty = structuredClone(validCp); + empty.computeConfiguration.ec2Configuration.launchTemplateSource.launchParameters.instanceRequirements.allowedInstanceTypes = + []; + expect(CapacityProviderSchema.safeParse(empty).success).toBe(false); + }); + + it('rejects malformed subnet and security group IDs', () => { + const badSubnet = structuredClone(validCp); + badSubnet.computeConfiguration.ec2Configuration.vpcConfiguration.subnets = ['not-a-subnet']; + expect(CapacityProviderSchema.safeParse(badSubnet).success).toBe(false); + }); + + it('accepts up to 5 volumes but rejects 6', () => { + const mkVol = (i: number) => ({ ebsConfiguration: { name: `vol${i}`, sizeGiB: 10 } }); + const five = structuredClone(validCp) as Record & typeof validCp; + (five.computeConfiguration.ec2Configuration as Record).volumes = [0, 1, 2, 3, 4].map(mkVol); + expect(CapacityProviderSchema.safeParse(five).success).toBe(true); + + const six = structuredClone(validCp) as Record & typeof validCp; + (six.computeConfiguration.ec2Configuration as Record).volumes = [0, 1, 2, 3, 4, 5].map(mkVol); + expect(CapacityProviderSchema.safeParse(six).success).toBe(false); + }); + + it('passes through unknown launch parameters (long tail)', () => { + const cp = structuredClone(validCp) as Record & typeof validCp; + ( + cp.computeConfiguration.ec2Configuration.launchTemplateSource.launchParameters as Record + ).sshKeyName = 'my-key'; + const result = CapacityProviderSchema.safeParse(cp); + expect(result.success).toBe(true); + }); +}); + +describe('isValidOperatorRoleArn', () => { + it('accepts standard and account-less role ARNs', () => { + expect(isValidOperatorRoleArn('arn:aws:iam::123456789012:role/MyRole')).toBe(true); + expect(isValidOperatorRoleArn('arn:aws:iam:::role/MyRole')).toBe(true); + expect(isValidOperatorRoleArn('arn:aws-us-gov:iam::123456789012:role/MyRole')).toBe(true); + }); + + it('rejects non-role and malformed ARNs', () => { + expect(isValidOperatorRoleArn('arn:aws:iam::123456789012:user/Bob')).toBe(false); + expect(isValidOperatorRoleArn('not-an-arn')).toBe(false); + }); +}); diff --git a/src/schema/schemas/primitives/capacity-provider.ts b/src/schema/schemas/primitives/capacity-provider.ts new file mode 100644 index 000000000..0dbd434c4 --- /dev/null +++ b/src/schema/schemas/primitives/capacity-provider.ts @@ -0,0 +1,213 @@ +import { TagsSchema } from './tags'; +import { z } from 'zod'; + +// ============================================================================ +// Capacity Provider Types +// +// Models the AWS::BedrockAgentCore::CapacityProvider CFN resource. Known fields +// are typed and validated here so bad input is rejected at `add` time instead +// of failing late at deploy/CFN time; the long tail of launch parameters is +// accepted via `.passthrough()` and validated by CFN on deploy. +// +// NOTE: This schema is duplicated in @aws/agentcore-cdk +// (src/schema/schemas/primitives/capacity-provider.ts). Keep the two in sync. +// ============================================================================ + +/** + * Capacity provider name validation. + * Pattern: ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ (matches the CFN Name property). + */ +export const CapacityProviderNameSchema = z + .string() + .min(1, 'Capacity provider name is required') + .max(48) + .regex( + /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, + 'Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)' + ); + +// ============================================================================ +// Operator Role ARN Validation +// ============================================================================ + +/** + * Pattern for the capacity provider operator role ARN, matching the CFN + * resource contract exactly. The account segment is OPTIONAL — the service + * accepts role ARNs without an account id, so we must not force 12 digits. + */ +// eslint-disable-next-line security/detect-unsafe-regex -- anchored ARN pattern, no backtracking risk +export const CAPACITY_PROVIDER_OPERATOR_ROLE_ARN_PATTERN = /^arn:[^:]+:iam::([0-9]{12})?:role\/.+$/; + +export const OperatorRoleArnSchema = z + .string() + .min(1, 'Operator role ARN is required') + .max(2048) + .regex( + CAPACITY_PROVIDER_OPERATOR_ROLE_ARN_PATTERN, + 'Must be a valid IAM role ARN (e.g. arn::iam::123456789012:role/MyOperatorRole)' + ); + +export function isValidOperatorRoleArn(value: string): boolean { + return CAPACITY_PROVIDER_OPERATOR_ROLE_ARN_PATTERN.test(value); +} + +// ============================================================================ +// Operating System +// +// The CFN resource enum lists four values (LINUX_X86_64, LINUX_ARM64, +// MAC_ARM64, WINDOWS_X86_64), but the service API contract only supports the +// two Linux values today; the extra two leaked into the CFN autogen ahead of +// real support. The CLI follows the API and exposes only the Linux values. +// ============================================================================ + +export const OperatingSystemSchema = z.enum(['LINUX_X86_64', 'LINUX_ARM64']); +export type OperatingSystem = z.infer; + +// ============================================================================ +// VPC Configuration +// ============================================================================ + +export const VpcConfigurationSchema = z.object({ + subnets: z + .array(z.string().regex(/^subnet-[0-9a-zA-Z]{8,17}$/, 'Must be a valid subnet ID')) + .min(1, 'At least one subnet is required') + .max(16), + securityGroups: z + .array(z.string().regex(/^sg-[0-9a-zA-Z]{8,17}$/, 'Must be a valid security group ID')) + .min(1, 'At least one security group is required') + .max(16), +}); + +export type VpcConfiguration = z.infer; + +// ============================================================================ +// Instance Requirements +// ============================================================================ + +export const InstanceRequirementsSchema = z.object({ + allowedInstanceTypes: z.array(z.string().min(1).max(255)).min(1, 'At least one instance type is required').max(30), +}); + +export type InstanceRequirements = z.infer; + +// ============================================================================ +// Launch Parameters +// +// Known fields are typed; the long tail (sshKeyName, monitoring, +// licenseSpecifications, capacityReservationSpecification, ephemeralVolumes, +// propagatedTags) is accepted via `.passthrough()` and validated by CFN. +// ============================================================================ + +export const LaunchParametersSchema = z + .object({ + operatingSystem: OperatingSystemSchema, + instanceRequirements: InstanceRequirementsSchema, + instanceProfileArn: z + .string() + .regex(/^arn:[^:]+:iam::[0-9]{12}:instance-profile\/.+$/, 'Must be a valid IAM instance profile ARN') + .optional(), + }) + .passthrough(); + +export type LaunchParameters = z.infer; + +// ============================================================================ +// EBS Volume Configuration +// +// Known fields typed; EBS long-tail tuning (iops, throughput, snapshotId) is +// accepted via `.passthrough()`. +// ============================================================================ + +export const EbsVolumeConfigurationSchema = z + .object({ + name: z + .string() + .min(1) + .max(48) + .regex( + /^[a-zA-Z][a-zA-Z0-9_-]{0,47}$/, + 'Volume name must begin with a letter and contain only alphanumerics, underscores, and hyphens (max 48 chars)' + ), + sizeGiB: z.number().int().min(1).max(65536), + volumeType: z.enum(['standard', 'io1', 'io2', 'gp2', 'sc1', 'st1', 'gp3']).optional(), + encrypted: z.boolean().optional(), + kmsKeyId: z + .string() + .regex( + /^arn:[^:]+:kms:[a-z0-9-]+:[0-9]{12}:key\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/, + 'Must be a valid KMS key ARN' + ) + .optional(), + }) + .passthrough(); + +export type EbsVolumeConfiguration = z.infer; + +export const VolumeConfigurationSchema = z.object({ + ebsConfiguration: EbsVolumeConfigurationSchema, +}); + +export type VolumeConfiguration = z.infer; + +// ============================================================================ +// Instance Lifecycle Configuration +// ============================================================================ + +const LIFECYCLE_SECONDS_MIN = 60; +const LIFECYCLE_SECONDS_MAX = 1209600; + +export const InstanceLifecycleConfigurationSchema = z.object({ + idleInstanceTimeout: z.number().int().min(LIFECYCLE_SECONDS_MIN).max(LIFECYCLE_SECONDS_MAX).optional(), + maxLifetime: z.number().int().min(LIFECYCLE_SECONDS_MIN).max(LIFECYCLE_SECONDS_MAX).optional(), +}); + +export type InstanceLifecycleConfiguration = z.infer; + +// ============================================================================ +// Compute Configuration +// +// `rootVolume` is accepted via `.passthrough()` on ec2Configuration (long-tail, +// service-managed). +// ============================================================================ + +export const Ec2ConfigurationSchema = z + .object({ + launchTemplateSource: z.object({ + launchParameters: LaunchParametersSchema, + }), + vpcConfiguration: VpcConfigurationSchema, + volumes: z.array(VolumeConfigurationSchema).max(5).optional(), + lifecycleConfiguration: InstanceLifecycleConfigurationSchema.optional(), + }) + .passthrough(); + +export type Ec2Configuration = z.infer; + +export const ComputeConfigurationSchema = z.object({ + ec2Configuration: Ec2ConfigurationSchema, +}); + +export type ComputeConfiguration = z.infer; + +// ============================================================================ +// Capacity Provider Schema +// ============================================================================ + +export const CapacityProviderSchema = z.object({ + /** Capacity provider name (immutable after creation). */ + name: CapacityProviderNameSchema, + /** Optional description (max 4096 chars). The only mutable field besides tags. */ + description: z.string().min(1).max(4096).optional(), + /** + * ARN of the IAM role AgentCore assumes to manage the capacity provider (immutable). Optional: + * when omitted, an operator role with the required trust policy and managed permissions is + * created automatically at deploy time. + */ + operatorRoleArn: OperatorRoleArnSchema.optional(), + /** Compute resources for the capacity provider (immutable after creation). */ + computeConfiguration: ComputeConfigurationSchema, + /** Optional resource tags. */ + tags: TagsSchema.optional(), +}); + +export type CapacityProvider = z.infer; diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index 962989e54..3be2a5543 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -9,6 +9,35 @@ export type { export type { Dataset, DatasetSchemaType } from './dataset'; export { DatasetNameSchema, DatasetSchema, DatasetSchemaTypeSchema } from './dataset'; + +export type { + CapacityProvider, + ComputeConfiguration, + Ec2Configuration, + EbsVolumeConfiguration, + InstanceLifecycleConfiguration, + InstanceRequirements, + LaunchParameters, + OperatingSystem, + VolumeConfiguration, + VpcConfiguration, +} from './capacity-provider'; +export { + CAPACITY_PROVIDER_OPERATOR_ROLE_ARN_PATTERN, + CapacityProviderNameSchema, + CapacityProviderSchema, + ComputeConfigurationSchema, + Ec2ConfigurationSchema, + EbsVolumeConfigurationSchema, + InstanceLifecycleConfigurationSchema, + InstanceRequirementsSchema, + isValidOperatorRoleArn, + LaunchParametersSchema, + OperatingSystemSchema, + OperatorRoleArnSchema, + VolumeConfigurationSchema, + VpcConfigurationSchema, +} from './capacity-provider'; export { ABTestNameSchema, ABTestDescriptionSchema, From 9c6e3cca50e46c6baa53b50fcad9c308bc794fd1 Mon Sep 17 00:00:00 2001 From: Xin Xu Date: Sat, 22 Aug 2026 02:47:09 +0000 Subject: [PATCH 2/2] feat(capacity-provider): complete journeys 2-4, TUI wizard parity, and DevEx polish - J2/J3: attach a runtime to a capacity provider by name or ARN, and mount CP volumes - J4: `capacity-provider delete-session` data-plane command - TUI: full add-capacity-provider wizard (volumes, encryption/KMS, instance profile, lifecycle timeouts); capacity-provider attach in the agent create/template and BYO flows - paired `--volume-name`/`--volume-size` flags (replacing `--volume name:sizeGiB`) - referential-integrity guard: block removing a capacity provider still referenced by a runtime - drop `[preview]` from the capacity-provider add/remove menus Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + docs/commands.md | 37 +- .../add-agent-capacity-provider.test.ts | 194 ++++++++++ .../add-remove-capacity-provider.test.ts | 6 +- npm-shrinkwrap.json | 188 +++++---- package.json | 2 +- .../assets.snapshot.test.ts.snap | 1 + src/assets/agents/AGENTS.md | 1 + src/cli/aws/agentcore.ts | 44 +++ src/cli/cli.ts | 2 + src/cli/commands/add/types.ts | 3 + .../__tests__/action.test.ts | 103 +++++ src/cli/commands/capacity-provider/action.ts | 110 ++++++ .../commands/capacity-provider/command.tsx | 86 +++++ .../commands/capacity-provider/constants.ts | 27 ++ src/cli/commands/capacity-provider/index.ts | 1 + .../shared/__tests__/filesystem-utils.test.ts | 50 +++ src/cli/commands/shared/filesystem-utils.ts | 51 ++- .../generate/__tests__/schema-mapper.test.ts | 19 + .../agent/generate/schema-mapper.ts | 10 +- src/cli/primitives/AgentPrimitive.tsx | 66 ++++ .../primitives/CapacityProviderPrimitive.ts | 88 ++++- .../CapacityProviderPrimitive.test.ts | 163 +++++++- src/cli/telemetry/schemas/command-run.ts | 9 + src/cli/tui/copy.ts | 8 + src/cli/tui/screens/add/AddScreen.tsx | 2 +- src/cli/tui/screens/agent/AddAgentScreen.tsx | 232 ++++++++--- .../__tests__/buildCreateAgentConfig.test.ts | 84 ++++ .../agent/__tests__/computeByoSteps.test.ts | 33 ++ .../screens/agent/buildCreateAgentConfig.ts | 44 +++ src/cli/tui/screens/agent/types.ts | 12 + src/cli/tui/screens/agent/useAddAgent.ts | 15 +- .../AddCapacityProviderFlow.tsx | 20 +- .../AddCapacityProviderScreen.tsx | 365 +++++++++++++++++- .../__tests__/buildSteps.test.ts | 45 +++ .../tui/screens/generate/GenerateWizardUI.tsx | 85 +++- .../advanced-setting-options.test.ts | 17 + .../__tests__/useGenerateWizard.test.tsx | 46 +++ src/cli/tui/screens/generate/types.ts | 18 + .../tui/screens/generate/useGenerateWizard.ts | 80 ++++ src/cli/tui/screens/remove/RemoveScreen.tsx | 2 +- .../schemas/__tests__/agent-env.test.ts | 107 +++++ .../__tests__/agentcore-project.test.ts | 94 +++++ src/schema/schemas/agent-env.ts | 48 ++- src/schema/schemas/agentcore-project.ts | 43 ++- .../schemas/primitives/capacity-provider.ts | 62 +++ src/schema/schemas/primitives/index.ts | 6 + 47 files changed, 2517 insertions(+), 213 deletions(-) create mode 100644 integ-tests/add-agent-capacity-provider.test.ts create mode 100644 src/cli/commands/capacity-provider/__tests__/action.test.ts create mode 100644 src/cli/commands/capacity-provider/action.ts create mode 100644 src/cli/commands/capacity-provider/command.tsx create mode 100644 src/cli/commands/capacity-provider/constants.ts create mode 100644 src/cli/commands/capacity-provider/index.ts create mode 100644 src/cli/tui/screens/agent/__tests__/buildCreateAgentConfig.test.ts create mode 100644 src/cli/tui/screens/agent/buildCreateAgentConfig.ts create mode 100644 src/cli/tui/screens/capacity-provider/__tests__/buildSteps.test.ts create mode 100644 src/cli/tui/screens/generate/__tests__/advanced-setting-options.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6f8e92036..f9b5de870 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ These options are available on all commands: - `status` - Check deployment status - `dev` - Local development server (CodeZip: uvicorn with hot-reload; Container: Docker build + run with volume mount) - `invoke` - Invoke agents (local or deployed) +- `capacity-provider delete-session` - Delete (deprovision) a live capacity provider session (data-plane) - `run eval` - Run on-demand evaluation against agent sessions - `evals history` - View past eval run results - `fetch access` - Fetch access info for a deployed gateway or agent diff --git a/docs/commands.md b/docs/commands.md index f1ad57055..ceef9b2a0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -318,6 +318,9 @@ agentcore add agent \ | `--client-secret ` | OAuth client secret | | `--request-header-allowlist ` | Comma-separated list of inbound header names to forward to the agent. `X-*` names (e.g. `X-Api-Key`, `X-Custom-Signature`) pass through unchanged; bare names without an `X-` prefix are auto-prefixed with the legacy `X-Amzn-Bedrock-AgentCore-Runtime-Custom-` prefix for backward compatibility. | | `--session-storage-mount-path ` | Absolute mount path for session filesystem storage (e.g. `/mnt/session-storage`) | +| `--capacity-provider ` | Attach the runtime to a capacity provider (customer-managed EC2 compute). Accepts an in-project capacity-provider name or an external CP ARN. Mutually exclusive with `--network-mode VPC`. | +| `--cp-volume-name ` | Capacity provider volume name to mount (repeatable, paired by position with `--cp-volume-mount-path`). The name must match a volume defined on the attached capacity provider. | +| `--cp-volume-mount-path ` | Capacity provider volume mount path under `/mnt` (e.g. `/mnt/models`, repeatable, paired with `--cp-volume-name`) | | `--with-config-bundle` | Wire a config bundle into the generated agent template | | `--idle-timeout ` | Idle session timeout in seconds | | `--max-lifetime ` | Max instance lifetime in seconds | @@ -807,7 +810,7 @@ agentcore add capacity-provider \ --security-groups sg-0123456789abcdef0 \ --os LINUX_ARM64 \ --instance-types c7g.large,c7g.xlarge \ - --volume data:20 --volume-encrypted \ + --volume-name data --volume-size 20 --volume-encrypted \ --idle-instance-timeout 3600 \ --max-lifetime 28800 ``` @@ -821,7 +824,8 @@ agentcore add capacity-provider \ | `--security-groups ` | Comma-separated security group IDs, 1–16 (required) | | `--os ` | `LINUX_X86_64` (default) or `LINUX_ARM64` | | `--instance-types ` | Comma-separated allowed EC2 instance types, 1–30 (required) | -| `--volume ` | Named EBS volume as `name:sizeGiB` (repeatable, max 5) | +| `--volume-name ` | Named EBS volume name (repeatable, max 5; paired with `--volume-size`) | +| `--volume-size ` | EBS volume size in GiB (repeatable; paired with `--volume-name`) | | `--volume-encrypted` | Encrypt EBS volumes | | `--volume-kms-key ` | KMS key ARN for EBS volume encryption | | `--instance-profile-arn ` | IAM instance profile ARN for launched instances | @@ -898,6 +902,35 @@ agentcore dev call-tool --tool myTool --input '{"arg": "value"}' | `-b, --no-browser` | Use terminal TUI instead of web-based chat UI | | `--no-traces` | Disable local OTEL trace collection | +### capacity-provider delete-session + +Delete (deprovision) a single live capacity provider session. This is a data-plane operation: it terminates the +session's EC2 instance and **permanently deletes any persistent EBS volumes** attached to the session (data loss). The +operation is asynchronous — it returns immediately with status `Deprovisioning`. You get the session id from `invoke` +(it echoes the session it used); there is no list-sessions API. + +```bash +# By in-project capacity provider name (resolved to its id from deployed state) +agentcore capacity-provider delete-session --capacity-provider my-pool --session-id + +# By capacity provider id, without a project (the data-plane API is keyed on the id) +agentcore capacity-provider delete-session \ + --capacity-provider my-pool-a1b2c3d4e5 --session-id --region us-west-2 --yes + +# By ARN (the id is extracted from it), without a project (region auto-detected from the ARN) +agentcore capacity-provider delete-session \ + --capacity-provider arn:aws:bedrock-agentcore:us-west-2:123456789012:capacity-provider/my-pool-a1b2c3d4e5 \ + --session-id --yes +``` + +| Option | Description | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--capacity-provider ` | Capacity provider: an in-project name (resolved to its id via deployed state), a capacity provider id, or an ARN (id extracted from it). The API is keyed on the id. (**required**) | +| `--session-id ` | Session id to delete (**required**) | +| `--region ` | AWS region (auto-detected from the ARN / project otherwise; required with a bare id outside a project unless the environment sets one) | +| `--yes` | Skip the destructive confirmation prompt (required for non-interactive use) | +| `--json` | JSON output | + ### invoke Invoke a deployed agent endpoint. diff --git a/integ-tests/add-agent-capacity-provider.test.ts b/integ-tests/add-agent-capacity-provider.test.ts new file mode 100644 index 000000000..1812b7aca --- /dev/null +++ b/integ-tests/add-agent-capacity-provider.test.ts @@ -0,0 +1,194 @@ +import { createTestProject, readProjectConfig, runCLI } from '../src/test-utils/index.js'; +import type { TestProject } from '../src/test-utils/index.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const OPERATOR_ROLE_ARN = 'arn:aws:iam::123456789012:role/MyOperatorRole'; + +describe('integration: attach capacity provider to an agent (J2/J3) + delete-session (J4)', () => { + let project: TestProject; + const cpName = `IntegAttachCp${Date.now().toString().slice(-6)}`; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + // A capacity provider with a named volume for the runtime to mount (J3). + const cp = await runCLI( + [ + 'add', + 'capacity-provider', + '--name', + cpName, + '--operator-role-arn', + OPERATOR_ROLE_ARN, + '--subnets', + 'subnet-0123456789abcdef0', + '--security-groups', + 'sg-0123456789abcdef0', + '--os', + 'LINUX_X86_64', + '--instance-types', + 'c6a.large', + '--volume-name', + 'model-weights', + '--volume-size', + '100', + '--json', + ], + project.projectPath + ); + expect(cp.exitCode, `stdout: ${cp.stdout}, stderr: ${cp.stderr}`).toBe(0); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + it('attaches a BYO agent to a sibling capacity provider by name and mounts a CP volume', async () => { + const agentName = 'CpAttachAgent'; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + agentName, + '--type', + 'byo', + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--code-location', + `apps/${agentName}`, + '--capacity-provider', + cpName, + '--cp-volume-name', + 'model-weights', + '--cp-volume-mount-path', + '/mnt/models', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + expect(JSON.parse(result.stdout).success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const agent = config.runtimes.find(a => a.name === agentName); + expect(agent, `Agent "${agentName}" should be in config`).toBeTruthy(); + expect(agent!.capacityProviderConfiguration).toEqual({ capacityProviderName: cpName }); + expect(agent!.filesystemConfigurations).toContainEqual({ + capacityProviderVolume: { volumeName: 'model-weights', mountPath: '/mnt/models' }, + }); + }); + + it('attaches a BYO agent to an external capacity provider by ARN', async () => { + const agentName = 'CpArnAgent'; + const arn = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:capacity-provider/ext_pool-a1b2c3d4e5'; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + agentName, + '--type', + 'byo', + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--code-location', + `apps/${agentName}`, + '--capacity-provider', + arn, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const config = await readProjectConfig(project.projectPath); + const agent = config.runtimes.find(a => a.name === agentName); + expect(agent!.capacityProviderConfiguration).toEqual({ capacityProviderArn: arn }); + }); + + it('rejects a CP volume mount without a capacity provider attachment', async () => { + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + 'CpVolNoAttach', + '--type', + 'byo', + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--code-location', + 'apps/CpVolNoAttach', + '--cp-volume-name', + 'model-weights', + '--cp-volume-mount-path', + '/mnt/models', + '--json', + ], + project.projectPath + ); + expect(result.exitCode).not.toBe(0); + }); + + it('rejects attaching a capacity provider together with VPC networking', async () => { + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + 'CpVpcAgent', + '--type', + 'byo', + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--code-location', + 'apps/CpVpcAgent', + '--capacity-provider', + cpName, + '--network-mode', + 'VPC', + '--subnets', + 'subnet-0123456789abcdef0', + '--security-groups', + 'sg-0123456789abcdef0', + '--json', + ], + project.projectPath + ); + expect(result.exitCode).not.toBe(0); + }); + + it('delete-session rejects an invalid session id', async () => { + const result = await runCLI( + [ + 'capacity-provider', + 'delete-session', + '--capacity-provider', + 'arn:aws:bedrock-agentcore:us-west-2:123456789012:capacity-provider/x_pool-a1b2c3d4e5', + '--session-id', + 'bad session id', + '--yes', + '--json', + ], + project.projectPath + ); + expect(result.exitCode).not.toBe(0); + }); +}); diff --git a/integ-tests/add-remove-capacity-provider.test.ts b/integ-tests/add-remove-capacity-provider.test.ts index df07a429f..65e14d029 100644 --- a/integ-tests/add-remove-capacity-provider.test.ts +++ b/integ-tests/add-remove-capacity-provider.test.ts @@ -78,8 +78,10 @@ describe('integration: add and remove capacity providers', () => { 'LINUX_ARM64', '--instance-types', 'c7g.large,c7g.xlarge', - '--volume', - 'data:20', + '--volume-name', + 'data', + '--volume-size', + '20', '--volume-encrypted', '--idle-instance-timeout', '3600', diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 51381483d..17df354d9 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.26.0", + "version": "0.27.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.26.0", + "version": "0.27.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -15,7 +15,7 @@ "@aws-sdk/client-application-signals": "^3.1003.0", "@aws-sdk/client-bedrock": "^3.1012.0", "@aws-sdk/client-bedrock-agent": "^3.1012.0", - "@aws-sdk/client-bedrock-agentcore": "^3.1061.0", + "@aws-sdk/client-bedrock-agentcore": "^3.1114.0", "@aws-sdk/client-bedrock-agentcore-control": "^3.1099.0", "@aws-sdk/client-bedrock-runtime": "^3.893.0", "@aws-sdk/client-cloudformation": "^3.893.0", @@ -839,20 +839,18 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore": { - "version": "3.1066.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1066.0.tgz", - "integrity": "sha512-7wpBOVhp5zWi2Ngd726MJY5wN5QSX9hBDDGdc2kgZaOoW15iiA87atCbPGwjZTspTIBJH73737jgRIhT/O484A==", + "version": "3.1114.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1114.0.tgz", + "integrity": "sha512-iyZ68wmdDL2eA0cDxsdu0UzNCliukZf19vUaeFazz4XrE2dNxkPFYQ6RMyLlLYd1RzG7NRnrTCieblJq+tVAyg==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.20", - "@aws-sdk/credential-provider-node": "^3.972.55", - "@aws-sdk/types": "^3.973.12", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-node": "^3.972.80", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2102,13 +2100,13 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", - "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", + "version": "3.977.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz", + "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@aws-sdk/xml-builder": "^3.972.37", + "@aws-sdk/types": "^3.974.4", + "@aws-sdk/xml-builder": "^3.972.39", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", @@ -2159,13 +2157,13 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.67", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", - "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz", + "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -2175,13 +2173,13 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.69", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", - "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz", + "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -2193,20 +2191,20 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", - "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz", + "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/credential-provider-env": "^3.972.67", - "@aws-sdk/credential-provider-http": "^3.972.69", - "@aws-sdk/credential-provider-login": "^3.972.74", - "@aws-sdk/credential-provider-process": "^3.972.67", - "@aws-sdk/credential-provider-sso": "^3.973.11", - "@aws-sdk/credential-provider-web-identity": "^3.972.73", - "@aws-sdk/nested-clients": "^3.997.41", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-login": "^3.972.76", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -2217,14 +2215,14 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.74", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", - "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz", + "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/nested-clients": "^3.997.41", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -2234,18 +2232,18 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.78", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", - "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz", + "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.67", - "@aws-sdk/credential-provider-http": "^3.972.69", - "@aws-sdk/credential-provider-ini": "^3.973.12", - "@aws-sdk/credential-provider-process": "^3.972.67", - "@aws-sdk/credential-provider-sso": "^3.973.11", - "@aws-sdk/credential-provider-web-identity": "^3.972.73", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-ini": "^3.973.14", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -2256,13 +2254,13 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.67", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", - "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz", + "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -2272,15 +2270,15 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", - "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz", + "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/nested-clients": "^3.997.41", - "@aws-sdk/token-providers": "3.1103.0", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/token-providers": "3.1111.0", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -2290,14 +2288,14 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.73", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", - "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz", + "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/nested-clients": "^3.997.41", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -2639,14 +2637,14 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", - "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", + "version": "3.997.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz", + "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/signature-v4-multi-region": "^3.996.43", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -2674,12 +2672,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", - "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "version": "3.996.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz", + "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -2689,14 +2687,14 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1103.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", - "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz", + "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.6", - "@aws-sdk/nested-clients": "^3.997.41", - "@aws-sdk/types": "^3.974.2", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -2706,9 +2704,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", - "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "version": "3.974.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz", + "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", diff --git a/package.json b/package.json index 36c821c1f..64663e04b 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@aws-sdk/client-application-signals": "^3.1003.0", "@aws-sdk/client-bedrock": "^3.1012.0", "@aws-sdk/client-bedrock-agent": "^3.1012.0", - "@aws-sdk/client-bedrock-agentcore": "^3.1061.0", + "@aws-sdk/client-bedrock-agentcore": "^3.1114.0", "@aws-sdk/client-bedrock-agentcore-control": "^3.1099.0", "@aws-sdk/client-bedrock-runtime": "^3.893.0", "@aws-sdk/client-cloudformation": "^3.893.0", diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index a50303b57..803915535 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -7613,6 +7613,7 @@ Run \`agentcore --help\` or \`agentcore --help\` for full flags. Commo | \`agentcore dev\` | Run agent locally with hot-reload | | \`agentcore deploy\` | Deploy to AWS | | \`agentcore invoke\` | Invoke agent (local or deployed) | +| \`agentcore capacity-provider delete-session\` | Delete (deprovision) a live capacity provider session | | \`agentcore status\` | Show deployment status | | \`agentcore validate\` | Validate configuration | | \`agentcore package\` | Package agent artifacts | diff --git a/src/assets/agents/AGENTS.md b/src/assets/agents/AGENTS.md index 8a020757e..f781a329d 100644 --- a/src/assets/agents/AGENTS.md +++ b/src/assets/agents/AGENTS.md @@ -158,6 +158,7 @@ Run `agentcore --help` or `agentcore --help` for full flags. Commonly | `agentcore dev` | Run agent locally with hot-reload | | `agentcore deploy` | Deploy to AWS | | `agentcore invoke` | Invoke agent (local or deployed) | +| `agentcore capacity-provider delete-session` | Delete (deprovision) a live capacity provider session | | `agentcore status` | Show deployment status | | `agentcore validate` | Validate configuration | | `agentcore package` | Package agent artifacts | diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index 49b0cfb60..d3632e04d 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -5,6 +5,7 @@ import { serviceEndpoint } from './partition'; import { dataPlaneEndpoint } from './stage-endpoint'; import { BedrockAgentCoreClient, + DeleteCapacityProviderSessionCommand, EvaluateCommand, InvokeAgentRuntimeCommand, InvokeAgentRuntimeCommandCommand, @@ -1161,6 +1162,49 @@ export async function stopRuntimeSession(options: StopRuntimeSessionOptions): Pr }; } +// --------------------------------------------------------------------------- +// Delete Capacity Provider Session (data-plane): deprovision a live CP session +// --------------------------------------------------------------------------- + +export interface DeleteCapacityProviderSessionOptions { + region: string; + /** Capacity provider id (the `{cpName}-{suffix}` id, NOT the ARN). */ + capacityProviderId: string; + /** Session id to delete. */ + sessionId: string; +} + +export interface DeleteCapacityProviderSessionResult { + capacityProviderArn?: string; + sessionId?: string; + /** Session status after the (async) delete — typically `Deprovisioning`. */ + status?: string; +} + +/** + * Delete (deprovision) a single capacity provider session. Idempotent + asynchronous: the service + * returns 202 with status `Deprovisioning` while it terminates the EC2 instance and deletes any + * persistent EBS volumes attached to the session in the background (data loss). + */ +export async function deleteCapacityProviderSession( + options: DeleteCapacityProviderSessionOptions +): Promise { + const client = createAgentCoreClient(options.region); + + const command = new DeleteCapacityProviderSessionCommand({ + capacityProviderId: options.capacityProviderId, + sessionId: options.sessionId, + }); + + const response = await client.send(command); + + return { + capacityProviderArn: response.capacityProviderArn, + sessionId: response.sessionId, + status: response.status, + }; +} + // --------------------------------------------------------------------------- // Execute Bash: Run shell commands in runtime containers // --------------------------------------------------------------------------- diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 30ec862d7..042188bcb 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -4,6 +4,7 @@ import { registerAddSkill } from './commands/add/skill-command'; import { registerAddTool } from './commands/add/tool-command'; import { registerArchive } from './commands/archive'; import { registerBatchEvaluations } from './commands/batch-evaluations'; +import { registerCapacityProvider } from './commands/capacity-provider'; import { registerConfig } from './commands/config'; import { registerConfigBundle } from './commands/config-bundle'; import { registerCreate } from './commands/create'; @@ -107,6 +108,7 @@ export function registerCommands(program: Command) { registerPause(program); registerView(program); registerBatchEvaluations(program); + registerCapacityProvider(program); const removeCmd = registerRemove(program); registerResume(program); registerRun(program); diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index afa34c2b3..e70cd63b2 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -42,6 +42,9 @@ export interface AddAgentOptions extends VpcOptions { efsMountPath?: string[]; s3AccessPointArn?: string[]; s3MountPath?: string[]; + capacityProvider?: string; + cpVolumeName?: string[]; + cpVolumeMountPath?: string[]; withConfigBundle?: boolean; json?: boolean; } diff --git a/src/cli/commands/capacity-provider/__tests__/action.test.ts b/src/cli/commands/capacity-provider/__tests__/action.test.ts new file mode 100644 index 000000000..76feefd24 --- /dev/null +++ b/src/cli/commands/capacity-provider/__tests__/action.test.ts @@ -0,0 +1,103 @@ +import { resolveDeleteTarget } from '../action'; +import { isValidCapacityProviderSessionId } from '../constants'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const readDeployedState = vi.fn(); +const resolveAWSDeploymentTargets = vi.fn().mockResolvedValue([]); +const findConfigRoot = vi.fn(); + +vi.mock('../../../../lib', () => ({ + findConfigRoot: () => findConfigRoot(), + ConfigIO: class { + readDeployedState = readDeployedState; + resolveAWSDeploymentTargets = resolveAWSDeploymentTargets; + }, +})); + +vi.mock('../../../aws/region', () => ({ detectRegion: vi.fn().mockResolvedValue({ region: 'us-east-1' }) })); + +const CP_ARN = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:capacity-provider/my_pool-a1b2c3d4e5'; + +describe('isValidCapacityProviderSessionId', () => { + it('accepts valid session ids', () => { + expect(isValidCapacityProviderSessionId('sess-abc123')).toBe(true); + expect(isValidCapacityProviderSessionId('A')).toBe(true); + expect(isValidCapacityProviderSessionId('abc_def-123')).toBe(true); + }); + + it('rejects invalid session ids', () => { + expect(isValidCapacityProviderSessionId('')).toBe(false); + expect(isValidCapacityProviderSessionId('-leading-hyphen')).toBe(false); + expect(isValidCapacityProviderSessionId('has space')).toBe(false); + expect(isValidCapacityProviderSessionId('a'.repeat(101))).toBe(false); + }); +}); + +describe('resolveDeleteTarget', () => { + beforeEach(() => vi.clearAllMocks()); + + it('resolves an external CP by ARN (id + region from the ARN, no project needed)', async () => { + const target = await resolveDeleteTarget({ capacityProvider: CP_ARN, sessionId: 'sess-1' }); + expect(target).toEqual({ + capacityProviderId: 'my_pool-a1b2c3d4e5', + capacityProviderArn: CP_ARN, + region: 'us-west-2', + targetByArn: true, + displayName: CP_ARN, + }); + expect(findConfigRoot).not.toHaveBeenCalled(); + }); + + it('honors an explicit --region override for the ARN path', async () => { + const target = await resolveDeleteTarget({ capacityProvider: CP_ARN, sessionId: 'sess-1', region: 'eu-west-1' }); + expect(target.region).toBe('eu-west-1'); + }); + + it('accepts a literal capacity provider id directly (no project; the data-plane API path param)', async () => { + const target = await resolveDeleteTarget({ + capacityProvider: 'my_pool-a1b2c3d4e5', + sessionId: 'sess-1', + region: 'us-west-2', + }); + expect(target).toEqual({ + capacityProviderId: 'my_pool-a1b2c3d4e5', + region: 'us-west-2', + targetByArn: false, + displayName: 'my_pool-a1b2c3d4e5', + }); + expect(findConfigRoot).not.toHaveBeenCalled(); + }); + + it('resolves an in-project CP by name from deployed-state (across targets)', async () => { + findConfigRoot.mockReturnValue('/proj/agentcore'); + readDeployedState.mockResolvedValue({ + targets: { + prod: { + resources: { + capacityProviders: { my_pool: { capacityProviderId: 'my_pool-zzz', capacityProviderArn: CP_ARN } }, + }, + }, + }, + }); + const target = await resolveDeleteTarget({ capacityProvider: 'my_pool', sessionId: 'sess-1' }); + expect(target.capacityProviderId).toBe('my_pool-zzz'); + expect(target.region).toBe('us-west-2'); // from the CP ARN + expect(target.targetByArn).toBe(false); + expect(target.displayName).toBe('my_pool'); + }); + + it('throws when the named CP is not deployed', async () => { + findConfigRoot.mockReturnValue('/proj/agentcore'); + readDeployedState.mockResolvedValue({ targets: { prod: { resources: { capacityProviders: {} } } } }); + await expect(resolveDeleteTarget({ capacityProvider: 'ghost', sessionId: 'sess-1' })).rejects.toThrow( + /not deployed/ + ); + }); + + it('throws when referencing a name outside a project', async () => { + findConfigRoot.mockReturnValue(undefined); + await expect(resolveDeleteTarget({ capacityProvider: 'my_pool', sessionId: 'sess-1' })).rejects.toThrow( + /No AgentCore project/ + ); + }); +}); diff --git a/src/cli/commands/capacity-provider/action.ts b/src/cli/commands/capacity-provider/action.ts new file mode 100644 index 000000000..043e4319e --- /dev/null +++ b/src/cli/commands/capacity-provider/action.ts @@ -0,0 +1,110 @@ +import { ConfigIO, findConfigRoot } from '../../../lib'; +import { isCapacityProviderArn } from '../../../schema'; +import { deleteCapacityProviderSession as deleteCapacityProviderSessionApi } from '../../aws/agentcore'; +import { regionFromArn } from '../../aws/arn'; +import { detectRegion } from '../../aws/region'; +import { isCapacityProviderId } from './constants'; + +export interface DeleteCapacityProviderSessionActionOptions { + /** Capacity provider name (in-project) or ARN (external). */ + capacityProvider: string; + /** Session id to delete. */ + sessionId: string; + /** AWS region override (auto-detected from ARN / project / environment otherwise). */ + region?: string; +} + +export interface ResolvedDeleteTarget { + capacityProviderId: string; + capacityProviderArn?: string; + region: string; + /** True when targeted by raw ARN (no project needed). */ + targetByArn: boolean; + /** Human-friendly label for prompts/output (name or ARN). */ + displayName: string; +} + +/** Extract the capacity provider id (the last path segment) from a CP ARN. */ +function capacityProviderIdFromArn(arn: string): string { + return arn.split('/').pop() ?? arn; +} + +/** + * Resolve the capacity provider id + region for a delete-session call, from either a raw ARN + * (no project required) or an in-project capacity-provider name (resolved via deployed-state). + * Throws with an actionable message when the name is not found / not deployed. + */ +export async function resolveDeleteTarget( + options: DeleteCapacityProviderSessionActionOptions +): Promise { + // (1) External CP by ARN — extract the id the API requires (the last ARN path segment). + if (isCapacityProviderArn(options.capacityProvider)) { + const arn = options.capacityProvider; + const region = options.region ?? regionFromArn(arn) ?? (await detectRegion()).region; + return { + capacityProviderId: capacityProviderIdFromArn(arn), + capacityProviderArn: arn, + region, + targetByArn: true, + displayName: arn, + }; + } + + // (2) A literal capacity provider id (`{name}-{10 alnum}`) — the data-plane API path parameter. + // No project needed; region comes from --region or the environment. + if (isCapacityProviderId(options.capacityProvider)) { + const region = options.region ?? (await detectRegion()).region; + return { + capacityProviderId: options.capacityProvider, + region, + targetByArn: false, + displayName: options.capacityProvider, + }; + } + + // (3) An in-project capacity provider name — resolve to its id via deployed-state. + const name = options.capacityProvider; + const configRoot = findConfigRoot(); + if (!configRoot) { + throw new Error( + `No AgentCore project found. Run inside a project to reference "${name}" by name, or pass the capacity provider id or ARN.` + ); + } + const configIO = new ConfigIO({ baseDir: configRoot }); + const deployed = await configIO.readDeployedState(); + + // Deployed state is keyed per target; find the target that has this capacity provider. + let record: { capacityProviderId: string; capacityProviderArn: string } | undefined; + for (const target of Object.values(deployed.targets)) { + const found = target.resources?.capacityProviders?.[name]; + if (found) { + record = found; + break; + } + } + if (!record) { + throw new Error( + `Capacity provider "${name}" is not deployed in this project. Deploy it first, or pass its id or ARN with --capacity-provider.` + ); + } + + // The capacity provider ARN always carries the region, so it is the most reliable source. + const region = options.region ?? regionFromArn(record.capacityProviderArn) ?? (await detectRegion()).region; + + return { + capacityProviderId: record.capacityProviderId, + capacityProviderArn: record.capacityProviderArn, + region, + targetByArn: false, + displayName: name, + }; +} + +/** Perform the delete-session data-plane call for a resolved target. */ +export async function executeDeleteCapacityProviderSession(target: ResolvedDeleteTarget, sessionId: string) { + return deleteCapacityProviderSessionApi({ + region: target.region, + capacityProviderId: target.capacityProviderId, + sessionId, + }); +} diff --git a/src/cli/commands/capacity-provider/command.tsx b/src/cli/commands/capacity-provider/command.tsx new file mode 100644 index 000000000..d4c3163e4 --- /dev/null +++ b/src/cli/commands/capacity-provider/command.tsx @@ -0,0 +1,86 @@ +import { runCliCommand } from '../../telemetry/cli-command-run'; +import { executeDeleteCapacityProviderSession, resolveDeleteTarget } from './action'; +import { CAPACITY_PROVIDER_SESSION_ID_MAX_LENGTH, isValidCapacityProviderSessionId } from './constants'; +import type { Command } from '@commander-js/extra-typings'; +import * as readline from 'node:readline/promises'; + +/** + * Interactive destructive-confirmation prompt. Returns false on a bare Enter or a non-TTY stdin so + * the delete never proceeds implicitly — callers must pass --yes for non-interactive use. + */ +async function confirmDestructiveDelete(displayName: string, sessionId: string): Promise { + if (!process.stdin.isTTY) return false; + process.stdout.write( + `\n⚠ Deleting session "${sessionId}" on capacity provider "${displayName}" will deprovision its ` + + `EC2 instance and permanently delete any persistent EBS volumes on the session. This cannot be undone.\n` + ); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = (await rl.question('Continue? [y/N] ')).trim().toLowerCase(); + return answer === 'y' || answer === 'yes'; + } finally { + rl.close(); + } +} + +/** + * Registers `agentcore capacity-provider delete-session` — a data-plane command that tears down a + * single live capacity provider session (deprovisioning its EC2 instance + persistent EBS volumes). + */ +export function registerCapacityProvider(program: Command) { + const capacityProvider = program.command('capacity-provider').description('Manage capacity provider sessions'); + + capacityProvider + .command('delete-session') + .description( + 'Delete (deprovision) a live capacity provider session. Terminates its EC2 instance and deletes any persistent EBS volumes.' + ) + .requiredOption( + '--capacity-provider ', + 'Capacity provider: an in-project name, a capacity provider id, or an ARN. The data-plane API is keyed on the id; a name is resolved to its id via deployed state.' + ) + .requiredOption('--session-id ', 'Session id to delete') + .option('--region ', 'AWS region (auto-detected from the ARN / project otherwise)') + .option('--yes', 'Skip the destructive confirmation prompt') + .option('--json', 'Output as JSON') + .action(cliOptions => { + return runCliCommand('capacity-provider.delete-session', !!cliOptions.json, async () => { + if (!isValidCapacityProviderSessionId(cliOptions.sessionId)) { + throw new Error( + `Invalid --session-id "${cliOptions.sessionId}". Must be 1-${CAPACITY_PROVIDER_SESSION_ID_MAX_LENGTH} characters, start with an alphanumeric, and contain only letters, digits, hyphens, and underscores.` + ); + } + + const target = await resolveDeleteTarget({ + capacityProvider: cliOptions.capacityProvider, + sessionId: cliOptions.sessionId, + region: cliOptions.region, + }); + + if (!cliOptions.yes) { + const confirmed = await confirmDestructiveDelete(target.displayName, cliOptions.sessionId); + if (!confirmed) { + throw new Error( + process.stdin.isTTY + ? 'Aborted — session was not deleted.' + : 'Refusing to delete a capacity provider session without confirmation. Re-run with --yes to skip the prompt.' + ); + } + } + + const result = await executeDeleteCapacityProviderSession(target, cliOptions.sessionId); + + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, ...result })); + } else { + console.log(`Deprovisioning session "${result.sessionId ?? cliOptions.sessionId}" (asynchronous)…`); + console.log(` Status: ${result.status ?? 'Deprovisioning'}`); + console.log( + ` Capacity provider: ${result.capacityProviderArn ?? target.capacityProviderArn ?? target.displayName}` + ); + } + + return { target_by_arn: target.targetByArn }; + }); + }); +} diff --git a/src/cli/commands/capacity-provider/constants.ts b/src/cli/commands/capacity-provider/constants.ts new file mode 100644 index 000000000..5af4c3475 --- /dev/null +++ b/src/cli/commands/capacity-provider/constants.ts @@ -0,0 +1,27 @@ +/** + * Session id validation for capacity-provider data-plane operations. + * Matches the service `SessionId` shape (GenesisCommonModel/common.smithy): + * `@length(min:1, max:100)` + `@pattern("^[a-zA-Z0-9][a-zA-Z0-9-_]*$")`. + * Note: unlike InvokeAgentRuntime's `runtimeSessionId`, delete has no 33-char minimum. + */ +export const CAPACITY_PROVIDER_SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-_]*$/; +export const CAPACITY_PROVIDER_SESSION_ID_MAX_LENGTH = 100; + +export function isValidCapacityProviderSessionId(value: string): boolean { + return ( + value.length >= 1 && + value.length <= CAPACITY_PROVIDER_SESSION_ID_MAX_LENGTH && + CAPACITY_PROVIDER_SESSION_ID_PATTERN.test(value) + ); +} + +/** + * Capacity provider id shape (`{name}-{10 alnum}`, per the service `capacityProviderId` pattern). + * Distinguishes a literal id from a bare project name (names have no `-{suffix}`), so a standalone + * caller can pass the id the data-plane API actually requires. Mirrors the KB name-vs-id disambiguation. + */ +export const CAPACITY_PROVIDER_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,47}-[a-zA-Z0-9]{10}$/; + +export function isCapacityProviderId(value: string): boolean { + return CAPACITY_PROVIDER_ID_PATTERN.test(value); +} diff --git a/src/cli/commands/capacity-provider/index.ts b/src/cli/commands/capacity-provider/index.ts new file mode 100644 index 000000000..0f4c3ff5a --- /dev/null +++ b/src/cli/commands/capacity-provider/index.ts @@ -0,0 +1 @@ +export { registerCapacityProvider } from './command'; diff --git a/src/cli/commands/shared/__tests__/filesystem-utils.test.ts b/src/cli/commands/shared/__tests__/filesystem-utils.test.ts index 9db60d5c6..d01518bce 100644 --- a/src/cli/commands/shared/__tests__/filesystem-utils.test.ts +++ b/src/cli/commands/shared/__tests__/filesystem-utils.test.ts @@ -3,9 +3,11 @@ import { resolveAndValidateFilesystemMounts, validateAccessPointMounts, validateBYOMountPath, + validateCapacityProviderVolumeMounts, validateEfsAccessPointArn, validateS3FilesAccessPointArn, zipAccessPointPairs, + zipCapacityProviderVolumePairs, } from '../filesystem-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -402,3 +404,51 @@ describe('resolveAndValidateFilesystemMounts', () => { expect(result.s3Mounts).toHaveLength(1); }); }); + +describe('zipCapacityProviderVolumePairs', () => { + it('zips matching name/path pairs', () => { + const result = zipCapacityProviderVolumePairs(['v1', 'v2'], ['/mnt/a', '/mnt/b']); + expect(result).toEqual({ + success: true, + mounts: [ + { volumeName: 'v1', mountPath: '/mnt/a' }, + { volumeName: 'v2', mountPath: '/mnt/b' }, + ], + }); + }); + + it('returns success with no mounts for empty input', () => { + const result = zipCapacityProviderVolumePairs([], []); + expect(result).toEqual({ success: true, mounts: [] }); + }); + + it('fails on mismatched lengths', () => { + const result = zipCapacityProviderVolumePairs(['v1', 'v2'], ['/mnt/a']); + expect(result.success).toBe(false); + }); +}); + +describe('validateCapacityProviderVolumeMounts', () => { + it('accepts valid volume mounts', () => { + expect(validateCapacityProviderVolumeMounts([{ volumeName: 'v1', mountPath: '/mnt/models' }]).success).toBe(true); + }); + + it('rejects an empty volume name', () => { + expect(validateCapacityProviderVolumeMounts([{ volumeName: '', mountPath: '/mnt/models' }]).success).toBe(false); + }); + + it('rejects a bad mount path', () => { + expect(validateCapacityProviderVolumeMounts([{ volumeName: 'v1', mountPath: '/data/models' }]).success).toBe(false); + }); +}); + +describe('buildFilesystemConfigurations — capacity provider volumes', () => { + it('emits a capacityProviderVolume entry and normalizes the mount path', () => { + const result = buildFilesystemConfigurations(undefined, undefined, undefined, [ + { volumeName: 'model-weights', mountPath: '/mnt/models/' }, + ]); + expect(result).toEqual({ + filesystemConfigurations: [{ capacityProviderVolume: { volumeName: 'model-weights', mountPath: '/mnt/models' } }], + }); + }); +}); diff --git a/src/cli/commands/shared/filesystem-utils.ts b/src/cli/commands/shared/filesystem-utils.ts index e014faf63..2136da2d2 100644 --- a/src/cli/commands/shared/filesystem-utils.ts +++ b/src/cli/commands/shared/filesystem-utils.ts @@ -15,6 +15,12 @@ export interface AccessPointMount { mountPath: string; } +/** Parsed capacity-provider volume mount entry from CLI options. */ +export interface CapacityProviderVolumeMount { + volumeName: string; + mountPath: string; +} + /** * Validate an EFS access point ARN format. * Accepts any partition (arn:aws[-a-z]*) per multi-partition rules. @@ -70,6 +76,44 @@ export function zipAccessPointPairs( }; } +/** + * Zip capacity-provider volume-name and mount-path arrays into CapacityProviderVolumeMount pairs. + * Returns { success: false, error } when lengths differ. Mirrors zipAccessPointPairs, but the + * reference is a logical volume name (into the CP's volumes[]) rather than an ARN. + */ +export function zipCapacityProviderVolumePairs( + volumeNames: string[], + mountPaths: string[] +): { success: true; mounts: CapacityProviderVolumeMount[] } | { success: false; error: string } { + if (volumeNames.length !== mountPaths.length) { + return { + success: false, + error: `Capacity provider volumes: --cp-volume-name and --cp-volume-mount-path must be provided in matching pairs (got ${volumeNames.length} name(s) and ${mountPaths.length} path(s))`, + }; + } + return { + success: true, + mounts: volumeNames.map((volumeName, i) => ({ volumeName, mountPath: mountPaths[i]! })), + }; +} + +/** + * Validate a set of capacity-provider volume mounts (sync format checks only). + * Reuses validateBYOMountPath for the /mnt path rule (shared filesystem framework). + */ +export function validateCapacityProviderVolumeMounts( + mounts: CapacityProviderVolumeMount[] +): { success: true } | { success: false; error: string } { + for (const { volumeName, mountPath } of mounts) { + if (!volumeName || volumeName.trim().length === 0) { + return { success: false, error: 'Capacity provider volume name must not be empty' }; + } + const pathResult = validateBYOMountPath(mountPath); + if (pathResult !== true) return { success: false, error: pathResult }; + } + return { success: true }; +} + /** * Validate a full set of AccessPointMount pairs (sync format checks only). * Returns { success: false, error } on first failure. @@ -94,13 +138,15 @@ export function validateAccessPointMounts( export function buildFilesystemConfigurations( sessionStorageMountPath?: string, efsAccessPoints?: AccessPointMount[], - s3AccessPoints?: AccessPointMount[] + s3AccessPoints?: AccessPointMount[], + capacityProviderVolumes?: CapacityProviderVolumeMount[] ): | { filesystemConfigurations: ( | { sessionStorage: { mountPath: string } } | { efsAccessPoint: AccessPointMount } | { s3FilesAccessPoint: AccessPointMount } + | { capacityProviderVolume: CapacityProviderVolumeMount } )[]; } | Record { @@ -108,11 +154,14 @@ export function buildFilesystemConfigurations( | { sessionStorage: { mountPath: string } } | { efsAccessPoint: AccessPointMount } | { s3FilesAccessPoint: AccessPointMount } + | { capacityProviderVolume: CapacityProviderVolumeMount } )[] = []; const norm = (p: string) => p.replace(/\/$/, ''); if (sessionStorageMountPath) fcs.push({ sessionStorage: { mountPath: norm(sessionStorageMountPath) } }); for (const ap of efsAccessPoints ?? []) fcs.push({ efsAccessPoint: { ...ap, mountPath: norm(ap.mountPath) } }); for (const ap of s3AccessPoints ?? []) fcs.push({ s3FilesAccessPoint: { ...ap, mountPath: norm(ap.mountPath) } }); + for (const vol of capacityProviderVolumes ?? []) + fcs.push({ capacityProviderVolume: { ...vol, mountPath: norm(vol.mountPath) } }); return fcs.length ? { filesystemConfigurations: fcs } : {}; } diff --git a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts index 6d5604df6..476b2792a 100644 --- a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts +++ b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts @@ -531,6 +531,25 @@ describe('mapGenerateConfigToAgent - filesystem configurations', () => { }); expect(result.filesystemConfigurations).toHaveLength(3); }); + + it('writes capacityProviderConfiguration (J2)', () => { + const result = mapGenerateConfigToAgent({ + ...fsBase, + capacityProviderConfiguration: { capacityProviderName: 'my_pool' }, + }); + expect(result.capacityProviderConfiguration).toEqual({ capacityProviderName: 'my_pool' }); + }); + + it('writes capacityProviderVolume filesystem entry (J3)', () => { + const result = mapGenerateConfigToAgent({ + ...fsBase, + capacityProviderConfiguration: { capacityProviderName: 'my_pool' }, + capacityProviderVolumes: [{ volumeName: 'model-weights', mountPath: '/mnt/models' }], + }); + expect(result.filesystemConfigurations).toContainEqual({ + capacityProviderVolume: { volumeName: 'model-weights', mountPath: '/mnt/models' }, + }); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index 3e1521141..bf948ab0f 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -167,7 +167,15 @@ export function mapGenerateConfigToAgent(config: GenerateConfig): AgentEnvSpec { }, } : {}), - ...buildFilesystemConfigurations(config.sessionStorageMountPath, config.efsAccessPoints, config.s3AccessPoints), + ...(config.capacityProviderConfiguration && { + capacityProviderConfiguration: config.capacityProviderConfiguration, + }), + ...buildFilesystemConfigurations( + config.sessionStorageMountPath, + config.efsAccessPoints, + config.s3AccessPoints, + config.capacityProviderVolumes + ), ...(protocol === 'MCP' && { instrumentation: { enableOtel: false } }), }; } diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index 1f3cbbfcd..032541db8 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -14,6 +14,7 @@ import type { Result } from '../../lib/result'; import type { AgentEnvSpec, BuildType, + CapacityProviderConfiguration, CustomClaimValidation, DirectoryPath, FilePath, @@ -30,6 +31,7 @@ import { DEFAULT_PYTHON_VERSION, LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN, + isCapacityProviderArn, } from '../../schema'; import { getCredentialProvider } from '../aws/account'; import type { AddAgentOptions as CLIAddAgentOptions } from '../commands/add/types'; @@ -37,10 +39,12 @@ import { validateAddAgentOptions } from '../commands/add/validate'; import { buildFilesystemConfigurations, validateAccessPointMounts, + validateCapacityProviderVolumeMounts, validateEfsAccessPointArn, validateFilesystemMountsConfiguration, validateS3FilesAccessPointArn, zipAccessPointPairs, + zipCapacityProviderVolumePairs, } from '../commands/shared/filesystem-utils'; import { parseAndNormalizeHeaders } from '../commands/shared/header-utils'; import type { VpcOptions } from '../commands/shared/vpc-utils'; @@ -117,6 +121,12 @@ export interface AddAgentOptions extends VpcOptions { efsMountPaths?: string[]; s3AccessPointArns?: string[]; s3MountPaths?: string[]; + /** Attach the runtime to a capacity provider — an in-project sibling name or an external CP ARN. */ + capacityProvider?: string; + /** CP volume names to mount (paired by position with cpVolumeMountPaths). */ + cpVolumeNames?: string[]; + /** CP volume mount paths (paired by position with cpVolumeNames). */ + cpVolumeMountPaths?: string[]; withConfigBundle?: boolean; } @@ -133,6 +143,16 @@ export class AgentPrimitive extends BasePrimitive` + * value. An `arn:` prefix is treated as an external CP (by ARN); anything else is an in-project + * sibling (by name). Returns undefined when the flag was not provided. + */ + private buildCapacityProviderConfiguration(value?: string): CapacityProviderConfiguration | undefined { + if (!value) return undefined; + return isCapacityProviderArn(value) ? { capacityProviderArn: value } : { capacityProviderName: value }; + } + /** Build lifecycleConfiguration block from flat options - only if at least one value is set. */ private buildLifecycleConfig(options: { idleTimeout?: number; maxLifetime?: number }) { if (options.idleTimeout === undefined && options.maxLifetime === undefined) return undefined; @@ -324,6 +344,22 @@ export class AgentPrimitive extends BasePrimitive [...prev, val], [] as string[] ) + .option( + '--capacity-provider ', + 'Attach to a capacity provider — an in-project capacity-provider name or an external CP ARN [non-interactive]' + ) + .option( + '--cp-volume-name ', + 'Capacity provider volume name to mount (repeatable, paired with --cp-volume-mount-path) [non-interactive]', + (val: string, prev: string[]) => [...prev, val], + [] as string[] + ) + .option( + '--cp-volume-mount-path ', + 'Capacity provider volume mount path (e.g. /mnt/models, paired with --cp-volume-name) [non-interactive]', + (val: string, prev: string[]) => [...prev, val], + [] as string[] + ) .option('--with-config-bundle', 'Create a config bundle wired into the agent template [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') .action(async options => { @@ -359,6 +395,18 @@ export class AgentPrimitive extends BasePrimitive 0 && !cliOptions.capacityProvider) { + throw new Error( + 'Capacity provider volume mounts (--cp-volume-name / --cp-volume-mount-path) require attaching the runtime to a capacity provider (--capacity-provider ).' + ); + } + const hasByoFs = efsArns.length > 0 || s3Arns.length > 0; if (hasByoFs && cliOptions.networkMode !== 'VPC') { throw new Error( @@ -445,6 +493,9 @@ export class AgentPrimitive extends BasePrimitive ({ + volumeName, + mountPath: (options.cpVolumeMountPaths ?? [])[i] ?? '', + })), withConfigBundle: options.withConfigBundle, }; @@ -720,6 +780,7 @@ export class AgentPrimitive extends BasePrimitive ({ @@ -755,6 +817,10 @@ export class AgentPrimitive extends BasePrimitive ({ accessPointArn: arn, mountPath: (options.s3MountPaths ?? [])[i] ?? '', + })), + (options.cpVolumeNames ?? []).map((volumeName, i) => ({ + volumeName, + mountPath: (options.cpVolumeMountPaths ?? [])[i] ?? '', })) ), }; diff --git a/src/cli/primitives/CapacityProviderPrimitive.ts b/src/cli/primitives/CapacityProviderPrimitive.ts index d823d94f6..3b77e3702 100644 --- a/src/cli/primitives/CapacityProviderPrimitive.ts +++ b/src/cli/primitives/CapacityProviderPrimitive.ts @@ -1,6 +1,13 @@ -import { ResourceNotFoundError, ValidationError, findConfigRoot, serializeResult, toError } from '../../lib'; +import { + ConflictError, + ResourceNotFoundError, + ValidationError, + findConfigRoot, + serializeResult, + toError, +} from '../../lib'; import type { Result } from '../../lib/result'; -import type { CapacityProvider } from '../../schema'; +import type { AgentCoreProjectSpec, CapacityProvider } from '../../schema'; import { CapacityProviderSchema } from '../../schema'; import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { runCliCommand } from '../telemetry/cli-command-run.js'; @@ -20,7 +27,8 @@ export interface AddCapacityProviderOptions { securityGroups: string; os?: string; instanceTypes: string; - volume?: string[]; + volumeName?: string[]; + volumeSize?: string[]; volumeEncrypted?: boolean; volumeKmsKey?: string; instanceProfileArn?: string; @@ -74,6 +82,19 @@ export class CapacityProviderPrimitive extends BasePrimitive 0) { + return { + success: false, + error: new ConflictError( + `Capacity provider "${name}" is referenced by agent(s): ${referencingRuntimes.join(', ')}. Remove those references first.` + ), + }; + } + const remaining = existing.filter(cp => cp.name !== name); await this.writeProjectSpec({ ...project, @@ -91,7 +112,14 @@ export class CapacityProviderPrimitive extends BasePrimitive cp.name === name)) { - throw new Error(`Capacity provider "${name}" not found.`); + throw new ResourceNotFoundError(`Capacity provider "${name}" not found.`); + } + + const referencingRuntimes = this.findReferencingRuntimes(project, name); + if (referencingRuntimes.length > 0) { + throw new ConflictError( + `Capacity provider "${name}" is referenced by agent(s): ${referencingRuntimes.join(', ')}. Remove those references first.` + ); } const remaining = existing.filter(cp => cp.name !== name); @@ -144,9 +172,16 @@ export class CapacityProviderPrimitive extends BasePrimitive', 'Operating system: LINUX_X86_64 or LINUX_ARM64 (default: LINUX_X86_64) [non-interactive]') .option('--instance-types ', 'Comma-separated allowed EC2 instance types (1-30) [non-interactive]') .option( - '--volume ', - 'Named EBS volume as name:sizeGiB (repeatable, max 5) [non-interactive]', - (val: string, prev: string[] = []) => [...prev, val] + '--volume-name ', + 'Named EBS volume name (repeatable, max 5, paired with --volume-size) [non-interactive]', + (val: string, prev: string[]) => [...prev, val], + [] as string[] + ) + .option( + '--volume-size ', + 'EBS volume size in GiB (repeatable, paired with --volume-name) [non-interactive]', + (val: string, prev: string[]) => [...prev, val], + [] as string[] ) .option('--volume-encrypted', 'Encrypt EBS volumes [non-interactive]') .option('--volume-kms-key ', 'KMS key ARN for EBS volume encryption [non-interactive]') @@ -195,6 +230,17 @@ export class CapacityProviderPrimitive extends BasePrimitive r.capacityProviderConfiguration?.capacityProviderName === name) + .map(r => r.name); + } + /** * Validate that all required CLI flags are present, throwing ValidationError * with an actionable message when they are not. @@ -216,20 +262,28 @@ export class CapacityProviderPrimitive extends BasePrimitive { - // Require exactly `name:sizeGiB`. Splitting without a segment count lets `data:20:gp3` - // silently drop the trailing segment, and Number() accepts hex/exponent (`0x14`, `2e1`) - // as 20 — so validate the size as literal digits instead of trusting Number(). - const segments = entry.split(':'); - const [volName, sizeRaw] = segments; - if (segments.length !== 2 || !volName || !sizeRaw || !/^[0-9]+$/.test(sizeRaw)) { - throw new ValidationError(`Invalid --volume "${entry}". Expected format name:sizeGiB (e.g. data:20).`); + // Paired repeatable flags: --volume-name and --volume-size must line up 1:1 (mirrors the + // --efs-access-point-arn/--efs-mount-path and --cp-volume-name/--cp-volume-mount-path idiom). + const names = options.volumeName ?? []; + const sizes = options.volumeSize ?? []; + if (names.length !== sizes.length) { + throw new ValidationError( + `--volume-name and --volume-size must be provided in matching pairs (got ${names.length} name(s) and ${sizes.length} size(s)).` + ); + } + const volumes = names.map((volName, i) => { + const sizeRaw = sizes[i]!; + // Validate the size as literal digits — Number() would accept hex/exponent (`0x14`, `2e1`) + // as 20. Zod (CapacityProviderSchema.parse below) enforces the 1–65536 GiB range. + if (!volName.trim() || !/^[0-9]+$/.test(sizeRaw)) { + throw new ValidationError( + `Invalid volume "${volName}:${sizeRaw}". --volume-name must be non-empty and --volume-size a whole number of GiB (e.g. --volume-name data --volume-size 20).` + ); } - const sizeGiB = Number(sizeRaw); return { ebsConfiguration: { name: volName, - sizeGiB, + sizeGiB: Number(sizeRaw), ...(options.volumeEncrypted !== undefined && { encrypted: options.volumeEncrypted }), ...(options.volumeKmsKey && { kmsKeyId: options.volumeKmsKey }), }, diff --git a/src/cli/primitives/__tests__/CapacityProviderPrimitive.test.ts b/src/cli/primitives/__tests__/CapacityProviderPrimitive.test.ts index 09fcfb629..c4dc3c723 100644 --- a/src/cli/primitives/__tests__/CapacityProviderPrimitive.test.ts +++ b/src/cli/primitives/__tests__/CapacityProviderPrimitive.test.ts @@ -54,6 +54,14 @@ function baseOptions(overrides: Partial = {}): AddCa }; } +/** Minimal runtime that attaches to a capacity provider by name (an in-project sibling). */ +function makeRuntimeRefByName(name: string, cpName: string): AgentCoreProjectSpec['runtimes'][number] { + return { + name, + capacityProviderConfiguration: { capacityProviderName: cpName }, + } as AgentCoreProjectSpec['runtimes'][number]; +} + function makeCapacityProvider(name: string): CapacityProvider { return { name, @@ -125,7 +133,8 @@ describe('CapacityProviderPrimitive', () => { instanceTypes: 'c7g.large, c7g.xlarge', subnets: 'subnet-0123456789abcdef0,subnet-0fedcba9876543210', securityGroups: 'sg-0123456789abcdef0', - volume: ['data:20'], + volumeName: ['data', 'cache'], + volumeSize: ['20', '50'], volumeEncrypted: true, idleInstanceTimeout: '3600', maxLifetime: '28800', @@ -140,7 +149,10 @@ describe('CapacityProviderPrimitive', () => { 'c7g.xlarge', ]); expect(ec2.vpcConfiguration.subnets).toHaveLength(2); - expect(ec2.volumes).toEqual([{ ebsConfiguration: { name: 'data', sizeGiB: 20, encrypted: true } }]); + expect(ec2.volumes).toEqual([ + { ebsConfiguration: { name: 'data', sizeGiB: 20, encrypted: true } }, + { ebsConfiguration: { name: 'cache', sizeGiB: 50, encrypted: true } }, + ]); expect(ec2.lifecycleConfiguration).toEqual({ idleInstanceTimeout: 3600, maxLifetime: 28800 }); expect(written.capacityProviders![0]!.description).toBe('my cp'); }); @@ -185,39 +197,112 @@ describe('CapacityProviderPrimitive', () => { expect(mockWriteProjectSpec).not.toHaveBeenCalled(); }); - it('rejects a malformed --volume value', async () => { + it('rejects mismatched --volume-name / --volume-size pairs', async () => { mockReadProjectSpec.mockResolvedValue(makeProject()); - const result = await primitive.add(baseOptions({ volume: ['data-no-size'] })); + const result = await primitive.add(baseOptions({ volumeName: ['data', 'cache'], volumeSize: ['20'] })); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.message).toContain('name:sizeGiB'); + expect(result.error.message).toContain('matching pairs'); } expect(mockWriteProjectSpec).not.toHaveBeenCalled(); }); - // A plain split(':') + Number() silently accepted all of these before the fix: - // extra segments were dropped, and hex/exponent notation coerced to a number. + // Number() would coerce hex/exponent/decimal notation to a valid number — validate the + // size as literal digits instead. Empty name is also rejected. it.each([ - ['extra segment', 'data:20:gp3'], - ['hex size', 'data:0x14'], - ['exponent size', 'data:2e1'], - ['decimal size', 'data:20.5'], - ['empty size', 'data:'], - ])('rejects a --volume with %s (%s)', async (_label, value) => { + ['hex size', 'data', '0x14'], + ['exponent size', 'data', '2e1'], + ['decimal size', 'data', '20.5'], + ['empty size', 'data', ''], + ['empty name', '', '20'], + ])('rejects a volume with %s (name=%s size=%s)', async (_label, volName, size) => { mockReadProjectSpec.mockResolvedValue(makeProject()); - const result = await primitive.add(baseOptions({ volume: [value] })); + const result = await primitive.add(baseOptions({ volumeName: [volName], volumeSize: [size] })); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.message).toContain('name:sizeGiB'); + expect(result.error.message.toLowerCase()).toContain('volume'); } expect(mockWriteProjectSpec).not.toHaveBeenCalled(); }); }); + // These flags reach parity with the interactive wizard's Instance Profile / Encryption / KMS / + // Lifecycle steps — assert each lands in the right place in the built spec. + describe('add() — advanced parity flags', () => { + const VALID_INSTANCE_PROFILE = 'arn:aws:iam::123456789012:instance-profile/MyProfile'; + const VALID_KMS = 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'; + + it('maps --instance-profile-arn onto launchParameters', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.add(baseOptions({ instanceProfileArn: VALID_INSTANCE_PROFILE })); + + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect( + written.capacityProviders![0]!.computeConfiguration.ec2Configuration.launchTemplateSource.launchParameters + .instanceProfileArn + ).toBe(VALID_INSTANCE_PROFILE); + }); + + it('rejects a malformed instance profile ARN', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + const result = await primitive.add(baseOptions({ instanceProfileArn: 'not-an-arn' })); + expect(result.success).toBe(false); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('maps --volume-encrypted and --volume-kms-key onto each volume', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.add( + baseOptions({ volumeName: ['data'], volumeSize: ['20'], volumeEncrypted: true, volumeKmsKey: VALID_KMS }) + ); + + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.capacityProviders![0]!.computeConfiguration.ec2Configuration.volumes).toEqual([ + { ebsConfiguration: { name: 'data', sizeGiB: 20, encrypted: true, kmsKeyId: VALID_KMS } }, + ]); + }); + + it('rejects a malformed volume KMS key ARN', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + const result = await primitive.add( + baseOptions({ volumeName: ['data'], volumeSize: ['20'], volumeEncrypted: true, volumeKmsKey: 'not-an-arn' }) + ); + expect(result.success).toBe(false); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('maps --idle-instance-timeout and --max-lifetime onto lifecycleConfiguration', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.add(baseOptions({ idleInstanceTimeout: '900', maxLifetime: '28800' })); + + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.capacityProviders![0]!.computeConfiguration.ec2Configuration.lifecycleConfiguration).toEqual({ + idleInstanceTimeout: 900, + maxLifetime: 28800, + }); + }); + + it('rejects an out-of-range lifecycle timeout', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + const result = await primitive.add(baseOptions({ idleInstanceTimeout: '30' })); // below the 60s minimum + expect(result.success).toBe(false); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + }); + describe('remove()', () => { it('removes a capacity provider from spec', async () => { const project = makeProject({ @@ -257,6 +342,44 @@ describe('CapacityProviderPrimitive', () => { } expect(mockWriteProjectSpec).not.toHaveBeenCalled(); }); + + it('blocks removal while a runtime references the CP by name — clear error, no write', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ + capacityProviders: [makeCapacityProvider('cpA')], + runtimes: [makeRuntimeRefByName('agentX', 'cpA'), makeRuntimeRefByName('agentY', 'cpA')], + }) + ); + + const result = await primitive.remove('cpA'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('cpA'); + expect(result.error.message).toContain('agentX'); + expect(result.error.message).toContain('agentY'); + expect(result.error.message).toContain('Remove those references first'); + } + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('allows removal when a runtime attaches by ARN (external CP, not a sibling)', async () => { + const runtime = { + name: 'agentZ', + capacityProviderConfiguration: { + capacityProviderArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:capacity-provider/foo-AbCdEfGhIj', + }, + } as AgentCoreProjectSpec['runtimes'][number]; + mockReadProjectSpec.mockResolvedValue( + makeProject({ capacityProviders: [makeCapacityProvider('cpA')], runtimes: [runtime] }) + ); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('cpA'); + + expect(result.success).toBe(true); + expect(mockWriteProjectSpec).toHaveBeenCalled(); + }); }); describe('getRemovable()', () => { @@ -292,5 +415,15 @@ describe('CapacityProviderPrimitive', () => { mockReadProjectSpec.mockResolvedValue(makeProject()); await expect(primitive.previewRemove('missing')).rejects.toThrow('not found'); }); + + it('throws when a runtime still references the CP by name', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ + capacityProviders: [makeCapacityProvider('cpA')], + runtimes: [makeRuntimeRefByName('agentX', 'cpA')], + }) + ); + await expect(primitive.previewRemove('cpA')).rejects.toThrow('Remove those references first'); + }); }); }); diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index ab091bd27..8063fc954 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -63,6 +63,9 @@ const AddAgentAttrs = safeSchema({ memory_type: MemoryType, efs_mount_count: Count, s3_mount_count: Count, + has_capacity_provider: z.boolean(), + capacity_provider_by_arn: z.boolean(), + cp_volume_mount_count: Count, }); const AddMemoryAttrs = safeSchema({ @@ -123,6 +126,11 @@ const AddCapacityProviderAttrs = safeSchema({ has_description: z.boolean(), }); +const DeleteCapacityProviderSessionAttrs = safeSchema({ + /** Whether the capacity provider was targeted by raw ARN (no project) vs by project name. */ + target_by_arn: z.boolean(), +}); + const DeployAttrs = safeSchema({ runtime_count: Count, harness_count: Count, @@ -319,6 +327,7 @@ export const COMMAND_SCHEMAS = { 'remove.payment-manager': NoAttrs, 'remove.payment-connector': NoAttrs, 'remove.capacity-provider': NoAttrs, + 'capacity-provider.delete-session': DeleteCapacityProviderSessionAttrs, 'remove.skill': NoAttrs, 'telemetry.disable': NoAttrs, 'telemetry.enable': NoAttrs, diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 794dc000b..0be01da80 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -66,6 +66,14 @@ export const COMMAND_DESCRIPTIONS = { * These commands must run in the terminal, not in the TUI. */ export const CLI_ONLY_EXAMPLES: Record = { + 'capacity-provider': { + description: + 'Manage capacity provider sessions. delete-session tears down a live session (deprovisioning its EC2 instance and persistent EBS volumes). This command runs in the terminal.', + examples: [ + 'agentcore capacity-provider delete-session --capacity-provider my-pool --session-id ', + 'agentcore capacity-provider delete-session --capacity-provider --session-id --region us-west-2 --yes', + ], + }, traces: { description: 'View and download agent traces. This command runs in the terminal.', examples: [ diff --git a/src/cli/tui/screens/add/AddScreen.tsx b/src/cli/tui/screens/add/AddScreen.tsx index f6cd12e49..ee14da2a0 100644 --- a/src/cli/tui/screens/add/AddScreen.tsx +++ b/src/cli/tui/screens/add/AddScreen.tsx @@ -42,7 +42,7 @@ const BASE_ADD_RESOURCES: { id: AddResourceType; title: string; description: str }, { id: 'capacity-provider', - title: 'Capacity Provider [preview]', + title: 'Capacity Provider', description: 'Customer-managed EC2 compute pool for runtimes', }, ]; diff --git a/src/cli/tui/screens/agent/AddAgentScreen.tsx b/src/cli/tui/screens/agent/AddAgentScreen.tsx index bffc118d1..d2d9e9551 100644 --- a/src/cli/tui/screens/agent/AddAgentScreen.tsx +++ b/src/cli/tui/screens/agent/AddAgentScreen.tsx @@ -2,12 +2,14 @@ import { APP_DIR, ConfigIO } from '../../../../lib'; import type { ModelProvider, NetworkMode, RuntimeAuthorizerType, SDKFramework } from '../../../../schema'; import { AgentNameSchema, + CapacityProviderArnSchema, DEFAULT_MODEL_IDS, LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN, MAX_EFS_MOUNTS, MAX_S3_MOUNTS, SessionStorageSchema, + isCapacityProviderArn, } from '../../../../schema'; import { listBedrockAgentAliases, listBedrockAgents } from '../../../aws/bedrock-import'; import type { BedrockAgentSummary, BedrockAliasSummary } from '../../../aws/bedrock-import-types'; @@ -49,6 +51,7 @@ import { BUILD_TYPE_OPTIONS, GenerateWizardUI, getWizardHelpText, useGenerateWiz import type { BuildType, MemoryOption } from '../generate'; import type { AdvancedSettingId } from '../generate/types'; import { ADVANCED_SETTING_OPTIONS, MEMORY_OPTIONS } from '../generate/types'; +import { buildCreateAgentConfig } from './buildCreateAgentConfig'; import { buildMountListItems } from './buildMountListItems'; import type { AddAgentConfig, AddAgentStep, AgentType } from './types'; import { @@ -98,6 +101,9 @@ type ByoStep = | 's3Arn' | 's3MountPath' | 's3AddAnother' + | 'capacityProvider' + | 'capacityProviderArn' + | 'cpVolumeMounts' | 'confirm'; const INITIAL_STEPS: InitialStep[] = ['name', 'agentType']; @@ -109,6 +115,12 @@ export interface ComputeByoStepsInput { networkMode: string; authorizerType: string; advancedSettings: Set; + /** + * Capacity-provider attachment choice (drives dynamic step insertion, like authorizerType→jwtConfig): + * 'none' = not attached, 'name' = an in-project sibling, 'arn' = an external ARN (adds an ARN entry step). + * Any non-'none' value adds the CP-volume mounts step. + */ + capacityProviderMode?: 'none' | 'name' | 'arn'; } /** Pure function to compute BYO wizard steps from config. Exported for testing. */ @@ -155,6 +167,16 @@ export function computeByoSteps(input: ComputeByoStepsInput): ByoStep[] { 's3AddAnother' ); } + if (input.advancedSettings.has('capacityProvider')) { + subSteps.push('capacityProvider'); + if (input.capacityProviderMode === 'arn') { + subSteps.push('capacityProviderArn'); + } + // CP volumes can only be mounted once the runtime is attached to a capacity provider. + if (input.capacityProviderMode && input.capacityProviderMode !== 'none') { + subSteps.push('cpVolumeMounts'); + } + } steps = [...steps.slice(0, afterAdvanced), ...subSteps, ...steps.slice(afterAdvanced)]; } if (input.authorizerType === 'CUSTOM_JWT' && steps.includes('authorizerType')) { @@ -212,8 +234,12 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg sessionStorageMountPath: '' as string, efsAccessPoints: [] as { accessPointArn: string; mountPath: string }[], s3AccessPoints: [] as { accessPointArn: string; mountPath: string }[], + capacityProvider: '' as string, + capacityProviderVolumes: [] as { volumeName: string; mountPath: string }[], withConfigBundle: undefined as boolean | undefined, }); + // Capacity-provider attachment mode drives dynamic step insertion (mirrors authorizerType→jwtConfig). + const [byoCapacityProviderMode, setByoCapacityProviderMode] = useState<'none' | 'name' | 'arn'>('none'); const [byoAdvancedSettings, setByoAdvancedSettings] = useState>(new Set()); const [byoAuthorizerType, setByoAuthorizerType] = useState('AWS_IAM'); const [byoJwtConfig, setByoJwtConfig] = useState(undefined); @@ -222,6 +248,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg // State for project name (fetched from project spec for credential naming) const [projectName, setProjectName] = useState(''); + // In-project capacity provider names, offered when attaching a runtime to a sibling CP. + const [projectCapacityProviderNames, setProjectCapacityProviderNames] = useState([]); // Fetch project name when component mounts useEffect(() => { @@ -230,6 +258,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg const configIO = new ConfigIO(); const projectSpec = await configIO.readProjectSpec(); setProjectName(projectSpec.name); + setProjectCapacityProviderNames((projectSpec.capacityProviders ?? []).map(cp => cp.name)); } catch { // Ignore errors - project name will remain empty } @@ -308,47 +337,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg // ───────────────────────────────────────────────────────────────────────────── const handleGenerateComplete = useCallback(() => { - // Map GenerateConfig to AddAgentConfig - const config: AddAgentConfig = { - name, - agentType: 'create', - codeLocation: `${name}/`, - entrypoint: 'main.py', - language: generateWizard.config.language, - buildType: generateWizard.config.buildType, - ...(generateWizard.config.buildType === 'Container' && - generateWizard.config.dockerfile && { - dockerfile: generateWizard.config.dockerfile, - }), - protocol: generateWizard.config.protocol, - framework: generateWizard.config.sdk, - modelProvider: generateWizard.config.modelProvider, - apiKey: generateWizard.config.apiKey, - networkMode: generateWizard.config.networkMode, - subnets: generateWizard.config.networkMode === 'VPC' ? generateWizard.config.subnets : undefined, - securityGroups: generateWizard.config.networkMode === 'VPC' ? generateWizard.config.securityGroups : undefined, - ...(generateWizard.config.networkMode === 'VPC' && - generateWizard.config.buildType === 'Container' && - generateWizard.config.vpcId && { vpcId: generateWizard.config.vpcId }), - requestHeaderAllowlist: generateWizard.config.requestHeaderAllowlist, - ...(generateWizard.config.authorizerType && - generateWizard.config.authorizerType !== 'AWS_IAM' && { - authorizerType: generateWizard.config.authorizerType, - }), - ...(generateWizard.config.authorizerType === 'CUSTOM_JWT' && - generateWizard.config.jwtConfig && { - jwtConfig: generateWizard.config.jwtConfig, - }), - idleRuntimeSessionTimeout: generateWizard.config.idleRuntimeSessionTimeout, - maxLifetime: generateWizard.config.maxLifetime, - sessionStorageMountPath: generateWizard.config.sessionStorageMountPath, - ...(generateWizard.config.efsAccessPoints?.length && { efsAccessPoints: generateWizard.config.efsAccessPoints }), - ...(generateWizard.config.s3AccessPoints?.length && { s3AccessPoints: generateWizard.config.s3AccessPoints }), - withConfigBundle: generateWizard.config.withConfigBundle, - pythonVersion: DEFAULT_PYTHON_VERSION, - memory: generateWizard.config.memory, - }; - onComplete(config); + onComplete(buildCreateAgentConfig(name, generateWizard.config)); }, [name, generateWizard.config, onComplete]); const handleGenerateBack = useCallback(() => { @@ -374,8 +363,16 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg networkMode: byoConfig.networkMode, authorizerType: byoAuthorizerType, advancedSettings: byoAdvancedSettings, + capacityProviderMode: byoCapacityProviderMode, }), - [byoConfig.buildType, byoConfig.modelProvider, byoConfig.networkMode, byoAdvancedSettings, byoAuthorizerType] + [ + byoConfig.buildType, + byoConfig.modelProvider, + byoConfig.networkMode, + byoAdvancedSettings, + byoAuthorizerType, + byoCapacityProviderMode, + ] ); const byoCurrentIndex = byoSteps.indexOf(byoStep); @@ -412,16 +409,17 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg setStep: setByoStep as (step: string) => void, }); - // Advanced multi-select items — filter out dockerfile when not a Container build - const byoAdvancedItems: SelectableItem[] = useMemo( - () => - ADVANCED_SETTING_OPTIONS.filter(o => o.id !== 'dockerfile' || byoConfig.buildType === 'Container').map(o => ({ - id: o.id, - title: o.title, - description: o.description, - })), - [byoConfig.buildType] - ); + // Advanced multi-select items — filter out dockerfile when not a Container build. Capacity + // provider now lives in the shared ADVANCED_SETTING_OPTIONS (it's offered on the template path + // too), so it comes through the loop; do NOT insert it separately or it would appear twice. + const byoAdvancedItems: SelectableItem[] = useMemo(() => { + const items: SelectableItem[] = []; + for (const o of ADVANCED_SETTING_OPTIONS) { + if (o.id === 'dockerfile' && byoConfig.buildType !== 'Container') continue; + items.push({ id: o.id, title: o.title, description: o.description }); + } + return items; + }, [byoConfig.buildType]); // BYO build type options const buildTypeItems: SelectableItem[] = useMemo( @@ -510,6 +508,14 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg // For BYO, language/framework are not asked - we default to Python/Strands // since the actual values don't matter for BYO (code already exists) const requestHeaderAllowlist = parseAndNormalizeHeaders(byoConfig.requestHeaderAllowlist); + // Capacity-provider mode supplies its own network topology and is mutually exclusive with VPC — + // when a CP is attached, force PUBLIC networking so the wizard never emits an invalid CP+VPC config. + const capacityProviderConfiguration = byoConfig.capacityProvider + ? isCapacityProviderArn(byoConfig.capacityProvider) + ? { capacityProviderArn: byoConfig.capacityProvider } + : { capacityProviderName: byoConfig.capacityProvider } + : undefined; + const effectiveNetworkMode: NetworkMode = capacityProviderConfiguration ? 'PUBLIC' : byoConfig.networkMode; const config: AddAgentConfig = { name, agentType: 'byo', @@ -522,10 +528,10 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg framework: 'Strands', // Default - not used for BYO agents modelProvider: byoConfig.modelProvider, apiKey: byoConfig.apiKey, - networkMode: byoConfig.networkMode, - subnets: byoConfig.networkMode === 'VPC' ? parseCommaSeparatedList(byoConfig.subnets) : undefined, - securityGroups: byoConfig.networkMode === 'VPC' ? parseCommaSeparatedList(byoConfig.securityGroups) : undefined, - ...(byoConfig.networkMode === 'VPC' && + networkMode: effectiveNetworkMode, + subnets: effectiveNetworkMode === 'VPC' ? parseCommaSeparatedList(byoConfig.subnets) : undefined, + securityGroups: effectiveNetworkMode === 'VPC' ? parseCommaSeparatedList(byoConfig.securityGroups) : undefined, + ...(effectiveNetworkMode === 'VPC' && byoConfig.buildType === 'Container' && byoConfig.vpcId && { vpcId: byoConfig.vpcId }), ...(requestHeaderAllowlist.length > 0 && { requestHeaderAllowlist }), @@ -536,6 +542,10 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg ...(byoConfig.sessionStorageMountPath && { sessionStorageMountPath: byoConfig.sessionStorageMountPath }), ...(byoConfig.efsAccessPoints.length > 0 && { efsAccessPoints: byoConfig.efsAccessPoints }), ...(byoConfig.s3AccessPoints.length > 0 && { s3AccessPoints: byoConfig.s3AccessPoints }), + ...(capacityProviderConfiguration && { capacityProviderConfiguration }), + ...(byoConfig.capacityProviderVolumes.length > 0 && { + capacityProviderVolumes: byoConfig.capacityProviderVolumes, + }), ...(byoConfig.withConfigBundle && { withConfigBundle: true }), pythonVersion: DEFAULT_PYTHON_VERSION, memory: 'none', @@ -601,9 +611,12 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg sessionStorageMountPath: '', efsAccessPoints: [], s3AccessPoints: [], + capacityProvider: '', + capacityProviderVolumes: [], withConfigBundle: undefined, })); resetByoFilesystemState(); + setByoCapacityProviderMode('none'); setByoAuthorizerType('AWS_IAM'); setByoJwtConfig(undefined); setByoStep('confirm'); @@ -613,6 +626,11 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg setByoConfig(c => ({ ...c, sessionStorageMountPath: '', efsAccessPoints: [], s3AccessPoints: [] })); resetByoFilesystemState(); } + // Clear capacity-provider state if it was deselected + if (!selected.has('capacityProvider')) { + setByoConfig(c => ({ ...c, capacityProvider: '', capacityProviderVolumes: [] })); + setByoCapacityProviderMode('none'); + } // Config bundle has no sub-steps — set flag immediately setByoConfig(c => ({ ...c, @@ -632,6 +650,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg setByoStep('idleTimeout'); } else if (selected.has('filesystem')) { setByoStep('sessionStorageMountPath'); + } else if (selected.has('capacityProvider')) { + setByoStep('capacityProvider'); } else { setByoStep('confirm'); } @@ -714,6 +734,49 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg isActive: isByoPath && byoStep === 's3AddAnother', }); + // Capacity provider attach: None / in-project sibling by name / external ARN. + const capacityProviderItems: SelectableItem[] = useMemo( + () => [ + { id: '__none__', title: 'None', description: 'Use AgentCore-managed compute (default)' }, + ...projectCapacityProviderNames.map(cpName => ({ + id: cpName, + title: cpName, + description: 'Capacity provider in this project', + })), + { id: '__arn__', title: 'Enter an ARN…', description: 'Attach to an external capacity provider by ARN' }, + ], + [projectCapacityProviderNames] + ); + const capacityProviderNav = useListNavigation({ + items: capacityProviderItems, + onSelect: item => { + if (item.id === '__none__') { + setByoConfig(c => ({ ...c, capacityProvider: '', capacityProviderVolumes: [] })); + setByoCapacityProviderMode('none'); + // Navigate directly (like the ARN/name branches) rather than via goToNextByoStep + setTimeout: + // when re-selecting None after having chosen a CP (e.g. Esc back-nav), the memoized byoSteps + // still includes cpVolumeMounts (mode hasn't recomputed to 'none' yet), so goToNextByoStep + // would land on cpVolumeMounts — a step that then disappears, corrupting the breadcrumb and + // stranding the flow. capacityProvider is always the last advanced sub-step, so None's next + // step is always confirm. + setByoStep('confirm'); + } else if (item.id === '__arn__') { + setByoCapacityProviderMode('arn'); + setByoStep('capacityProviderArn'); + } else { + setByoConfig(c => ({ ...c, capacityProvider: item.id })); + setByoCapacityProviderMode('name'); + // Navigate directly (like the ARN branch) rather than via goToNextByoStep + setTimeout: + // the memoized byoSteps hasn't recomputed with mode='name' yet, so goToNextByoStep would + // not see the cpVolumeMounts step and would skip straight to confirm. A non-none attachment + // always has cpVolumeMounts next. + setByoStep('cpVolumeMounts'); + } + }, + onExit: handleByoBack, + isActive: isByoPath && byoStep === 'capacityProvider', + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: handleByoComplete, @@ -1058,6 +1121,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg onConfirm={handleGenerateComplete} isActive={true} credentialProjectName={projectName} + capacityProviderNames={projectCapacityProviderNames} /> ); @@ -1525,6 +1589,62 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg )} + {byoStep === 'capacityProvider' && ( + + )} + + {byoStep === 'capacityProviderArn' && ( + { + setByoConfig(c => ({ ...c, capacityProvider: value })); + goToNextByoStep('capacityProviderArn'); + }} + onCancel={handleByoBack} + /> + )} + + {byoStep === 'cpVolumeMounts' && ( + `${v.volumeName}:${v.mountPath}`).join(', ')} + allowEmpty + customValidation={value => { + if (!value.trim()) return true; + for (const entry of value.split(',')) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const sep = trimmed.indexOf(':'); + if (sep <= 0) + return `Invalid volume mount "${trimmed}". Expected name:path (e.g. model-weights:/mnt/models).`; + const mountPath = trimmed.slice(sep + 1); + const r = validateBYOMountPath(mountPath); + if (r !== true) return r; + } + return true; + }} + onSubmit={value => { + const volumes = value + .split(',') + .map(e => e.trim()) + .filter(Boolean) + .map(entry => { + const sep = entry.indexOf(':'); + return { volumeName: entry.slice(0, sep), mountPath: entry.slice(sep + 1) }; + }); + setByoConfig(c => ({ ...c, capacityProviderVolumes: volumes })); + goToNextByoStep('cpVolumeMounts'); + }} + onCancel={handleByoBack} + /> + )} + {byoStep === 'confirm' && ( = {}): GenerateConfig { + return { + projectName: 'demo', + buildType: 'CodeZip', + protocol: 'HTTP', + sdk: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + language: 'Python', + ...overrides, + }; +} + +describe('buildCreateAgentConfig', () => { + it('maps the core template fields', () => { + const result = buildCreateAgentConfig('myagent', baseConfig()); + expect(result).toMatchObject({ + name: 'myagent', + agentType: 'create', + codeLocation: 'myagent/', + entrypoint: 'main.py', + language: 'Python', + buildType: 'CodeZip', + protocol: 'HTTP', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + // Regression guard: the capacity-provider attachment was silently dropped here before — the + // confirm screen showed it but the written agent had capacityProviderConfiguration: null. + it('carries a by-name capacity provider attachment and volume mounts', () => { + const result = buildCreateAgentConfig( + 'cpagent', + baseConfig({ + networkMode: 'PUBLIC', + capacityProviderConfiguration: { capacityProviderName: 'MyCp' }, + capacityProviderVolumes: [{ volumeName: 'model-weights', mountPath: '/mnt/models' }], + }) + ); + expect(result.capacityProviderConfiguration).toEqual({ capacityProviderName: 'MyCp' }); + expect(result.capacityProviderVolumes).toEqual([{ volumeName: 'model-weights', mountPath: '/mnt/models' }]); + }); + + it('carries a by-ARN capacity provider attachment', () => { + const arn = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:capacity-provider/foo-AbCdEfGhIj'; + const result = buildCreateAgentConfig( + 'cpagent', + baseConfig({ capacityProviderConfiguration: { capacityProviderArn: arn } }) + ); + expect(result.capacityProviderConfiguration).toEqual({ capacityProviderArn: arn }); + }); + + it('omits capacity-provider fields when none are attached', () => { + const result = buildCreateAgentConfig('plain', baseConfig()); + expect(result.capacityProviderConfiguration).toBeUndefined(); + expect(result.capacityProviderVolumes).toBeUndefined(); + }); + + it('drops empty capacity-provider volume arrays rather than emitting []', () => { + const result = buildCreateAgentConfig( + 'cpagent', + baseConfig({ capacityProviderConfiguration: { capacityProviderName: 'MyCp' }, capacityProviderVolumes: [] }) + ); + expect(result.capacityProviderVolumes).toBeUndefined(); + }); + + it('only carries subnets/securityGroups when networkMode is VPC', () => { + const publicCfg = buildCreateAgentConfig('a', baseConfig({ networkMode: 'PUBLIC', subnets: ['subnet-x'] })); + expect(publicCfg.subnets).toBeUndefined(); + + const vpcCfg = buildCreateAgentConfig( + 'a', + baseConfig({ networkMode: 'VPC', subnets: ['subnet-x'], securityGroups: ['sg-y'] }) + ); + expect(vpcCfg.subnets).toEqual(['subnet-x']); + expect(vpcCfg.securityGroups).toEqual(['sg-y']); + }); +}); diff --git a/src/cli/tui/screens/agent/__tests__/computeByoSteps.test.ts b/src/cli/tui/screens/agent/__tests__/computeByoSteps.test.ts index bc8dee596..fa8855ec3 100644 --- a/src/cli/tui/screens/agent/__tests__/computeByoSteps.test.ts +++ b/src/cli/tui/screens/agent/__tests__/computeByoSteps.test.ts @@ -161,3 +161,36 @@ describe('computeByoSteps - filesystem', () => { expect(steps).toContain('s3AddAnother'); }); }); + +describe('computeByoSteps - capacity provider (J2/J3)', () => { + it('includes the capacityProvider step when the advanced setting is selected', () => { + const steps = computeByoSteps(makeInput({ advancedSettings: new Set(['capacityProvider']) })); + expect(steps).toContain('capacityProvider'); + // No mode selected yet → no ARN entry, no volume step. + expect(steps).not.toContain('capacityProviderArn'); + expect(steps).not.toContain('cpVolumeMounts'); + }); + + it('adds the ARN entry step and cp-volume step when attaching by ARN', () => { + const steps = computeByoSteps( + makeInput({ advancedSettings: new Set(['capacityProvider']), capacityProviderMode: 'arn' }) + ); + const cpIdx = steps.indexOf('capacityProvider'); + expect(steps.slice(cpIdx, cpIdx + 3)).toEqual(['capacityProvider', 'capacityProviderArn', 'cpVolumeMounts']); + }); + + it('adds the cp-volume step (no ARN entry) when attaching by sibling name', () => { + const steps = computeByoSteps( + makeInput({ advancedSettings: new Set(['capacityProvider']), capacityProviderMode: 'name' }) + ); + expect(steps).not.toContain('capacityProviderArn'); + const cpIdx = steps.indexOf('capacityProvider'); + expect(steps[cpIdx + 1]).toBe('cpVolumeMounts'); + }); + + it('omits capacity provider steps entirely when the setting is not selected', () => { + const steps = computeByoSteps(makeInput()); + expect(steps).not.toContain('capacityProvider'); + expect(steps).not.toContain('cpVolumeMounts'); + }); +}); diff --git a/src/cli/tui/screens/agent/buildCreateAgentConfig.ts b/src/cli/tui/screens/agent/buildCreateAgentConfig.ts new file mode 100644 index 000000000..e4368c3be --- /dev/null +++ b/src/cli/tui/screens/agent/buildCreateAgentConfig.ts @@ -0,0 +1,44 @@ +import type { GenerateConfig } from '../generate/types'; +import type { AddAgentConfig } from './types'; +import { DEFAULT_PYTHON_VERSION } from './types'; + +/** + * Map a completed generate-wizard {@link GenerateConfig} to the {@link AddAgentConfig} consumed by + * the `add agent` create path. Pure so it can be unit-tested independently of the React screen — + * a regression here silently drops fields from the created agent (e.g. the capacity-provider + * attachment, which had exactly that bug and no test coverage before). + */ +export function buildCreateAgentConfig(name: string, config: GenerateConfig): AddAgentConfig { + return { + name, + agentType: 'create', + codeLocation: `${name}/`, + entrypoint: 'main.py', + language: config.language, + buildType: config.buildType, + ...(config.buildType === 'Container' && config.dockerfile && { dockerfile: config.dockerfile }), + protocol: config.protocol, + framework: config.sdk, + modelProvider: config.modelProvider, + apiKey: config.apiKey, + networkMode: config.networkMode, + subnets: config.networkMode === 'VPC' ? config.subnets : undefined, + securityGroups: config.networkMode === 'VPC' ? config.securityGroups : undefined, + ...(config.networkMode === 'VPC' && config.buildType === 'Container' && config.vpcId && { vpcId: config.vpcId }), + requestHeaderAllowlist: config.requestHeaderAllowlist, + ...(config.authorizerType && config.authorizerType !== 'AWS_IAM' && { authorizerType: config.authorizerType }), + ...(config.authorizerType === 'CUSTOM_JWT' && config.jwtConfig && { jwtConfig: config.jwtConfig }), + idleRuntimeSessionTimeout: config.idleRuntimeSessionTimeout, + maxLifetime: config.maxLifetime, + sessionStorageMountPath: config.sessionStorageMountPath, + ...(config.efsAccessPoints?.length && { efsAccessPoints: config.efsAccessPoints }), + ...(config.s3AccessPoints?.length && { s3AccessPoints: config.s3AccessPoints }), + ...(config.capacityProviderConfiguration && { + capacityProviderConfiguration: config.capacityProviderConfiguration, + }), + ...(config.capacityProviderVolumes?.length && { capacityProviderVolumes: config.capacityProviderVolumes }), + withConfigBundle: config.withConfigBundle, + pythonVersion: DEFAULT_PYTHON_VERSION, + memory: config.memory, + }; +} diff --git a/src/cli/tui/screens/agent/types.ts b/src/cli/tui/screens/agent/types.ts index 7fb85b48c..a9dccdf77 100644 --- a/src/cli/tui/screens/agent/types.ts +++ b/src/cli/tui/screens/agent/types.ts @@ -1,5 +1,7 @@ import type { BuildType, + CapacityProviderConfiguration, + CapacityProviderVolumeConfig, EfsAccessPointConfig, ModelProvider, NetworkMode, @@ -65,6 +67,9 @@ export type AddAgentStep = | 's3Arn' | 's3MountPath' | 's3AddAnother' + | 'capacityProvider' + | 'capacityProviderArn' + | 'cpVolumeMounts' | 'memory' | 'region' | 'bedrockAgent' @@ -112,6 +117,10 @@ export interface AddAgentConfig { efsAccessPoints?: EfsAccessPointConfig[]; /** S3 Files access point mounts configured for this agent */ s3AccessPoints?: S3FilesAccessPointConfig[]; + /** Capacity provider the runtime is attached to (in-project sibling name or external ARN) */ + capacityProviderConfiguration?: CapacityProviderConfiguration; + /** Capacity provider volume mounts configured for this agent */ + capacityProviderVolumes?: CapacityProviderVolumeConfig[]; /** When true, create a config bundle wired into the agent template */ withConfigBundle?: boolean; /** Python version (only for Python agents) */ @@ -154,6 +163,9 @@ export const ADD_AGENT_STEP_LABELS: Record = { s3Arn: 'S3 Files ARN', s3MountPath: 'S3 Files Path', s3AddAnother: 'Add S3 Files', + capacityProvider: 'Capacity Provider', + capacityProviderArn: 'Capacity Provider ARN', + cpVolumeMounts: 'CP Volumes', memory: 'Memory', region: 'Region', bedrockAgent: 'Agent', diff --git a/src/cli/tui/screens/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index d63aec2cb..a2dfccff9 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -118,7 +118,15 @@ export function mapByoConfigToAgent(config: AddAgentConfig): AgentEnvSpec { }, } : {}), - ...buildFilesystemConfigurations(config.sessionStorageMountPath, config.efsAccessPoints, config.s3AccessPoints), + ...(config.capacityProviderConfiguration && { + capacityProviderConfiguration: config.capacityProviderConfiguration, + }), + ...buildFilesystemConfigurations( + config.sessionStorageMountPath, + config.efsAccessPoints, + config.s3AccessPoints, + config.capacityProviderVolumes + ), }; } @@ -151,6 +159,8 @@ export function mapAddAgentConfigToGenerateConfig(config: AddAgentConfig): Gener sessionStorageMountPath: config.sessionStorageMountPath, efsAccessPoints: config.efsAccessPoints, s3AccessPoints: config.s3AccessPoints, + capacityProviderConfiguration: config.capacityProviderConfiguration, + capacityProviderVolumes: config.capacityProviderVolumes, withConfigBundle: config.withConfigBundle, }; } @@ -183,6 +193,9 @@ export function useAddAgent() { memory_type: standardize(MemoryEnum, config.memory ?? 'none'), efs_mount_count: (config.efsAccessPoints ?? []).length, s3_mount_count: (config.s3AccessPoints ?? []).length, + has_capacity_provider: !!config.capacityProviderConfiguration, + capacity_provider_by_arn: !!config.capacityProviderConfiguration?.capacityProviderArn, + cp_volume_mount_count: (config.capacityProviderVolumes ?? []).length, }, () => addAgentInner(config) ); diff --git a/src/cli/tui/screens/capacity-provider/AddCapacityProviderFlow.tsx b/src/cli/tui/screens/capacity-provider/AddCapacityProviderFlow.tsx index 2fc056e79..6492adec4 100644 --- a/src/cli/tui/screens/capacity-provider/AddCapacityProviderFlow.tsx +++ b/src/cli/tui/screens/capacity-provider/AddCapacityProviderFlow.tsx @@ -8,7 +8,14 @@ import React, { useCallback, useEffect, useState } from 'react'; type FlowState = | { name: 'create-wizard' } - | { name: 'create-success'; capacityProviderName: string; os: string; instanceTypes: string; description?: string } + | { + name: 'create-success'; + capacityProviderName: string; + os: string; + instanceTypes: string; + volumes: { name: string; sizeGiB: number }[]; + description?: string; + } | { name: 'error'; message: string }; interface AddCapacityProviderFlowProps { @@ -50,6 +57,13 @@ export function AddCapacityProviderFlow({ securityGroups: config.securityGroups, os: config.os, instanceTypes: config.instanceTypes, + instanceProfileArn: config.instanceProfileArn, + volumeName: config.volumes.map(v => v.name), + volumeSize: config.volumes.map(v => String(v.sizeGiB)), + volumeEncrypted: config.volumeEncrypted, + volumeKmsKey: config.volumeKmsKey, + idleInstanceTimeout: config.idleInstanceTimeout, + maxLifetime: config.maxLifetime, }) .then(result => { if (result.success) { @@ -58,6 +72,7 @@ export function AddCapacityProviderFlow({ capacityProviderName: result.capacityProviderName, os: config.os, instanceTypes: config.instanceTypes, + volumes: config.volumes, description: config.description, }); return; @@ -82,6 +97,9 @@ export function AddCapacityProviderFlow({ OS: {flow.os} Instance types: {flow.instanceTypes} + {flow.volumes.length > 0 && ( + Volumes: {flow.volumes.map(v => `${v.name} (${v.sizeGiB} GiB)`).join(', ')} + )} {flow.description && Desc: {flow.description}} } diff --git a/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx b/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx index 4b238ae5f..17b5681e5 100644 --- a/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx +++ b/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx @@ -5,6 +5,7 @@ import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; +import { Box, Text } from 'ink'; import React, { useMemo, useState } from 'react'; const OS_OPTIONS: SelectableItem[] = [ @@ -12,6 +13,22 @@ const OS_OPTIONS: SelectableItem[] = [ { id: 'LINUX_ARM64', title: 'Linux ARM64', description: 'Graviton/ARM 64-bit Linux instances' }, ]; +const ADD_ANOTHER_OPTIONS: SelectableItem[] = [ + { id: 'yes', title: 'Yes', description: 'Add another volume' }, + { id: 'no', title: 'No', description: 'Continue' }, +]; + +const ENCRYPTION_OPTIONS: SelectableItem[] = [ + { id: 'no', title: 'No encryption', description: 'EBS volumes are not encrypted' }, + { id: 'yes', title: 'Encrypt EBS volumes', description: 'Optionally with your own KMS key' }, +]; + +/** A named EBS volume defined on the capacity provider. */ +export interface CapacityProviderVolumeInput { + name: string; + sizeGiB: number; +} + export interface AddCapacityProviderConfig { name: string; operatorRoleArn?: string; @@ -20,6 +37,12 @@ export interface AddCapacityProviderConfig { securityGroups: string; os: OperatingSystem; instanceTypes: string; + instanceProfileArn?: string; + volumes: CapacityProviderVolumeInput[]; + volumeEncrypted?: boolean; + volumeKmsKey?: string; + idleInstanceTimeout?: string; + maxLifetime?: string; } type Step = @@ -29,9 +52,18 @@ type Step = | 'security-groups' | 'os' | 'instance-types' + | 'instance-profile' + | 'volumes' + | 'volume-encryption' + | 'volume-kms' + | 'idle-timeout' + | 'max-lifetime' | 'description' | 'confirm'; +/** Sub-phase within the (single) Volumes step — an add-another loop. */ +type VolumePhase = 'name' | 'size' | 'another'; + const STEP_LABELS: Record = { name: 'Name', 'operator-role': 'Operator Role', @@ -39,23 +71,49 @@ const STEP_LABELS: Record = { 'security-groups': 'Security Groups', os: 'OS', 'instance-types': 'Instance Types', + 'instance-profile': 'Instance Profile', + volumes: 'Volumes', + 'volume-encryption': 'Encryption', + 'volume-kms': 'KMS Key', + 'idle-timeout': 'Idle Timeout', + 'max-lifetime': 'Max Lifetime', description: 'Description', confirm: 'Confirm', }; -const STEPS: Step[] = [ - 'name', - 'operator-role', - 'subnets', - 'security-groups', - 'os', - 'instance-types', - 'description', - 'confirm', -]; +/** + * The step sequence. Encryption is only relevant when volumes are defined, and the KMS-key step + * only appears when encryption is enabled — so the tail is computed from current state. + */ +export function buildSteps(volumeCount: number, encrypted: boolean): Step[] { + const steps: Step[] = [ + 'name', + 'operator-role', + 'subnets', + 'security-groups', + 'os', + 'instance-types', + 'instance-profile', + 'volumes', + ]; + if (volumeCount > 0) { + steps.push('volume-encryption'); + if (encrypted) steps.push('volume-kms'); + } + steps.push('idle-timeout', 'max-lifetime', 'description', 'confirm'); + return steps; +} const SUBNET_PATTERN = /^subnet-[0-9a-zA-Z]{8,17}$/; const SECURITY_GROUP_PATTERN = /^sg-[0-9a-zA-Z]{8,17}$/; +// Mirror the schema patterns in src/schema/schemas/primitives/capacity-provider.ts. +const INSTANCE_PROFILE_ARN_PATTERN = /^arn:[^:]+:iam::[0-9]{12}:instance-profile\/.+$/; +const KMS_KEY_ARN_PATTERN = + /^arn:[^:]+:kms:[a-z0-9-]+:[0-9]{12}:key\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +const MAX_VOLUMES = 5; +const MAX_VOLUME_SIZE_GIB = 65536; +const LIFECYCLE_SECONDS_MIN = 60; +const LIFECYCLE_SECONDS_MAX = 1209600; function splitList(value: string): string[] { return value @@ -64,6 +122,18 @@ function splitList(value: string): string[] { .filter(Boolean); } +/** Validate an optional whole-number lifecycle timeout (seconds). Empty is allowed (skip). */ +function validateOptionalLifetime(value: string): true | string { + const trimmed = value.trim(); + if (trimmed === '') return true; + if (!/^[0-9]+$/.test(trimmed)) return 'Enter a whole number of seconds'; + const n = Number(trimmed); + return ( + (n >= LIFECYCLE_SECONDS_MIN && n <= LIFECYCLE_SECONDS_MAX) || + `Must be between ${LIFECYCLE_SECONDS_MIN} and ${LIFECYCLE_SECONDS_MAX} seconds` + ); +} + interface AddCapacityProviderScreenProps { onComplete: (config: AddCapacityProviderConfig) => void; onExit: () => void; @@ -78,6 +148,14 @@ export function AddCapacityProviderScreen({ onComplete, onExit, existingNames }: const [securityGroups, setSecurityGroups] = useState(''); const [os, setOs] = useState('LINUX_X86_64'); const [instanceTypes, setInstanceTypes] = useState(''); + const [instanceProfileArn, setInstanceProfileArn] = useState(''); + const [volumes, setVolumes] = useState([]); + const [volumePhase, setVolumePhase] = useState('name'); + const [pendingVolumeName, setPendingVolumeName] = useState(''); + const [volumeEncrypted, setVolumeEncrypted] = useState(false); + const [volumeKmsKey, setVolumeKmsKey] = useState(''); + const [idleTimeout, setIdleTimeout] = useState(''); + const [maxLifetime, setMaxLifetime] = useState(''); const [description, setDescription] = useState(''); const isNameStep = step === 'name'; @@ -86,9 +164,27 @@ export function AddCapacityProviderScreen({ onComplete, onExit, existingNames }: const isSecurityGroupsStep = step === 'security-groups'; const isOsStep = step === 'os'; const isInstanceTypesStep = step === 'instance-types'; + const isInstanceProfileStep = step === 'instance-profile'; + const isVolumesStep = step === 'volumes'; + const isVolumeEncryptionStep = step === 'volume-encryption'; + const isVolumeKmsStep = step === 'volume-kms'; + const isIdleTimeoutStep = step === 'idle-timeout'; + const isMaxLifetimeStep = step === 'max-lifetime'; const isDescriptionStep = step === 'description'; const isConfirmStep = step === 'confirm'; + // Navigate to the step before `cur` in the (state-dependent) sequence. When that step is the + // Volumes step, re-enter it at the review phase if volumes exist. + const goToPrevStep = (cur: Step) => { + const seq = buildSteps(volumes.length, volumeEncrypted); + const prev = seq[seq.indexOf(cur) - 1] ?? 'name'; + if (prev === 'volumes') { + setPendingVolumeName(''); + setVolumePhase(volumes.length > 0 ? 'another' : 'name'); + } + setStep(prev); + }; + const osNav = useListNavigation({ items: OS_OPTIONS, isActive: isOsStep, @@ -99,6 +195,46 @@ export function AddCapacityProviderScreen({ onComplete, onExit, existingNames }: onExit: () => setStep('security-groups'), }); + // "Add another volume?" prompt within the Volumes step. + const volumeAnotherNav = useListNavigation({ + items: ADD_ANOTHER_OPTIONS, + isActive: isVolumesStep && volumePhase === 'another', + onSelect: (item: SelectableItem) => { + if (item.id === 'yes') { + setPendingVolumeName(''); + setVolumePhase('name'); + } else { + // At least one volume exists here, so encryption is the next step. + setStep('volume-encryption'); + } + }, + // Esc backs out of the decision to the volume-name entry (empty name finishes). + onExit: () => { + setPendingVolumeName(''); + setVolumePhase('name'); + }, + }); + + const encryptionNav = useListNavigation({ + items: ENCRYPTION_OPTIONS, + isActive: isVolumeEncryptionStep, + onSelect: (item: SelectableItem) => { + const encrypt = item.id === 'yes'; + setVolumeEncrypted(encrypt); + if (encrypt) { + setStep('volume-kms'); + } else { + setVolumeKmsKey(''); + setStep('idle-timeout'); + } + }, + onExit: () => { + setPendingVolumeName(''); + setVolumePhase('another'); + setStep('volumes'); + }, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => @@ -109,19 +245,27 @@ export function AddCapacityProviderScreen({ onComplete, onExit, existingNames }: securityGroups, os, instanceTypes, + instanceProfileArn: instanceProfileArn || undefined, + volumes, + volumeEncrypted: volumes.length > 0 && volumeEncrypted ? true : undefined, + volumeKmsKey: volumeEncrypted && volumeKmsKey ? volumeKmsKey : undefined, + idleInstanceTimeout: idleTimeout || undefined, + maxLifetime: maxLifetime || undefined, description: description || undefined, }), onExit: () => setStep('description'), isActive: isConfirmStep, }); - const helpText = isOsStep + const isSelectStep = isOsStep || isVolumeEncryptionStep || (isVolumesStep && volumePhase === 'another'); + const helpText = isSelectStep ? HELP_TEXT.NAVIGATE_SELECT : isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL : HELP_TEXT.TEXT_INPUT; - const headerContent = ; + const steps = useMemo(() => buildSteps(volumes.length, volumeEncrypted), [volumes.length, volumeEncrypted]); + const headerContent = ; const confirmFields = useMemo( () => [ @@ -131,9 +275,42 @@ export function AddCapacityProviderScreen({ onComplete, onExit, existingNames }: { label: 'Security Groups', value: splitList(securityGroups).join(', ') }, { label: 'OS', value: os }, { label: 'Instance Types', value: splitList(instanceTypes).join(', ') }, + ...(instanceProfileArn ? [{ label: 'Instance Profile', value: instanceProfileArn }] : []), + { + label: 'Volumes', + value: volumes.length ? volumes.map(v => `${v.name} (${v.sizeGiB} GiB)`).join(', ') : '(none)', + }, + ...(volumes.length > 0 + ? [ + { + label: 'Encryption', + value: volumeEncrypted + ? volumeKmsKey + ? `Enabled (KMS: ${volumeKmsKey})` + : 'Enabled (AWS-managed)' + : 'Off', + }, + ] + : []), + ...(idleTimeout ? [{ label: 'Idle Timeout', value: `${idleTimeout}s` }] : []), + ...(maxLifetime ? [{ label: 'Max Lifetime', value: `${maxLifetime}s` }] : []), ...(description ? [{ label: 'Description', value: description }] : []), ], - [name, operatorRoleArn, subnets, securityGroups, os, instanceTypes, description] + [ + name, + operatorRoleArn, + subnets, + securityGroups, + os, + instanceTypes, + instanceProfileArn, + volumes, + volumeEncrypted, + volumeKmsKey, + idleTimeout, + maxLifetime, + description, + ] ); return ( @@ -231,7 +408,7 @@ export function AddCapacityProviderScreen({ onComplete, onExit, existingNames }: initialValue={instanceTypes} onSubmit={(value: string) => { setInstanceTypes(value); - setStep('description'); + setStep('instance-profile'); }} onCancel={() => setStep('os')} customValidation={value => { @@ -241,6 +418,164 @@ export function AddCapacityProviderScreen({ onComplete, onExit, existingNames }: /> )} + {isInstanceProfileStep && ( + { + setInstanceProfileArn(value.trim()); + setPendingVolumeName(''); + setVolumePhase('name'); + setStep('volumes'); + }} + onCancel={() => setStep('instance-types')} + allowEmpty + customValidation={value => + value.trim() === '' || + INSTANCE_PROFILE_ARN_PATTERN.test(value.trim()) || + 'Must be a valid IAM instance profile ARN (arn:...:instance-profile/...)' + } + /> + )} + + {isVolumesStep && ( + + {volumes.length > 0 && ( + + Added volumes: + {volumes.map(v => ( + + {' • '} + {v.name} {' '} + {v.sizeGiB} GiB + + ))} + + )} + + {volumePhase === 'name' && ( + = MAX_VOLUMES + ? `Maximum of ${MAX_VOLUMES} volumes reached — press Enter to continue` + : 'Volume name (optional — press Enter to finish adding volumes)' + } + initialValue={pendingVolumeName} + onSubmit={(value: string) => { + const trimmed = value.trim(); + if (trimmed === '' || volumes.length >= MAX_VOLUMES) { + // Finished defining volumes — encryption applies only when volumes exist. + setStep(volumes.length > 0 ? 'volume-encryption' : 'idle-timeout'); + return; + } + setPendingVolumeName(trimmed); + setVolumePhase('size'); + }} + onCancel={() => setStep('instance-profile')} + allowEmpty + customValidation={value => { + const trimmed = value.trim(); + if (trimmed === '') return true; // empty = finish + return volumes.every(v => v.name !== trimmed) || `Volume "${trimmed}" already added`; + }} + /> + )} + + {volumePhase === 'size' && ( + { + const next = [...volumes, { name: pendingVolumeName, sizeGiB: Number(value.trim()) }]; + setVolumes(next); + setPendingVolumeName(''); + // At the max, there's nothing more to add — go straight to encryption. + if (next.length >= MAX_VOLUMES) { + setStep('volume-encryption'); + } else { + setVolumePhase('another'); + } + }} + onCancel={() => setVolumePhase('name')} + customValidation={value => { + const trimmed = value.trim(); + if (!/^[0-9]+$/.test(trimmed)) return 'Enter a whole number of GiB'; + const n = Number(trimmed); + return ( + (n >= 1 && n <= MAX_VOLUME_SIZE_GIB) || `Size must be between 1 and ${MAX_VOLUME_SIZE_GIB} GiB` + ); + }} + /> + )} + + {volumePhase === 'another' && ( + + )} + + )} + + {isVolumeEncryptionStep && ( + + )} + + {isVolumeKmsStep && ( + { + setVolumeKmsKey(value.trim()); + setStep('idle-timeout'); + }} + onCancel={() => setStep('volume-encryption')} + allowEmpty + customValidation={value => + value.trim() === '' || KMS_KEY_ARN_PATTERN.test(value.trim()) || 'Must be a valid KMS key ARN' + } + /> + )} + + {isIdleTimeoutStep && ( + { + setIdleTimeout(value.trim()); + setStep('max-lifetime'); + }} + onCancel={() => goToPrevStep('idle-timeout')} + allowEmpty + customValidation={validateOptionalLifetime} + /> + )} + + {isMaxLifetimeStep && ( + { + setMaxLifetime(value.trim()); + setStep('description'); + }} + onCancel={() => setStep('idle-timeout')} + allowEmpty + customValidation={validateOptionalLifetime} + /> + )} + {isDescriptionStep && ( setStep('instance-types')} + onCancel={() => setStep('max-lifetime')} allowEmpty /> )} diff --git a/src/cli/tui/screens/capacity-provider/__tests__/buildSteps.test.ts b/src/cli/tui/screens/capacity-provider/__tests__/buildSteps.test.ts new file mode 100644 index 000000000..4ef3ff929 --- /dev/null +++ b/src/cli/tui/screens/capacity-provider/__tests__/buildSteps.test.ts @@ -0,0 +1,45 @@ +import { buildSteps } from '../AddCapacityProviderScreen'; +import { describe, expect, it } from 'vitest'; + +describe('AddCapacityProviderScreen buildSteps', () => { + it('always includes the base steps and lifecycle timeouts', () => { + const steps = buildSteps(0, false); + expect(steps).toEqual([ + 'name', + 'operator-role', + 'subnets', + 'security-groups', + 'os', + 'instance-types', + 'instance-profile', + 'volumes', + 'idle-timeout', + 'max-lifetime', + 'description', + 'confirm', + ]); + }); + + it('omits the encryption step when there are no volumes (even if encrypted is stale-true)', () => { + const steps = buildSteps(0, true); + expect(steps).not.toContain('volume-encryption'); + expect(steps).not.toContain('volume-kms'); + }); + + it('includes the encryption step once volumes exist', () => { + const steps = buildSteps(1, false); + expect(steps).toContain('volume-encryption'); + expect(steps).not.toContain('volume-kms'); + // Encryption is asked right after volumes, before lifecycle timeouts. + expect(steps.indexOf('volume-encryption')).toBe(steps.indexOf('volumes') + 1); + expect(steps.indexOf('volume-encryption')).toBeLessThan(steps.indexOf('idle-timeout')); + }); + + it('adds the KMS-key step only when encryption is enabled with volumes present', () => { + const steps = buildSteps(2, true); + expect(steps).toContain('volume-encryption'); + expect(steps).toContain('volume-kms'); + // KMS immediately follows the encryption choice. + expect(steps.indexOf('volume-kms')).toBe(steps.indexOf('volume-encryption') + 1); + }); +}); diff --git a/src/cli/tui/screens/generate/GenerateWizardUI.tsx b/src/cli/tui/screens/generate/GenerateWizardUI.tsx index bdd8708c3..a80eb2b24 100644 --- a/src/cli/tui/screens/generate/GenerateWizardUI.tsx +++ b/src/cli/tui/screens/generate/GenerateWizardUI.tsx @@ -1,5 +1,6 @@ import type { NetworkMode, RuntimeAuthorizerType } from '../../../../schema'; import { + CapacityProviderArnSchema, DEFAULT_MODEL_IDS, LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN, @@ -52,6 +53,8 @@ interface GenerateWizardUIProps { onConfirm: () => void; isActive: boolean; credentialProjectName?: string; // Override for credential naming (add agent flow) + /** In-project capacity provider names, offered as by-name attach options (empty for `create`). */ + capacityProviderNames?: string[]; } /** @@ -64,6 +67,7 @@ export function GenerateWizardUI({ onConfirm, isActive, credentialProjectName, + capacityProviderNames = [], }: GenerateWizardUIProps) { const getItems = (): SelectableItem[] => { switch (wizard.step) { @@ -102,6 +106,16 @@ export function GenerateWizardUI({ return NETWORK_MODE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })); case 'authorizerType': return RUNTIME_AUTHORIZER_TYPE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })); + case 'capacityProvider': + return [ + { id: '__none__', title: 'None', description: 'Use AgentCore-managed compute (default)' }, + ...capacityProviderNames.map(cpName => ({ + id: cpName, + title: cpName, + description: 'Capacity provider in this project', + })), + { id: '__arn__', title: 'Enter an ARN…', description: 'Attach to an external capacity provider by ARN' }, + ]; default: return []; } @@ -127,6 +141,8 @@ export function GenerateWizardUI({ const isS3ArnStep = wizard.step === 's3Arn'; const isS3MountPathStep = wizard.step === 's3MountPath'; const isS3AddAnotherStep = wizard.step === 's3AddAnother'; + const isCapacityProviderArnStep = wizard.step === 'capacityProviderArn'; + const isCpVolumeMountsStep = wizard.step === 'cpVolumeMounts'; const isConfirmStep = wizard.step === 'confirm'; // Advanced multi-select items — filter out options not applicable to current config @@ -162,6 +178,9 @@ export function GenerateWizardUI({ case 'authorizerType': wizard.setAuthorizerType(item.id as RuntimeAuthorizerType); break; + case 'capacityProvider': + wizard.setCapacityProvider(item.id); + break; } }; @@ -558,6 +577,51 @@ export function GenerateWizardUI({ {isS3AddAnotherStep && } + {isCapacityProviderArnStep && ( + + )} + + {isCpVolumeMountsStep && ( + `${v.volumeName}:${v.mountPath}`) + .join(', ')} + allowEmpty + customValidation={value => { + if (!value.trim()) return true; + for (const entry of value.split(',')) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const sep = trimmed.indexOf(':'); + if (sep <= 0) + return `Invalid volume mount "${trimmed}". Expected name:path (e.g. model-weights:/mnt/models).`; + const r = validateBYOMountPath(trimmed.slice(sep + 1)); + if (r !== true) return r; + } + return true; + }} + onSubmit={value => { + const volumes = value + .split(',') + .map(e => e.trim()) + .filter(Boolean) + .map(entry => { + const sep = entry.indexOf(':'); + return { volumeName: entry.slice(0, sep), mountPath: entry.slice(sep + 1) }; + }); + wizard.setCpVolumeMounts(volumes); + }} + onCancel={onBack} + /> + )} + {isConfirmStep && } ); @@ -582,7 +646,9 @@ export function getWizardHelpText(step: GenerateStep): string { step === 'efsArn' || step === 'efsMountPath' || step === 's3Arn' || - step === 's3MountPath' + step === 's3MountPath' || + step === 'capacityProviderArn' || + step === 'cpVolumeMounts' ) return 'Enter submit · Esc cancel'; if (step === 'efsAddAnother' || step === 's3AddAnother') return '↑↓ navigate · Enter select · Esc back'; @@ -759,6 +825,23 @@ function ConfirmView({ config, credentialProjectName }: { config: GenerateConfig ))} + {config.capacityProviderConfiguration && ( + + Capacity Provider: + + {config.capacityProviderConfiguration.capacityProviderName ?? + config.capacityProviderConfiguration.capacityProviderArn} + + + )} + {(config.capacityProviderVolumes ?? []).map((v, i) => ( + + CP Volume {i + 1}: + + {v.volumeName} → {v.mountPath} + + + ))} {config.withConfigBundle && ( Config Bundle: diff --git a/src/cli/tui/screens/generate/__tests__/advanced-setting-options.test.ts b/src/cli/tui/screens/generate/__tests__/advanced-setting-options.test.ts new file mode 100644 index 000000000..028b184c5 --- /dev/null +++ b/src/cli/tui/screens/generate/__tests__/advanced-setting-options.test.ts @@ -0,0 +1,17 @@ +import { ADVANCED_SETTING_OPTIONS } from '../types'; +import { describe, expect, it } from 'vitest'; + +// Guards a real regression: capacityProvider was added to this shared list AND separately +// inserted by the BYO screen's byoAdvancedItems, so it rendered twice (duplicate React key). +// byoAdvancedItems and the generate wizard both derive their menu from this list, so unique +// ids here is the invariant that keeps either menu from showing a duplicate option. +describe('ADVANCED_SETTING_OPTIONS', () => { + it('has unique ids', () => { + const ids = ADVANCED_SETTING_OPTIONS.map(o => o.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('includes capacityProvider exactly once', () => { + expect(ADVANCED_SETTING_OPTIONS.filter(o => o.id === 'capacityProvider')).toHaveLength(1); + }); +}); diff --git a/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx b/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx index 9cebb121f..61369b23c 100644 --- a/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx +++ b/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx @@ -908,3 +908,49 @@ describe('validateDockerfileInput', () => { expect(result).not.toBe(true); }); }); + +describe('useGenerateWizard — capacity provider attach', () => { + it('by-name attach sets config, forces PUBLIC networking, and goes to the volume step', () => { + const { ref } = setup(); + act(() => { + ref.current!.wizard.setCapacityProvider('MyCp'); + }); + expect(ref.current!.wizard.config.capacityProviderConfiguration).toEqual({ capacityProviderName: 'MyCp' }); + expect(ref.current!.wizard.config.networkMode).toBe('PUBLIC'); + expect(ref.current!.wizard.step).toBe('cpVolumeMounts'); + }); + + it('by-ARN attach records the ARN and forces PUBLIC networking', () => { + const arn = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:capacity-provider/foo-AbCdEfGhIj'; + const { ref } = setup(); + act(() => { + ref.current!.wizard.setCapacityProviderArn(arn); + }); + expect(ref.current!.wizard.config.capacityProviderConfiguration).toEqual({ capacityProviderArn: arn }); + expect(ref.current!.wizard.config.networkMode).toBe('PUBLIC'); + }); + + it('volume mounts are recorded on the config', () => { + const { ref } = setup(); + act(() => { + ref.current!.wizard.setCapacityProvider('MyCp'); + ref.current!.wizard.setCpVolumeMounts([{ volumeName: 'model-weights', mountPath: '/mnt/models' }]); + }); + expect(ref.current!.wizard.config.capacityProviderVolumes).toEqual([ + { volumeName: 'model-weights', mountPath: '/mnt/models' }, + ]); + }); + + it('selecting None clears any prior attachment and skips to confirm', () => { + const { ref } = setup(); + act(() => { + ref.current!.wizard.setCapacityProvider('MyCp'); + }); + act(() => { + ref.current!.wizard.setCapacityProvider('__none__'); + }); + expect(ref.current!.wizard.config.capacityProviderConfiguration).toBeUndefined(); + expect(ref.current!.wizard.config.capacityProviderVolumes).toBeUndefined(); + expect(ref.current!.wizard.step).toBe('confirm'); + }); +}); diff --git a/src/cli/tui/screens/generate/types.ts b/src/cli/tui/screens/generate/types.ts index fd28d119e..96e9dd66d 100644 --- a/src/cli/tui/screens/generate/types.ts +++ b/src/cli/tui/screens/generate/types.ts @@ -1,5 +1,7 @@ import type { BuildType, + CapacityProviderConfiguration, + CapacityProviderVolumeConfig, EfsAccessPointConfig, ModelProvider, NetworkMode, @@ -44,6 +46,9 @@ export type GenerateStep = | 's3Arn' | 's3MountPath' | 's3AddAnother' + | 'capacityProvider' + | 'capacityProviderArn' + | 'cpVolumeMounts' | 'confirm'; export type MemoryOption = 'none' | 'shortTerm' | 'longAndShortTerm'; @@ -83,6 +88,10 @@ export interface GenerateConfig { efsAccessPoints?: EfsAccessPointConfig[]; /** S3 Files access point mounts configured for this agent */ s3AccessPoints?: S3FilesAccessPointConfig[]; + /** Capacity provider the runtime is attached to (in-project sibling name or external ARN) */ + capacityProviderConfiguration?: CapacityProviderConfiguration; + /** Capacity provider volume mounts configured for this agent */ + capacityProviderVolumes?: CapacityProviderVolumeConfig[]; /** When true, create a config bundle wired into the agent template */ withConfigBundle?: boolean; } @@ -127,6 +136,9 @@ export const STEP_LABELS: Record = { s3Arn: 'S3 Files ARN', s3MountPath: 'S3 Files Path', s3AddAnother: 'Add S3 Files', + capacityProvider: 'Capacity Provider', + capacityProviderArn: 'CP ARN', + cpVolumeMounts: 'CP Volumes', confirm: 'Confirm', }; @@ -212,6 +224,7 @@ export type AdvancedSettingId = | 'auth' | 'lifecycle' | 'filesystem' + | 'capacityProvider' | 'configBundle'; export const ADVANCED_SETTING_OPTIONS = [ @@ -221,6 +234,11 @@ export const ADVANCED_SETTING_OPTIONS = [ { id: 'auth', title: 'Custom auth (JWT)', description: 'OIDC-based token validation for inbound requests' }, { id: 'lifecycle', title: 'Lifecycle timeouts', description: 'Idle timeout & max instance lifetime' }, { id: 'filesystem', title: 'Filesystem mounts', description: 'Session storage, EFS, and S3 Files mounts' }, + { + id: 'capacityProvider', + title: 'Capacity provider', + description: 'Run on a customer-managed EC2 compute pool (+ mount its volumes)', + }, { id: 'configBundle', title: 'Config bundle', diff --git a/src/cli/tui/screens/generate/useGenerateWizard.ts b/src/cli/tui/screens/generate/useGenerateWizard.ts index 153ded775..b10e60079 100644 --- a/src/cli/tui/screens/generate/useGenerateWizard.ts +++ b/src/cli/tui/screens/generate/useGenerateWizard.ts @@ -37,6 +37,9 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { // Track if user has selected a framework (moved past sdk step) const [sdkSelected, setSdkSelected] = useState(false); const [advancedSettings, setAdvancedSettings] = useState>(new Set()); + // How the runtime attaches to a capacity provider: none, an in-project sibling by name, or an + // external ARN. Drives which CP sub-steps appear (see the steps memo). + const [capacityProviderMode, setCapacityProviderMode] = useState<'none' | 'name' | 'arn'>('none'); const advancedSelected = advancedSettings.size > 0; @@ -103,6 +106,17 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { 's3AddAnother' ); } + // Capacity provider — a CP select, an optional ARN entry, and volume mounts (only once attached) + if (advancedSettings.has('capacityProvider')) { + subSteps.push('capacityProvider'); + if (capacityProviderMode === 'arn') { + subSteps.push('capacityProviderArn'); + } + // CP volumes can only be mounted once the runtime is attached to a capacity provider. + if (capacityProviderMode !== 'none') { + subSteps.push('cpVolumeMounts'); + } + } // Config bundle — no sub-steps needed, uses smart defaults filtered = [...filtered.slice(0, afterAdvanced), ...subSteps, ...filtered.slice(afterAdvanced)]; } @@ -124,6 +138,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { sdkSelected, advancedSelected, advancedSettings, + capacityProviderMode, ]); const currentIndex = steps.indexOf(step); @@ -275,8 +290,11 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { sessionStorageMountPath: undefined, efsAccessPoints: undefined, s3AccessPoints: undefined, + capacityProviderConfiguration: undefined, + capacityProviderVolumes: undefined, withConfigBundle: undefined, })); + setCapacityProviderMode('none'); resetFilesystemState(); setStep('confirm'); } else { @@ -290,6 +308,11 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { })); resetFilesystemState(); } + // Clear capacity-provider state if it was deselected + if (!selected.has('capacityProvider')) { + setCapacityProviderMode('none'); + setConfig(c => ({ ...c, capacityProviderConfiguration: undefined, capacityProviderVolumes: undefined })); + } // Config bundle has no sub-steps — set flag immediately setConfig(c => ({ ...c, @@ -311,6 +334,8 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { setStep('idleTimeout'); } else if (selected.has('filesystem')) { setStep('sessionStorageMountPath'); + } else if (selected.has('capacityProvider')) { + setStep('capacityProvider'); } else { setStep('confirm'); } @@ -433,6 +458,56 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { setTimeout(() => goToNextStep('sessionStorageMountPath'), 0); }, [goToNextStep]); + const setCapacityProvider = useCallback((id: string) => { + if (id === '__none__') { + setCapacityProviderMode('none'); + setConfig(c => ({ ...c, capacityProviderConfiguration: undefined, capacityProviderVolumes: undefined })); + // CP is the last advanced sub-step, so None's next step is always confirm. Navigate directly + // (not via goToNextStep) to avoid the steps-memo race when re-selecting None after a CP. + setStep('confirm'); + } else if (id === '__arn__') { + setCapacityProviderMode('arn'); + setStep('capacityProviderArn'); + } else { + // In-project sibling by name. CP and VPC are mutually exclusive → force PUBLIC networking. + setCapacityProviderMode('name'); + setConfig(c => ({ + ...c, + capacityProviderConfiguration: { capacityProviderName: id }, + networkMode: 'PUBLIC', + subnets: undefined, + securityGroups: undefined, + vpcId: undefined, + })); + // A non-none attachment always has cpVolumeMounts next; navigate directly (steps memo hasn't + // recomputed with mode='name' yet, so goToNextStep would skip it). + setStep('cpVolumeMounts'); + } + }, []); + + const setCapacityProviderArn = useCallback( + (arn: string) => { + setConfig(c => ({ + ...c, + capacityProviderConfiguration: { capacityProviderArn: arn }, + networkMode: 'PUBLIC', + subnets: undefined, + securityGroups: undefined, + vpcId: undefined, + })); + setTimeout(() => goToNextStep('capacityProviderArn'), 0); + }, + [goToNextStep] + ); + + const setCpVolumeMounts = useCallback( + (volumes: NonNullable) => { + setConfig(c => ({ ...c, capacityProviderVolumes: volumes.length > 0 ? volumes : undefined })); + setTimeout(() => goToNextStep('cpVolumeMounts'), 0); + }, + [goToNextStep] + ); + const goBack = useCallback(() => { setError(null); // efsMountPath: editing → review screen, adding → ARN entry @@ -507,6 +582,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { setError(null); setSdkSelected(false); setAdvancedSettings(new Set()); + setCapacityProviderMode('none'); resetFilesystemState(); }, [resetFilesystemState]); @@ -555,6 +631,10 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { skipMaxLifetime, setSessionStorageMountPath, skipSessionStorageMountPath, + capacityProviderMode, + setCapacityProvider, + setCapacityProviderArn, + setCpVolumeMounts, pendingEfsArn, editingEfsIndex, submitEfsArn, diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index e009b8822..acb7196ed 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -42,7 +42,7 @@ const REMOVE_RESOURCES: { id: RemoveResourceType; title: string; description: st { id: 'config-bundle', title: 'Configuration Bundle', description: 'Remove a configuration bundle' }, { id: 'runtime-endpoint', title: 'Runtime Endpoint', description: 'Remove a runtime endpoint' }, { id: 'dataset', title: 'Dataset', description: 'Remove a dataset' }, - { id: 'capacity-provider', title: 'Capacity Provider [preview]', description: 'Remove a capacity provider' }, + { id: 'capacity-provider', title: 'Capacity Provider', description: 'Remove a capacity provider' }, { id: 'all', title: 'All', description: 'Reset entire agentcore project' }, ]; diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index 750f1a0d0..8f453c79d 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -1038,3 +1038,110 @@ describe('AgentEnvSpecSchema — SG≤5 for Container builds in VPC mode', () => expect(result.success).toBe(true); }); }); + +describe('AgentEnvSpecSchema — capacityProviderConfiguration (J2)', () => { + const base = { + name: 'CpAgent', + build: 'CodeZip', + entrypoint: 'main.py:handler', + codeLocation: './agents/cp', + runtimeVersion: 'PYTHON_3_12', + protocol: 'HTTP', + }; + const CP_ARN = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:capacity-provider/my_pool-a1b2c3d4e5'; + + it('accepts a sibling capacity provider by name', () => { + const r = AgentEnvSpecSchema.safeParse({ + ...base, + capacityProviderConfiguration: { capacityProviderName: 'my_pool' }, + }); + expect(r.success).toBe(true); + }); + + it('accepts an external capacity provider by ARN', () => { + const r = AgentEnvSpecSchema.safeParse({ ...base, capacityProviderConfiguration: { capacityProviderArn: CP_ARN } }); + expect(r.success).toBe(true); + }); + + it('rejects setting both name and ARN', () => { + const r = AgentEnvSpecSchema.safeParse({ + ...base, + capacityProviderConfiguration: { capacityProviderName: 'my_pool', capacityProviderArn: CP_ARN }, + }); + expect(r.success).toBe(false); + }); + + it('rejects setting neither name nor ARN', () => { + const r = AgentEnvSpecSchema.safeParse({ ...base, capacityProviderConfiguration: {} }); + expect(r.success).toBe(false); + }); + + it('rejects a malformed capacity provider ARN', () => { + const r = AgentEnvSpecSchema.safeParse({ + ...base, + capacityProviderConfiguration: { capacityProviderArn: 'arn:aws:bedrock-agentcore:us-west-2:123:runtime/x' }, + }); + expect(r.success).toBe(false); + }); + + it('rejects capacity provider combined with VPC networkMode', () => { + const r = AgentEnvSpecSchema.safeParse({ + ...base, + capacityProviderConfiguration: { capacityProviderName: 'my_pool' }, + networkMode: 'VPC', + networkConfig: { subnets: ['subnet-0123456789abcdef0'], securityGroups: ['sg-0123456789abcdef0'] }, + }); + expect(r.success).toBe(false); + }); +}); + +describe('AgentEnvSpecSchema — capacityProviderVolume filesystem mount (J3)', () => { + const base = { + name: 'CpVolAgent', + build: 'CodeZip', + entrypoint: 'main.py:handler', + codeLocation: './agents/cpvol', + runtimeVersion: 'PYTHON_3_12', + protocol: 'HTTP', + }; + + it('accepts a capacityProviderVolume mount when attached to a capacity provider', () => { + const r = AgentEnvSpecSchema.safeParse({ + ...base, + capacityProviderConfiguration: { capacityProviderName: 'my_pool' }, + filesystemConfigurations: [{ capacityProviderVolume: { volumeName: 'model-weights', mountPath: '/mnt/models' } }], + }); + expect(r.success).toBe(true); + }); + + it('rejects a capacityProviderVolume mount without a capacity provider attachment', () => { + const r = AgentEnvSpecSchema.safeParse({ + ...base, + filesystemConfigurations: [{ capacityProviderVolume: { volumeName: 'model-weights', mountPath: '/mnt/models' } }], + }); + expect(r.success).toBe(false); + }); + + it('rejects a bad mount path', () => { + const r = AgentEnvSpecSchema.safeParse({ + ...base, + capacityProviderConfiguration: { capacityProviderName: 'my_pool' }, + filesystemConfigurations: [ + { capacityProviderVolume: { volumeName: 'model-weights', mountPath: '/data/models' } }, + ], + }); + expect(r.success).toBe(false); + }); + + it('rejects duplicate mount paths across arms', () => { + const r = AgentEnvSpecSchema.safeParse({ + ...base, + capacityProviderConfiguration: { capacityProviderName: 'my_pool' }, + filesystemConfigurations: [ + { sessionStorage: { mountPath: '/mnt/models' } }, + { capacityProviderVolume: { volumeName: 'model-weights', mountPath: '/mnt/models' } }, + ], + }); + expect(r.success).toBe(false); + }); +}); diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 1034a370c..ecad52db4 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -839,3 +839,97 @@ describe('AgentCoreProjectSpec — knowledgeBases', () => { ).toThrow(/Duplicate knowledge base name/); }); }); + +describe('AgentCoreProjectSpecSchema — capacity provider cross-references (J2/J3)', () => { + const validCp = { + name: 'my_pool', + computeConfiguration: { + ec2Configuration: { + launchTemplateSource: { + launchParameters: { + operatingSystem: 'LINUX_X86_64', + instanceRequirements: { allowedInstanceTypes: ['c5.xlarge'] }, + }, + }, + vpcConfiguration: { subnets: ['subnet-0123456789abcdef0'], securityGroups: ['sg-0123456789abcdef0'] }, + volumes: [{ ebsConfiguration: { name: 'model-weights', sizeGiB: 100 } }], + }, + }, + }; + const runtime = (extra: Record) => ({ + name: 'MyAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: './agents/my-agent', + runtimeVersion: 'PYTHON_3_12', + protocol: 'HTTP', + ...extra, + }); + + it('accepts a runtime attached to a sibling capacity provider by name', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + name: 'P', + version: 1, + capacityProviders: [validCp], + runtimes: [runtime({ capacityProviderConfiguration: { capacityProviderName: 'my_pool' } })], + }); + expect(result.success).toBe(true); + }); + + it('rejects a runtime referencing an unknown capacity provider name', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + name: 'P', + version: 1, + capacityProviders: [validCp], + runtimes: [runtime({ capacityProviderConfiguration: { capacityProviderName: 'nope' } })], + }); + expect(result.success).toBe(false); + }); + + it('accepts a runtime attached by external ARN without a sibling', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + name: 'P', + version: 1, + runtimes: [ + runtime({ + capacityProviderConfiguration: { + capacityProviderArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:capacity-provider/ext-a1b2c3d4e5', + }, + }), + ], + }); + expect(result.success).toBe(true); + }); + + it('accepts a cp-volume mount whose volumeName exists on the attached CP', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + name: 'P', + version: 1, + capacityProviders: [validCp], + runtimes: [ + runtime({ + capacityProviderConfiguration: { capacityProviderName: 'my_pool' }, + filesystemConfigurations: [ + { capacityProviderVolume: { volumeName: 'model-weights', mountPath: '/mnt/models' } }, + ], + }), + ], + }); + expect(result.success).toBe(true); + }); + + it('rejects a cp-volume mount whose volumeName is not defined on the attached CP', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + name: 'P', + version: 1, + capacityProviders: [validCp], + runtimes: [ + runtime({ + capacityProviderConfiguration: { capacityProviderName: 'my_pool' }, + filesystemConfigurations: [{ capacityProviderVolume: { volumeName: 'ghost-vol', mountPath: '/mnt/models' } }], + }), + ], + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index dc286134a..5a5cad3fc 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -16,6 +16,7 @@ import { import type { DirectoryPath, FilePath } from '../types'; import { AuthorizerConfigSchema, RuntimeAuthorizerTypeSchema } from './auth'; import { ConnectionSchema } from './connections'; +import { CapacityProviderConfigurationSchema, CapacityProviderVolumeNameSchema } from './primitives/capacity-provider'; import { TagsSchema } from './primitives/tags'; import { z } from 'zod'; @@ -315,23 +316,38 @@ export const S3FilesAccessPointConfigSchema = z.object({ }); export type S3FilesAccessPointConfig = z.infer; +/** Capacity provider volume mount — references a named volume on the attached CP. */ +export const CapacityProviderVolumeConfigSchema = z.object({ + /** Logical name of a volume defined on the attached capacity provider (CP's volumes[]). */ + volumeName: CapacityProviderVolumeNameSchema, + /** Absolute mount path under /mnt (e.g. /mnt/models). */ + mountPath: z + .string() + .min(6) + .max(200) + .regex(/^\/mnt\/[a-zA-Z0-9._-]+\/?$/, 'Must be a path under /mnt with exactly one subdirectory (e.g. /mnt/models)'), +}); +export type CapacityProviderVolumeConfig = z.infer; + /** Maximum number of EFS access point mounts per runtime. */ export const MAX_EFS_MOUNTS = 2; /** Maximum number of S3 Files access point mounts per runtime. */ export const MAX_S3_MOUNTS = 2; /** - * Filesystem configuration — union of three mount types. + * Filesystem configuration — union of four mount types. * Exactly one key must be present per entry. * * Service limits per runtime: max 5 total, max 1 sessionStorage, * max MAX_EFS_MOUNTS efsAccessPoint, max MAX_S3_MOUNTS s3FilesAccessPoint. * efsAccessPoint and s3FilesAccessPoint require networkMode: VPC. + * capacityProviderVolume requires the runtime to be attached to a capacity provider. */ export const FilesystemConfigurationSchema = z.union([ z.strictObject({ sessionStorage: SessionStorageSchema }), z.strictObject({ efsAccessPoint: EfsAccessPointConfigSchema }), z.strictObject({ s3FilesAccessPoint: S3FilesAccessPointConfigSchema }), + z.strictObject({ capacityProviderVolume: CapacityProviderVolumeConfigSchema }), ]); export type FilesystemConfiguration = z.infer; @@ -460,6 +476,10 @@ export const AgentEnvSpecSchema = z /** Connections to external AgentCore resources (memory/gateway/runtime). The construct * generates IAM + discovery env vars onto this runtime's execution role. */ connections: z.array(ConnectionSchema).optional(), + /** Attach the runtime to a capacity provider (customer-managed EC2 compute) — either an + * in-project sibling by name or an external CP by ARN. Mutually exclusive with VPC networking: + * in CP mode the network topology comes from the capacity provider. */ + capacityProviderConfiguration: CapacityProviderConfigurationSchema.optional(), }) .superRefine((data, ctx) => { if (data.networkMode === 'VPC' && !data.networkConfig) { @@ -476,6 +496,16 @@ export const AgentEnvSpecSchema = z path: ['networkConfig'], }); } + // Capacity-provider mode supplies its own network topology, so it is mutually exclusive + // with VPC networking. The service rejects a runtime that sets both. + if (data.capacityProviderConfiguration && (data.networkMode === 'VPC' || data.networkConfig)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'capacityProviderConfiguration cannot be combined with VPC networking (networkMode: VPC / networkConfig) — a capacity provider supplies its own network topology', + path: ['capacityProviderConfiguration'], + }); + } // NOTE: vpcId is NOT required here at the schema (read/write) level. A Container+VPC agent needs // a vpcId to build (CodeBuild can't infer it), but enforcing that on read would hard-fail // pre-existing agentcore.json files written before vpcId existed. Instead: the CLI input @@ -533,6 +563,18 @@ export const AgentEnvSpecSchema = z const efsCount = fcs.filter(fc => 'efsAccessPoint' in fc).length; const s3Count = fcs.filter(fc => 's3FilesAccessPoint' in fc).length; const ssCount = fcs.filter(fc => 'sessionStorage' in fc).length; + const cpVolCount = fcs.filter(fc => 'capacityProviderVolume' in fc).length; + + // A capacity-provider volume can only be mounted when the runtime is attached to a + // capacity provider — the volume is defined on that CP's volumes[]. + if (cpVolCount > 0 && !data.capacityProviderConfiguration) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'capacityProviderVolume filesystem mounts require the runtime to be attached to a capacity provider (set capacityProviderConfiguration)', + path: ['filesystemConfigurations'], + }); + } if (fcs.length > 5) { ctx.addIssue({ @@ -577,7 +619,9 @@ export const AgentEnvSpecSchema = z ? fc.sessionStorage.mountPath : 'efsAccessPoint' in fc ? fc.efsAccessPoint.mountPath - : fc.s3FilesAccessPoint.mountPath + : 's3FilesAccessPoint' in fc + ? fc.s3FilesAccessPoint.mountPath + : fc.capacityProviderVolume.mountPath ).replace(/\/$/, '') ); if (new Set(mountPaths).size !== mountPaths.length) { diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 26539b063..644e215f5 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -94,13 +94,18 @@ export { DatasetNameSchema, DatasetSchemaTypeSchema } from './primitives/dataset export type { Dataset, DatasetSchemaType } from './primitives/dataset'; export { CapacityProviderSchema }; export { + CapacityProviderArnSchema, + CapacityProviderConfigurationSchema, CapacityProviderNameSchema, + CapacityProviderVolumeNameSchema, + CAPACITY_PROVIDER_ARN_PATTERN, CAPACITY_PROVIDER_OPERATOR_ROLE_ARN_PATTERN, + isCapacityProviderArn, isValidOperatorRoleArn, OperatingSystemSchema, OperatorRoleArnSchema, } from './primitives/capacity-provider'; -export type { CapacityProvider, OperatingSystem } from './primitives/capacity-provider'; +export type { CapacityProvider, CapacityProviderConfiguration, OperatingSystem } from './primitives/capacity-provider'; export type { ABTestMode, TargetRef, GatewayFilter, PerVariantOnlineEvaluationConfig } from './primitives/ab-test'; export { ABTestModeSchema, TargetRefSchema, GatewayFilterSchema } from './primitives/ab-test'; export type { @@ -634,6 +639,42 @@ export const AgentCoreProjectSpecSchema = z } } + // Validate runtime -> capacity-provider sibling references. A runtime may attach to a + // capacity provider by name (an in-project sibling) or by ARN (external). A name must + // resolve to a capacityProviders[] entry; ARNs are accepted as external and not checked here. + const capacityProvidersByName = new Map((spec.capacityProviders ?? []).map(cp => [cp.name, cp])); + for (const runtime of spec.runtimes) { + const cpName = runtime.capacityProviderConfiguration?.capacityProviderName; + if (cpName && !capacityProvidersByName.has(cpName)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Runtime "${runtime.name}" references unknown capacity provider "${cpName}". Check spec.capacityProviders.`, + }); + } + + // A capacityProviderVolume mount must reference a volume defined on the attached CP. We can + // only introspect an in-project sibling (by name); an external CP referenced by ARN is skipped. + const cpVolumeMounts = (runtime.filesystemConfigurations ?? []).filter(fc => 'capacityProviderVolume' in fc); + if (cpVolumeMounts.length > 0 && cpName) { + const cp = capacityProvidersByName.get(cpName); + if (cp) { + const definedVolumeNames = new Set( + (cp.computeConfiguration.ec2Configuration.volumes ?? []).map(v => v.ebsConfiguration.name) + ); + for (const fc of cpVolumeMounts) { + const volumeName = (fc as { capacityProviderVolume: { volumeName: string } }).capacityProviderVolume + .volumeName; + if (!definedVolumeNames.has(volumeName)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Runtime "${runtime.name}" mounts capacity provider volume "${volumeName}" which is not defined on capacity provider "${cpName}". Check its computeConfiguration.ec2Configuration.volumes.`, + }); + } + } + } + } + } + // Validate AB test gateway references for (const test of spec.abTests ?? []) { const gwField = test.gatewayRef; diff --git a/src/schema/schemas/primitives/capacity-provider.ts b/src/schema/schemas/primitives/capacity-provider.ts index 0dbd434c4..70dff6d86 100644 --- a/src/schema/schemas/primitives/capacity-provider.ts +++ b/src/schema/schemas/primitives/capacity-provider.ts @@ -51,6 +51,68 @@ export function isValidOperatorRoleArn(value: string): boolean { return CAPACITY_PROVIDER_OPERATOR_ROLE_ARN_PATTERN.test(value); } +// ============================================================================ +// Capacity Provider Attach (runtime -> capacity provider reference) +// +// A runtime attaches to a capacity provider via `capacityProviderConfiguration`. +// The reference is EITHER an in-project sibling (by name) OR an external CP (by +// ARN) — exactly one. At synth the CDK app resolves a sibling name to the created +// CP's ARN, or passes the ARN through, then injects +// CapacityProviderConfiguration.CapacityProviderArn onto the CfnRuntime. +// +// NOTE: This block is duplicated in @aws/agentcore-cdk. Keep the two in sync. +// ============================================================================ + +/** Partition-agnostic capacity-provider ARN (arn:[^:]+: per multi-partition rules). */ +export const CAPACITY_PROVIDER_ARN_PATTERN = /^arn:[^:]+:bedrock-agentcore:[a-z0-9-]+:\d{12}:capacity-provider\/.+$/; + +export const CapacityProviderArnSchema = z + .string() + .regex(CAPACITY_PROVIDER_ARN_PATTERN, 'Must be a valid bedrock-agentcore capacity provider ARN'); + +/** + * Route a `--capacity-provider ` value: an `arn:` prefix means an + * external CP referenced by ARN, otherwise it is an in-project sibling name. + */ +export function isCapacityProviderArn(value: string): boolean { + return value.startsWith('arn:'); +} + +/** + * Logical name of a capacity-provider volume, referenced by a runtime's + * `capacityProviderVolume` filesystem mount. Must match the name of a volume + * defined on the capacity provider (EbsVolumeConfiguration.name). + */ +export const CapacityProviderVolumeNameSchema = z + .string() + .min(1, 'Capacity provider volume name is required') + .max(48) + .regex( + /^[a-zA-Z][a-zA-Z0-9_-]{0,47}$/, + 'Volume name must begin with a letter and contain only alphanumerics, underscores, and hyphens (max 48 chars)' + ); + +/** + * Runtime -> capacity-provider reference. Exactly one of `capacityProviderArn` + * (external CP) or `capacityProviderName` (in-project sibling) must be set. + */ +export const CapacityProviderConfigurationSchema = z + .strictObject({ + /** ARN of an external capacity provider (not defined in this project). */ + capacityProviderArn: CapacityProviderArnSchema.optional(), + /** Name of an in-project capacity provider (a `capacityProviders[]` sibling). */ + capacityProviderName: CapacityProviderNameSchema.optional(), + }) + .superRefine((data, ctx) => { + if ((data.capacityProviderArn !== undefined) === (data.capacityProviderName !== undefined)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'capacityProviderConfiguration must set exactly one of capacityProviderArn or capacityProviderName', + }); + } + }); +export type CapacityProviderConfiguration = z.infer; + // ============================================================================ // Operating System // diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index 3be2a5543..4a586722a 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -12,6 +12,7 @@ export { DatasetNameSchema, DatasetSchema, DatasetSchemaTypeSchema } from './dat export type { CapacityProvider, + CapacityProviderConfiguration, ComputeConfiguration, Ec2Configuration, EbsVolumeConfiguration, @@ -23,14 +24,19 @@ export type { VpcConfiguration, } from './capacity-provider'; export { + CAPACITY_PROVIDER_ARN_PATTERN, CAPACITY_PROVIDER_OPERATOR_ROLE_ARN_PATTERN, + CapacityProviderArnSchema, + CapacityProviderConfigurationSchema, CapacityProviderNameSchema, CapacityProviderSchema, + CapacityProviderVolumeNameSchema, ComputeConfigurationSchema, Ec2ConfigurationSchema, EbsVolumeConfigurationSchema, InstanceLifecycleConfigurationSchema, InstanceRequirementsSchema, + isCapacityProviderArn, isValidOperatorRoleArn, LaunchParametersSchema, OperatingSystemSchema,