From 3661db13aaaf2ef882dcdae9eaabc8073693788f Mon Sep 17 00:00:00 2001 From: Aryan Khanna Date: Wed, 22 Apr 2026 15:57:05 -0700 Subject: [PATCH] fix(harness): build and push harness Dockerfile via CDK asset (#927) When a harness's harness.json sets "dockerfile" instead of "containerUri", the deploy pipeline previously silently dropped the field: the CDK stack created no image asset and the mapper omitted environmentArtifact from CreateHarness, so the harness came up without the user's custom container. See #927 for the repro. This change wires the dockerfile through end-to-end: - src/assets/cdk/bin/cdk.ts now forwards `dockerfile` and a resolved `codeLocation` (absolute harness directory) into the stack props. - src/assets/cdk/lib/cdk-stack.ts creates a linux/arm64 DockerImageAsset per harness that specifies a dockerfile and emits its imageUri as a per-harness CfnOutput named `ApplicationHarnessImageUri`. - src/cli/operations/deploy/imperative/deployers/harness-mapper.ts resolves that output from cdkOutputs and populates environmentArtifact.containerConfiguration.containerUri on the CreateHarness call. If the stack lacks the output (e.g. Docker was unavailable during synth), the mapper throws an actionable error instead of continuing silently. Behavior preserved when neither `dockerfile` nor `containerUri` is set (harness uses the managed default image). Explicit `containerUri` still takes precedence over a built image. Docker daemon with buildx linux/arm64 support is required at `agentcore deploy` time, matching the existing requirement for agent runtime Dockerfiles. Tests: - 3 new harness-mapper unit tests covering dockerfile + cdkOutputs, missing-output error, and containerUri precedence. - Updated asset snapshot tests for the new CDK files. - Verified end-to-end against a real project: * arm64 image built and pushed to cdk-hnb659fds-container-assets--us-east-1. * ApplicationHarnessPptagentImageUri stack output emitted. * CreateHarness succeeded and harness reached READY. Fixes #927 --- .../assets.snapshot.test.ts.snap | 52 ++++++++++++++++++- src/assets/cdk/bin/cdk.ts | 7 ++- src/assets/cdk/lib/cdk-stack.ts | 45 ++++++++++++++++ .../__tests__/harness-mapper.test.ts | 49 +++++++++++++++++ .../imperative/deployers/harness-mapper.ts | 31 ++++++++++- 5 files changed, 181 insertions(+), 3 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index d4ad9d60..8b9057b9 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -110,11 +110,14 @@ async function main() { memoryName?: string; containerUri?: string; hasDockerfile?: boolean; + dockerfile?: string; + codeLocation?: string; tools?: { type: string; name: string }[]; apiKeyArn?: string; }[] = []; for (const entry of specAny.harnesses ?? []) { - const harnessPath = path.resolve(projectRoot, entry.path, 'harness.json'); + const harnessDir = path.resolve(projectRoot, entry.path); + const harnessPath = path.join(harnessDir, 'harness.json'); try { const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8')); harnessConfigs.push({ @@ -123,6 +126,8 @@ async function main() { memoryName: harnessSpec.memory?.name, containerUri: harnessSpec.containerUri, hasDockerfile: !!harnessSpec.dockerfile, + dockerfile: harnessSpec.dockerfile, + codeLocation: harnessSpec.dockerfile ? harnessDir : undefined, tools: harnessSpec.tools, apiKeyArn: harnessSpec.model?.apiKeyArn, }); @@ -298,6 +303,7 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/lib/cdk-stack.ts shou type AgentCoreMcpSpec, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; +import { DockerImageAsset, Platform } from 'aws-cdk-lib/aws-ecr-assets'; import { Construct } from 'constructs'; export interface AgentCoreStackProps extends StackProps { @@ -315,6 +321,11 @@ export interface AgentCoreStackProps extends StackProps { credentials?: Record; /** * Harness role configurations. Each entry creates an IAM execution role for a harness. + * + * When \`dockerfile\` + \`codeLocation\` are provided (and \`containerUri\` is not), the stack + * also builds and pushes a linux/arm64 Docker image as a CDK asset and emits its URI as + * a stack output named \`ApplicationHarnessImageUri\`, which the post-CDK + * harness deployer uses as the \`environmentArtifact.containerConfiguration.containerUri\`. */ harnesses?: { name: string; @@ -322,6 +333,8 @@ export interface AgentCoreStackProps extends StackProps { memoryName?: string; containerUri?: string; hasDockerfile?: boolean; + dockerfile?: string; + codeLocation?: string; tools?: { type: string; name: string }[]; apiKeyArn?: string; }[]; @@ -348,6 +361,25 @@ export class AgentCoreStack extends Stack { harnesses, }); + // Build and push harness container images via CDK asset pipeline. + // Emitted per-harness so the imperative HarnessDeployer can look the URI up + // by stack-output key when it calls CreateHarness. + for (const harness of harnesses ?? []) { + if (!harness.dockerfile || !harness.codeLocation || harness.containerUri) continue; + + const asset = new DockerImageAsset(this, \`HarnessImage\${toPascalId(harness.name)}\`, { + directory: harness.codeLocation, + file: harness.dockerfile, + platform: Platform.LINUX_ARM64, + assetName: \`\${spec.name}-\${harness.name}-harness\`, + }); + + new CfnOutput(this, \`ApplicationHarness\${toPascalId(harness.name)}ImageUri\`, { + description: \`Container image URI for harness "\${harness.name}"\`, + value: asset.imageUri, + }); + } + // Create AgentCoreMcp if there are gateways configured if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { new AgentCoreMcp(this, 'Mcp', { @@ -366,6 +398,24 @@ export class AgentCoreStack extends Stack { }); } } + +/** + * Convert arbitrary identifier fragments into a single PascalCase string safe + * for use in a CloudFormation logical ID. Mirrors the helper in the CLI's + * \`cloudformation/logical-ids\` module, inlined here because vended CDK assets + * cannot import from \`src/cli/\`. + */ +function toPascalId(...parts: string[]): string { + return parts + .map(part => + part + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join('') + ) + .join(''); +} " `; diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 12b6f370..40d6ba17 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -65,11 +65,14 @@ async function main() { memoryName?: string; containerUri?: string; hasDockerfile?: boolean; + dockerfile?: string; + codeLocation?: string; tools?: { type: string; name: string }[]; apiKeyArn?: string; }[] = []; for (const entry of specAny.harnesses ?? []) { - const harnessPath = path.resolve(projectRoot, entry.path, 'harness.json'); + const harnessDir = path.resolve(projectRoot, entry.path); + const harnessPath = path.join(harnessDir, 'harness.json'); try { const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8')); harnessConfigs.push({ @@ -78,6 +81,8 @@ async function main() { memoryName: harnessSpec.memory?.name, containerUri: harnessSpec.containerUri, hasDockerfile: !!harnessSpec.dockerfile, + dockerfile: harnessSpec.dockerfile, + codeLocation: harnessSpec.dockerfile ? harnessDir : undefined, tools: harnessSpec.tools, apiKeyArn: harnessSpec.model?.apiKeyArn, }); diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index ac33c1dc..0e7fdd76 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -5,6 +5,7 @@ import { type AgentCoreMcpSpec, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; +import { DockerImageAsset, Platform } from 'aws-cdk-lib/aws-ecr-assets'; import { Construct } from 'constructs'; export interface AgentCoreStackProps extends StackProps { @@ -22,6 +23,11 @@ export interface AgentCoreStackProps extends StackProps { credentials?: Record; /** * Harness role configurations. Each entry creates an IAM execution role for a harness. + * + * When `dockerfile` + `codeLocation` are provided (and `containerUri` is not), the stack + * also builds and pushes a linux/arm64 Docker image as a CDK asset and emits its URI as + * a stack output named `ApplicationHarnessImageUri`, which the post-CDK + * harness deployer uses as the `environmentArtifact.containerConfiguration.containerUri`. */ harnesses?: { name: string; @@ -29,6 +35,8 @@ export interface AgentCoreStackProps extends StackProps { memoryName?: string; containerUri?: string; hasDockerfile?: boolean; + dockerfile?: string; + codeLocation?: string; tools?: { type: string; name: string }[]; apiKeyArn?: string; }[]; @@ -55,6 +63,25 @@ export class AgentCoreStack extends Stack { harnesses, }); + // Build and push harness container images via CDK asset pipeline. + // Emitted per-harness so the imperative HarnessDeployer can look the URI up + // by stack-output key when it calls CreateHarness. + for (const harness of harnesses ?? []) { + if (!harness.dockerfile || !harness.codeLocation || harness.containerUri) continue; + + const asset = new DockerImageAsset(this, `HarnessImage${toPascalId(harness.name)}`, { + directory: harness.codeLocation, + file: harness.dockerfile, + platform: Platform.LINUX_ARM64, + assetName: `${spec.name}-${harness.name}-harness`, + }); + + new CfnOutput(this, `ApplicationHarness${toPascalId(harness.name)}ImageUri`, { + description: `Container image URI for harness "${harness.name}"`, + value: asset.imageUri, + }); + } + // Create AgentCoreMcp if there are gateways configured if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { new AgentCoreMcp(this, 'Mcp', { @@ -73,3 +100,21 @@ export class AgentCoreStack extends Stack { }); } } + +/** + * Convert arbitrary identifier fragments into a single PascalCase string safe + * for use in a CloudFormation logical ID. Mirrors the helper in the CLI's + * `cloudformation/logical-ids` module, inlined here because vended CDK assets + * cannot import from `src/cli/`. + */ +function toPascalId(...parts: string[]): string { + return parts + .map(part => + part + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join('') + ) + .join(''); +} diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts index 0d00573b..cc01355f 100644 --- a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts +++ b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts @@ -300,6 +300,55 @@ describe('mapHarnessSpecToCreateOptions', () => { }, }); }); + + it('resolves built image URI from CDK outputs when dockerfile is set', async () => { + const spec = minimalSpec({ name: 'my_harness', dockerfile: 'Dockerfile' }); + + const result = await mapHarnessSpecToCreateOptions({ + ...BASE_OPTIONS, + harnessSpec: spec, + cdkOutputs: { + ApplicationHarnessMyHarnessImageUriABCD1234: + '123456789012.dkr.ecr.us-east-1.amazonaws.com/cdk-hnb659fds-container-assets-123456789012-us-east-1:sha256hash', + }, + }); + + expect(result.environmentArtifact).toEqual({ + containerConfiguration: { + containerUri: + '123456789012.dkr.ecr.us-east-1.amazonaws.com/cdk-hnb659fds-container-assets-123456789012-us-east-1:sha256hash', + }, + }); + }); + + it('throws with actionable error when dockerfile is set but CDK outputs lack the image URI', async () => { + const spec = minimalSpec({ name: 'my_harness', dockerfile: 'Dockerfile' }); + + await expect( + mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec, cdkOutputs: {} }) + ).rejects.toThrow(/ApplicationHarnessMyHarnessImageUri/); + }); + + it('prefers explicit containerUri over dockerfile-derived image URI', async () => { + const spec = minimalSpec({ + name: 'my_harness', + containerUri: '123456789012.dkr.ecr.us-east-1.amazonaws.com/explicit:v1', + }); + + const result = await mapHarnessSpecToCreateOptions({ + ...BASE_OPTIONS, + harnessSpec: spec, + cdkOutputs: { + ApplicationHarnessMyHarnessImageUriABCD1234: '123456789012.dkr.ecr.us-east-1.amazonaws.com/built:latest', + }, + }); + + expect(result.environmentArtifact).toEqual({ + containerConfiguration: { + containerUri: '123456789012.dkr.ecr.us-east-1.amazonaws.com/explicit:v1', + }, + }); + }); }); // ── Network/Lifecycle mapping ────────────────────────────────────────── diff --git a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts index 8a4b7122..b6f8dc4a 100644 --- a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts +++ b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts @@ -96,9 +96,23 @@ export async function mapHarnessSpecToCreateOptions(options: MapHarnessOptions): result.timeoutSeconds = harnessSpec.timeoutSeconds; } - // Container artifact + // Container artifact. `containerUri` wins if set explicitly; otherwise if the + // user supplied a `dockerfile`, resolve the image URI from CDK outputs (the + // stack emits `ApplicationHarnessImageUri` via a DockerImageAsset). if (harnessSpec.containerUri) { result.environmentArtifact = mapEnvironmentArtifact(harnessSpec.containerUri); + } else if (harnessSpec.dockerfile) { + const builtImageUri = resolveHarnessImageUri(harnessSpec.name, cdkOutputs); + if (!builtImageUri) { + throw new Error( + `Harness "${harnessSpec.name}" sets "dockerfile": "${harnessSpec.dockerfile}" but no built ` + + `image URI was found in CDK outputs. Expected an output starting with ` + + `"ApplicationHarness${toPascalId(harnessSpec.name)}ImageUri". ` + + `This usually indicates the CDK stack was synthesized without Docker access; ensure a Docker ` + + `daemon capable of building linux/arm64 images is available during \`agentcore deploy\`.` + ); + } + result.environmentArtifact = mapEnvironmentArtifact(builtImageUri); } // Environment provider (network + lifecycle) @@ -329,6 +343,21 @@ function mapEnvironmentArtifact(containerUri: string): HarnessEnvironmentArtifac }; } +/** + * Resolve a harness's built image URI from CDK stack outputs. The stack emits + * an output whose logical ID starts with `ApplicationHarnessImageUri` + * whenever a DockerImageAsset was built for the harness (see cdk-stack.ts). + * CDK may append a hash suffix to the logical ID, so we match by prefix. + */ +function resolveHarnessImageUri(harnessName: string, cdkOutputs?: Record): string | undefined { + if (!cdkOutputs) return undefined; + const prefix = `ApplicationHarness${toPascalId(harnessName)}ImageUri`; + for (const [key, value] of Object.entries(cdkOutputs)) { + if (key.startsWith(prefix)) return value; + } + return undefined; +} + // ============================================================================ // Environment Provider (Network + Lifecycle) Mapping // ============================================================================