Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
});
Expand Down Expand Up @@ -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 {
Expand All @@ -315,13 +321,20 @@ export interface AgentCoreStackProps extends StackProps {
credentials?: Record<string, { credentialProviderArn: string; clientSecretArn?: string }>;
/**
* 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 \`ApplicationHarness<PascalName>ImageUri\`, which the post-CDK
* harness deployer uses as the \`environmentArtifact.containerConfiguration.containerUri\`.
*/
harnesses?: {
name: string;
executionRoleArn?: string;
memoryName?: string;
containerUri?: string;
hasDockerfile?: boolean;
dockerfile?: string;
codeLocation?: string;
tools?: { type: string; name: string }[];
apiKeyArn?: string;
}[];
Expand All @@ -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', {
Expand All @@ -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('');
}
"
`;

Expand Down
7 changes: 6 additions & 1 deletion src/assets/cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
});
Expand Down
45 changes: 45 additions & 0 deletions src/assets/cdk/lib/cdk-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -22,13 +23,20 @@ export interface AgentCoreStackProps extends StackProps {
credentials?: Record<string, { credentialProviderArn: string; clientSecretArn?: string }>;
/**
* 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 `ApplicationHarness<PascalName>ImageUri`, which the post-CDK
* harness deployer uses as the `environmentArtifact.containerConfiguration.containerUri`.
*/
harnesses?: {
name: string;
executionRoleArn?: string;
memoryName?: string;
containerUri?: string;
hasDockerfile?: boolean;
dockerfile?: string;
codeLocation?: string;
tools?: { type: string; name: string }[];
apiKeyArn?: string;
}[];
Expand All @@ -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', {
Expand All @@ -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('');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────
Expand Down
31 changes: 30 additions & 1 deletion src/cli/operations/deploy/imperative/deployers/harness-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `ApplicationHarness<Pascal>ImageUri` 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)
Expand Down Expand Up @@ -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 `ApplicationHarness<Pascal>ImageUri`
* 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, string>): 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
// ============================================================================
Expand Down