From 74b1b5056a400af2d8ccd2b30fa0c9ffad92e911 Mon Sep 17 00:00:00 2001 From: Edouard Courty Date: Mon, 18 May 2026 16:53:38 +0200 Subject: [PATCH] feat(issue-1236): enhance docker build with additional build configuration - allow buildContextPath argument to change build context path - allow custom docker build arguments --- docs/configuration.md | 38 ++++---- docs/container-builds.md | 45 ++++++++++ .../operations/dev/__tests__/config.test.ts | 65 ++++++++++++++ src/cli/operations/dev/config.ts | 9 ++ .../operations/dev/container-dev-server.ts | 7 +- src/lib/packaging/__tests__/container.test.ts | 51 +++++++++++ src/lib/packaging/container.ts | 9 +- src/schema/llm-compacted/agentcore.ts | 2 + .../schemas/__tests__/agent-env.test.ts | 90 +++++++++++++++++++ src/schema/schemas/agent-env.ts | 26 ++++++ 10 files changed, 322 insertions(+), 20 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6e42c101a..105ea243b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -175,24 +175,26 @@ on the next deployment. } ``` -| Field | Required | Description | -| ------------------------- | -------- | --------------------------------------------------------------- | -| `name` | Yes | Agent name (1-48 chars, alphanumeric + underscore) | -| `build` | Yes | `"CodeZip"` or `"Container"` | -| `entrypoint` | Yes | Entry file (e.g., `main.py` or `main.py:handler`) | -| `codeLocation` | Yes | Directory containing agent code | -| `runtimeVersion` | Yes | Runtime version (see below) | -| `networkMode` | No | `"PUBLIC"` (default) or `"VPC"` | -| `networkConfig` | No | VPC configuration (subnets, security groups) | -| `protocol` | No | `"HTTP"` (default), `"MCP"`, or `"A2A"` | -| `envVars` | No | Custom environment variables | -| `instrumentation` | No | OpenTelemetry settings | -| `authorizerType` | No | `"AWS_IAM"` or `"CUSTOM_JWT"` | -| `authorizerConfiguration` | No | JWT authorizer settings (for `CUSTOM_JWT`) | -| `requestHeaderAllowlist` | No | Headers to forward to the agent | -| `lifecycleConfiguration` | No | Runtime session lifecycle settings (idle timeout, max lifetime) | -| `executionRoleArn` | No | ARN of an existing IAM execution role (skips CDK-managed role) | -| `tags` | No | Agent-level tags | +| Field | Required | Description | +| ------------------------- | -------- | ---------------------------------------------------------------------------------------- | +| `name` | Yes | Agent name (1-48 chars, alphanumeric + underscore) | +| `build` | Yes | `"CodeZip"` or `"Container"` | +| `entrypoint` | Yes | Entry file (e.g., `main.py` or `main.py:handler`) | +| `codeLocation` | Yes | Directory containing agent code (and the `Dockerfile` for Container builds) | +| `runtimeVersion` | Yes | Runtime version (see below) | +| `networkMode` | No | `"PUBLIC"` (default) or `"VPC"` | +| `networkConfig` | No | VPC configuration (subnets, security groups) | +| `protocol` | No | `"HTTP"` (default), `"MCP"`, or `"A2A"` | +| `envVars` | No | Custom environment variables | +| `instrumentation` | No | OpenTelemetry settings | +| `authorizerType` | No | `"AWS_IAM"` or `"CUSTOM_JWT"` | +| `authorizerConfiguration` | No | JWT authorizer settings (for `CUSTOM_JWT`) | +| `requestHeaderAllowlist` | No | Headers to forward to the agent | +| `lifecycleConfiguration` | No | Runtime session lifecycle settings (idle timeout, max lifetime) | +| `executionRoleArn` | No | ARN of an existing IAM execution role (skips CDK-managed role) | +| `tags` | No | Agent-level tags | +| `buildContextPath` | No | **Container only.** Docker build context directory (replaces `codeLocation` as context). | +| `customDockerBuildArgs` | No | **Container only.** Key/value pairs forwarded as `--build-arg` flags. | ### Runtime Versions diff --git a/docs/container-builds.md b/docs/container-builds.md index 61d65bcde..d57b00432 100644 --- a/docs/container-builds.md +++ b/docs/container-builds.md @@ -68,6 +68,51 @@ All other fields work the same as CodeZip agents. > must also add a `Dockerfile` and `.dockerignore` to the agent's code directory. The easiest way is to create a > throwaway container agent with `agentcore add agent --build Container` and copy the generated files. +### Advanced: Shared Dockerfile (monorepo) + +When multiple agents share the same build logic, you can point them all at a single `Dockerfile` using two optional +fields: + +| Field | Description | +| ----------------------- | --------------------------------------------------------------------------------------------------- | +| `buildContextPath` | Docker build context directory. Replaces `codeLocation` as the positional `docker build` argument. | +| `customDockerBuildArgs` | Key/value pairs forwarded as `--build-arg` flags, allowing a shared Dockerfile to branch per agent. | + +**Example — two agents, one Dockerfile at the project root:** + +```json +{ + "name": "agent-one", + "build": "Container", + "entrypoint": "main.py", + "codeLocation": "app/agent-one/", + "buildContextPath": ".", + "customDockerBuildArgs": { "AGENT_NAME": "agent-one" } +}, +{ + "name": "agent-two", + "build": "Container", + "entrypoint": "main.py", + "codeLocation": "app/agent-two/", + "buildContextPath": ".", + "customDockerBuildArgs": { "AGENT_NAME": "agent-two" } +} +``` + +The shared `Dockerfile` can then branch on the build arg: + +```dockerfile +ARG AGENT_NAME +COPY app/${AGENT_NAME}/ ./app/ +``` + +**When to use `buildContextPath`:** use it when your `Dockerfile` needs to `COPY` files that live outside of +`codeLocation` (e.g. shared libraries at the project root). Without it, Docker only sees the `codeLocation` directory as +its build context. + +**When to use `customDockerBuildArgs`:** use it to parameterise a shared `Dockerfile` so each agent produces a different +image (different entry point, bundled code, etc.) without duplicating the file. + ## Local Development ```bash diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index 844ab437c..d539d41fd 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -438,6 +438,71 @@ describe('getDevConfig', () => { expect(config).not.toBeNull(); expect(config?.dockerfile).toBe('Dockerfile.gpu'); }); + + it('threads buildContextPath from Container agent spec to DevConfig (resolved absolute)', () => { + const project: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [ + { + name: 'ContainerAgent', + build: 'Container', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('main.py'), + codeLocation: dirPath('./agents/container'), + protocol: 'HTTP', + buildContextPath: dirPath('.'), + }, + ], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], + }; + + const config = getDevConfig(workingDir, project, '/test/project/agentcore'); + expect(config).not.toBeNull(); + // resolveCodeDirectory('.', '/test/project/agentcore') => dirname('/test/project/agentcore') + '/' + '.' => '/test/project' + expect(config?.buildContextPath).toBe('/test/project'); + }); + + it('threads customDockerBuildArgs from Container agent spec to DevConfig', () => { + const project: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [ + { + name: 'ContainerAgent', + build: 'Container', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('main.py'), + codeLocation: dirPath('./agents/container'), + protocol: 'HTTP', + customDockerBuildArgs: { AGENT_NAME: 'myagent' }, + }, + ], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], + }; + + const config = getDevConfig(workingDir, project, '/test/project/agentcore'); + expect(config).not.toBeNull(); + expect(config?.customDockerBuildArgs).toEqual({ AGENT_NAME: 'myagent' }); + }); }); describe('getAgentPort', () => { diff --git a/src/cli/operations/dev/config.ts b/src/cli/operations/dev/config.ts index fd13637bb..b2374e513 100644 --- a/src/cli/operations/dev/config.ts +++ b/src/cli/operations/dev/config.ts @@ -11,6 +11,10 @@ export interface DevConfig { buildType: BuildType; protocol: ProtocolMode; dockerfile?: string; + /** Resolved absolute path to use as the Docker build context. Defaults to `directory`. */ + buildContextPath?: string; + /** Custom `--build-arg` key/value pairs forwarded to `docker build`. */ + customDockerBuildArgs?: Record; } interface DevSupportResult { @@ -142,6 +146,11 @@ export function getDevConfig( buildType: targetAgent.build, protocol: targetAgent.protocol ?? 'HTTP', dockerfile: targetAgent.dockerfile, + buildContextPath: + configRoot && targetAgent.buildContextPath + ? resolveCodeDirectory(targetAgent.buildContextPath, configRoot) + : undefined, + customDockerBuildArgs: targetAgent.customDockerBuildArgs, }; } diff --git a/src/cli/operations/dev/container-dev-server.ts b/src/cli/operations/dev/container-dev-server.ts index 10ef21b3f..0057f7fb5 100644 --- a/src/cli/operations/dev/container-dev-server.ts +++ b/src/cli/operations/dev/container-dev-server.ts @@ -78,8 +78,13 @@ export class ContainerDevServer extends DevServer { // 4. Build the container image, streaming output in real-time onLog('system', `Building container image: ${this.imageName}...`); + const buildContext = this.config.buildContextPath ?? this.config.directory; + const buildArgFlags = Object.entries(this.config.customDockerBuildArgs ?? {}).flatMap(([k, v]) => [ + '--build-arg', + `${k}=${v}`, + ]); const exitCode = await this.streamBuild( - ['-t', this.imageName, '-f', dockerfilePath, ...getUvBuildArgs(), this.config.directory], + ['-t', this.imageName, '-f', dockerfilePath, ...getUvBuildArgs(), ...buildArgFlags, buildContext], onLog ); diff --git a/src/lib/packaging/__tests__/container.test.ts b/src/lib/packaging/__tests__/container.test.ts index cb5685dbf..f60a6ad2f 100644 --- a/src/lib/packaging/__tests__/container.test.ts +++ b/src/lib/packaging/__tests__/container.test.ts @@ -210,6 +210,57 @@ describe('ContainerPackager', () => { await expect(packager.pack(specWithDockerfile as any)).rejects.toThrow('Dockerfile.custom not found'); }); + it('uses buildContextPath as docker build context instead of codeLocation', async () => { + mockResolveCodeLocation.mockImplementation((path: string) => { + if (path === './src') return '/resolved/src'; + if (path === './context') return '/resolved/context'; + return path; + }); + mockExistsSync.mockReturnValue(true); + mockSpawnSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'which' && args[0] === 'docker') return { status: 0 }; + if (cmd === 'docker' && args[0] === '--version') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'build') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'image') return { status: 0, stdout: Buffer.from('1000') }; + return { status: 1 }; + }); + + const specWithContext = { ...baseSpec, buildContextPath: './context' }; + await packager.pack(specWithContext as any); + + const buildCall = mockSpawnSync.mock.calls.find( + (c: unknown[]) => c[0] === 'docker' && (c[1] as string[])[0] === 'build' + ); + expect(buildCall).toBeDefined(); + const buildArgs = buildCall![1] as string[]; + // Last arg should be the buildContextPath, not codeLocation + expect(buildArgs[buildArgs.length - 1]).toBe('/resolved/context'); + }); + + it('passes customDockerBuildArgs as --build-arg flags', async () => { + mockResolveCodeLocation.mockReturnValue('/resolved/src'); + mockExistsSync.mockReturnValue(true); + mockSpawnSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'which' && args[0] === 'docker') return { status: 0 }; + if (cmd === 'docker' && args[0] === '--version') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'build') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'image') return { status: 0, stdout: Buffer.from('1000') }; + return { status: 1 }; + }); + + const specWithBuildArgs = { ...baseSpec, customDockerBuildArgs: { AGENT_NAME: 'dummyagent', BUILD_ENV: 'prod' } }; + await packager.pack(specWithBuildArgs as any); + + const buildCall = mockSpawnSync.mock.calls.find( + (c: unknown[]) => c[0] === 'docker' && (c[1] as string[])[0] === 'build' + ); + expect(buildCall).toBeDefined(); + const buildArgs = buildCall![1] as string[]; + expect(buildArgs).toContain('--build-arg'); + expect(buildArgs).toContain('AGENT_NAME=dummyagent'); + expect(buildArgs).toContain('BUILD_ENV=prod'); + }); + it('detects podman runtime last', async () => { mockResolveCodeLocation.mockReturnValue('/resolved/src'); mockExistsSync.mockReturnValue(true); diff --git a/src/lib/packaging/container.ts b/src/lib/packaging/container.ts index a166a08f3..85416f270 100644 --- a/src/lib/packaging/container.ts +++ b/src/lib/packaging/container.ts @@ -36,6 +36,9 @@ export class ContainerPackager implements RuntimePackager { const configBaseDir = options.artifactDir ?? options.projectRoot ?? process.cwd(); const codeLocation = resolveCodeLocation(spec.codeLocation, configBaseDir); const dockerfilePath = getDockerfilePath(codeLocation, spec.dockerfile); + const buildContext = spec.buildContextPath + ? resolveCodeLocation(spec.buildContextPath, configBaseDir) + : codeLocation; // Preflight: Dockerfile must exist if (!existsSync(dockerfilePath)) { @@ -59,9 +62,13 @@ export class ContainerPackager implements RuntimePackager { // Build locally const imageName = `agentcore-package-${agentName}`; + const buildArgFlags = Object.entries(spec.customDockerBuildArgs ?? {}).flatMap(([k, v]) => [ + '--build-arg', + `${k}=${v}`, + ]); const buildResult = spawnSync( runtime, - ['build', '-t', imageName, '-f', dockerfilePath, ...getUvBuildArgs(), codeLocation], + ['build', '-t', imageName, '-f', dockerfilePath, ...getUvBuildArgs(), ...buildArgFlags, buildContext], { stdio: 'pipe', } diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index c86c14cb7..34448cf49 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -68,6 +68,8 @@ interface AgentEnvSpec { entrypoint: string; // @regex ^[a-zA-Z0-9_][a-zA-Z0-9_/.-]*\.(py|ts|js)(:[a-zA-Z_][a-zA-Z0-9_]*)?$ e.g. "main.py:handler" or "index.ts" codeLocation: string; // Directory path dockerfile?: string; // Custom Dockerfile name for Container builds (default: 'Dockerfile'). Must be a filename, not a path. + buildContextPath?: string; // Docker build context directory for Container builds. Replaces codeLocation as the positional `docker build` argument. + customDockerBuildArgs?: Record; // Key/value pairs forwarded as --build-arg flags. Container builds only. runtimeVersion?: RuntimeVersion; envVars?: EnvVar[]; networkMode?: NetworkMode; // default 'PUBLIC' diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index b5b0e55a2..0d0c4f982 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -458,6 +458,96 @@ describe('AgentEnvSpecSchema - dockerfile', () => { }); }); +describe('AgentEnvSpecSchema - buildContextPath', () => { + const validContainerAgent = { + name: 'ContainerAgent', + build: 'Container', + entrypoint: 'main.py', + codeLocation: './agents/container', + }; + + const validCodeZipAgent = { + name: 'CodeZipAgent', + build: 'CodeZip', + entrypoint: 'main.py:handler', + codeLocation: './agents/test', + runtimeVersion: 'PYTHON_3_12', + }; + + it('accepts Container agent with buildContextPath', () => { + const result = AgentEnvSpecSchema.safeParse({ ...validContainerAgent, buildContextPath: '.' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.buildContextPath).toBe('.'); + } + }); + + it('accepts Container agent without buildContextPath (optional)', () => { + const result = AgentEnvSpecSchema.safeParse(validContainerAgent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.buildContextPath).toBeUndefined(); + } + }); + + it('rejects buildContextPath on CodeZip builds', () => { + const result = AgentEnvSpecSchema.safeParse({ ...validCodeZipAgent, buildContextPath: '.' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('only allowed for Container'))).toBe(true); + } + }); +}); + +describe('AgentEnvSpecSchema - customDockerBuildArgs', () => { + const validContainerAgent = { + name: 'ContainerAgent', + build: 'Container', + entrypoint: 'main.py', + codeLocation: './agents/container', + }; + + const validCodeZipAgent = { + name: 'CodeZipAgent', + build: 'CodeZip', + entrypoint: 'main.py:handler', + codeLocation: './agents/test', + runtimeVersion: 'PYTHON_3_12', + }; + + it('accepts Container agent with customDockerBuildArgs', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validContainerAgent, + customDockerBuildArgs: { AGENT_NAME: 'dummyagent', BUILD_ENV: 'prod' }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customDockerBuildArgs).toEqual({ AGENT_NAME: 'dummyagent', BUILD_ENV: 'prod' }); + } + }); + + it('accepts Container agent without customDockerBuildArgs (optional)', () => { + const result = AgentEnvSpecSchema.safeParse(validContainerAgent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customDockerBuildArgs).toBeUndefined(); + } + }); + + it('rejects customDockerBuildArgs on CodeZip builds', () => { + const result = AgentEnvSpecSchema.safeParse({ ...validCodeZipAgent, customDockerBuildArgs: { KEY: 'val' } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('only allowed for Container'))).toBe(true); + } + }); + + it('accepts empty customDockerBuildArgs object', () => { + const result = AgentEnvSpecSchema.safeParse({ ...validContainerAgent, customDockerBuildArgs: {} }); + expect(result.success).toBe(true); + }); +}); + describe('AgentEnvSpecSchema - lifecycleConfiguration', () => { const validAgent = { name: 'TestAgent', diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 789109a38..f7ec3bdb6 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -232,6 +232,18 @@ export const AgentEnvSpecSchema = z .max(255) .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, 'Must be a filename (no path separators or traversal)') .optional(), + /** + * Docker build context directory for Container builds. + * When set, this path is used as the positional argument to `docker build` instead of `codeLocation`. + * Useful when a single Dockerfile is shared across multiple agents in a monorepo. + */ + buildContextPath: DirectoryPathSchema.optional(), + /** + * Custom build arguments passed to `docker build` as `--build-arg KEY=VALUE` flags. + * Useful for parameterising a shared Dockerfile per agent (e.g. `{ "AGENT_NAME": "myagent" }`). + * Container builds only. + */ + customDockerBuildArgs: z.record(z.string().min(1), z.string()).optional(), runtimeVersion: RuntimeVersionSchemaFromConstants.optional(), /** Environment variables to set on the runtime */ envVars: z.array(EnvVarSchema).optional(), @@ -296,6 +308,20 @@ export const AgentEnvSpecSchema = z path: ['dockerfile'], }); } + if (data.build !== 'Container' && data.buildContextPath) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'buildContextPath is only allowed for Container builds', + path: ['buildContextPath'], + }); + } + if (data.build !== 'Container' && data.customDockerBuildArgs) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'customDockerBuildArgs is only allowed for Container builds', + path: ['customDockerBuildArgs'], + }); + } }); export type AgentEnvSpec = z.infer;