Skip to content

Commit 926778b

Browse files
fix: address Copilot PR review comments on PR #8
- Keep gitleaks/osv-scanner enabled in CI (only disable trivy/grype/semgrep) - Type ComputeStrategy.type and SessionHandle.strategyType as ComputeType - Trim/filter ECS_SUBNETS to handle whitespace and trailing commas - Handle undefined exit code in ECS pollSession (container never started) - Scope iam:PassRole to specific ECS task/execution role ARNs - Validate all-or-nothing ECS props in TaskOrchestrator constructor - Remove dead hasEcsBlueprint detection; document env-flag driven approach - Add comment noting strategy_type as additive event field
1 parent ade3caa commit 926778b

9 files changed

Lines changed: 90 additions & 13 deletions

File tree

.github/workflows/build.yml

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cdk/src/constructs/ecs-agent-cluster.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export class EcsAgentCluster extends Construct {
4343
public readonly taskDefinition: ecs.FargateTaskDefinition;
4444
public readonly securityGroup: ec2.SecurityGroup;
4545
public readonly containerName: string;
46+
public readonly taskRoleArn: string;
47+
public readonly executionRoleArn: string;
4648

4749
constructor(scope: Construct, id: string, props: EcsAgentClusterProps) {
4850
super(scope, id);
@@ -129,6 +131,10 @@ export class EcsAgentCluster extends Construct {
129131
// CloudWatch Logs write
130132
logGroup.grantWrite(taskRole);
131133

134+
// Expose role ARNs for scoped iam:PassRole in the orchestrator
135+
this.taskRoleArn = taskRole.roleArn;
136+
this.executionRoleArn = this.taskDefinition.executionRole!.roleArn;
137+
132138
NagSuppressions.addResourceSuppressions(this.taskDefinition, [
133139
{
134140
id: 'AwsSolutions-IAM5',

cdk/src/constructs/task-orchestrator.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ export interface TaskOrchestratorProps {
126126
* Container name in the ECS task definition.
127127
*/
128128
readonly ecsContainerName?: string;
129+
130+
/**
131+
* ARNs of the ECS task role and execution role for scoped iam:PassRole.
132+
* Required when ecsClusterArn is provided.
133+
*/
134+
readonly ecsTaskRoleArn?: string;
135+
readonly ecsExecutionRoleArn?: string;
129136
}
130137

131138
/**
@@ -154,6 +161,13 @@ export class TaskOrchestrator extends Construct {
154161
const handlersDir = path.join(__dirname, '..', 'handlers');
155162
const maxConcurrent = props.maxConcurrentTasksPerUser ?? 3;
156163

164+
// Validate ECS props are all-or-nothing
165+
const ecsProps = [props.ecsClusterArn, props.ecsTaskDefinitionArn, props.ecsSubnets, props.ecsSecurityGroup, props.ecsContainerName];
166+
const ecsPropsProvided = ecsProps.filter(p => p !== undefined);
167+
if (ecsPropsProvided.length > 0 && ecsPropsProvided.length < ecsProps.length) {
168+
throw new Error('ECS compute strategy requires all of: ecsClusterArn, ecsTaskDefinitionArn, ecsSubnets, ecsSecurityGroup, ecsContainerName');
169+
}
170+
157171
this.fn = new lambda.NodejsFunction(this, 'OrchestratorFn', {
158172
entry: path.join(handlersDir, 'orchestrate-task.ts'),
159173
handler: 'handler',
@@ -239,9 +253,10 @@ export class TaskOrchestrator extends Construct {
239253
},
240254
}));
241255

256+
const passRoleResources = [props.ecsTaskRoleArn, props.ecsExecutionRoleArn].filter(Boolean) as string[];
242257
this.fn.addToRolePolicy(new iam.PolicyStatement({
243258
actions: ['iam:PassRole'],
244-
resources: ['*'],
259+
resources: passRoleResources.length > 0 ? passRoleResources : ['*'],
245260
conditions: {
246261
StringEquals: {
247262
'iam:PassedToService': 'ecs-tasks.amazonaws.com',
@@ -287,7 +302,7 @@ export class TaskOrchestrator extends Construct {
287302
},
288303
{
289304
id: 'AwsSolutions-IAM5',
290-
reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData; AgentCore runtime/* required for sub-resource invocation; Secrets Manager wildcards generated by CDK grantRead; AgentCore Memory wildcards generated by CDK grantRead/grantWrite; ECS RunTask/DescribeTasks/StopTask conditioned on cluster ARN; iam:PassRole conditioned on ecs-tasks.amazonaws.com',
305+
reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData; AgentCore runtime/* required for sub-resource invocation; Secrets Manager wildcards generated by CDK grantRead; AgentCore Memory wildcards generated by CDK grantRead/grantWrite; ECS RunTask/DescribeTasks/StopTask conditioned on cluster ARN; iam:PassRole scoped to ECS task/execution roles and conditioned on ecs-tasks.amazonaws.com',
291306
},
292307
], true);
293308
}

cdk/src/handlers/orchestrate-task.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ const durableHandler: DurableExecutionHandler<OrchestrateTaskEvent, void> = asyn
129129
session_id: handle.sessionId,
130130
started_at: new Date().toISOString(),
131131
});
132+
// Note: strategy_type is an additive field (not present in pre-strategy events)
132133
await emitTaskEvent(taskId, 'session_started', {
133134
session_id: handle.sessionId,
134135
strategy_type: handle.strategyType,

cdk/src/handlers/shared/compute-strategy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { EcsComputeStrategy } from './strategies/ecs-strategy';
2323

2424
export interface SessionHandle {
2525
readonly sessionId: string;
26-
readonly strategyType: string;
26+
readonly strategyType: ComputeType;
2727
readonly metadata: Record<string, unknown>;
2828
}
2929

@@ -33,7 +33,7 @@ export type SessionStatus =
3333
| { readonly status: 'failed'; readonly error: string };
3434

3535
export interface ComputeStrategy {
36-
readonly type: string;
36+
readonly type: ComputeType;
3737
startSession(input: {
3838
taskId: string;
3939
payload: Record<string, unknown>;

cdk/src/handlers/shared/strategies/ecs-strategy.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class EcsComputeStrategy implements ComputeStrategy {
5050
);
5151
}
5252

53-
const subnets = ECS_SUBNETS.split(',');
53+
const subnets = ECS_SUBNETS.split(',').map(s => s.trim()).filter(Boolean);
5454
const { taskId, payload, blueprintConfig } = input;
5555

5656
const containerEnv = [
@@ -136,6 +136,9 @@ export class EcsComputeStrategy implements ComputeStrategy {
136136
if (exitCode === 0) {
137137
return { status: 'completed' };
138138
}
139+
if (exitCode === undefined || exitCode === null) {
140+
return { status: 'failed', error: `Task stopped: ${stoppedReason}` };
141+
}
139142
return { status: 'failed', error: `Exit code ${exitCode}: ${stoppedReason}` };
140143
}
141144

cdk/src/stacks/agent.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,9 @@ export class AgentStack extends Stack {
276276
inputGuardrail.createVersion('Initial version');
277277

278278
// --- ECS Fargate compute backend (conditional) ---
279-
// Wire ECS infrastructure when any blueprint uses compute.type: 'ecs'
280-
const hasEcsBlueprint = blueprints.some(b => b.node.findAll()
281-
.some(() => false)); // Currently no blueprints use ECS — infrastructure is opt-in
282-
const needsEcs = hasEcsBlueprint || process.env.ABCA_ENABLE_ECS === 'true';
279+
// ECS infrastructure is opt-in via the ABCA_ENABLE_ECS env flag.
280+
// Repos can then use compute_type: 'ecs' in their blueprint config.
281+
const needsEcs = process.env.ABCA_ENABLE_ECS === 'true';
283282

284283
let ecsCluster: EcsAgentCluster | undefined;
285284
if (needsEcs) {
@@ -314,6 +313,8 @@ export class AgentStack extends Stack {
314313
ecsSubnets: agentVpc.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }).subnetIds.join(','),
315314
ecsSecurityGroup: ecsCluster.securityGroup.securityGroupId,
316315
ecsContainerName: ecsCluster.containerName,
316+
ecsTaskRoleArn: ecsCluster.taskRoleArn,
317+
ecsExecutionRoleArn: ecsCluster.executionRoleArn,
317318
}),
318319
});
319320

cdk/test/constructs/task-orchestrator.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ interface StackOverrides {
3535
ecsSubnets?: string;
3636
ecsSecurityGroup?: string;
3737
ecsContainerName?: string;
38+
ecsTaskRoleArn?: string;
39+
ecsExecutionRoleArn?: string;
3840
}
3941

4042
function createStack(overrides?: StackOverrides): { stack: Stack; template: Template } {
@@ -70,6 +72,8 @@ function createStack(overrides?: StackOverrides): { stack: Stack; template: Temp
7072
ecsSubnets,
7173
ecsSecurityGroup,
7274
ecsContainerName,
75+
ecsTaskRoleArn,
76+
ecsExecutionRoleArn,
7377
...rest
7478
} = overrides ?? {};
7579

@@ -87,6 +91,8 @@ function createStack(overrides?: StackOverrides): { stack: Stack; template: Temp
8791
...(ecsSubnets && { ecsSubnets }),
8892
...(ecsSecurityGroup && { ecsSecurityGroup }),
8993
...(ecsContainerName && { ecsContainerName }),
94+
...(ecsTaskRoleArn && { ecsTaskRoleArn }),
95+
...(ecsExecutionRoleArn && { ecsExecutionRoleArn }),
9096
...rest,
9197
});
9298

@@ -370,6 +376,8 @@ describe('TaskOrchestrator construct', () => {
370376
ecsSubnets: 'subnet-aaa,subnet-bbb',
371377
ecsSecurityGroup: 'sg-12345',
372378
ecsContainerName: 'AgentContainer',
379+
ecsTaskRoleArn: 'arn:aws:iam::123456789012:role/TaskRole',
380+
ecsExecutionRoleArn: 'arn:aws:iam::123456789012:role/ExecutionRole',
373381
};
374382

375383
test('includes ECS env vars when ECS props are provided', () => {
@@ -421,15 +429,18 @@ describe('TaskOrchestrator construct', () => {
421429
});
422430
});
423431

424-
test('grants iam:PassRole conditioned on ecs-tasks.amazonaws.com', () => {
432+
test('grants iam:PassRole scoped to task/execution role ARNs', () => {
425433
const { template } = createStack(ecsOverrides);
426434
template.hasResourceProperties('AWS::IAM::Policy', {
427435
PolicyDocument: {
428436
Statement: Match.arrayWith([
429437
Match.objectLike({
430438
Action: 'iam:PassRole',
431439
Effect: 'Allow',
432-
Resource: '*',
440+
Resource: Match.arrayWith([
441+
'arn:aws:iam::123456789012:role/TaskRole',
442+
'arn:aws:iam::123456789012:role/ExecutionRole',
443+
]),
433444
Condition: {
434445
StringEquals: {
435446
'iam:PassedToService': 'ecs-tasks.amazonaws.com',
@@ -441,6 +452,12 @@ describe('TaskOrchestrator construct', () => {
441452
});
442453
});
443454

455+
test('throws when only some ECS props are provided', () => {
456+
expect(() => createStack({
457+
ecsClusterArn: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster',
458+
})).toThrow('ECS compute strategy requires all of');
459+
});
460+
444461
test('does not grant ECS permissions when ECS props are omitted', () => {
445462
const { template } = createStack();
446463
const policies = template.findResources('AWS::IAM::Policy');

cdk/test/handlers/shared/strategies/ecs-strategy.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,40 @@ describe('EcsComputeStrategy', () => {
168168
expect(result).toEqual({ status: 'completed' });
169169
});
170170

171+
test('returns failed for STOPPED with undefined exit code (container never started)', async () => {
172+
mockSend.mockResolvedValueOnce({
173+
tasks: [{
174+
lastStatus: 'STOPPED',
175+
stoppedReason: 'CannotPullContainerError',
176+
containers: [{}],
177+
}],
178+
});
179+
180+
const strategy = new EcsComputeStrategy();
181+
const result = await strategy.pollSession(makeHandle());
182+
expect(result).toEqual({
183+
status: 'failed',
184+
error: 'Task stopped: CannotPullContainerError',
185+
});
186+
});
187+
188+
test('returns failed for STOPPED with no containers', async () => {
189+
mockSend.mockResolvedValueOnce({
190+
tasks: [{
191+
lastStatus: 'STOPPED',
192+
stoppedReason: 'EssentialContainerExited',
193+
containers: [],
194+
}],
195+
});
196+
197+
const strategy = new EcsComputeStrategy();
198+
const result = await strategy.pollSession(makeHandle());
199+
expect(result).toEqual({
200+
status: 'failed',
201+
error: 'Task stopped: EssentialContainerExited',
202+
});
203+
});
204+
171205
test('returns failed for STOPPED with non-zero exit code', async () => {
172206
mockSend.mockResolvedValueOnce({
173207
tasks: [{

0 commit comments

Comments
 (0)