Skip to content
Open
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
58 changes: 11 additions & 47 deletions src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ async function main() {
memoryName?: string;
containerUri?: string;
hasDockerfile?: boolean;
dockerfileName?: string;
harnessDir?: string;
dockerfile?: string;
codeLocation?: string;
tools?: { type: string; name: string }[];
apiKeyArn?: string;
}[] = [];
Expand All @@ -126,8 +126,8 @@ async function main() {
memoryName: harnessSpec.memory?.name,
containerUri: harnessSpec.containerUri,
hasDockerfile: !!harnessSpec.dockerfile,
dockerfileName: harnessSpec.dockerfile,
harnessDir,
dockerfile: harnessSpec.dockerfile,
codeLocation: harnessSpec.dockerfile ? harnessDir : undefined,
tools: harnessSpec.tools,
apiKeyArn: harnessSpec.model?.apiKeyArn,
});
Expand Down Expand Up @@ -301,10 +301,6 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/lib/cdk-stack.ts shou
AgentCoreMcp,
type AgentCoreProjectSpec,
type AgentCoreMcpSpec,
ContainerSourceAssetFromPath,
AgentEcrRepository,
ContainerBuildProject,
ContainerImageBuilder,
} from '@aws/agentcore-cdk';
import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
Expand All @@ -315,8 +311,8 @@ export interface HarnessConfig {
memoryName?: string;
containerUri?: string;
hasDockerfile?: boolean;
dockerfileName?: string;
harnessDir?: string;
dockerfile?: string;
codeLocation?: string;
tools?: { type: string; name: string }[];
apiKeyArn?: string;
}
Expand All @@ -336,6 +332,10 @@ 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 \`hasDockerfile\` is true and \`codeLocation\` is provided (without an explicit
* \`containerUri\`), the L3 construct builds and pushes a container image via CodeBuild
* and emits its URI as a stack output for the post-CDK harness deployer.
*/
harnesses?: HarnessConfig[];
}
Expand All @@ -355,46 +355,10 @@ export class AgentCoreStack extends Stack {

const { spec, mcpSpec, credentials, harnesses } = props;

// Build container images for harnesses that specify a dockerfile (no containerUri).
// Produces CDK outputs consumed by the imperative harness deployer.
const harnessesForCdk = harnesses ? [...harnesses] : [];
if (harnesses) {
for (let i = 0; i < harnesses.length; i++) {
const h = harnesses[i]!;
if (h.hasDockerfile && !h.containerUri && h.harnessDir) {
const pascalName = h.name.replace(/(^|_)([a-z])/g, (_: string, __: string, c: string) => c.toUpperCase());
const sourceAsset = new ContainerSourceAssetFromPath(this, \`Harness\${pascalName}SourceAsset\`, {
sourcePath: h.harnessDir,
});
const ecrRepo = new AgentEcrRepository(this, \`Harness\${pascalName}EcrRepo\`, {
projectName: spec.name,
agentName: \`harness-\${h.name}\`,
});
const buildProject = ContainerBuildProject.getOrCreate(this);
buildProject.grantPushTo(ecrRepo.repository);
sourceAsset.asset.grantRead(buildProject.role);

const builder = new ContainerImageBuilder(this, \`Harness\${pascalName}ContainerBuild\`, {
buildProject,
sourceAsset,
repository: ecrRepo,
dockerfile: h.dockerfileName ?? 'Dockerfile',
});

new CfnOutput(this, \`Harness\${pascalName}ContainerUriOutput\`, {
value: builder.containerUri,
});

// Pass the built containerUri to the harness role construct so it gets ECR pull permissions
harnessesForCdk[i] = { ...h, containerUri: builder.containerUri };
}
}
}

// Create AgentCoreApplication with all agents and harness roles
this.application = new AgentCoreApplication(this, 'Application', {
spec,
harnesses: harnessesForCdk.length > 0 ? harnessesForCdk : undefined,
harnesses: harnesses?.length ? harnesses : undefined,
});

// Create AgentCoreMcp if there are gateways configured
Expand Down
8 changes: 4 additions & 4 deletions src/assets/cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ async function main() {
memoryName?: string;
containerUri?: string;
hasDockerfile?: boolean;
dockerfileName?: string;
harnessDir?: string;
dockerfile?: string;
codeLocation?: string;
tools?: { type: string; name: string }[];
apiKeyArn?: string;
}[] = [];
Expand All @@ -81,8 +81,8 @@ async function main() {
memoryName: harnessSpec.memory?.name,
containerUri: harnessSpec.containerUri,
hasDockerfile: !!harnessSpec.dockerfile,
dockerfileName: harnessSpec.dockerfile,
harnessDir,
dockerfile: harnessSpec.dockerfile,
codeLocation: harnessSpec.dockerfile ? harnessDir : undefined,
tools: harnessSpec.tools,
apiKeyArn: harnessSpec.model?.apiKeyArn,
});
Expand Down
50 changes: 7 additions & 43 deletions src/assets/cdk/lib/cdk-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@ import {
AgentCoreMcp,
type AgentCoreProjectSpec,
type AgentCoreMcpSpec,
ContainerSourceAssetFromPath,
AgentEcrRepository,
ContainerBuildProject,
ContainerImageBuilder,
} from '@aws/agentcore-cdk';
import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
Expand All @@ -17,8 +13,8 @@ export interface HarnessConfig {
memoryName?: string;
containerUri?: string;
hasDockerfile?: boolean;
dockerfileName?: string;
harnessDir?: string;
dockerfile?: string;
codeLocation?: string;
tools?: { type: string; name: string }[];
apiKeyArn?: string;
}
Expand All @@ -38,6 +34,10 @@ 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 `hasDockerfile` is true and `codeLocation` is provided (without an explicit
* `containerUri`), the L3 construct builds and pushes a container image via CodeBuild
* and emits its URI as a stack output for the post-CDK harness deployer.
*/
harnesses?: HarnessConfig[];
}
Expand All @@ -57,46 +57,10 @@ export class AgentCoreStack extends Stack {

const { spec, mcpSpec, credentials, harnesses } = props;

// Build container images for harnesses that specify a dockerfile (no containerUri).
// Produces CDK outputs consumed by the imperative harness deployer.
const harnessesForCdk = harnesses ? [...harnesses] : [];
if (harnesses) {
for (let i = 0; i < harnesses.length; i++) {
const h = harnesses[i]!;
if (h.hasDockerfile && !h.containerUri && h.harnessDir) {
const pascalName = h.name.replace(/(^|_)([a-z])/g, (_: string, __: string, c: string) => c.toUpperCase());
const sourceAsset = new ContainerSourceAssetFromPath(this, `Harness${pascalName}SourceAsset`, {
sourcePath: h.harnessDir,
});
const ecrRepo = new AgentEcrRepository(this, `Harness${pascalName}EcrRepo`, {
projectName: spec.name,
agentName: `harness-${h.name}`,
});
const buildProject = ContainerBuildProject.getOrCreate(this);
buildProject.grantPushTo(ecrRepo.repository);
sourceAsset.asset.grantRead(buildProject.role);

const builder = new ContainerImageBuilder(this, `Harness${pascalName}ContainerBuild`, {
buildProject,
sourceAsset,
repository: ecrRepo,
dockerfile: h.dockerfileName ?? 'Dockerfile',
});

new CfnOutput(this, `Harness${pascalName}ContainerUriOutput`, {
value: builder.containerUri,
});

// Pass the built containerUri to the harness role construct so it gets ECR pull permissions
harnessesForCdk[i] = { ...h, containerUri: builder.containerUri };
}
}
}

// Create AgentCoreApplication with all agents and harness roles
this.application = new AgentCoreApplication(this, 'Application', {
spec,
harnesses: harnessesForCdk.length > 0 ? harnessesForCdk : undefined,
harnesses: harnesses?.length ? harnesses : undefined,
});

// Create AgentCoreMcp if there are gateways configured
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,47 @@ describe('HarnessDeployer', () => {
expect(result.notes).toContain('Created harness "my_harness"');
});

it('resolves role ARN from new AgentCoreHarnessEnvironment output key', async () => {
const createOptions = {
region: REGION,
harnessName: 'my_harness',
executionRoleArn: 'arn:aws:iam::123456789012:role/HarnessRole',
model: { bedrockModelConfig: { modelId: 'anthropic.claude-3-sonnet-20240229-v1:0' } },
};

mockedReadFile.mockResolvedValueOnce(HARNESS_SPEC_JSON);
mockedMapHarness.mockResolvedValueOnce(createOptions);
mockedCreateHarness.mockResolvedValueOnce({
harness: {
harnessId: 'h-new',
harnessName: 'my_harness',
arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-new',
status: 'READY',
executionRoleArn: 'arn:aws:iam::123456789012:role/HarnessRole',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
});

const ctx = createContext({
harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }],
cdkOutputs: {
ApplicationHarnessMyHarnessRoleRoleArnOutput123: 'arn:aws:iam::123456789012:role/HarnessRole',
},
});

const result = await deployer.deploy(ctx);

expect(result.success).toBe(true);
expect(result.state!.my_harness).toEqual(
expect.objectContaining({
harnessId: 'h-new',
roleArn: 'arn:aws:iam::123456789012:role/HarnessRole',
})
);
expect(mockedCreateHarness).toHaveBeenCalledWith(createOptions);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Consider adding a test case where both old and new output keys are present in cdkOutputs (e.g. both ...RoleRoleArn... and ...RoleArn... keys). This would document the precedence contract and guard against regressions if the prefix array order changes. Same applies to the harness-mapper.test.ts counterpart.

});

it('updates a harness when already deployed', async () => {
const createOptions = {
region: REGION,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,22 @@ describe('mapHarnessSpecToCreateOptions', () => {
});
});

it('resolves containerUri from new AgentCoreHarnessEnvironment output key', async () => {
const spec = minimalSpec({ dockerfile: 'Dockerfile' });
const cdkOutputs = {
ApplicationHarnessTestHarnessImageUriOutputABC123:
'123456789012.dkr.ecr.us-east-1.amazonaws.com/harness-test:new',
};

const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec, cdkOutputs });

expect(result.environmentArtifact).toEqual({
containerConfiguration: {
containerUri: '123456789012.dkr.ecr.us-east-1.amazonaws.com/harness-test:new',
},
});
});

it('throws when dockerfile is set but no container URI found in CDK outputs', async () => {
const spec = minimalSpec({ dockerfile: 'Dockerfile' });

Expand Down
19 changes: 13 additions & 6 deletions src/cli/operations/deploy/imperative/deployers/harness-deployer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,19 @@ export class HarnessDeployer implements ImperativeDeployer<HarnessDeployedStateM
harnessSpec = validated.data;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: `Failed to read harness.json for "${entry.name}": ${message}`, state: resultState };
return {
success: false,
error: `Failed to read harness.json for "${entry.name}": ${message}`,
state: resultState,
};
}

// Resolve role ARN from CDK outputs
const roleArn = resolveRoleArn(entry.name, cdkOutputs);
if (!roleArn) {
return {
success: false,
error: `Could not find role ARN in CDK outputs for harness "${entry.name}". Expected output key starting with "ApplicationHarness${toPascalId(entry.name)}RoleArn".`,
error: `Could not find role ARN in CDK outputs for harness "${entry.name}". Expected output key starting with "ApplicationHarness${toPascalId(entry.name)}RoleArn" or "ApplicationHarness${toPascalId(entry.name)}RoleRoleArn".`,
state: resultState,
};
}
Expand Down Expand Up @@ -265,17 +269,20 @@ export class HarnessDeployer implements ImperativeDeployer<HarnessDeployedStateM
/**
* Resolve the IAM role ARN for a harness from CDK stack outputs.
*
* The CDK construct exports the role ARN with a key matching the pattern:
* ApplicationHarness{PascalName}RoleArn...
* Supports two construct tree layouts:
* Old (AgentCoreHarnessRole directly under Application):
* ApplicationHarness{PascalName}RoleArnOutput...
* New (AgentCoreHarnessEnvironment wrapping AgentCoreHarnessRole):
* ApplicationHarness{PascalName}RoleRoleArnOutput...
*/
function resolveRoleArn(harnessName: string, cdkOutputs?: Record<string, string>): string | undefined {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ordering matters here: RoleRoleArn must come before RoleArn since the shorter prefix is a substring of the longer one. If someone reorders this array, the old prefix will match first and return the wrong value. Worth a short comment noting the ordering is intentional.

if (!cdkOutputs) return undefined;

const pascalName = toPascalId(harnessName);
const prefix = `ApplicationHarness${pascalName}RoleArn`;
const prefixes = [`ApplicationHarness${pascalName}RoleRoleArn`, `ApplicationHarness${pascalName}RoleArn`];

for (const [key, value] of Object.entries(cdkOutputs)) {
if (key.startsWith(prefix)) {
if (prefixes.some(p => key.startsWith(p))) {
return value;
}
}
Expand Down
13 changes: 10 additions & 3 deletions src/cli/operations/deploy/imperative/deployers/harness-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export async function mapHarnessSpecToCreateOptions(options: MapHarnessOptions):
if (!builtUri) {
throw new Error(
`Harness "${harnessSpec.name}" specifies "dockerfile" but no container URI was found in CDK outputs. ` +
`Expected a CDK output key starting with "Harness${toPascalId(harnessSpec.name)}ContainerUri".`
`Expected a CDK output key starting with "ApplicationHarness${toPascalId(harnessSpec.name)}ImageUri" or "Harness${toPascalId(harnessSpec.name)}ContainerUri".`
);
}
result.environmentArtifact = mapEnvironmentArtifact(builtUri);
Expand Down Expand Up @@ -332,14 +332,21 @@ function mapTruncation(truncation: NonNullable<HarnessSpec['truncation']>): Harn
// Container URI Resolution (from CDK outputs for dockerfile-based harnesses)
// ============================================================================

/**
* Supports two construct tree layouts:
* Old (CfnOutput on stack root):
* Harness{PascalName}ContainerUri...
* New (CfnOutput inside AgentCoreHarnessEnvironment):
* ApplicationHarness{PascalName}ImageUriOutput...
*/
function resolveContainerUriFromOutputs(harnessName: string, cdkOutputs?: Record<string, string>): string | undefined {
if (!cdkOutputs) return undefined;

const pascalName = toPascalId(harnessName);
const prefix = `Harness${pascalName}ContainerUri`;
const prefixes = [`ApplicationHarness${pascalName}ImageUri`, `Harness${pascalName}ContainerUri`];

for (const [key, value] of Object.entries(cdkOutputs)) {
if (key.startsWith(prefix)) {
if (prefixes.some(p => key.startsWith(p))) {
return value;
}
}
Expand Down