diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 5a3b728b..9eb78699 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -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; }[] = []; @@ -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, }); @@ -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'; @@ -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; } @@ -336,6 +332,10 @@ export interface AgentCoreStackProps extends StackProps { credentials?: Record; /** * 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[]; } @@ -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 diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 57b29238..1c010e19 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -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; }[] = []; @@ -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, }); diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 40c6ec16..a89efc85 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -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'; @@ -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; } @@ -38,6 +34,10 @@ export interface AgentCoreStackProps extends StackProps { credentials?: Record; /** * 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[]; } @@ -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 diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts index 95af235e..c64558c8 100644 --- a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts +++ b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts @@ -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); + }); + it('updates a harness when already deployed', async () => { const createOptions = { region: REGION, 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 cc16da9b..a87c598f 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 @@ -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' }); diff --git a/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts b/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts index ccb383b7..bacb97fb 100644 --- a/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts +++ b/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts @@ -102,7 +102,11 @@ export class HarnessDeployer implements ImperativeDeployer): string | undefined { 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; } } diff --git a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts index d91623e2..e1321914 100644 --- a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts +++ b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts @@ -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); @@ -332,14 +332,21 @@ function mapTruncation(truncation: NonNullable): 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 | 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; } }