Skip to content
3 changes: 3 additions & 0 deletions cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@
"@aws-cdk/mixins-preview": "2.238.0-alpha.0",
"@aws-sdk/client-bedrock-agentcore": "^3.1021.0",
"@aws-sdk/client-bedrock-runtime": "^3.1021.0",
"@aws-sdk/client-ec2": "^3.1021.0",
"@aws-sdk/client-ecs": "^3.1021.0",
"@aws-sdk/client-dynamodb": "^3.1021.0",
"@aws-sdk/client-lambda": "^3.1021.0",
"@aws-sdk/client-s3": "^3.1021.0",
"@aws-sdk/client-secrets-manager": "^3.1021.0",
"@aws-sdk/client-ssm": "^3.1021.0",
"@aws-sdk/lib-dynamodb": "^3.1021.0",
"@aws/durable-execution-sdk-js": "^1.1.0",
"aws-cdk-lib": "^2.238.0",
Expand Down
2 changes: 1 addition & 1 deletion cdk/src/constructs/blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export interface BlueprintProps {
* Compute strategy type.
* @default 'agentcore'
*/
readonly type?: 'agentcore' | 'ecs';
readonly type?: 'agentcore' | 'ecs' | 'ec2';

/**
* Override the default runtime ARN (agentcore strategy).
Expand Down
221 changes: 221 additions & 0 deletions cdk/src/constructs/ec2-agent-fleet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/**
* MIT No Attribution
*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

import { Duration, RemovalPolicy } from 'aws-cdk-lib';
import * as autoscaling from 'aws-cdk-lib/aws-autoscaling';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import { NagSuppressions } from 'cdk-nag';
import { Construct } from 'constructs';

export interface Ec2AgentFleetProps {
readonly vpc: ec2.IVpc;
readonly agentImageAsset: ecr_assets.DockerImageAsset;
readonly taskTable: dynamodb.ITable;
readonly taskEventsTable: dynamodb.ITable;
readonly userConcurrencyTable: dynamodb.ITable;
readonly githubTokenSecret: secretsmanager.ISecret;
readonly memoryId?: string;
readonly instanceType?: ec2.InstanceType;
readonly desiredCapacity?: number;
readonly maxCapacity?: number;
}

export class Ec2AgentFleet extends Construct {
public readonly securityGroup: ec2.SecurityGroup;
public readonly instanceRole: iam.Role;
public readonly payloadBucket: s3.Bucket;
public readonly autoScalingGroup: autoscaling.AutoScalingGroup;
public readonly fleetTagKey: string;
public readonly fleetTagValue: string;

constructor(scope: Construct, id: string, props: Ec2AgentFleetProps) {
super(scope, id);

this.fleetTagKey = 'bgagent:fleet';
this.fleetTagValue = id;

// Security group — egress TCP 443 only
this.securityGroup = new ec2.SecurityGroup(this, 'FleetSG', {
vpc: props.vpc,
description: 'EC2 Agent Fleet - egress TCP 443 only',
allowAllOutbound: false,
});

this.securityGroup.addEgressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(443),
'Allow HTTPS egress (GitHub API, AWS services)',
);

// S3 bucket for payload overflow
this.payloadBucket = new s3.Bucket(this, 'PayloadBucket', {
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
lifecycleRules: [
{ expiration: Duration.days(7) },
],
});

// CloudWatch log group
const logGroup = new logs.LogGroup(this, 'FleetLogGroup', {
retention: logs.RetentionDays.THREE_MONTHS,
removalPolicy: RemovalPolicy.DESTROY,
});

// IAM Role for instances
this.instanceRole = new iam.Role(this, 'InstanceRole', {
assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'),
],
});

// DynamoDB read/write on task tables
props.taskTable.grantReadWriteData(this.instanceRole);
props.taskEventsTable.grantReadWriteData(this.instanceRole);
props.userConcurrencyTable.grantReadWriteData(this.instanceRole);

// Secrets Manager read for GitHub token
props.githubTokenSecret.grantRead(this.instanceRole);

// Bedrock model invocation
this.instanceRole.addToPrincipalPolicy(new iam.PolicyStatement({
actions: [
'bedrock:InvokeModel',
'bedrock:InvokeModelWithResponseStream',
],
resources: ['*'],
}));

// CloudWatch Logs write
logGroup.grantWrite(this.instanceRole);

// ECR pull
this.instanceRole.addToPrincipalPolicy(new iam.PolicyStatement({
actions: [
'ecr:GetAuthorizationToken',
],
resources: ['*'],
}));
this.instanceRole.addToPrincipalPolicy(new iam.PolicyStatement({
actions: [
'ecr:BatchGetImage',
'ecr:GetDownloadUrlForLayer',
],
resources: [props.agentImageAsset.repository.repositoryArn],
}));
Comment thread
MichaelWalker-git marked this conversation as resolved.

// S3 read on payload bucket
this.payloadBucket.grantRead(this.instanceRole);

// EC2 tag management on self (conditioned on fleet tag)
this.instanceRole.addToPrincipalPolicy(new iam.PolicyStatement({
actions: ['ec2:CreateTags', 'ec2:DeleteTags'],
resources: ['*'],
conditions: {
StringEquals: {
[`ec2:ResourceTag/${this.fleetTagKey}`]: this.fleetTagValue,
},
},
}));
Comment thread
MichaelWalker-git marked this conversation as resolved.

const imageUri = props.agentImageAsset.imageUri;

// User data: install Docker, pull image, tag as idle
const userData = ec2.UserData.forLinux();
userData.addCommands(
'#!/bin/bash',
'set -euo pipefail',
'',
'# Install Docker',
'dnf install -y docker',
'systemctl enable docker',
'systemctl start docker',
'',
'# ECR login and pre-pull agent image',
'REGION=$(ec2-metadata --availability-zone | cut -d" " -f2 | sed \'s/.$//\')',
`aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin $(echo '${imageUri}' | cut -d/ -f1)`,
`docker pull '${imageUri}'`,
'',
'# Tag self as idle',
'INSTANCE_ID=$(ec2-metadata -i | cut -d" " -f2)',
'aws ec2 create-tags --resources "$INSTANCE_ID" --region "$REGION" --tags Key=bgagent:status,Value=idle',
);

// Auto Scaling Group
this.autoScalingGroup = new autoscaling.AutoScalingGroup(this, 'ASG', {
vpc: props.vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
instanceType: props.instanceType ?? new ec2.InstanceType('m7g.xlarge'),
machineImage: ec2.MachineImage.latestAmazonLinux2023({
cpuType: ec2.AmazonLinuxCpuType.ARM_64,
}),
role: this.instanceRole,
securityGroup: this.securityGroup,
userData,
desiredCapacity: props.desiredCapacity ?? 1,
minCapacity: props.desiredCapacity ?? 1,
maxCapacity: props.maxCapacity ?? 3,
healthCheck: autoscaling.HealthCheck.ec2(),
});

// Tag the ASG instances for fleet identification
// CDK auto-propagates tags from the ASG to instances
this.autoScalingGroup.node.defaultChild;
this.autoScalingGroup.addUserData(`aws ec2 create-tags --resources "$(ec2-metadata -i | cut -d' ' -f2)" --region "$(ec2-metadata --availability-zone | cut -d' ' -f2 | sed 's/.$//')" --tags Key=${this.fleetTagKey},Value=${this.fleetTagValue}`);
Comment thread
MichaelWalker-git marked this conversation as resolved.
Outdated

NagSuppressions.addResourceSuppressions(this.instanceRole, [
{
id: 'AwsSolutions-IAM4',
reason: 'AmazonSSMManagedInstanceCore is the AWS-recommended managed policy for SSM-managed instances',
},
{
id: 'AwsSolutions-IAM5',
reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData; Bedrock InvokeModel requires * resource; Secrets Manager wildcards from CDK grantRead; CloudWatch Logs wildcards from CDK grantWrite; ECR GetAuthorizationToken requires * resource; EC2 CreateTags/DeleteTags conditioned on fleet tag; S3 read wildcards from CDK grantRead',
},
], true);

NagSuppressions.addResourceSuppressions(this.autoScalingGroup, [
{
id: 'AwsSolutions-AS3',
reason: 'ASG scaling notifications are not required for this dev/preview compute backend',
},
{
id: 'AwsSolutions-EC26',
reason: 'EBS encryption uses default AWS-managed key — sufficient for agent ephemeral workloads',
},
], true);

NagSuppressions.addResourceSuppressions(this.payloadBucket, [
{
id: 'AwsSolutions-S1',
reason: 'Server access logging not required for ephemeral payload overflow bucket with 7-day lifecycle',
},
], true);
}
}
15 changes: 15 additions & 0 deletions cdk/src/constructs/task-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ export interface TaskApiProps {
* When provided, the cancel Lambda gets `ECS_CLUSTER_ARN` env var and `ecs:StopTask` permission.
*/
readonly ecsClusterArn?: string;

/**
* EC2 fleet configuration for cancel-task to stop EC2-backed tasks.
* When provided, the cancel Lambda gets `ssm:CancelCommand` permission.
*/
readonly ec2FleetConfig?: {
readonly instanceRoleArn: string;
Comment thread
MichaelWalker-git marked this conversation as resolved.
Outdated
};
}

/**
Expand Down Expand Up @@ -384,6 +392,13 @@ export class TaskApi extends Construct {
}));
}

if (props.ec2FleetConfig) {
cancelTaskFn.addToRolePolicy(new iam.PolicyStatement({
actions: ['ssm:CancelCommand'],
resources: ['*'],
}));
}

// Repo table read for onboarding gate
if (props.repoTable) {
props.repoTable.grantReadData(createTaskFn);
Expand Down
55 changes: 54 additions & 1 deletion cdk/src/constructs/task-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

import * as path from 'path';
import { Duration, Stack } from 'aws-cdk-lib';
import { Aws, Duration, Stack } from 'aws-cdk-lib';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as iam from 'aws-cdk-lib/aws-iam';
Expand Down Expand Up @@ -127,6 +127,18 @@ export interface TaskOrchestratorProps {
readonly taskRoleArn: string;
readonly executionRoleArn: string;
};

/**
* EC2 fleet compute strategy configuration.
* When provided, EC2-related env vars and IAM policies are added to the orchestrator.
*/
readonly ec2Config?: {
readonly fleetTagKey: string;
readonly fleetTagValue: string;
readonly payloadBucketName: string;
readonly ecrImageUri: string;
readonly instanceRoleArn: string;
};
}

/**
Expand Down Expand Up @@ -195,6 +207,12 @@ export class TaskOrchestrator extends Construct {
ECS_SECURITY_GROUP: props.ecsConfig.securityGroup,
ECS_CONTAINER_NAME: props.ecsConfig.containerName,
}),
...(props.ec2Config && {
EC2_FLEET_TAG_KEY: props.ec2Config.fleetTagKey,
EC2_FLEET_TAG_VALUE: props.ec2Config.fleetTagValue,
EC2_PAYLOAD_BUCKET: props.ec2Config.payloadBucketName,
ECR_IMAGE_URI: props.ec2Config.ecrImageUri,
}),
},
bundling: {
externalModules: ['@aws-sdk/*'],
Expand Down Expand Up @@ -262,6 +280,41 @@ export class TaskOrchestrator extends Construct {
}));
}

// EC2 fleet compute strategy permissions (only when EC2 is configured)
if (props.ec2Config) {
this.fn.addToRolePolicy(new iam.PolicyStatement({
actions: [
'ec2:DescribeInstances',
'ec2:CreateTags',
Comment thread
MichaelWalker-git marked this conversation as resolved.
Outdated
],
resources: ['*'],
}));

this.fn.addToRolePolicy(new iam.PolicyStatement({
actions: [
'ssm:SendCommand',
'ssm:GetCommandInvocation',
'ssm:CancelCommand',
],
resources: ['*'],
}));
Comment thread
MichaelWalker-git marked this conversation as resolved.

this.fn.addToRolePolicy(new iam.PolicyStatement({
actions: ['s3:PutObject'],
resources: [`arn:${Aws.PARTITION}:s3:::${props.ec2Config.payloadBucketName}/*`],
}));

this.fn.addToRolePolicy(new iam.PolicyStatement({
actions: ['iam:PassRole'],
resources: [props.ec2Config.instanceRoleArn],
conditions: {
StringEquals: {
'iam:PassedToService': 'ec2.amazonaws.com',
},
},
}));
Comment thread
MichaelWalker-git marked this conversation as resolved.
Outdated
}

// Per-repo Secrets Manager grants (e.g. per-repo GitHub tokens from Blueprints)
for (const [index, secretArn] of (props.additionalSecretArns ?? []).entries()) {
const secret = secretsmanager.Secret.fromSecretCompleteArn(
Expand Down
Loading
Loading