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 // ============================================================================