@raindancers/raindancers-crew 0.0.0

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,395 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import {
4
+ Annotations,
5
+ CfnOutput,
6
+ CfnWaitCondition,
7
+ CfnWaitConditionHandle,
8
+ Stack,
9
+ Tags,
10
+ Token,
11
+ } from 'aws-cdk-lib';
12
+ import * as ec2 from 'aws-cdk-lib/aws-ec2';
13
+ import * as iam from 'aws-cdk-lib/aws-iam';
14
+ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
15
+ import { Construct } from 'constructs';
16
+ import {
17
+ CrewArchitecture,
18
+ RemoteCrewInstanceProps,
19
+ } from './remote-crew-instance-props';
20
+
21
+ const STACK_TAG_RE = /^[a-zA-Z0-9-]{1,51}$/;
22
+ const CIDR_RE =
23
+ /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|2[0-9]|1[6-9])$/;
24
+
25
+ const DEFAULT_REPO = 'https://github.com/kirodotdev/KiroCrew.git';
26
+ const DEFAULT_REF = 'main';
27
+ const DEFAULT_PORT = 5476;
28
+ const DEFAULT_STACK_TAG = 'kirocrew';
29
+ const DEFAULT_VOLUME_GB = 60;
30
+ const DEFAULT_TIMEOUT_MIN = 25;
31
+
32
+ /**
33
+ * A self-hosted KiroCrew gateway on a single EC2 instance, reached over SSM
34
+ * Session Manager with no inbound ports.
35
+ *
36
+ * This is a pipeline-native, version-controlled CDK port of the upstream
37
+ * `kirocrew-ec2` CloudFormation template
38
+ * (github.com/kirodotdev/KiroCrew). It provisions the same shape — an IAM
39
+ * role (SSM core + optional scoped S3 read) under a required permissions
40
+ * boundary, an SSM-only security group, an IMDSv2-enforced instance on an
41
+ * encrypted gp3 volume, and a WaitCondition that blocks stack completion
42
+ * until the gateway is serving — but under your naming, boundary, and
43
+ * deploy pipeline instead of an imperative `kirocrew cloud launch`.
44
+ *
45
+ * Access is via SSM port-forward only; the public DNS output is for
46
+ * diagnostics.
47
+ */
48
+ export class RemoteCrewInstance extends Construct {
49
+ /** The EC2 instance (SSM target). */
50
+ public readonly instance: ec2.Instance;
51
+ /** The instance's IAM role (carries the permissions boundary). */
52
+ public readonly role: iam.Role;
53
+ /** The SSM-only security group (no inbound unless allowSshCidr is set). */
54
+ public readonly securityGroup: ec2.SecurityGroup;
55
+ /** The discovery tag value written as `kirocrew:instance`. */
56
+ public readonly stackTag: string;
57
+
58
+ constructor(scope: Construct, id: string, props: RemoteCrewInstanceProps) {
59
+ super(scope, id);
60
+
61
+ const stackTag = props.stackTag ?? DEFAULT_STACK_TAG;
62
+ if (!STACK_TAG_RE.test(stackTag)) {
63
+ throw new Error(
64
+ `stackTag must match ${STACK_TAG_RE} (got '${stackTag}')`,
65
+ );
66
+ }
67
+ if (props.allowSshCidr && !CIDR_RE.test(props.allowSshCidr)) {
68
+ throw new Error(
69
+ `allowSshCidr must be a CIDR no wider than /16 (got '${props.allowSshCidr}')`,
70
+ );
71
+ }
72
+ if (props.webhookIngress && !props.webhookTokenSecretArn) {
73
+ throw new Error(
74
+ 'webhookIngress requires webhookTokenSecretArn: a reachable webhook ' +
75
+ 'with no Bearer token is a defect, not a default.',
76
+ );
77
+ }
78
+
79
+ const arch = props.architecture ?? CrewArchitecture.ARM64;
80
+ const source = props.source ?? {};
81
+ if (source.sourceBucket && !source.sourceKey) {
82
+ throw new Error('source.sourceKey is required when source.sourceBucket is set');
83
+ }
84
+ const dashboardPort = props.dashboardPort ?? DEFAULT_PORT;
85
+ this.stackTag = stackTag;
86
+
87
+ // --- IAM role: SSM core managed policy + required permissions boundary.
88
+ // The only extra grant is a scoped s3:GetObject when an S3 source is used.
89
+ this.role = new iam.Role(this, 'InstanceRole', {
90
+ assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
91
+ permissionsBoundary: iam.ManagedPolicy.fromManagedPolicyArn(
92
+ this,
93
+ 'Boundary',
94
+ props.permissionsBoundaryArn,
95
+ ),
96
+ managedPolicies: [
97
+ iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'),
98
+ ],
99
+ });
100
+ if (source.sourceBucket) {
101
+ this.role.addToPolicy(
102
+ new iam.PolicyStatement({
103
+ actions: ['s3:GetObject'],
104
+ resources: [`arn:aws:s3:::${source.sourceBucket}/${source.sourceKey}`],
105
+ }),
106
+ );
107
+ }
108
+
109
+ // --- Security group: SSM-only. No inbound by default; egress open for
110
+ // package install + LLM backend reach.
111
+ this.securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', {
112
+ vpc: props.vpc,
113
+ description: `KiroCrew ${stackTag} - SSM-only (no inbound by default)`,
114
+ allowAllOutbound: true,
115
+ // CDK's allowAllOutbound renders IPv4 0.0.0.0/0 egress only; a dual-stack
116
+ // instance needs IPv6 egress explicitly or its IPv6 traffic is dropped.
117
+ allowAllIpv6Outbound: props.enableIpv6 ?? false,
118
+ });
119
+ if (props.allowSshCidr) {
120
+ this.securityGroup.addIngressRule(
121
+ ec2.Peer.ipv4(props.allowSshCidr),
122
+ ec2.Port.tcp(22),
123
+ 'SSH fallback from the user\'s CIDR',
124
+ );
125
+ }
126
+
127
+ // --- Arch-aware Amazon Linux 2023 AMI, resolved at deploy time via the
128
+ // public SSM parameter (matches upstream, no hardcoded AMI id).
129
+ const cpuType =
130
+ arch === CrewArchitecture.ARM64
131
+ ? ec2.AmazonLinuxCpuType.ARM_64
132
+ : ec2.AmazonLinuxCpuType.X86_64;
133
+ const machineImage = ec2.MachineImage.latestAmazonLinux2023({ cpuType });
134
+
135
+ const instanceType =
136
+ props.instanceType ??
137
+ (arch === CrewArchitecture.ARM64
138
+ ? new ec2.InstanceType('m7g.2xlarge')
139
+ : new ec2.InstanceType('m7i.2xlarge'));
140
+
141
+ // --- WaitCondition: block stack completion until the gateway serves.
142
+ const waitHandle = new CfnWaitConditionHandle(this, 'WaitHandle');
143
+
144
+ // --- UserData: a small header injecting the params the bootstrap body
145
+ // reads, then the faithful bootstrap script asset.
146
+ const bootstrapBody = fs.readFileSync(resolveAsset('bootstrap.sh'), 'utf8');
147
+ const userData = ec2.UserData.forLinux();
148
+ userData.addCommands(
149
+ 'WAIT_HANDLE=' + shellQuote(waitHandle.ref),
150
+ 'DASHBOARD_PORT=' + String(dashboardPort),
151
+ 'SOURCE_BUCKET=' + shellQuote(source.sourceBucket ?? ''),
152
+ 'SOURCE_KEY=' + shellQuote(source.sourceKey ?? ''),
153
+ 'KIROCREW_REPO=' + shellQuote(source.kirocrewRepo ?? DEFAULT_REPO),
154
+ 'KIROCREW_REF=' + shellQuote(source.kirocrewRef ?? DEFAULT_REF),
155
+ 'WEBHOOK_TOKEN_SECRET_ARN=' + shellQuote(props.webhookTokenSecretArn ?? ''),
156
+ 'export WAIT_HANDLE DASHBOARD_PORT SOURCE_BUCKET SOURCE_KEY KIROCREW_REPO KIROCREW_REF WEBHOOK_TOKEN_SECRET_ARN',
157
+ );
158
+
159
+ // --- Always-on crew runtime (RC4): autopilot + no-idle-close, threaded
160
+ // into config.json at boot. Empty => current gateway defaults, unchanged.
161
+ userData.addCommands(
162
+ 'CREW_AUTOPILOT=' + shellQuote(props.crewRuntime?.autopilot ? '1' : ''),
163
+ 'CREW_DISABLE_IDLE_CLOSE=' +
164
+ shellQuote(props.crewRuntime?.disableIdleClose ? '1' : ''),
165
+ 'export CREW_AUTOPILOT CREW_DISABLE_IDLE_CLOSE',
166
+ );
167
+
168
+ // --- Webhook Bearer token: grant GetSecretValue on the one ARN only; the
169
+ // token is fetched at boot and written into config.json (RC3). Loopback-
170
+ // only bind is unchanged — routable exposure is a consumer concern.
171
+ if (props.webhookTokenSecretArn) {
172
+ const webhookSecret = secretsmanager.Secret.fromSecretCompleteArn(
173
+ this,
174
+ 'WebhookToken',
175
+ props.webhookTokenSecretArn,
176
+ );
177
+ webhookSecret.grantRead(this.role);
178
+ }
179
+
180
+ // --- Off-box backup wiring (only when a backup bucket is configured).
181
+ if (props.backupBucket) {
182
+ props.backupBucket.grantWrite(this.role);
183
+ props.backupBucket.grantRead(this.role); // read too, so this box can self-restore
184
+ let prefix = props.backupPrefix ?? 'crew-snapshots/';
185
+ if (!prefix.endsWith('/')) {
186
+ prefix = `${prefix}/`;
187
+ }
188
+ const schedule = props.backupSchedule ?? 'daily';
189
+ const region = Stack.of(this).region;
190
+ const backupBody = fs.readFileSync(resolveAsset('backup.sh'), 'utf8');
191
+ const restoreBody = fs.readFileSync(resolveAsset('restore-from-s3.sh'), 'utf8');
192
+ userData.addCommands(
193
+ // Header the backup + restore scripts read.
194
+ 'BACKUP_BUCKET=' + shellQuote(props.backupBucket.bucket.bucketName),
195
+ 'BACKUP_PREFIX=' + shellQuote(prefix),
196
+ 'AWS_REGION_ARG=' + shellQuote(Token.isUnresolved(region) ? '' : `--region ${region}`),
197
+ 'export BACKUP_BUCKET BACKUP_PREFIX AWS_REGION_ARG',
198
+ // Install the backup script.
199
+ "cat > /usr/local/sbin/kirocrew-backup <<'KCBACKUP'",
200
+ backupBody,
201
+ 'KCBACKUP',
202
+ 'chmod 0755 /usr/local/sbin/kirocrew-backup',
203
+ // The backup/restore scripts need the header env at RUN time (the timer
204
+ // runs them fresh), so bake it into an env file both units read.
205
+ 'mkdir -p /etc/kirocrew',
206
+ 'cat > /etc/kirocrew/backup.env <<KCENV',
207
+ `BACKUP_BUCKET=${props.backupBucket.bucket.bucketName}`,
208
+ `BACKUP_PREFIX=${prefix}`,
209
+ `AWS_REGION_ARG=${Token.isUnresolved(region) ? '' : `--region ${region}`}`,
210
+ 'KCENV',
211
+ // Install the restore helper.
212
+ "cat > /usr/local/sbin/kirocrew-restore-from-s3 <<'KCRESTORE'",
213
+ restoreBody,
214
+ 'KCRESTORE',
215
+ 'chmod 0755 /usr/local/sbin/kirocrew-restore-from-s3',
216
+ // systemd service + timer for the scheduled backup.
217
+ 'cat > /etc/systemd/system/kirocrew-backup.service <<UNIT',
218
+ '[Unit]',
219
+ 'Description=KiroCrew snapshot-to-S3 backup',
220
+ 'After=kirocrew.service',
221
+ '',
222
+ '[Service]',
223
+ 'Type=oneshot',
224
+ 'EnvironmentFile=/etc/kirocrew/backup.env',
225
+ 'ExecStart=/usr/local/sbin/kirocrew-backup',
226
+ 'UNIT',
227
+ 'cat > /etc/systemd/system/kirocrew-backup.timer <<UNIT',
228
+ '[Unit]',
229
+ 'Description=Run KiroCrew snapshot-to-S3 backup on a schedule',
230
+ '',
231
+ '[Timer]',
232
+ `OnCalendar=${schedule}`,
233
+ 'Persistent=true',
234
+ '',
235
+ '[Install]',
236
+ 'WantedBy=timers.target',
237
+ 'UNIT',
238
+ 'systemctl daemon-reload',
239
+ 'systemctl enable --now kirocrew-backup.timer || true',
240
+ );
241
+ }
242
+
243
+ userData.addCommands(bootstrapBody);
244
+
245
+ // --- The instance. IMDSv2 enforced (prompt-injectable agent must not be
246
+ // able to read role creds via IMDSv1), encrypted gp3 root.
247
+ this.instance = new ec2.Instance(this, 'Instance', {
248
+ vpc: props.vpc,
249
+ vpcSubnets: props.vpcSubnets ?? { subnetType: ec2.SubnetType.PUBLIC },
250
+ instanceType,
251
+ machineImage,
252
+ role: this.role,
253
+ securityGroup: this.securityGroup,
254
+ userData,
255
+ requireImdsv2: true,
256
+ associatePublicIpAddress: props.associatePublicIp ?? true,
257
+ blockDevices: [
258
+ {
259
+ deviceName: '/dev/xvda',
260
+ volume: ec2.BlockDeviceVolume.ebs(props.volumeSizeGb ?? DEFAULT_VOLUME_GB, {
261
+ volumeType: ec2.EbsDeviceVolumeType.GP3,
262
+ encrypted: true,
263
+ deleteOnTermination: true,
264
+ }),
265
+ },
266
+ ],
267
+ });
268
+ Tags.of(this.instance).add('Name', `kirocrew-${stackTag}`);
269
+
270
+ // --- IPv6 egress is handled at SG construction via allowAllIpv6Outbound
271
+ // (see the SecurityGroup above). Webhook ingress from a single source SG,
272
+ // gated on webhookIngress. Never a CIDR peer — the box is not internet-
273
+ // reachable by contract (RC2).
274
+ if (props.webhookIngress) {
275
+ const webhookPort = props.webhookIngress.port ?? dashboardPort;
276
+ this.securityGroup.addIngressRule(
277
+ ec2.Peer.securityGroupId(props.webhookIngress.source.securityGroupId),
278
+ ec2.Port.tcp(webhookPort),
279
+ 'Native webhook reach from the source SG only (no CIDR)',
280
+ );
281
+ }
282
+ // prop, so set it on the underlying CfnInstance. The VPC owns the subnet
283
+ // IPv6 CIDR + Egress-Only IGW + routes (see enableIpv6 doc / RC5).
284
+ if (props.enableIpv6) {
285
+ const cfnInstance = this.instance.node.defaultChild as ec2.CfnInstance;
286
+ cfnInstance.ipv6AddressCount = 1;
287
+
288
+ // Best-effort synth-time note when the resolved subnets expose no IPv6
289
+ // CIDR. Subnet IPv6 state is only knowable for concrete (non-token)
290
+ // subnets; a token means a looked-up/imported VPC where we cannot see it,
291
+ // so we stay silent rather than warn spuriously.
292
+ const selected = props.vpc.selectSubnets(
293
+ props.vpcSubnets ?? { subnetType: ec2.SubnetType.PUBLIC },
294
+ );
295
+ const anyWithoutIpv6 = selected.subnets.some(
296
+ (s) => !Token.isUnresolved(s.ipv4CidrBlock) && !hasIpv6(s),
297
+ );
298
+ if (anyWithoutIpv6) {
299
+ Annotations.of(this).addWarning(
300
+ 'enableIpv6 is set but at least one selected subnet exposes no IPv6 ' +
301
+ 'CIDR. Ensure the VPC assigns IPv6 CIDRs to these subnets and has an ' +
302
+ 'Egress-Only Internet Gateway route — this construct provisions none ' +
303
+ 'of that (consumer VPC responsibility).',
304
+ );
305
+ }
306
+ }
307
+
308
+ // Discovery tags on the instance and SG (matches the registry contract).
309
+ for (const taggable of [this.instance, this.securityGroup]) {
310
+ Tags.of(taggable).add('kirocrew:managed', 'true');
311
+ Tags.of(taggable).add('kirocrew:instance', stackTag);
312
+ }
313
+
314
+ const timeoutSecs =
315
+ (props.bootstrapTimeoutMinutes ?? DEFAULT_TIMEOUT_MIN) * 60;
316
+ const waitCondition = new CfnWaitCondition(this, 'WaitCondition', {
317
+ handle: waitHandle.ref,
318
+ timeout: String(timeoutSecs),
319
+ count: 1,
320
+ });
321
+ waitCondition.addDependency(
322
+ this.instance.node.defaultChild as ec2.CfnInstance,
323
+ );
324
+
325
+ // Outputs so the connect step can read the instance id from the stack.
326
+ // Export names are derived from the stack tag so they are predictable for a
327
+ // `describe-stacks` / cross-stack lookup (the generated logical id is
328
+ // scope-prefixed and hashed, so match on the export name, not the id).
329
+ new CfnOutput(this, 'CrewInstanceId', {
330
+ value: this.instance.instanceId,
331
+ description: 'EC2 instance id — the SSM target for connecting.',
332
+ exportName: `kirocrew-${stackTag}-instance-id`,
333
+ });
334
+ new CfnOutput(this, 'CrewPublicDnsName', {
335
+ value: this.instance.instancePublicDnsName,
336
+ description: 'Public DNS (diagnostics only; access is via SSM, never direct).',
337
+ exportName: `kirocrew-${stackTag}-public-dns`,
338
+ });
339
+ }
340
+
341
+ /** Public DNS of the instance (diagnostics only; access is via SSM). */
342
+ public get publicDnsName(): string {
343
+ return this.instance.instancePublicDnsName;
344
+ }
345
+
346
+ /** Instance id — the SSM target. */
347
+ public get instanceId(): string {
348
+ return this.instance.instanceId;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Whether a subnet carries an IPv6 CIDR association. CDK does not surface this
354
+ * on ISubnet, so read the underlying CfnSubnet's ipv6CidrBlock when available.
355
+ * Returns false when it cannot be determined (best-effort synth-time note).
356
+ */
357
+ function hasIpv6(subnet: ec2.ISubnet): boolean {
358
+ const cfn = subnet.node.defaultChild as ec2.CfnSubnet | undefined;
359
+ if (!cfn) {
360
+ return false;
361
+ }
362
+ const block = cfn.ipv6CidrBlock;
363
+ return block !== undefined && block !== null && block !== '';
364
+ }
365
+
366
+ /** Single-quote a literal for safe embedding; pass CDK tokens through. */
367
+ function shellQuote(value: string): string { // An unresolved CDK token (e.g. the WaitHandle ref) must reach CloudFormation
368
+ // intact — UserData interpolation resolves it. Only literals are quoted.
369
+ if (Token.isUnresolved(value)) {
370
+ return value;
371
+ }
372
+ return `'${value.replace(/'/g, "'\\''")}'`;
373
+ }
374
+
375
+ /**
376
+ * Resolve a bundled asset by name. Works whether the module runs from `src/`
377
+ * (ts-jest during tests, `__dirname` = src) or `lib/` (a consumer's compiled
378
+ * install, `__dirname` = lib): the asset ships under `src/assets`, so walk up
379
+ * to the package root and read it there. Reading from `lib/assets` would fail
380
+ * for consumers because jsii/tsc does not copy non-TS files into `lib`.
381
+ */
382
+ function resolveAsset(name: string): string {
383
+ const candidates = [
384
+ path.join(__dirname, 'assets', name), // running from src/ (tests)
385
+ path.join(__dirname, '..', 'src', 'assets', name), // running from lib/ (consumers)
386
+ ];
387
+ for (const candidate of candidates) {
388
+ if (fs.existsSync(candidate)) {
389
+ return candidate;
390
+ }
391
+ }
392
+ throw new Error(
393
+ `bundled asset '${name}' not found (looked in: ${candidates.join(', ')})`,
394
+ );
395
+ }