@raindancers/raindancers-crew 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.jsii +1026 -3
- package/API.md +1459 -326
- package/README.md +58 -0
- package/docs/spec-ecs-crew-construct.md +200 -0
- package/lib/crew-backup-bucket.js +1 -1
- package/lib/crew-webhook-ingress.d.ts +104 -0
- package/lib/crew-webhook-ingress.js +111 -0
- package/lib/ecs-crew-host.d.ts +247 -0
- package/lib/ecs-crew-host.js +383 -0
- package/lib/fargate-crew-base.js +6 -2
- package/lib/fargate-crew.js +1 -1
- package/lib/index.d.ts +2 -0
- package/lib/index.js +3 -1
- package/lib/remote-crew-instance.js +1 -1
- package/package.json +2 -2
- package/src/assets/ecs-host-bootstrap.sh +205 -0
- package/src/crew-webhook-ingress.ts +175 -0
- package/src/ecs-crew-host.ts +616 -0
- package/src/fargate-crew-base.ts +4 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { Stack, Tags, Token } from 'aws-cdk-lib';
|
|
4
|
+
import * as autoscaling from 'aws-cdk-lib/aws-autoscaling';
|
|
5
|
+
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
6
|
+
import * as ecs from 'aws-cdk-lib/aws-ecs';
|
|
7
|
+
import * as iam from 'aws-cdk-lib/aws-iam';
|
|
8
|
+
import { Construct } from 'constructs';
|
|
9
|
+
import { FargateCrew } from './fargate-crew';
|
|
10
|
+
import { FargateCpuArchitecture, FargateCrewBase } from './fargate-crew-base';
|
|
11
|
+
import { CrewArchitecture, ICrewBackupBucket } from './remote-crew-instance-props';
|
|
12
|
+
|
|
13
|
+
const STACK_TAG_RE = /^[a-zA-Z0-9-]{1,51}$/;
|
|
14
|
+
const CREW_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
15
|
+
|
|
16
|
+
const DEFAULT_STACK_TAG = 'kirocrew';
|
|
17
|
+
const DEFAULT_CREW_COUNT = 1;
|
|
18
|
+
const MAX_CREW_COUNT = 8;
|
|
19
|
+
const DEFAULT_ROOT_VOLUME_GB = 60;
|
|
20
|
+
const DEFAULT_CREW_VOLUME_GB = 20;
|
|
21
|
+
const DEFAULT_MEMORY_RESERVATION_MIB = 1024;
|
|
22
|
+
const DEFAULT_MEMORY_HARD_LIMIT_MIB = 2048;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How a per-crew durable EBS volume is exposed to the host and its container.
|
|
26
|
+
*
|
|
27
|
+
* The device name the construct asks for (for example `/dev/sdf`) is NOT the
|
|
28
|
+
* kernel device name on Nitro/Graviton, where every EBS volume surfaces as an
|
|
29
|
+
* unpredictable `/dev/nvmeXn1`. The bootstrap therefore resolves each volume by
|
|
30
|
+
* a stable filesystem LABEL, never by the device path.
|
|
31
|
+
*/
|
|
32
|
+
export interface CrewDataVolume {
|
|
33
|
+
/** The crew this volume belongs to. */
|
|
34
|
+
readonly crew: string;
|
|
35
|
+
/** The block device name requested at attach time (a hint, not the kernel name). */
|
|
36
|
+
readonly deviceName: string;
|
|
37
|
+
/** The stable filesystem label the bootstrap resolves and mounts by. */
|
|
38
|
+
readonly label: string;
|
|
39
|
+
/** The host mount path the container bind-mounts for `~/.kiro/crew`. */
|
|
40
|
+
readonly mountPath: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Properties for {@link EcsCrewHost}.
|
|
45
|
+
*/
|
|
46
|
+
export interface EcsCrewHostProps {
|
|
47
|
+
/**
|
|
48
|
+
* VPC to run in. Passed in, never created here.
|
|
49
|
+
*
|
|
50
|
+
* The construct consumes a two-subnet host-NAT topology it does not build:
|
|
51
|
+
* one PUBLIC subnet (host ENI + Elastic IP + IGW route) and one PRIVATE
|
|
52
|
+
* subnet (task ENIs, `0.0.0.0/0` -> the host ENI, no IGW route). Select them
|
|
53
|
+
* with {@link hostSubnets} and {@link taskSubnets}.
|
|
54
|
+
*/
|
|
55
|
+
readonly vpc: ec2.IVpc;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* ARN of an IAM permissions boundary applied to the host instance role.
|
|
59
|
+
*
|
|
60
|
+
* The host runs a prompt-injectable agent per crew, so its role MUST carry a
|
|
61
|
+
* boundary that caps blast radius. Required, not optional, matching
|
|
62
|
+
* {@link RemoteCrewInstanceProps.permissionsBoundaryArn}. Any valid managed-
|
|
63
|
+
* policy ARN is accepted here (this is the EC2/host boundary).
|
|
64
|
+
*
|
|
65
|
+
* The per-crew task/execution roles take {@link crewPermissionsBoundaryArn},
|
|
66
|
+
* which is validated separately against the `kirocrew-crew-boundary` pattern
|
|
67
|
+
* the crew roles require.
|
|
68
|
+
*/
|
|
69
|
+
readonly permissionsBoundaryArn: string;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* ARN of the pre-created shared crew permissions boundary
|
|
73
|
+
* (`arn:aws:iam::<account>:policy/kirocrew-crew-boundary`), applied to every
|
|
74
|
+
* per-crew task and execution role.
|
|
75
|
+
*
|
|
76
|
+
* Separate from {@link permissionsBoundaryArn} because the crew roles enforce
|
|
77
|
+
* the `kirocrew-crew-boundary` policy name (a different boundary from the
|
|
78
|
+
* host's), and validating them together would force one ARN to satisfy two
|
|
79
|
+
* distinct patterns. Optional, mirroring {@link FargateCrew}'s declared
|
|
80
|
+
* degraded mode: omit it only when no crew-boundary creator exists yet.
|
|
81
|
+
*
|
|
82
|
+
* @default - no crew boundary (declared degraded mode; see FargateCrew)
|
|
83
|
+
*/
|
|
84
|
+
readonly crewPermissionsBoundaryArn?: string;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* EC2 instance type for the single ECS-registered host.
|
|
88
|
+
*
|
|
89
|
+
* The type STAYS a settable prop: the 55minutes consumer passes
|
|
90
|
+
* `new ec2.InstanceType('m9g.xlarge')`. Never hardcoded to one family.
|
|
91
|
+
*
|
|
92
|
+
* @default - m7g.2xlarge (arm64) / m7i.2xlarge (x86_64), matching
|
|
93
|
+
* RemoteCrewInstance
|
|
94
|
+
*/
|
|
95
|
+
readonly instanceType?: ec2.InstanceType;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* CPU architecture of the host and the crew container images. Must match
|
|
99
|
+
* {@link instanceType} when that is set.
|
|
100
|
+
*
|
|
101
|
+
* @default CrewArchitecture.ARM64
|
|
102
|
+
*/
|
|
103
|
+
readonly architecture?: CrewArchitecture;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Number of Kiro Crew instances to host, each one ECS task/service.
|
|
107
|
+
*
|
|
108
|
+
* DEFAULT 1 — identical to today's single-crew behaviour, so existing
|
|
109
|
+
* consumers gain nothing new. The 55minutes consumer passes 3. Validated
|
|
110
|
+
* 1..8: awsvpc gives each task its own ENI, and (crewCount + 1) ENIs (the
|
|
111
|
+
* host ENI plus one per task) must fit the instance type's ENI budget.
|
|
112
|
+
*
|
|
113
|
+
* @default 1
|
|
114
|
+
*/
|
|
115
|
+
readonly crewCount?: number;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Explicit crew names. Each must match the {@link FargateCrew} regex
|
|
119
|
+
* `^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`; the log group, roles, and secret
|
|
120
|
+
* namespace are derived from it. When omitted, names are generated as
|
|
121
|
+
* `crew-1`..`crew-<crewCount>`. When set, the list length must equal
|
|
122
|
+
* {@link crewCount}.
|
|
123
|
+
*
|
|
124
|
+
* @default - crew-1 .. crew-<crewCount>
|
|
125
|
+
*/
|
|
126
|
+
readonly crews?: string[];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Subnet selection for the host ENI. This is the PUBLIC subnet (IGW-routed,
|
|
130
|
+
* carries the Elastic IP), because the host does the NAT for the tasks.
|
|
131
|
+
*
|
|
132
|
+
* @default - one public subnet in the VPC
|
|
133
|
+
*/
|
|
134
|
+
readonly hostSubnets?: ec2.SubnetSelection;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Subnet selection for the crew task ENIs. This is the PRIVATE subnet whose
|
|
138
|
+
* `0.0.0.0/0` route points at the host ENI (no IGW route). Tasks get no
|
|
139
|
+
* public IP.
|
|
140
|
+
*
|
|
141
|
+
* @default - the VPC's private-with-egress subnets
|
|
142
|
+
*/
|
|
143
|
+
readonly taskSubnets?: ec2.SubnetSelection;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* gp3 root volume size in GiB for the host. Always encrypted.
|
|
147
|
+
*
|
|
148
|
+
* @default 60
|
|
149
|
+
*/
|
|
150
|
+
readonly rootVolumeSizeGb?: number;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Per-crew durable EBS data volume size in GiB. Always encrypted, always
|
|
154
|
+
* `deleteOnTermination: false` (a crew's `~/.kiro/crew` learnings must
|
|
155
|
+
* outlive an instance replacement, matching the CrewBackupBucket RETAIN
|
|
156
|
+
* intent).
|
|
157
|
+
*
|
|
158
|
+
* @default 20
|
|
159
|
+
*/
|
|
160
|
+
readonly crewDataVolumeSizeGb?: number;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Per-crew EBS volume type.
|
|
164
|
+
*
|
|
165
|
+
* @default ec2.EbsDeviceVolumeType.GP3
|
|
166
|
+
*/
|
|
167
|
+
readonly crewDataVolumeType?: ec2.EbsDeviceVolumeType;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* SOFT memory reservation (MiB) per crew container. ECS uses it for
|
|
171
|
+
* placement; a crew may burst above it when the host has spare RAM, so idle
|
|
172
|
+
* crews cost little.
|
|
173
|
+
*
|
|
174
|
+
* @default 1024
|
|
175
|
+
*/
|
|
176
|
+
readonly crewMemoryReservationMiB?: number;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* HARD memory cap (MiB) per crew container. A crew is OOM-killed at this
|
|
180
|
+
* ceiling, so no single crew can consume the whole host. Set the SUM of hard
|
|
181
|
+
* caps at or below (physical RAM minus host + ECS-agent headroom) for a hard
|
|
182
|
+
* cross-crew guarantee — see the host-OOM caution in the README.
|
|
183
|
+
*
|
|
184
|
+
* @default 2048
|
|
185
|
+
*/
|
|
186
|
+
readonly crewMemoryHardLimitMiB?: number;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Optional SOFT CPU shares per crew container (1024 = one vCPU). Omit to
|
|
190
|
+
* leave CPU unconstrained (crews share the host CPU fairly under contention).
|
|
191
|
+
*
|
|
192
|
+
* @default - unset (shared CPU)
|
|
193
|
+
*/
|
|
194
|
+
readonly crewCpuShares?: number;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* ARN of the private ECR repository holding the crew image. Leave unset when
|
|
198
|
+
* the image is pulled from a public registry. When set, each per-crew
|
|
199
|
+
* execution role gets a pull grant scoped to this one repository.
|
|
200
|
+
*
|
|
201
|
+
* @default - public registry; no pull grant
|
|
202
|
+
*/
|
|
203
|
+
readonly ecrRepositoryArn?: string;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Container image reference for the crew task. When {@link ecrRepositoryArn}
|
|
207
|
+
* is set this is typically the repo URI with a tag; otherwise a public
|
|
208
|
+
* registry reference.
|
|
209
|
+
*
|
|
210
|
+
* @default 'public.ecr.aws/kirocrew/crew:latest'
|
|
211
|
+
*/
|
|
212
|
+
readonly crewImage?: string;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Days a crew's task logs are kept. One of the CloudWatch retention values
|
|
216
|
+
* (see {@link FargateCrew}).
|
|
217
|
+
*
|
|
218
|
+
* @default 30
|
|
219
|
+
*/
|
|
220
|
+
readonly logRetentionDays?: number;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* An S3 backup bucket. When set, each per-crew TASK role is granted write so
|
|
224
|
+
* the running container pushes its own snapshots off-box.
|
|
225
|
+
*
|
|
226
|
+
* @default - no off-box backup
|
|
227
|
+
*/
|
|
228
|
+
readonly backupBucket?: ICrewBackupBucket;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Discovery tag value written as `kirocrew:ecs-host`. Must match
|
|
232
|
+
* `[a-zA-Z0-9-]{1,51}`. The cluster is named `kirocrew-crew-<stackTag>`.
|
|
233
|
+
*
|
|
234
|
+
* @default 'kirocrew'
|
|
235
|
+
*/
|
|
236
|
+
readonly stackTag?: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* One self-provisioned EC2 host running the ECS agent, hosting `crewCount`
|
|
241
|
+
* Kiro Crew instances as ECS-on-EC2 container tasks.
|
|
242
|
+
*
|
|
243
|
+
* The host registers to the {@link FargateCrewBase} cluster and does the NAT
|
|
244
|
+
* for the crew tasks itself, so the tasks stay fully private with no public
|
|
245
|
+
* IPs and no NAT Gateway, fck-nat, VPC endpoints, or ALB. Each task runs in
|
|
246
|
+
* awsvpc mode with its own ENI and private VPC IP; each crew keeps its durable
|
|
247
|
+
* `~/.kiro/crew` state on its own encrypted EBS volume that survives instance
|
|
248
|
+
* replacement.
|
|
249
|
+
*
|
|
250
|
+
* Isolation is container-level (shared kernel), accepted as sufficient:
|
|
251
|
+
* Graviton Nitro protects the box from other AWS tenants, containers cover
|
|
252
|
+
* crew-to-crew separation. See the host-OOM caution: hard per-task memory caps
|
|
253
|
+
* bound each crew's ceiling but a busy crew can still pressure siblings when
|
|
254
|
+
* the sum of actual usage exceeds physical RAM.
|
|
255
|
+
*
|
|
256
|
+
* `crewCount` defaults to 1, identical to the single-crew shape, so a plain
|
|
257
|
+
* instantiation with no new props gains no extra resources.
|
|
258
|
+
*/
|
|
259
|
+
export class EcsCrewHost extends Construct {
|
|
260
|
+
/** The shared ECS scaffolding (cluster + egress-only task SG). */
|
|
261
|
+
public readonly base: FargateCrewBase;
|
|
262
|
+
/** The size-1 Auto Scaling Group holding the single ECS-registered EC2 host. */
|
|
263
|
+
public readonly autoScalingGroup: autoscaling.AutoScalingGroup;
|
|
264
|
+
/** The capacity provider registering the host with the cluster. */
|
|
265
|
+
public readonly capacityProvider: ecs.AsgCapacityProvider;
|
|
266
|
+
/** The host instance's IAM role (carries the permissions boundary). */
|
|
267
|
+
public readonly role: iam.Role;
|
|
268
|
+
/** The host's SSM-only security group (no inbound). */
|
|
269
|
+
public readonly hostSecurityGroup: ec2.SecurityGroup;
|
|
270
|
+
/** The per-crew scaffolding (roles + log group), one per crew. */
|
|
271
|
+
public readonly crews: FargateCrew[];
|
|
272
|
+
/** The per-crew EC2 services. */
|
|
273
|
+
public readonly services: ecs.Ec2Service[];
|
|
274
|
+
/** The per-crew durable data volumes (device/label/mount metadata). */
|
|
275
|
+
public readonly dataVolumes: CrewDataVolume[];
|
|
276
|
+
/** The discovery tag value written as `kirocrew:ecs-host`. */
|
|
277
|
+
public readonly stackTag: string;
|
|
278
|
+
/** The resolved crew names. */
|
|
279
|
+
public readonly crewNames: string[];
|
|
280
|
+
|
|
281
|
+
constructor(scope: Construct, id: string, props: EcsCrewHostProps) {
|
|
282
|
+
super(scope, id);
|
|
283
|
+
|
|
284
|
+
const stackTag = props.stackTag ?? DEFAULT_STACK_TAG;
|
|
285
|
+
if (!STACK_TAG_RE.test(stackTag)) {
|
|
286
|
+
throw new Error(`stackTag must match ${STACK_TAG_RE} (got '${stackTag}')`);
|
|
287
|
+
}
|
|
288
|
+
this.stackTag = stackTag;
|
|
289
|
+
|
|
290
|
+
const crewCount = props.crewCount ?? DEFAULT_CREW_COUNT;
|
|
291
|
+
if (!Number.isInteger(crewCount) || crewCount < 1 || crewCount > MAX_CREW_COUNT) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`crewCount must be an integer 1..${MAX_CREW_COUNT} (got ${crewCount}); ` +
|
|
294
|
+
'awsvpc needs (crewCount + 1) ENIs to fit the instance type',
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const crewNames = props.crews ?? defaultCrewNames(crewCount);
|
|
299
|
+
if (crewNames.length !== crewCount) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`crews must list exactly crewCount (${crewCount}) names (got ${crewNames.length})`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
for (const crew of crewNames) {
|
|
305
|
+
if (!CREW_RE.test(crew)) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`crew name must match ${CREW_RE} (got '${crew}'); it derives the ` +
|
|
308
|
+
'log group, roles, and secret namespace',
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (new Set(crewNames).size !== crewNames.length) {
|
|
313
|
+
throw new Error(`crew names must be unique (got ${crewNames.join(', ')})`);
|
|
314
|
+
}
|
|
315
|
+
this.crewNames = crewNames;
|
|
316
|
+
|
|
317
|
+
const arch = props.architecture ?? CrewArchitecture.ARM64;
|
|
318
|
+
const fargateArch =
|
|
319
|
+
arch === CrewArchitecture.ARM64
|
|
320
|
+
? FargateCpuArchitecture.ARM64
|
|
321
|
+
: FargateCpuArchitecture.X86_64;
|
|
322
|
+
|
|
323
|
+
// --- Shared ECS scaffolding: reuse FargateCrewBase for the cluster and the
|
|
324
|
+
// egress-only task security group, so there is one code path for the
|
|
325
|
+
// cluster the tasks register to.
|
|
326
|
+
this.base = new FargateCrewBase(this, 'Base', {
|
|
327
|
+
vpc: props.vpc,
|
|
328
|
+
vpcSubnets: props.taskSubnets,
|
|
329
|
+
cpuArchitecture: fargateArch,
|
|
330
|
+
stackTag,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// --- Host instance role: SSM core + required boundary, mirroring
|
|
334
|
+
// RemoteCrewInstance. Plus the ECS-agent register/telemetry permissions so
|
|
335
|
+
// this box can join the cluster and run tasks.
|
|
336
|
+
this.role = new iam.Role(this, 'HostRole', {
|
|
337
|
+
assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
|
|
338
|
+
permissionsBoundary: iam.ManagedPolicy.fromManagedPolicyArn(
|
|
339
|
+
this,
|
|
340
|
+
'Boundary',
|
|
341
|
+
props.permissionsBoundaryArn,
|
|
342
|
+
),
|
|
343
|
+
managedPolicies: [
|
|
344
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'),
|
|
345
|
+
// The ECS container-instance role: RegisterContainerInstance, Poll,
|
|
346
|
+
// Submit*, and the ECR/log actions the agent needs. Standard for an
|
|
347
|
+
// ECS-on-EC2 capacity host.
|
|
348
|
+
iam.ManagedPolicy.fromAwsManagedPolicyName(
|
|
349
|
+
'service-role/AmazonEC2ContainerServiceforEC2Role',
|
|
350
|
+
),
|
|
351
|
+
],
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// --- Host security group: SSM-only, no inbound. Egress open for image
|
|
355
|
+
// pulls, the model endpoint, and forwarding the tasks' NATed traffic.
|
|
356
|
+
this.hostSecurityGroup = new ec2.SecurityGroup(this, 'HostSecurityGroup', {
|
|
357
|
+
vpc: props.vpc,
|
|
358
|
+
description: `KiroCrew ECS host ${stackTag} - SSM-only (no inbound), does NAT for crew tasks`,
|
|
359
|
+
allowAllOutbound: true,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// --- Arch-aware ECS-optimized Amazon Linux 2023 AMI (the agent is
|
|
363
|
+
// pre-installed), resolved via the public SSM parameter, no hardcoded id.
|
|
364
|
+
const amiHardwareType =
|
|
365
|
+
arch === CrewArchitecture.ARM64
|
|
366
|
+
? ecs.AmiHardwareType.ARM
|
|
367
|
+
: ecs.AmiHardwareType.STANDARD;
|
|
368
|
+
const machineImage = ecs.EcsOptimizedImage.amazonLinux2023(amiHardwareType);
|
|
369
|
+
|
|
370
|
+
const instanceType =
|
|
371
|
+
props.instanceType ??
|
|
372
|
+
(arch === CrewArchitecture.ARM64
|
|
373
|
+
? new ec2.InstanceType('m7g.2xlarge')
|
|
374
|
+
: new ec2.InstanceType('m7i.2xlarge'));
|
|
375
|
+
|
|
376
|
+
// --- Per-crew durable data volumes: one gp3 EBS each, encrypted,
|
|
377
|
+
// deleteOnTermination:false, resolved in the bootstrap by a stable LABEL
|
|
378
|
+
// (never /dev/sdf, which the Nitro NVMe layer renames).
|
|
379
|
+
const volumeType = props.crewDataVolumeType ?? ec2.EbsDeviceVolumeType.GP3;
|
|
380
|
+
const volumeSize = props.crewDataVolumeSizeGb ?? DEFAULT_CREW_VOLUME_GB;
|
|
381
|
+
this.dataVolumes = crewNames.map((crew, i) => ({
|
|
382
|
+
crew,
|
|
383
|
+
// Device letters f.. (sdf, sdg, ...) are attach-time hints; the kernel
|
|
384
|
+
// renames them on Nitro, so the bootstrap ignores them and uses label.
|
|
385
|
+
deviceName: `/dev/sd${String.fromCharCode('f'.charCodeAt(0) + i)}`,
|
|
386
|
+
label: `crew-${crew}`,
|
|
387
|
+
mountPath: `/var/lib/kirocrew/${crew}`,
|
|
388
|
+
}));
|
|
389
|
+
|
|
390
|
+
// The ASG uses the autoscaling module's own BlockDevice types (distinct
|
|
391
|
+
// from ec2's). The public crewDataVolumeType prop stays ec2-typed for
|
|
392
|
+
// library consistency; translate it by its shared string value here.
|
|
393
|
+
const asgVolumeType = volumeType as unknown as autoscaling.EbsDeviceVolumeType;
|
|
394
|
+
const rootBlockDevice: autoscaling.BlockDevice = {
|
|
395
|
+
deviceName: '/dev/xvda',
|
|
396
|
+
volume: autoscaling.BlockDeviceVolume.ebs(props.rootVolumeSizeGb ?? DEFAULT_ROOT_VOLUME_GB, {
|
|
397
|
+
volumeType: autoscaling.EbsDeviceVolumeType.GP3,
|
|
398
|
+
encrypted: true,
|
|
399
|
+
deleteOnTermination: true,
|
|
400
|
+
}),
|
|
401
|
+
};
|
|
402
|
+
const crewBlockDevices: autoscaling.BlockDevice[] = this.dataVolumes.map((v) => ({
|
|
403
|
+
deviceName: v.deviceName,
|
|
404
|
+
volume: autoscaling.BlockDeviceVolume.ebs(volumeSize, {
|
|
405
|
+
volumeType: asgVolumeType,
|
|
406
|
+
encrypted: true,
|
|
407
|
+
// The whole point: a crew's memory outlives an instance replacement.
|
|
408
|
+
deleteOnTermination: false,
|
|
409
|
+
}),
|
|
410
|
+
}));
|
|
411
|
+
|
|
412
|
+
// --- UserData: a small header the bootstrap body reads (per-crew volume
|
|
413
|
+
// labels + mount paths, ECS cluster name), then the ECS-host bootstrap
|
|
414
|
+
// asset (host NAT + resolve-by-label EBS mount). The AsgCapacityProvider
|
|
415
|
+
// below ALSO appends the ECS-cluster join to this UserData; writing
|
|
416
|
+
// ECS_CLUSTER here is a harmless idempotent belt-and-braces.
|
|
417
|
+
const bootstrapBody = fs.readFileSync(resolveAsset('ecs-host-bootstrap.sh'), 'utf8');
|
|
418
|
+
const userData = ec2.UserData.forLinux();
|
|
419
|
+
userData.addCommands(
|
|
420
|
+
'ECS_CLUSTER=' + shellQuote(this.base.cluster.clusterName),
|
|
421
|
+
'CREW_COUNT=' + String(crewCount),
|
|
422
|
+
'CREW_VOLUME_LABELS=' + shellQuote(this.dataVolumes.map((v) => v.label).join(' ')),
|
|
423
|
+
'CREW_MOUNT_PATHS=' + shellQuote(this.dataVolumes.map((v) => v.mountPath).join(' ')),
|
|
424
|
+
'export ECS_CLUSTER CREW_COUNT CREW_VOLUME_LABELS CREW_MOUNT_PATHS',
|
|
425
|
+
);
|
|
426
|
+
userData.addCommands(bootstrapBody);
|
|
427
|
+
|
|
428
|
+
// --- The single ECS-registered host, as a size-1 Auto Scaling Group behind
|
|
429
|
+
// an AsgCapacityProvider. A size-1 ASG (min=max=desired=1) is still ONE
|
|
430
|
+
// self-provisioned EC2 host and still ECS-on-EC2 (no Fargate); the capacity
|
|
431
|
+
// provider is what registers that capacity with the cluster so the L2
|
|
432
|
+
// Ec2Services below can schedule onto it. IMDSv2 enforced (a
|
|
433
|
+
// prompt-injectable agent must not read role creds via IMDSv1), encrypted
|
|
434
|
+
// gp3 root, plus the per-crew durable data volumes.
|
|
435
|
+
this.autoScalingGroup = new autoscaling.AutoScalingGroup(this, 'HostAsg', {
|
|
436
|
+
vpc: props.vpc,
|
|
437
|
+
vpcSubnets: props.hostSubnets ?? { subnetType: ec2.SubnetType.PUBLIC },
|
|
438
|
+
instanceType,
|
|
439
|
+
machineImage,
|
|
440
|
+
role: this.role,
|
|
441
|
+
securityGroup: this.hostSecurityGroup,
|
|
442
|
+
userData,
|
|
443
|
+
requireImdsv2: true,
|
|
444
|
+
minCapacity: 1,
|
|
445
|
+
maxCapacity: 1,
|
|
446
|
+
desiredCapacity: 1,
|
|
447
|
+
// The host is in the PUBLIC subnet and carries the public IP so it
|
|
448
|
+
// egresses via the IGW and can NAT the tasks. Tasks get none.
|
|
449
|
+
associatePublicIpAddress: true,
|
|
450
|
+
blockDevices: [rootBlockDevice, ...crewBlockDevices],
|
|
451
|
+
});
|
|
452
|
+
Tags.of(this.autoScalingGroup).add('Name', `kirocrew-ecs-host-${stackTag}`);
|
|
453
|
+
|
|
454
|
+
// --- Host does the NAT: it must forward packets that are neither from nor
|
|
455
|
+
// to its own address, which requires source/dest check DISABLED on the host
|
|
456
|
+
// ENI. CloudFormation cannot set SourceDestCheck on an ASG-launched instance
|
|
457
|
+
// declaratively, so the bootstrap disables it on the host itself via
|
|
458
|
+
// ec2:ModifyInstanceAttribute (scoped by an instance-tag condition so the
|
|
459
|
+
// host can only ever modify a KiroCrew ECS host, not an arbitrary instance).
|
|
460
|
+
// The bootstrap also sets ip_forward=1 and the iptables MASQUERADE rule.
|
|
461
|
+
this.role.addToPolicy(
|
|
462
|
+
new iam.PolicyStatement({
|
|
463
|
+
actions: ['ec2:ModifyInstanceAttribute'],
|
|
464
|
+
resources: ['*'],
|
|
465
|
+
conditions: {
|
|
466
|
+
StringEquals: { 'aws:ResourceTag/kirocrew:ecs-host': stackTag },
|
|
467
|
+
},
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
// --- Register this ASG's capacity with the cluster. This is what makes the
|
|
472
|
+
// cluster have Ec2 capacity as far as CDK's Ec2Service validation is
|
|
473
|
+
// concerned, and it appends the ECS-cluster join to the host UserData.
|
|
474
|
+
this.capacityProvider = new ecs.AsgCapacityProvider(this, 'HostCapacity', {
|
|
475
|
+
autoScalingGroup: this.autoScalingGroup,
|
|
476
|
+
// The instances are long-lived crew hosts; do not let ECS scale them in
|
|
477
|
+
// or terminate them from under running crews.
|
|
478
|
+
enableManagedTerminationProtection: false,
|
|
479
|
+
});
|
|
480
|
+
this.base.cluster.addAsgCapacityProvider(this.capacityProvider);
|
|
481
|
+
|
|
482
|
+
// --- Per-crew: FargateCrew scaffolding (roles + log group), an EC2 task
|
|
483
|
+
// definition (awsvpc), a container definition, and an Ec2Service.
|
|
484
|
+
this.crews = [];
|
|
485
|
+
this.services = [];
|
|
486
|
+
const crewImage = props.crewImage ?? 'public.ecr.aws/kirocrew/crew:latest';
|
|
487
|
+
const memoryReservationMiB =
|
|
488
|
+
props.crewMemoryReservationMiB ?? DEFAULT_MEMORY_RESERVATION_MIB;
|
|
489
|
+
const memoryHardLimitMiB =
|
|
490
|
+
props.crewMemoryHardLimitMiB ?? DEFAULT_MEMORY_HARD_LIMIT_MIB;
|
|
491
|
+
if (memoryReservationMiB > memoryHardLimitMiB) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
`crewMemoryReservationMiB (${memoryReservationMiB}) must not exceed ` +
|
|
494
|
+
`crewMemoryHardLimitMiB (${memoryHardLimitMiB}): the soft reservation ` +
|
|
495
|
+
'cannot be higher than the hard ceiling',
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
crewNames.forEach((crew, i) => {
|
|
500
|
+
const dataVolume = this.dataVolumes[i];
|
|
501
|
+
const crewScaffold = new FargateCrew(this, `Crew${i}`, {
|
|
502
|
+
crew,
|
|
503
|
+
logRetentionDays: props.logRetentionDays,
|
|
504
|
+
permissionsBoundaryArn: props.crewPermissionsBoundaryArn,
|
|
505
|
+
ecrRepositoryArn: props.ecrRepositoryArn,
|
|
506
|
+
backupBucket: props.backupBucket,
|
|
507
|
+
});
|
|
508
|
+
this.crews.push(crewScaffold);
|
|
509
|
+
|
|
510
|
+
const taskDef = new ecs.Ec2TaskDefinition(this, `TaskDef${i}`, {
|
|
511
|
+
// awsvpc: each task gets its own ENI + private VPC IP.
|
|
512
|
+
networkMode: ecs.NetworkMode.AWS_VPC,
|
|
513
|
+
taskRole: crewScaffold.taskRole,
|
|
514
|
+
executionRole: crewScaffold.executionRole,
|
|
515
|
+
// No runtimePlatform: an EC2 task's architecture is that of the host it
|
|
516
|
+
// lands on (the arm64 ECS-optimized AMI here), not a task-def field
|
|
517
|
+
// (that is Fargate-only).
|
|
518
|
+
// A host volume mapping the crew's mounted EBS path so ~/.kiro/crew is
|
|
519
|
+
// durable across task/instance replacement.
|
|
520
|
+
volumes: [
|
|
521
|
+
{
|
|
522
|
+
name: `crew-data-${crew}`,
|
|
523
|
+
host: { sourcePath: dataVolume.mountPath },
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const container = taskDef.addContainer(`Crew${i}Container`, {
|
|
529
|
+
image: ecs.ContainerImage.fromRegistry(crewImage),
|
|
530
|
+
// SOFT reservation for scheduling; idle crews cost little.
|
|
531
|
+
memoryReservationMiB,
|
|
532
|
+
// HARD cap so a runaway crew cannot exceed its ceiling (host-OOM
|
|
533
|
+
// blast-radius caution in the README).
|
|
534
|
+
memoryLimitMiB: memoryHardLimitMiB,
|
|
535
|
+
// Optional soft CPU shares; unset => shared CPU under contention.
|
|
536
|
+
cpu: props.crewCpuShares,
|
|
537
|
+
logging: ecs.LogDriver.awsLogs({
|
|
538
|
+
streamPrefix: crew,
|
|
539
|
+
logGroup: crewScaffold.logGroup,
|
|
540
|
+
}),
|
|
541
|
+
});
|
|
542
|
+
container.addMountPoints({
|
|
543
|
+
containerPath: '/home/kirocrew/.kiro/crew',
|
|
544
|
+
sourceVolume: `crew-data-${crew}`,
|
|
545
|
+
readOnly: false,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const service = new ecs.Ec2Service(this, `Service${i}`, {
|
|
549
|
+
cluster: this.base.cluster,
|
|
550
|
+
taskDefinition: taskDef,
|
|
551
|
+
desiredCount: 1,
|
|
552
|
+
// awsvpc placement: the PRIVATE task subnet, the egress-only task SG,
|
|
553
|
+
// no public IP (tasks are fully private; the host NATs their egress).
|
|
554
|
+
securityGroups: [this.base.securityGroup],
|
|
555
|
+
vpcSubnets: props.taskSubnets ?? {
|
|
556
|
+
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
// The service depends on the host capacity being registered.
|
|
560
|
+
service.node.addDependency(this.capacityProvider);
|
|
561
|
+
this.services.push(service);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// Tag the ASG (tags propagate to launched instances by default), so the
|
|
565
|
+
// kirocrew:ecs-host tag the ModifyInstanceAttribute condition keys off is
|
|
566
|
+
// present on the host, and the SG.
|
|
567
|
+
for (const taggable of [this.autoScalingGroup, this.hostSecurityGroup]) {
|
|
568
|
+
Tags.of(taggable).add('kirocrew:managed', 'true');
|
|
569
|
+
Tags.of(taggable).add('kirocrew:ecs-host', stackTag);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The region the host runs in (read from the stack env, never a prop). */
|
|
574
|
+
public get region(): string {
|
|
575
|
+
return Stack.of(this).region;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Generate default crew names crew-1 .. crew-<count>. */
|
|
580
|
+
function defaultCrewNames(count: number): string[] {
|
|
581
|
+
const names: string[] = [];
|
|
582
|
+
for (let i = 1; i <= count; i++) {
|
|
583
|
+
names.push(`crew-${i}`);
|
|
584
|
+
}
|
|
585
|
+
return names;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Single-quote a literal for safe embedding; pass CDK tokens through. */
|
|
589
|
+
function shellQuote(value: string): string {
|
|
590
|
+
if (Token.isUnresolved(value)) {
|
|
591
|
+
return value;
|
|
592
|
+
}
|
|
593
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Resolve a bundled asset by name. Works whether the module runs from `src/`
|
|
598
|
+
* (ts-jest during tests, `__dirname` = src) or `lib/` (a consumer's compiled
|
|
599
|
+
* install, `__dirname` = lib): the asset ships under `src/assets`, so walk up
|
|
600
|
+
* to the package root and read it there. Reading from `lib/assets` would fail
|
|
601
|
+
* for consumers because jsii/tsc does not copy non-TS files into `lib`.
|
|
602
|
+
*/
|
|
603
|
+
function resolveAsset(name: string): string {
|
|
604
|
+
const candidates = [
|
|
605
|
+
path.join(__dirname, 'assets', name),
|
|
606
|
+
path.join(__dirname, '..', 'src', 'assets', name),
|
|
607
|
+
];
|
|
608
|
+
for (const candidate of candidates) {
|
|
609
|
+
if (fs.existsSync(candidate)) {
|
|
610
|
+
return candidate;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
throw new Error(
|
|
614
|
+
`bundled asset '${name}' not found (looked in: ${candidates.join(', ')})`,
|
|
615
|
+
);
|
|
616
|
+
}
|
package/src/fargate-crew-base.ts
CHANGED
|
@@ -99,6 +99,10 @@ export class FargateCrewBase extends Construct {
|
|
|
99
99
|
vpc: props.vpc,
|
|
100
100
|
description: `Kiro Crew Fargate crews ${stackTag} - egress only, no inbound`,
|
|
101
101
|
allowAllOutbound: true,
|
|
102
|
+
// CDK's allowAllOutbound renders IPv4 0.0.0.0/0 egress only; a dual-stack
|
|
103
|
+
// VPC also needs an IPv6 ::/0 egress rule or tasks silently drop outbound
|
|
104
|
+
// IPv6. Counterpart to bwip-holdings/55minutes PR #17's dual-stack VPC.
|
|
105
|
+
allowAllIpv6Outbound: true,
|
|
102
106
|
});
|
|
103
107
|
|
|
104
108
|
for (const taggable of [this.cluster, this.securityGroup]) {
|