@raindancers/raindancers-crew 0.0.0 → 0.0.2

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.
@@ -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/index.ts CHANGED
@@ -3,3 +3,5 @@ export * from './remote-crew-instance-props';
3
3
  export * from './fargate-crew-base';
4
4
  export * from './fargate-crew';
5
5
  export * from './crew-backup-bucket';
6
+ export * from './ecs-crew-host';
7
+ export * from './crew-webhook-ingress';