From da9ab442a33542b4df5985352c5df5be3e949812 Mon Sep 17 00:00:00 2001 From: Xin Xu Date: Tue, 18 Aug 2026 20:54:06 +0000 Subject: [PATCH 1/2] feat(capacity-provider): add capacity-provider resource (Journey 1) Add the CapacityProviderPrimitive (create/remove) for a customer-managed EC2 compute pool that runtimes can run on. Includes the Zod schema, primitive with CLI flags, interactive TUI add wizard and remove picker, telemetry (add/remove command-run + OperatingSystem enum), and deploy/status plumbing: CloudFormation output parsing, deployed-state schema, preflight empty-project guard, and ResourceGraph rendering. Docs (AGENTS.md, README, commands) and integ tests included. The @aws/agentcore-cdk pin stays at 0.1.0-alpha.45 until the L3 construct publishes a new alpha; a follow-up bumps it. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 5 +- README.md | 12 +- docs/commands.md | 45 +++ .../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 + src/cli/operations/deploy/preflight.ts | 4 +- .../primitives/CapacityProviderPrimitive.ts | 271 ++++++++++++++++++ .../CapacityProviderPrimitive.test.ts | 263 +++++++++++++++++ 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 | 259 +++++++++++++++++ .../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/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 ++ 42 files changed, 2081 insertions(+), 18 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..5ad1eac36 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -778,6 +778,50 @@ 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. + +```bash +# Minimal +agentcore add capacity-provider \ + --name MyCapacityProvider \ + --operator-role-arn arn:aws:iam::123456789012:role/MyOperatorRole \ + --subnets subnet-0123456789abcdef0 \ + --security-groups sg-0123456789abcdef0 \ + --instance-types c6a.large + +# With a named EBS volume, lifecycle limits, and ARM64 +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 operators use to manage the capacity provider (required); 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 +841,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 857f0dbb3..26bfcab52 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/preflight.ts b/src/cli/operations/deploy/preflight.ts index 75c34b41a..300fcefda 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -112,6 +112,7 @@ export async function validateProject(selectedTarget?: AwsDeploymentTarget): Pro // Check for gateways in agentcore.json const hasGateways = projectSpec.agentCoreGateways && projectSpec.agentCoreGateways.length > 0; const hasPayments = projectSpec.payments && projectSpec.payments.length > 0; + const hasCapacityProviders = projectSpec.capacityProviders && projectSpec.capacityProviders.length > 0; if ( !hasAgents && @@ -122,7 +123,8 @@ export async function validateProject(selectedTarget?: AwsDeploymentTarget): Pro !hasPolicyEngines && !hasHarnesses && !hasDatasets && - !hasPayments + !hasPayments && + !hasCapacityProviders ) { if (!hasExistingStack) { throw new ValidationError( diff --git a/src/cli/primitives/CapacityProviderPrimitive.ts b/src/cli/primitives/CapacityProviderPrimitive.ts new file mode 100644 index 000000000..773bb4fd5 --- /dev/null +++ b/src/cli/primitives/CapacityProviderPrimitive.ts @@ -0,0 +1,271 @@ +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 operators use to manage the capacity provider [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.operatorRoleArn) missing.push('--operator-role-arn'); + 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 => { + const [volName, sizeRaw] = entry.split(':'); + const sizeGiB = Number(sizeRaw); + if (!volName || !Number.isInteger(sizeGiB)) { + throw new ValidationError(`Invalid --volume "${entry}". Expected format name:sizeGiB (e.g. data:20).`); + } + 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 }), + 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..2d1378436 --- /dev/null +++ b/src/cli/primitives/__tests__/CapacityProviderPrimitive.test.ts @@ -0,0 +1,263 @@ +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('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(); + }); + }); + + 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..0e506f8bf --- /dev/null +++ b/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx @@ -0,0 +1,259 @@ +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, + 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 }, + { 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')} + customValidation={value => 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/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..71ba8b01d --- /dev/null +++ b/src/schema/schemas/primitives/capacity-provider.ts @@ -0,0 +1,213 @@ +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:aws(-[^:]+)?: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( + // eslint-disable-next-line security/detect-unsafe-regex -- anchored ARN pattern, no backtracking risk + /^arn:aws(-[^:]+)?: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( + // eslint-disable-next-line security/detect-unsafe-regex -- anchored ARN pattern, no backtracking risk + /^arn:aws(-[^:]+)?: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 operators use to manage the capacity provider (immutable). */ + operatorRoleArn: OperatorRoleArnSchema, + /** Compute resources for the capacity provider (immutable after creation). */ + computeConfiguration: ComputeConfigurationSchema, + /** Optional resource tags. */ + tags: z.record(z.string(), z.string()).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 fcd27cb641e4cbb93dd8433567c60bd95d8dc38a Mon Sep 17 00:00:00 2001 From: Xin Xu Date: Wed, 19 Aug 2026 18:13:43 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(capacity-provider):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20TagsSchema,=20ARN=20partitions,=20deployable-res?= =?UTF-8?q?ource=20preflight,=20volume=20parsing,=20TUI=20parity,=20option?= =?UTF-8?q?al=20operator=20role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #2030 review feedback: - tags: use shared TagsSchema (mirrors the CDK schema) instead of a plain string record - ARN patterns (operator-role, instance-profile, KMS): partition-agnostic arn:[^:]+: per AGENTS.md; drop now-unneeded eslint-disable directives - preflight: replace the hand-maintained hasX teardown chain with a typed hasDeployableResources(spec) + DEPLOYABLE_RESOURCE_KEYS, fixing latent drift where a project containing only configBundles or onlineEvalConfigs was misclassified as empty - --volume parse: require exactly name:sizeGiB (segment count + digit regex), rejecting data:20:gp3, hex (0x14), and exponent (2e1) that Number() silently accepted - TUI: remove-all confirmation now enumerates capacity providers; add screen shows "Capacity Provider [preview]" to match the remove screen - operator role: --operator-role-arn is now optional (auto-created at deploy when omitted, matching the CDK construct); supplying an ARN is a bring-your-own override. Docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/commands.md | 45 +++++++----- .../deploy/__tests__/preflight.test.ts | 70 +++++++++++++++++++ src/cli/operations/deploy/preflight.ts | 68 +++++++++++------- .../primitives/CapacityProviderPrimitive.ts | 17 +++-- .../CapacityProviderPrimitive.test.ts | 33 +++++++++ src/cli/tui/screens/add/AddScreen.tsx | 2 +- .../AddCapacityProviderScreen.tsx | 13 ++-- src/cli/tui/screens/remove/useRemoveFlow.ts | 5 ++ .../schemas/primitives/capacity-provider.ts | 22 +++--- 9 files changed, 205 insertions(+), 70 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 5ad1eac36..f1ad57055 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -783,16 +783,23 @@ agentcore add config-bundle \ 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 +# Minimal — operator role is created automatically agentcore add capacity-provider \ --name MyCapacityProvider \ - --operator-role-arn arn:aws:iam::123456789012:role/MyOperatorRole \ --subnets subnet-0123456789abcdef0 \ --security-groups sg-0123456789abcdef0 \ --instance-types c6a.large -# With a named EBS volume, lifecycle limits, and ARM64 +# 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 \ @@ -805,22 +812,22 @@ agentcore add capacity-provider \ --max-lifetime 28800 ``` -| Flag | Description | -| -------------------------------- | -------------------------------------------------------------------------------- | -| `--name ` | Capacity provider name (required); immutable after creation | -| `--operator-role-arn ` | IAM role ARN operators use to manage the capacity provider (required); 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 | +| 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 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 300fcefda..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,34 +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; - const hasCapacityProviders = projectSpec.capacityProviders && projectSpec.capacityProviders.length > 0; - - if ( - !hasAgents && - !hasGateways && - !hasMemories && - !hasKnowledgeBases && - !hasEvaluators && - !hasPolicyEngines && - !hasHarnesses && - !hasDatasets && - !hasPayments && - !hasCapacityProviders - ) { + + 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 index 773bb4fd5..d823d94f6 100644 --- a/src/cli/primitives/CapacityProviderPrimitive.ts +++ b/src/cli/primitives/CapacityProviderPrimitive.ts @@ -14,7 +14,7 @@ import type { Command } from '@commander-js/extra-typings'; */ export interface AddCapacityProviderOptions { name: string; - operatorRoleArn: string; + operatorRoleArn?: string; description?: string; subnets: string; securityGroups: string; @@ -136,7 +136,7 @@ export class CapacityProviderPrimitive extends BasePrimitive', 'Capacity provider name [non-interactive]') .option( '--operator-role-arn ', - 'IAM role ARN operators use to manage the capacity provider [non-interactive]' + '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]') @@ -202,7 +202,6 @@ export class CapacityProviderPrimitive extends BasePrimitive { - const [volName, sizeRaw] = entry.split(':'); - const sizeGiB = Number(sizeRaw); - if (!volName || !Number.isInteger(sizeGiB)) { + // 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, @@ -244,7 +247,7 @@ export class CapacityProviderPrimitive extends BasePrimitive { 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); @@ -183,6 +196,26 @@ describe('CapacityProviderPrimitive', () => { } 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()', () => { diff --git a/src/cli/tui/screens/add/AddScreen.tsx b/src/cli/tui/screens/add/AddScreen.tsx index ee14da2a0..f6cd12e49 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', + title: 'Capacity Provider [preview]', description: 'Customer-managed EC2 compute pool for runtimes', }, ]; diff --git a/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx b/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx index 0e506f8bf..4b238ae5f 100644 --- a/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx +++ b/src/cli/tui/screens/capacity-provider/AddCapacityProviderScreen.tsx @@ -14,7 +14,7 @@ const OS_OPTIONS: SelectableItem[] = [ export interface AddCapacityProviderConfig { name: string; - operatorRoleArn: string; + operatorRoleArn?: string; description?: string; subnets: string; securityGroups: string; @@ -104,7 +104,7 @@ export function AddCapacityProviderScreen({ onComplete, onExit, existingNames }: onSelect: () => onComplete({ name, - operatorRoleArn, + operatorRoleArn: operatorRoleArn || undefined, subnets, securityGroups, os, @@ -126,7 +126,7 @@ export function AddCapacityProviderScreen({ onComplete, onExit, existingNames }: const confirmFields = useMemo( () => [ { label: 'Name', value: name }, - { label: 'Operator Role ARN', value: operatorRoleArn }, + { 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 }, @@ -163,14 +163,17 @@ export function AddCapacityProviderScreen({ onComplete, onExit, existingNames }: {isOperatorRoleStep && ( { setOperatorRoleArn(value); setStep('subnets'); }} onCancel={() => setStep('name')} - customValidation={value => isValidOperatorRoleArn(value) || 'Must be a valid IAM role ARN'} + allowEmpty + customValidation={value => + value.trim() === '' || isValidOperatorRoleArn(value) || 'Must be a valid IAM role ARN' + } /> )} 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/schemas/primitives/capacity-provider.ts b/src/schema/schemas/primitives/capacity-provider.ts index 71ba8b01d..0dbd434c4 100644 --- a/src/schema/schemas/primitives/capacity-provider.ts +++ b/src/schema/schemas/primitives/capacity-provider.ts @@ -1,3 +1,4 @@ +import { TagsSchema } from './tags'; import { z } from 'zod'; // ============================================================================ @@ -35,7 +36,7 @@ export const CapacityProviderNameSchema = z * 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:aws(-[^:]+)?:iam::([0-9]{12})?:role\/.+$/; +export const CAPACITY_PROVIDER_OPERATOR_ROLE_ARN_PATTERN = /^arn:[^:]+:iam::([0-9]{12})?:role\/.+$/; export const OperatorRoleArnSchema = z .string() @@ -103,11 +104,7 @@ export const LaunchParametersSchema = z instanceRequirements: InstanceRequirementsSchema, instanceProfileArn: z .string() - .regex( - // eslint-disable-next-line security/detect-unsafe-regex -- anchored ARN pattern, no backtracking risk - /^arn:aws(-[^:]+)?:iam::[0-9]{12}:instance-profile\/.+$/, - 'Must be a valid IAM instance profile ARN' - ) + .regex(/^arn:[^:]+:iam::[0-9]{12}:instance-profile\/.+$/, 'Must be a valid IAM instance profile ARN') .optional(), }) .passthrough(); @@ -137,8 +134,7 @@ export const EbsVolumeConfigurationSchema = z kmsKeyId: z .string() .regex( - // eslint-disable-next-line security/detect-unsafe-regex -- anchored ARN pattern, no backtracking risk - /^arn:aws(-[^:]+)?: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}$/, + /^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(), @@ -202,12 +198,16 @@ export const CapacityProviderSchema = z.object({ 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 operators use to manage the capacity provider (immutable). */ - operatorRoleArn: OperatorRoleArnSchema, + /** + * 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: z.record(z.string(), z.string()).optional(), + tags: TagsSchema.optional(), }); export type CapacityProvider = z.infer;