diff --git a/command.md b/command.md index 12412353e..9eba1bfb9 100644 --- a/command.md +++ b/command.md @@ -313,6 +313,8 @@ add a Runtime to the current project - `--filesystem-configurations `: filesystem mount configurations (JSON) - `--role-arn `: IAM role ARN that provides permissions for the Runtime - `--additional-policies `: additional IAM policy ARNs or policy document paths for the execution role +- `--container-build-role-arn `: existing CodeBuild execution role; selects a Container build (permissions managed externally) +- `--container-build-handler-role-arn `: existing build-handler Lambda role; selects a Container build (permissions managed externally) - `--authorizer-type `: inbound authorizer type (AWS\_IAM or CUSTOM\_JWT) - `--authorizer-configuration `: inbound authorizer configuration (JSON) - `--request-header-allowlist `: request headers to pass through to the Runtime diff --git a/docs/container-build-roles.md b/docs/container-build-roles.md new file mode 100644 index 000000000..ad097c4d0 --- /dev/null +++ b/docs/container-build-roles.md @@ -0,0 +1,75 @@ +# Existing roles for container builds + +Add a container runtime using centrally provisioned IAM roles: + +```sh +agentcore add runtime --name worker --language python \ + --role-arn arn:aws:iam::111122223333:role/Runtime \ + --container-build-role-arn arn:aws:iam::111122223333:role/CodeBuild \ + --container-build-handler-role-arn arn:aws:iam::111122223333:role/BuildHandler +``` + +The runtime entry in `agentcore/agentcore.json` retains `executionRoleArn`, +`containerBuildRoleArn`, and `containerBuildHandlerRoleArn`. Build roles are +optional and only apply to Container runtimes. Supplying either build-role flag +registers a Container build. Add your code and a `Dockerfile` in `app/worker/` +before building or deploying; `add runtime` creates documentation only. Omitted roles keep automatic role +creation. Region partitions and IAM role paths are supported. + +## CDK dependency + +This feature requires corresponding support in `@aws/agentcore-cdk`. Version +`1.0.0` does not include it. The CLI rejects build and deploy operations with +configured build roles when the installed CDK schema lacks those fields, before +provisioning credentials or synthesizing a stack. It never silently falls back +to creating roles. + +The [published-package patch](patches/agentcore-cdk-1.0.0-container-build-roles.patch) +and [verification script](patches/verify-container-build-roles.cjs) are a maintainer +handoff for the private CDK source repository. They target the published +`1.0.0` package, rather than its inaccessible TypeScript source. Maintainers +should port the changes to `ContainerBuildProject`, `ContainerImageBuilder`, +`RuntimeEnvironment`, and the runtime schema, regenerate declarations and source +maps, and publish a release before enabling this feature in deployed projects. + +For local verification, extract two copies of that npm package as `original` and +`patched`, install its dependencies, then run: + +```sh +patch -d patched -p1 < docs/patches/agentcore-cdk-1.0.0-container-build-roles.patch +node docs/patches/verify-container-build-roles.cjs original patched +``` + +The script performs local synthesis and validation. It checks unchanged default +templates, role ARNs, absence of generated IAM roles and policies when all roles +are supplied, every partial role combination, shared-project conflicts in both +creation orders, independent handler roles, separate VPC projects, and rejection +of build roles on CodeZip runtimes. Partition checks verify ARN preservation; +service availability still determines which regions can deploy a runtime. The +script does not deploy resources. + +## Permissions supplied by the role owner + +Imported roles are immutable. Their trust policies and permissions must already +allow the operations below; the constructs do not add policies to them. + +| Role | Trusted service | Required permissions | +| ------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Runtime | `bedrock-agentcore.amazonaws.com` | Normal runtime execution permissions, including image access and the services the agent uses. See the [runtime execution role guide](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-permissions.html). | +| CodeBuild | `codebuild.amazonaws.com` | `ecr:GetAuthorizationToken` on `*`; pull/push actions on the runtime ECR repository; read/list access to the S3 source asset and decrypt access if its key requires it; create/write the build's CloudWatch log group and streams. | +| Build handler | `lambda.amazonaws.com` | `codebuild:StartBuild` and `codebuild:BatchGetBuilds` on the shared build project; create/write its CloudWatch log group and streams. | + +For VPC builds, the CodeBuild role also needs EC2 network-interface creation, +deletion and describe actions, plus `ec2:CreateNetworkInterfacePermission` scoped +to the selected subnets and CodeBuild service. The handler needs the equivalent +of `AWSLambdaVPCAccessExecutionRole`. Network egress must reach CodeBuild, +CloudWatch Logs, ECR, S3, and the CloudFormation response URL. + +Runtimes sharing the same build network share a CodeBuild project and must specify +the same `containerBuildRoleArn`, including whether it is omitted. Conflicts are +rejected regardless of runtime order. Each runtime has its own build handler and +may use a different handler role. Distinct build networks may use different +CodeBuild roles. + +CDK bootstrap infrastructure is provisioned separately. Other resources in the +project, such as harnesses, may require additional roles of their own. diff --git a/docs/patches/agentcore-cdk-1.0.0-container-build-roles.patch b/docs/patches/agentcore-cdk-1.0.0-container-build-roles.patch new file mode 100644 index 000000000..d0d3621c5 --- /dev/null +++ b/docs/patches/agentcore-cdk-1.0.0-container-build-roles.patch @@ -0,0 +1,242 @@ +--- a/dist/cdk/constructs/components/container/ContainerBuildProject.d.ts ++++ b/dist/cdk/constructs/components/container/ContainerBuildProject.d.ts +@@ -45,11 +45,12 @@ + * (each is triggered by its own custom resource), so this is purely a project-reuse optimization — + * there is no requirement that they share a VPC. + */ +- static getOrCreate(scope: Construct, networkConfig?: BuildNetworkConfig): ContainerBuildProject; ++ static getOrCreate(scope: Construct, networkConfig?: BuildNetworkConfig, executionRoleArn?: string): ContainerBuildProject; + readonly project: codebuild.Project; +- readonly role: iam.Role; ++ readonly role: iam.IRole; ++ readonly executionRoleArn?: string; + readonly networkConfig?: BuildNetworkConfig; +- constructor(scope: Construct, id: string, networkConfig?: BuildNetworkConfig); ++ constructor(scope: Construct, id: string, networkConfig?: BuildNetworkConfig, executionRoleArn?: string); + /** + * Grant the build project permission to push to a specific ECR repository. + */ +--- a/dist/cdk/constructs/components/container/ContainerBuildProject.js ++++ b/dist/cdk/constructs/components/container/ContainerBuildProject.js +@@ -96,7 +96,7 @@ + * (each is triggered by its own custom resource), so this is purely a project-reuse optimization — + * there is no requirement that they share a VPC. + */ +- static getOrCreate(scope, networkConfig) { ++ static getOrCreate(scope, networkConfig, executionRoleArn) { + const stack = cdk.Stack.of(scope); + // The public (no-VPC) build keeps the original fixed id so existing deployed container stacks see + // no resource replacement on upgrade. VPC builds are net-new, so a config-derived id suffix has no +@@ -104,32 +104,41 @@ + const id = networkConfig ? `ContainerBuildProject-${networkConfigHash(networkConfig)}` : 'ContainerBuildProject'; + const existing = stack.node.tryFindChild(id); + if (existing) { ++ if (existing.executionRoleArn !== executionRoleArn) { ++ throw new Error('Conflicting containerBuildRoleArn values for a shared CodeBuild project. All runtimes and harnesses sharing a build network must use the same role configuration.'); ++ } + return existing; + } +- return new ContainerBuildProject(stack, id, networkConfig); ++ return new ContainerBuildProject(stack, id, networkConfig, executionRoleArn); + } + project; + role; + networkConfig; +- constructor(scope, id, networkConfig) { ++ executionRoleArn; ++ constructor(scope, id, networkConfig, executionRoleArn) { + super(scope, id); + this.networkConfig = networkConfig; ++ this.executionRoleArn = executionRoleArn; + const partition = cdk.Stack.of(this).partition; + const region = cdk.Stack.of(this).region; + const account = cdk.Stack.of(this).account; +- this.role = new iam.Role(this, 'ContainerBuildExecutionRole', { +- assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com'), +- }); +- // ECR auth token (must be * per AWS API — does not support resource-level permissions) +- this.role.addToPolicy(new iam.PolicyStatement({ +- actions: ['ecr:GetAuthorizationToken'], +- resources: ['*'], +- })); +- // CloudWatch Logs for build logs +- this.role.addToPolicy(new iam.PolicyStatement({ +- actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], +- resources: [`arn:${partition}:logs:${region}:${account}:log-group:/aws/codebuild/*`], +- })); ++ this.role = executionRoleArn ++ ? iam.Role.fromRoleArn(this, 'ContainerBuildExecutionRole', executionRoleArn, { mutable: false }) ++ : new iam.Role(this, 'ContainerBuildExecutionRole', { ++ assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com'), ++ }); ++ if (!executionRoleArn) { ++ // ECR auth token (must be * per AWS API — does not support resource-level permissions) ++ this.role.addToPrincipalPolicy(new iam.PolicyStatement({ ++ actions: ['ecr:GetAuthorizationToken'], ++ resources: ['*'], ++ })); ++ // CloudWatch Logs for build logs ++ this.role.addToPrincipalPolicy(new iam.PolicyStatement({ ++ actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], ++ resources: [`arn:${partition}:logs:${region}:${account}:log-group:/aws/codebuild/*`], ++ })); ++ } + // Pass an imported (immutable) role to the Project so CDK doesn't auto-add + // policies that reference the project name, which would create a circular + // dependency when we add DependsOn below. +@@ -179,28 +188,30 @@ + // only fires when a full ec2.IVpc is passed to the L2 prop). The project also + // uses an imported immutable role, so we add the EC2 permissions CodeBuild + // needs to create its VPC network interface to the real (mutable) role. +- this.role.addToPolicy(new iam.PolicyStatement({ +- actions: [ +- 'ec2:CreateNetworkInterface', +- 'ec2:DescribeNetworkInterfaces', +- 'ec2:DeleteNetworkInterface', +- 'ec2:DescribeSubnets', +- 'ec2:DescribeSecurityGroups', +- 'ec2:DescribeDhcpOptions', +- 'ec2:DescribeVpcs', +- ], +- resources: ['*'], +- })); +- this.role.addToPolicy(new iam.PolicyStatement({ +- actions: ['ec2:CreateNetworkInterfacePermission'], +- resources: [`arn:${partition}:ec2:${region}:${account}:network-interface/*`], +- conditions: { +- StringEquals: { +- 'ec2:AuthorizedService': 'codebuild.amazonaws.com', +- 'ec2:Subnet': networkConfig.subnets.map(s => `arn:${partition}:ec2:${region}:${account}:subnet/${s}`), +- }, +- }, +- })); ++ if (!executionRoleArn) { ++ this.role.addToPrincipalPolicy(new iam.PolicyStatement({ ++ actions: [ ++ 'ec2:CreateNetworkInterface', ++ 'ec2:DescribeNetworkInterfaces', ++ 'ec2:DeleteNetworkInterface', ++ 'ec2:DescribeSubnets', ++ 'ec2:DescribeSecurityGroups', ++ 'ec2:DescribeDhcpOptions', ++ 'ec2:DescribeVpcs', ++ ], ++ resources: ['*'], ++ })); ++ this.role.addToPrincipalPolicy(new iam.PolicyStatement({ ++ actions: ['ec2:CreateNetworkInterfacePermission'], ++ resources: [`arn:${partition}:ec2:${region}:${account}:network-interface/*`], ++ conditions: { ++ StringEquals: { ++ 'ec2:AuthorizedService': 'codebuild.amazonaws.com', ++ 'ec2:Subnet': networkConfig.subnets.map(s => `arn:${partition}:ec2:${region}:${account}:subnet/${s}`), ++ }, ++ }, ++ })); ++ } + } + this.project.node.addDependency(this.role); + } +@@ -208,7 +219,7 @@ + * Grant the build project permission to push to a specific ECR repository. + */ + grantPushTo(repository) { +- repository.grantPullPush(this.role); ++ if (!this.executionRoleArn) repository.grantPullPush(this.role); + } + } + exports.ContainerBuildProject = ContainerBuildProject; +--- a/dist/cdk/constructs/components/container/ContainerImageBuilder.d.ts ++++ b/dist/cdk/constructs/components/container/ContainerImageBuilder.d.ts +@@ -3,6 +3,8 @@ + import type { Asset } from 'aws-cdk-lib/aws-s3-assets'; + import { Construct } from 'constructs'; + export interface ContainerImageBuilderProps { ++ /** Existing immutable Lambda execution role; the caller supplies permissions. */ ++ executionRoleArn?: string; + /** + * The shared CodeBuild project used for building container images. + */ +--- a/dist/cdk/constructs/components/container/ContainerImageBuilder.js ++++ b/dist/cdk/constructs/components/container/ContainerImageBuilder.js +@@ -59,6 +59,7 @@ + const { buildProject, sourceAsset, repository, dockerfile, buildArgs } = props; + const hasBuildArgs = buildArgs !== undefined && Object.keys(buildArgs).length > 0; + const handler = new lambda.Function(this, 'ContainerBuildHandler', { ++ ...(props.executionRoleArn && { role: iam.Role.fromRoleArn(this, 'BuildHandlerRole', props.executionRoleArn, { mutable: false }) }), + runtime: lambda.Runtime.NODEJS_22_X, + handler: 'index.handler', + code: lambda.Code.fromInline(container_build_handler_1.CONTAINER_BUILD_HANDLER_CODE), +@@ -73,10 +74,10 @@ + }; + // The L1 override bypasses CDK's automatic VPC-access grant; attach the + // managed policy that lets the Lambda manage its ENI in the VPC. +- handler.role?.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole')); ++ if (!props.executionRoleArn) handler.role?.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole')); + } + // Grant Lambda permission to trigger builds and read results +- handler.addToRolePolicy(new iam.PolicyStatement({ ++ if (!props.executionRoleArn) handler.addToRolePolicy(new iam.PolicyStatement({ + actions: ['codebuild:StartBuild', 'codebuild:BatchGetBuilds'], + resources: [buildProject.project.projectArn], + })); +--- a/dist/cdk/constructs/l3/RuntimeEnvironment.js ++++ b/dist/cdk/constructs/l3/RuntimeEnvironment.js +@@ -61,12 +61,13 @@ + name: spec.name, + }); + // Get or create the shared build project (one per distinct network config) at stack level +- const buildProject = ContainerBuildProject_1.ContainerBuildProject.getOrCreate(this, buildNetwork); ++ const buildProject = ContainerBuildProject_1.ContainerBuildProject.getOrCreate(this, buildNetwork, spec.containerBuildRoleArn); + buildProject.grantPushTo(ecrRepo.repository); + // Grant CodeBuild read access to source asset +- sourceAsset.asset.grantRead(buildProject.role); ++ if (!spec.containerBuildRoleArn) sourceAsset.asset.grantRead(buildProject.role); + const builder = new ContainerImageBuilder_1.ContainerImageBuilder(this, 'ContainerImageBuilder', { + buildProject, ++ executionRoleArn: spec.containerBuildHandlerRoleArn, + sourceAsset, + repository: ecrRepo, + // dockerfile is resolved relative to the build context (the source asset root), which +--- a/dist/schema/schemas/agent-env.d.ts ++++ b/dist/schema/schemas/agent-env.d.ts +@@ -266,6 +266,8 @@ + }, z.core.$strict>>; + }, z.core.$strip>>; + executionRoleArn: z.ZodOptional; ++ containerBuildRoleArn: z.ZodOptional; ++ containerBuildHandlerRoleArn: z.ZodOptional; + additionalPolicies: z.ZodOptional>; + bedrockManagedAgents: z.ZodOptional; + tags: z.ZodOptional>; +--- a/dist/schema/schemas/agent-env.js ++++ b/dist/schema/schemas/agent-env.js +@@ -341,6 +341,8 @@ + authorizerConfiguration: auth_1.AuthorizerConfigSchema.optional(), + /** ARN of an existing IAM execution role to use instead of creating a new one. */ + executionRoleArn: zod_1.z.string().optional(), ++ containerBuildRoleArn: zod_1.z.string().max(2048).regex(/^arn:[^:]+:iam::[0-9]{12}:role\/[^\s]+$/).optional(), ++ containerBuildHandlerRoleArn: zod_1.z.string().max(2048).regex(/^arn:[^:]+:iam::[0-9]{12}:role\/[^\s]+$/).optional(), + /** + * Additional IAM policies attached to the runtime execution role. Each entry is either a + * managed-policy ARN (attached directly) or a `.json` policy-document file path relative to +@@ -440,7 +442,7 @@ + } + // Container-only fields. If adding more, extend this list (and consider consolidating into a + // containerConfig object — see the networkConfig pattern). +- for (const field of ['dockerfile', 'buildContextPath', 'customDockerBuildArgs']) { ++ for (const field of ['dockerfile', 'buildContextPath', 'customDockerBuildArgs', 'containerBuildRoleArn', 'containerBuildHandlerRoleArn']) { + if (data.build !== 'Container' && data[field]) { + ctx.addIssue({ + code: zod_1.z.ZodIssueCode.custom, +--- a/dist/schema/schemas/agentcore-project.d.ts ++++ b/dist/schema/schemas/agentcore-project.d.ts +@@ -369,6 +369,8 @@ + }, z.core.$strict>>; + }, z.core.$strip>>; + executionRoleArn: z.ZodOptional; ++ containerBuildRoleArn: z.ZodOptional; ++ containerBuildHandlerRoleArn: z.ZodOptional; + additionalPolicies: z.ZodOptional>; + bedrockManagedAgents: z.ZodOptional; + tags: z.ZodOptional>; diff --git a/docs/patches/verify-container-build-roles.cjs b/docs/patches/verify-container-build-roles.cjs new file mode 100644 index 000000000..f74c0d8bd --- /dev/null +++ b/docs/patches/verify-container-build-roles.cjs @@ -0,0 +1,172 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const originalRoot = path.resolve(process.argv[2]); +const patchedRoot = path.resolve(process.argv[3]); +const original = require(originalRoot); +const patched = require(patchedRoot); +const { App, Stack } = require(require.resolve("aws-cdk-lib", { paths: [patchedRoot] })); +const { Template } = require(require.resolve("aws-cdk-lib/assertions", { paths: [patchedRoot] })); +const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentcore-build-roles-")); +fs.mkdirSync(path.join(projectRoot, "agentcore")); +fs.mkdirSync(path.join(projectRoot, "app")); +fs.writeFileSync(path.join(projectRoot, "app", "main.py"), 'print("sample")\n'); +fs.writeFileSync( + path.join(projectRoot, "app", "Dockerfile"), + "FROM public.ecr.aws/docker/library/python:3.12-slim\n", +); + +const roles = { + executionRoleArn: "arn:aws:iam::111122223333:role/Runtime", + containerBuildRoleArn: "arn:aws:iam::111122223333:role/CodeBuild", + containerBuildHandlerRoleArn: "arn:aws:iam::111122223333:role/Handler", +}; +const runtime = (name, overrides = {}) => ({ + name, + build: "Container", + entrypoint: "main.py", + codeLocation: "app", + ...overrides, +}); +function synth(library, runtimes, region = "us-east-1") { + const props = library.transformAgentCoreJson( + { name: "RoleTest", version: 2, runtimes }, + { projectRoot }, + ); + const app = new App(); + const stack = new Stack(app, "RoleTest", { + env: { account: "111122223333", region }, + }); + new library.AgentCoreApplication(stack, "Application", props); + return Template.fromStack(stack).toJSON(); +} +const resources = (template, type) => + Object.values(template.Resources).filter((r) => r.Type === type); +function assertNoIam(template) { + for (const type of ["AWS::IAM::Role", "AWS::IAM::Policy", "AWS::IAM::ManagedPolicy"]) { + assert.equal(resources(template, type).length, 0, type); + } +} +try { + assert.deepEqual(synth(patched, [runtime("worker")]), synth(original, [runtime("worker")])); + const allImported = synth(patched, [runtime("worker", roles)]); + assertNoIam(allImported); + assert.equal( + resources(allImported, "AWS::CodeBuild::Project")[0].Properties.ServiceRole, + roles.containerBuildRoleArn, + ); + assert.equal( + resources(allImported, "AWS::Lambda::Function")[0].Properties.Role, + roles.containerBuildHandlerRoleArn, + ); + assert.equal( + resources(allImported, "AWS::BedrockAgentCore::Runtime")[0].Properties.RoleArn, + roles.executionRoleArn, + ); + const shared = synth(patched, [runtime("first", roles), runtime("second", roles)]); + assertNoIam(shared); + assert.equal(resources(shared, "AWS::CodeBuild::Project").length, 1); + const differentHandlers = synth(patched, [ + runtime("first", roles), + runtime("second", { + ...roles, + containerBuildHandlerRoleArn: "arn:aws:iam::111122223333:role/OtherHandler", + }), + ]); + assertNoIam(differentHandlers); + assert.equal(resources(differentHandlers, "AWS::CodeBuild::Project").length, 1); + assert.deepEqual( + resources(differentHandlers, "AWS::Lambda::Function") + .map((resource) => resource.Properties.Role) + .sort(), + [roles.containerBuildHandlerRoleArn, "arn:aws:iam::111122223333:role/OtherHandler"].sort(), + ); + for (const runtimes of [ + [ + runtime("first", roles), + runtime("second", { + ...roles, + containerBuildRoleArn: "arn:aws:iam::111122223333:role/Other", + }), + ], + [runtime("first", roles), runtime("second")], + [runtime("first"), runtime("second", roles)], + ]) { + assert.throws(() => synth(patched, runtimes), /Conflicting containerBuildRoleArn/); + } + const roleEntries = Object.entries(roles); + for (let mask = 1; mask < 7; mask++) { + const supplied = roleEntries.filter((_entry, index) => mask & (1 << index)); + const partial = Object.fromEntries(supplied); + assert.equal( + resources(synth(patched, [runtime("partial", partial)]), "AWS::IAM::Role").length, + 3 - supplied.length, + ); + } + // Check ARN preservation only. CDK may warn that a resource is unavailable in a region. + for (const [partition, region] of [ + ["aws-cn", "cn-north-1"], + ["aws-us-gov", "us-gov-west-1"], + ]) { + const partitionRoles = Object.fromEntries( + roleEntries.map(([key, arn]) => [key, arn.replace("arn:aws:", `arn:${partition}:`)]), + ); + const template = synth(patched, [runtime("partition", partitionRoles)], region); + assertNoIam(template); + assert.equal( + resources(template, "AWS::CodeBuild::Project")[0].Properties.ServiceRole, + partitionRoles.containerBuildRoleArn, + ); + assert.equal( + resources(template, "AWS::Lambda::Function")[0].Properties.Role, + partitionRoles.containerBuildHandlerRoleArn, + ); + assert.equal( + resources(template, "AWS::BedrockAgentCore::Runtime")[0].Properties.RoleArn, + partitionRoles.executionRoleArn, + ); + } + const vpc = { + networkMode: "VPC", + networkConfig: { + vpcId: "vpc-0123456789abcdef0", + subnets: ["subnet-0123456789abcdef0"], + securityGroups: ["sg-0123456789abcdef0"], + }, + }; + assert.deepEqual( + synth(patched, [runtime("vpc_default", vpc)]), + synth(original, [runtime("vpc_default", vpc)]), + ); + assert.deepEqual( + synth(patched, [runtime("first"), runtime("second")]), + synth(original, [runtime("first"), runtime("second")]), + ); + assertNoIam(synth(patched, [runtime("vpc", { ...roles, ...vpc })])); + const separate = synth(patched, [ + runtime("public", roles), + runtime("vpc", { + ...roles, + ...vpc, + containerBuildRoleArn: "arn:aws:iam::111122223333:role/VpcBuild", + }), + ]); + assertNoIam(separate); + assert.equal(resources(separate, "AWS::CodeBuild::Project").length, 2); + assert.throws( + () => + patched.AgentEnvSpecSchema.parse({ + ...runtime("zip", roles), + build: "CodeZip", + runtimeVersion: "PYTHON_3_12", + }), + /only allowed for Container builds/, + ); + console.log( + "CDK synthesis and validation checks passed; default template unchanged; supplied roles produce no IAM resources.", + ); +} finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); +} diff --git a/src/assets/templates/runtime-docs/python/AGENTS.md b/src/assets/templates/runtime-docs/python/AGENTS.md index dcae043eb..411eb5b69 100644 --- a/src/assets/templates/runtime-docs/python/AGENTS.md +++ b/src/assets/templates/runtime-docs/python/AGENTS.md @@ -76,8 +76,13 @@ in more detail. - Do not edit `agentcore/agentcore.json` unless the user asks. Set the build settings in the code instead (`Dockerfile`, `requires-python`, `[tool.agentcore] entrypoint`); the CLI updates `agentcore.json` from them. +{{#if containerBuildRoles}} +- Add a `Dockerfile` for this Container runtime. The user supplied externally + managed container-build roles; preserve them in `agentcore.json`. +{{else}} - Do not add a `Dockerfile` unless the user wants a container build. Adding one switches this runtime to container mode. +{{/if}} {{#if (or (eq protocol "HTTP") (eq protocol "A2A") (eq protocol "AGUI"))}} - If you implement `/ping` yourself, do not set `time_of_last_update` to the current time on every ping; only change it when the status changes. diff --git a/src/assets/templates/runtime-docs/python/README.md b/src/assets/templates/runtime-docs/python/README.md index eee3e2384..66acc5825 100644 --- a/src/assets/templates/runtime-docs/python/README.md +++ b/src/assets/templates/runtime-docs/python/README.md @@ -24,6 +24,11 @@ modules can live anywhere inside it and be imported normally. ## Direct code deploy or container? +{{#if containerBuildRoles}} +This runtime uses externally managed container-build roles and requires a +`Dockerfile`. Add it to this folder before running `dev`, `build`, or `deploy`. +The role owner must provision the CodeBuild and build-handler permissions. +{{else}} The CLI decides when you run `dev`, `build`, or `deploy`: - **No `Dockerfile` in this folder:** direct code deploy (CodeZip). The CLI @@ -34,21 +39,29 @@ The CLI decides when you run `dev`, `build`, or `deploy`: - **A `Dockerfile` in this folder:** container deploy. The CLI builds your image and AgentCore runs it. See "Container builds" below. +{{/if}} + ## How `agentcore.json` follows your code +{{#if containerBuildRoles}} +`agentcore add runtime` registered this runtime as a Container build with +externally managed build roles. Its `Dockerfile` controls the runtime version. +The CLI preserves those roles when it updates build settings from your code. +{{else}} `agentcore add runtime` registered this runtime with starting values: a direct code (CodeZip) build on Python {{versions.latestPython}} with entrypoint `main.py`. Each time you run `agentcore dev`, `agentcore build`, or `agentcore deploy`, the CLI reads your code, updates the runtime in `agentcore/agentcore.json`, and prints anything it changed: +{{/if}} - **Build type:** adding a file named exactly `Dockerfile` to this folder switches the runtime to a container build, and the CLI says so. It never switches back on its own. To return to a direct code deploy, delete the `Dockerfile`, then open `agentcore/agentcore.json`. In the `runtimes` array, find the object whose `"name"` is `"{{name}}"`, set `"build": "CodeZip"`, - remove `"dockerfile"` (and `"buildContextPath"` or - `"customDockerBuildArgs"` if present), and add + remove `"dockerfile"`, `"buildContextPath"`, `"customDockerBuildArgs"`, + `"containerBuildRoleArn"`, and `"containerBuildHandlerRoleArn"` if present, and add `"runtimeVersion": "{{versions.latestPythonRuntime}}"` to that same object. The CLI removed `runtimeVersion` when it switched to a container build, and CodeZip requires it. diff --git a/src/assets/templates/runtime-docs/typescript/AGENTS.md b/src/assets/templates/runtime-docs/typescript/AGENTS.md index 8de7f8283..7763c7aca 100644 --- a/src/assets/templates/runtime-docs/typescript/AGENTS.md +++ b/src/assets/templates/runtime-docs/typescript/AGENTS.md @@ -66,8 +66,13 @@ in more detail. - Do not edit `agentcore/agentcore.json` unless the user asks. Set the build settings in the code instead (`Dockerfile`, `engines.node`); the CLI updates `agentcore.json` from them. +{{#if containerBuildRoles}} +- Add a `Dockerfile` for this Container runtime. The user supplied externally + managed container-build roles; preserve them in `agentcore.json`. +{{else}} - Do not add a `Dockerfile` unless the user wants a container build. Adding one switches this runtime to container mode. +{{/if}} {{#if (or (eq protocol "HTTP") (eq protocol "A2A") (eq protocol "AGUI"))}} - If you implement `/ping` yourself, do not set `time_of_last_update` to the current time on every ping; only change it when the status changes. diff --git a/src/assets/templates/runtime-docs/typescript/README.md b/src/assets/templates/runtime-docs/typescript/README.md index e8abc3b3c..8d9f61858 100644 --- a/src/assets/templates/runtime-docs/typescript/README.md +++ b/src/assets/templates/runtime-docs/typescript/README.md @@ -26,6 +26,11 @@ the bundled output, `main.js`, as the entrypoint. ## Direct code deploy or container? +{{#if containerBuildRoles}} +This runtime uses externally managed container-build roles and requires a +`Dockerfile`. Add it to this folder before running `dev`, `build`, or `deploy`. +The role owner must provision the CodeBuild and build-handler permissions. +{{else}} The CLI decides when you run `dev`, `build`, or `deploy`: - **No `Dockerfile` in this folder:** direct code deploy (CodeZip). The CLI @@ -35,21 +40,29 @@ The CLI decides when you run `dev`, `build`, or `deploy`: - **A `Dockerfile` in this folder:** container deploy. The CLI builds your image and AgentCore runs it. See "Container builds" below. +{{/if}} + ## How `agentcore.json` follows your code +{{#if containerBuildRoles}} +`agentcore add runtime` registered this runtime as a Container build with +externally managed build roles. Its `Dockerfile` controls the runtime version. +The CLI preserves those roles when it updates build settings from your code. +{{else}} `agentcore add runtime` registered this runtime with starting values: a direct code (CodeZip) build on Node.js {{versions.latestNode}} with entrypoint `main.js` (the bundle the CLI builds from `main.ts`). Each time you run `agentcore dev`, `agentcore build`, or `agentcore deploy`, the CLI reads your code, updates the runtime in `agentcore/agentcore.json`, and prints anything it changed: +{{/if}} - **Build type:** adding a file named exactly `Dockerfile` to this folder switches the runtime to a container build, and the CLI says so. It never switches back on its own. To return to a direct code deploy, delete the `Dockerfile`, then open `agentcore/agentcore.json`. In the `runtimes` array, find the object whose `"name"` is `"{{name}}"`, set `"build": "CodeZip"`, - remove `"dockerfile"` (and `"buildContextPath"` or - `"customDockerBuildArgs"` if present), and add + remove `"dockerfile"`, `"buildContextPath"`, `"customDockerBuildArgs"`, + `"containerBuildRoleArn"`, and `"containerBuildHandlerRoleArn"` if present, and add `"runtimeVersion": "{{versions.latestNodeRuntime}}"` to that same object. The CLI removed `runtimeVersion` when it switched to a container build, and CodeZip requires it. diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 20ab3f552..e7248b900 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -310,6 +310,45 @@ function stepMessages(events: ProjectEvent[]): string[] { } describe("CdkBackend.build", () => { + test.each(["build", "deploy"])( + "%s rejects unsupported build roles before synthesis or credential provisioning", + async (operation) => { + const input = await project(); + input.spec.runtimes.push( + ProjectRuntimeSchema.parse({ + name: "worker", + build: "Container", + codeLocation: "app/worker", + entrypoint: "main.py", + containerBuildRoleArn: "arn:aws:iam::111122223333:role/Build", + }), + ); + const packageDirectory = join(cdkDirectory(input), "node_modules", "@aws", "agentcore-cdk"); + await mkdir(packageDirectory, { recursive: true }); + await writeFile( + join(packageDirectory, "index.js"), + "exports.AgentEnvSpecSchema = { shape: {} };", + ); + let provisioned = false; + // eslint-disable-next-line require-yield -- deployment must fail before this spy runs + const provisionCredentials: CredentialProvisioner = async function* () { + provisioned = true; + return {}; + }; + const subject = harness({ provisionCredentials }); + const result = + operation === "build" + ? collect(subject.backend.build(input)) + : collectDeploy(subject.backend.deploy(input, deployInput())); + await expect(result).rejects.toThrow( + "does not support externally managed container-build roles", + ); + expect(provisioned).toBe(false); + expect(subject.commands).toEqual([]); + expect(subject.runs).toEqual([]); + }, + ); + test("synthesizes into the assembly directory deploy reads", async () => { const input = await project(); const subject = harness(); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index d023e82c1..022bcd883 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -10,7 +10,7 @@ import type { ResolvedDeployedResource, ResolvedProjectResource, } from "../../../handlers/project/types"; -import { cdkCompatibilityWarning } from "./cdk/compatibility"; +import { assertContainerBuildRoleSupport, cdkCompatibilityWarning } from "./cdk/compatibility"; import { createLineSplitter, FsReadWriteJson, @@ -208,6 +208,7 @@ export class CdkBackend implements ProjectBackend { ); } await this.checkTool("npm", "Install Node.js: https://nodejs.org/"); + assertContainerBuildRoleSupport(cdkDir, project.spec); } private async *synthesize( diff --git a/src/core/project/backends/cdk/compatibility.test.ts b/src/core/project/backends/cdk/compatibility.test.ts index 706562788..37c16213d 100644 --- a/src/core/project/backends/cdk/compatibility.test.ts +++ b/src/core/project/backends/cdk/compatibility.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { cdkCompatibilityWarning, MINIMUM_COMPATIBLE_CDK_VERSION } from "./compatibility"; +import { + assertContainerBuildRoleSupport, + cdkCompatibilityWarning, + MINIMUM_COMPATIBLE_CDK_VERSION, +} from "./compatibility"; +import { ProjectSpecSchema } from "../../../../projectSchemas/project"; const tempDirectories: string[] = []; @@ -22,6 +27,43 @@ async function cdkDirectoryWith(version: unknown): Promise { } describe("cdkCompatibilityWarning", () => { + test("skips the build-role capability check for existing projects", () => { + const spec = ProjectSpecSchema.parse({ name: "example", version: 2 }); + expect(() => assertContainerBuildRoleSupport("/missing/cdk", spec)).not.toThrow(); + }); + + test.each([ + ["neither role", [], false], + ["build role only", ["containerBuildRoleArn"], false], + ["handler role only", ["containerBuildHandlerRoleArn"], false], + ["both roles", ["containerBuildRoleArn", "containerBuildHandlerRoleArn"], true], + ] as const)("checks installed CDK support for %s", async (_label, fields, supported) => { + const directory = await cdkDirectoryWith("1.0.0"); + await writeFile( + join(directory, "node_modules", "@aws", "agentcore-cdk", "index.js"), + `exports.AgentEnvSpecSchema = { shape: ${JSON.stringify(Object.fromEntries(fields.map((field) => [field, {}])))} };`, + ); + const spec = ProjectSpecSchema.parse({ + name: "example", + version: 2, + runtimes: [ + { + name: "example", + build: "Container", + codeLocation: "app", + entrypoint: "main.py", + containerBuildRoleArn: "arn:aws:iam::111122223333:role/Build", + containerBuildHandlerRoleArn: "arn:aws:iam::111122223333:role/Handler", + }, + ], + }); + if (supported) { + expect(() => assertContainerBuildRoleSupport(directory, spec)).not.toThrow(); + } else { + expect(() => assertContainerBuildRoleSupport(directory, spec)).toThrow("does not support"); + } + }); + test.each(["0.0.0-0", "1.0.0-rc.1", "1.0.0-rc.2"])( "warns for incompatible version %s", async (version) => { diff --git a/src/core/project/backends/cdk/compatibility.ts b/src/core/project/backends/cdk/compatibility.ts index 3e56bf0b0..13546c421 100644 --- a/src/core/project/backends/cdk/compatibility.ts +++ b/src/core/project/backends/cdk/compatibility.ts @@ -1,9 +1,32 @@ import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import { join } from "node:path"; import semver from "semver"; +import type { Project } from "../../../../handlers/project/types"; +import { ProjectStateError } from "../../../../errors"; export const MINIMUM_COMPATIBLE_CDK_VERSION = "1.0.0"; +/** Reject CDK versions that would silently strip externally managed build roles. */ +export function assertContainerBuildRoleSupport(cdkDirectory: string, spec: Project["spec"]): void { + const fields = ["containerBuildRoleArn", "containerBuildHandlerRoleArn"] as const; + const required = fields.filter((field) => + spec.runtimes.some((runtime) => runtime[field] !== undefined), + ); + if (required.length === 0) return; + + const projectRequire = createRequire(join(cdkDirectory, "package.json")); + const cdk = projectRequire("@aws/agentcore-cdk") as { + AgentEnvSpecSchema?: { shape?: Record }; + }; + if (required.some((field) => !(field in (cdk.AgentEnvSpecSchema?.shape ?? {})))) { + throw new ProjectStateError( + "The installed @aws/agentcore-cdk does not support externally managed container-build roles. " + + "Install a CDK package with container-build role support before building or deploying this project.", + ); + } +} + export async function cdkCompatibilityWarning(cdkDirectory: string): Promise { const packagePath = join(cdkDirectory, "node_modules", "@aws", "agentcore-cdk", "package.json"); diff --git a/src/core/project/runtimeBuild.test.ts b/src/core/project/runtimeBuild.test.ts index 47ab9452d..812280264 100644 --- a/src/core/project/runtimeBuild.test.ts +++ b/src/core/project/runtimeBuild.test.ts @@ -310,13 +310,16 @@ describe("build", () => { dockerfile: "Dockerfile", buildContextPath: "docker" as ProjectRuntime["buildContextPath"], customDockerBuildArgs: { FOO: "bar" }, + containerBuildRoleArn: "arn:aws:iam::111122223333:role/Build", + containerBuildHandlerRoleArn: "arn:aws:iam::111122223333:role/Handler", }); expect(() => prepareRuntimes(root, [container], "deploy")).toThrow( new ProjectStateError( 'runtime "search": no Dockerfile in docker/, and agentcore.json says this is a ' + "Container build. Add the Dockerfile, or deploy the code directly: in agentcore.json, " + 'set "build": "CodeZip", remove "dockerfile", "buildContextPath", ' + - '"customDockerBuildArgs", and add "runtimeVersion" (for example "PYTHON_3_14"). ' + + '"customDockerBuildArgs", "containerBuildRoleArn", "containerBuildHandlerRoleArn", ' + + 'and add "runtimeVersion" (for example "PYTHON_3_14"). ' + "See app/search/README.md.", ), ); diff --git a/src/core/project/runtimeBuild.ts b/src/core/project/runtimeBuild.ts index be74f38f3..b7f20d461 100644 --- a/src/core/project/runtimeBuild.ts +++ b/src/core/project/runtimeBuild.ts @@ -61,7 +61,15 @@ export function describeChange(name: string, change: RuntimeChange): string { */ function codeZipRevertEdits(runtime: ProjectRuntime): string { const edits = ['set "build": "CodeZip"']; - const containerOnly = (["dockerfile", "buildContextPath", "customDockerBuildArgs"] as const) + const containerOnly = ( + [ + "dockerfile", + "buildContextPath", + "customDockerBuildArgs", + "containerBuildRoleArn", + "containerBuildHandlerRoleArn", + ] as const + ) .filter((field) => runtime[field] !== undefined) .map((field) => `"${field}"`); if (containerOnly.length > 0) edits.push(`remove ${containerOnly.join(", ")}`); diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index e3c92630b..effa52558 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -112,6 +112,10 @@ function buildRuntimeSpec(input: RuntimeResourceConfig): ProjectRuntime { }), ...(infra.description && { description: infra.description }), ...(infra.executionRoleArn && { executionRoleArn: infra.executionRoleArn }), + ...(infra.containerBuildRoleArn && { containerBuildRoleArn: infra.containerBuildRoleArn }), + ...(infra.containerBuildHandlerRoleArn && { + containerBuildHandlerRoleArn: infra.containerBuildHandlerRoleArn, + }), ...(additionalPolicies.length > 0 && { additionalPolicies }), ...(scaffoldRuntimeInput.framework === "bma" && { bedrockManagedAgents: true, diff --git a/src/core/project/templates/runtimeDocs.ts b/src/core/project/templates/runtimeDocs.ts index 09261c4d5..b87614586 100644 --- a/src/core/project/templates/runtimeDocs.ts +++ b/src/core/project/templates/runtimeDocs.ts @@ -57,6 +57,7 @@ export function runtimeDocsTree( const protocol = input.protocol ?? "HTTP"; const context = { name: input.name, + containerBuildRoles: Boolean(input.containerBuildRoleArn || input.containerBuildHandlerRoleArn), protocol, port: PROTOCOL_PORTS[protocol], entrypoint: docsRuntimeEntrypoint(input.language), @@ -96,20 +97,30 @@ export function runtimeDocsTree( /** * The agentcore.json entry for a docs-only runtime, with starting values: a - * CodeZip build on the latest runtime for the language. dev, build, and deploy - * update build, runtimeVersion, and entrypoint from the code once it exists. + * CodeZip build on the latest runtime for the language, or a Container build + * when container-build roles are supplied. dev, build, and deploy update the + * build settings from the code once it exists. */ export function buildDocsRuntimeSpec(input: RuntimeDocsConfig): ProjectRuntime { const { name, language, ...infra } = input; + const container = Boolean(infra.containerBuildRoleArn || infra.containerBuildHandlerRoleArn); return { name, codeLocation: `app/${name}` as ProjectRuntime["codeLocation"], - build: "CodeZip", - runtimeVersion: language === "TypeScript" ? LATEST_NODE : LATEST_PYTHON, + ...(container + ? { build: "Container" as const, dockerfile: "Dockerfile" } + : { + build: "CodeZip" as const, + runtimeVersion: language === "TypeScript" ? LATEST_NODE : LATEST_PYTHON, + }), entrypoint: docsRuntimeEntrypoint(language), protocol: infra.protocol ?? "HTTP", ...(infra.description && { description: infra.description }), ...(infra.executionRoleArn && { executionRoleArn: infra.executionRoleArn }), + ...(infra.containerBuildRoleArn && { containerBuildRoleArn: infra.containerBuildRoleArn }), + ...(infra.containerBuildHandlerRoleArn && { + containerBuildHandlerRoleArn: infra.containerBuildHandlerRoleArn, + }), ...(infra.additionalPolicies?.length && { additionalPolicies: infra.additionalPolicies }), ...(infra.envVars?.length && { envVars: infra.envVars }), ...(infra.networkMode && { networkMode: infra.networkMode }), diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index c67ef6f2a..77b245ab5 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -66,6 +66,76 @@ async function runtimeOf(projectRoot: string, name: string) { } describe("project add runtime", () => { + const buildRoles = { + containerBuildRoleArn: "arn:aws:iam::111122223333:role/Build", + containerBuildHandlerRoleArn: "arn:aws:iam::111122223333:role/Handler", + }; + test.each(["python", "typescript"])( + "%s retains supplied roles and starts with a Container build", + async (language) => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + const executionRoleArn = "arn:aws:iam::111122223333:role/Runtime"; + await run([ + "add", + "runtime", + "--name", + "worker", + "--language", + language, + "--role-arn", + executionRoleArn, + "--container-build-role-arn", + buildRoles.containerBuildRoleArn, + "--container-build-handler-role-arn", + buildRoles.containerBuildHandlerRoleArn, + ]); + const runtime = await runtimeOf(projectRoot, "worker"); + expect(runtime).toMatchObject({ + ...buildRoles, + executionRoleArn, + build: "Container", + dockerfile: "Dockerfile", + }); + expect(runtime.runtimeVersion).toBeUndefined(); + const appDir = join(projectRoot, "app", "worker"); + expect((await readdir(appDir)).sort()).toEqual(["AGENTS.md", "README.md"]); + const readme = await Bun.file(join(appDir, "README.md")).text(); + expect(readme).toContain("registered this runtime as a Container build"); + expect(readme).toContain("containerBuildRoleArn"); + expect(readme).not.toContain("{{"); + expect(await Bun.file(join(appDir, "AGENTS.md")).text()).toContain( + "Add a `Dockerfile` for this Container runtime", + ); + }, + ); + + test.each([ + ["--container-build-role-arn", "containerBuildRoleArn"], + ["--container-build-handler-role-arn", "containerBuildHandlerRoleArn"], + ])("%s also works independently", async (flagName, field) => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + const arn = "arn:aws:iam::111122223333:role/team/Build"; + await run(["add", "runtime", "--name", "worker", "--language", "python", flagName!, arn]); + expect(await runtimeOf(projectRoot, "worker")).toMatchObject({ + build: "Container", + [field!]: arn, + }); + }); + + test.each(["--container-build-role-arn", "--container-build-handler-role-arn"])( + "%s rejects invalid role ARNs without writing a runtime", + async (flagName) => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + await expect( + run(["add", "runtime", "--name", "worker", "--language", "python", flagName, "invalid"]), + ).rejects.toThrow("Must be an IAM role ARN"); + expect((await specOf(projectRoot)).runtimes ?? []).toEqual([]); + }, + ); + const allInfrastructureFlags = [ "--description", "Configured runtime", @@ -343,6 +413,19 @@ describe("project add runtime --agent-id (Bedrock Agent import)", () => { "us-east-1", ]; + test("rejects build roles for CodeZip Bedrock Agent imports", async () => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + const core = new TestCoreClient(); + core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan(); + await expect( + run([...importArgs, "--container-build-role-arn", "arn:aws:iam::111122223333:role/Build"], { + core, + }), + ).rejects.toThrow("only allowed for Container builds"); + expect((await specOf(projectRoot)).runtimes ?? []).toEqual([]); + }); + test("scaffolds owned runtime code translated from the selected agent version", async () => { const { projectRoot, cleanup } = await initProject(); cleanups.push(cleanup); diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 84b033f8c..d11249622 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -4,7 +4,11 @@ import { isChinaRegion } from "../../../../core/partition"; import type { AddProjectResourceConfig } from "../types"; import { parseJsonFlag, parseTags, tagsFlag } from "../../../utils"; import { InputValidationError, RegionUnsupportedFeatureError } from "../../../../errors"; -import { AgentNameSchema, type EnvVar } from "../../../../projectSchemas/runtime"; +import { + AgentNameSchema, + ContainerBuildRoleArnSchema, + type EnvVar, +} from "../../../../projectSchemas/runtime"; import { RuntimeAuthorizerTypeSchema } from "../../../../projectSchemas/auth"; import { NetworkModeSchema, @@ -147,6 +151,18 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => z.array(z.string()).optional(), { group: ACCESS_AND_PERMISSIONS }, ), + flag( + "container-build-role-arn", + "existing CodeBuild execution role; selects a Container build (permissions managed externally)", + ContainerBuildRoleArnSchema.optional(), + { group: ACCESS_AND_PERMISSIONS }, + ), + flag( + "container-build-handler-role-arn", + "existing build-handler Lambda role; selects a Container build (permissions managed externally)", + ContainerBuildRoleArnSchema.optional(), + { group: ACCESS_AND_PERMISSIONS }, + ), flag( "authorizer-type", "inbound authorizer type (AWS_IAM or CUSTOM_JWT)", @@ -263,6 +279,8 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => name: flags.name, description: flags.description, executionRoleArn: flags["role-arn"], + containerBuildRoleArn: flags["container-build-role-arn"], + containerBuildHandlerRoleArn: flags["container-build-handler-role-arn"], additionalPolicies: flags["additional-policies"], envVars: toEnvironmentVariables(inputEnvironmentVariables), networkMode: flags["network-mode"], diff --git a/src/handlers/project/add/runtime/types.ts b/src/handlers/project/add/runtime/types.ts index 4ddafc7e6..8f1460dd7 100644 --- a/src/handlers/project/add/runtime/types.ts +++ b/src/handlers/project/add/runtime/types.ts @@ -10,6 +10,8 @@ const RuntimeInfraConfigSchema = z.object({ name: ProjectRuntimeSchema.shape.name, description: ProjectRuntimeSchema.shape.description, executionRoleArn: ProjectRuntimeSchema.shape.executionRoleArn, + containerBuildRoleArn: ProjectRuntimeSchema.shape.containerBuildRoleArn, + containerBuildHandlerRoleArn: ProjectRuntimeSchema.shape.containerBuildHandlerRoleArn, additionalPolicies: ProjectRuntimeSchema.shape.additionalPolicies, envVars: ProjectRuntimeSchema.shape.envVars, networkMode: ProjectRuntimeSchema.shape.networkMode, @@ -27,6 +29,16 @@ export const RuntimeResourceConfigSchema = RuntimeInfraConfigSchema.extend({ scaffoldRuntimeInput: ScaffoldRuntimeInputSchema, /** Present when runtime files were translated from a Bedrock Agent version. */ importBedrockAgent: ImportBedrockAgentInputSchema.optional(), +}).superRefine((runtime, ctx) => { + for (const field of ["containerBuildRoleArn", "containerBuildHandlerRoleArn"] as const) { + if (runtime[field] && runtime.scaffoldRuntimeInput.build !== "Container") { + ctx.addIssue({ + code: "custom", + message: `${field} is only allowed for Container builds`, + path: [field], + }); + } + } }); export type RuntimeResourceConfig = z.infer; diff --git a/src/projectSchemas/runtime.test.ts b/src/projectSchemas/runtime.test.ts index 967991cb1..475e404f6 100644 --- a/src/projectSchemas/runtime.test.ts +++ b/src/projectSchemas/runtime.test.ts @@ -24,6 +24,38 @@ const networkConfig = { securityGroups: ["sg-0123456789abcdef0"], }; describe("runtime custom validation", () => { + it("retains valid externally managed build roles across AWS partitions", () => { + for (const partition of ["aws", "aws-cn", "aws-us-gov"]) { + const roles = { + containerBuildRoleArn: `arn:${partition}:iam::111122223333:role/build/CodeBuild`, + containerBuildHandlerRoleArn: `arn:${partition}:iam::111122223333:role/build/Handler`, + }; + expect(ProjectRuntimeSchema.parse({ ...containerAgent, ...roles })).toMatchObject(roles); + } + }); + it("rejects invalid build role ARNs and build roles on CodeZip runtimes", () => { + for (const field of ["containerBuildRoleArn", "containerBuildHandlerRoleArn"]) { + for (const arn of [ + "not-an-arn", + "arn:aws:iam::111122223333:user/Build", + "arn:aws:iam:us-east-1:111122223333:role/Build", + "arn:aws:iam::123:role/Build", + "arn:aws:iam::111122223333:role/Bad Role", + "arn:aws:iam::111122223333:role/", + `arn:aws:iam::111122223333:role/${"x".repeat(2048)}`, + ]) { + expect(ProjectRuntimeSchema.safeParse({ ...containerAgent, [field]: arn }).success).toBe( + false, + ); + } + expect( + ProjectRuntimeSchema.safeParse({ + ...codeZipAgent, + [field]: "arn:aws:iam::111122223333:role/Build", + }).success, + ).toBe(false); + } + }); it("validates Dockerfile paths through the shared guard", () => { expect(isValidDockerfilePath(".docker/Dockerfile")).toBe(true); for (const path of ["../Dockerfile", "/Dockerfile", "docker/", "a//Dockerfile", "a;rm"]) { diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index e74694dfb..550984fd6 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -43,6 +43,10 @@ export const AgentTypeSchema = z.literal("AgentCoreRuntime"); export type AgentType = z.infer; export const BuildTypeSchema = z.enum(["CodeZip", "Container"]); export type BuildType = z.infer; +export const ContainerBuildRoleArnSchema = z + .string() + .max(2048) + .regex(/^arn:[^:]+:iam::[0-9]{12}:role\/[^\s]+$/, "Must be an IAM role ARN"); export const EntrypointSchema = z .string() .min(1) @@ -319,6 +323,8 @@ export const ProjectRuntimeSchema = z protocol: ProtocolModeSchema.optional(), requestHeaderAllowlist: RequestHeaderAllowlistSchema.optional(), executionRoleArn: z.string().optional(), + containerBuildRoleArn: ContainerBuildRoleArnSchema.optional(), + containerBuildHandlerRoleArn: ContainerBuildRoleArnSchema.optional(), additionalPolicies: z.array(z.string().min(1)).optional(), /** Enables the CDK-managed Bedrock Managed Agents session role for this runtime. */ bedrockManagedAgents: z.boolean().optional(), @@ -396,7 +402,13 @@ export const ProjectRuntimeSchema = z path: ["runtimeVersion"], }); } - for (const field of ["dockerfile", "buildContextPath", "customDockerBuildArgs"] as const) { + for (const field of [ + "dockerfile", + "buildContextPath", + "customDockerBuildArgs", + "containerBuildRoleArn", + "containerBuildHandlerRoleArn", + ] as const) { if (data.build !== "Container" && data[field]) { ctx.addIssue({ code: "custom",