@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.
@@ -0,0 +1,205 @@
1
+ #!/bin/bash
2
+ # KiroCrew ECS-on-EC2 host bootstrap (cloud-init, first boot, root).
3
+ #
4
+ # This host runs the ECS agent (the AMI is ECS-optimized Amazon Linux 2023, so
5
+ # the agent is preinstalled) and does the NAT for the crew tasks, which run in
6
+ # awsvpc mode on their own private ENIs. It joins the cluster, enables IP
7
+ # forwarding + an iptables MASQUERADE rule, and mounts each per-crew durable
8
+ # EBS volume resolved by a STABLE FILESYSTEM LABEL (never /dev/sdf, which the
9
+ # Nitro NVMe layer renames unpredictably).
10
+ #
11
+ # The header the EcsCrewHost construct prepends defines, before this body runs:
12
+ # ECS_CLUSTER name of the cluster to register with
13
+ # CREW_COUNT number of crews (informational)
14
+ # CREW_VOLUME_LABELS space-separated filesystem labels, one per crew volume
15
+ # CREW_MOUNT_PATHS space-separated host mount paths, positionally matched
16
+ # to CREW_VOLUME_LABELS
17
+ #
18
+ # Security-load-bearing choices preserved from the EC2 bootstrap: IMDSv2 is
19
+ # enforced at the instance (construct side), and the bootstrap is resumable
20
+ # across a first-boot reboot via a systemd oneshot.
21
+ set -u
22
+
23
+ BOOTSTRAP_SCRIPT=/usr/local/sbin/kirocrew-ecs-host-bootstrap
24
+ BOOTSTRAP_STATE=/var/lib/kirocrew-ecs-host-bootstrap
25
+ SETUP_DONE=$BOOTSTRAP_STATE/setup-complete
26
+
27
+ # cloud-init runs UserData once. Persist the rendered script and hand off to a
28
+ # systemd oneshot so a managed patch reboot can resume it on boot.
29
+ if [ "$#" -eq 0 ]; then
30
+ install -m 0700 "$0" "$BOOTSTRAP_SCRIPT"
31
+ cat > /etc/systemd/system/kirocrew-ecs-host-bootstrap.service <<'UNIT'
32
+ [Unit]
33
+ Description=Kiro Crew ECS-host resumable bootstrap
34
+ After=network-online.target
35
+ Wants=network-online.target
36
+
37
+ [Service]
38
+ Type=oneshot
39
+ ExecStart=/usr/local/sbin/kirocrew-ecs-host-bootstrap --resume
40
+ RemainAfterExit=yes
41
+
42
+ [Install]
43
+ WantedBy=multi-user.target
44
+ UNIT
45
+ systemctl daemon-reload
46
+ systemctl enable kirocrew-ecs-host-bootstrap.service
47
+ systemctl start --no-block kirocrew-ecs-host-bootstrap.service
48
+ exit 0
49
+ fi
50
+
51
+ mkdir -p "$BOOTSTRAP_STATE"
52
+ [ -f "$SETUP_DONE" ] && exit 0
53
+ exec >>/var/log/kirocrew-ecs-host-setup.log 2>&1
54
+ echo "=== KiroCrew ECS-host bootstrap starting $(date -u) ==="
55
+
56
+ # --- 1. Per-crew durable EBS: resolve by LABEL, format on first use only,
57
+ # mount, and persist in fstab keyed by LABEL so a Nitro NVMe rename cannot
58
+ # mount the wrong crew's state.
59
+ echo "--- mounting per-crew data volumes (resolve-by-label) ---"
60
+ # Positional pairing of labels and mount paths.
61
+ read -r -a LABELS <<< "${CREW_VOLUME_LABELS:-}"
62
+ read -r -a MOUNTS <<< "${CREW_MOUNT_PATHS:-}"
63
+ i=0
64
+ while [ "$i" -lt "${#LABELS[@]}" ]; do
65
+ label="${LABELS[$i]}"
66
+ mount_path="${MOUNTS[$i]}"
67
+ i=$((i + 1))
68
+ [ -n "$label" ] || continue
69
+ [ -n "$mount_path" ] || continue
70
+ mkdir -p "$mount_path"
71
+
72
+ # Wait for the raw device to attach. On Nitro the device is /dev/nvmeXn1;
73
+ # match it by the EBS volume mapping rather than a guessed name. We find any
74
+ # unmounted, unformatted-or-labelled block device and label it on first use.
75
+ dev=""
76
+ for _ in $(seq 1 30); do
77
+ # A volume already carrying our label (instance replacement / reboot).
78
+ existing=$(blkid -L "$label" 2>/dev/null || true)
79
+ if [ -n "$existing" ]; then
80
+ dev="$existing"
81
+ break
82
+ fi
83
+ # Otherwise pick a data disk that is not the root device and has no fs yet.
84
+ while read -r cand; do
85
+ [ -n "$cand" ] || continue
86
+ # Skip the root device and any partition/child.
87
+ root_src=$(findmnt -n -o SOURCE / 2>/dev/null | sed 's/[0-9]*p\?[0-9]*$//')
88
+ case "/dev/$cand" in
89
+ "$root_src"*) continue ;;
90
+ esac
91
+ # Skip anything already carrying a filesystem or mounted.
92
+ if [ -z "$(blkid "/dev/$cand" 2>/dev/null)" ] && ! findmnt -S "/dev/$cand" >/dev/null 2>&1; then
93
+ dev="/dev/$cand"
94
+ break
95
+ fi
96
+ done < <(lsblk -dn -o NAME 2>/dev/null)
97
+ [ -n "$dev" ] && break
98
+ sleep 5
99
+ done
100
+
101
+ if [ -z "$dev" ]; then
102
+ echo "WARNING: could not resolve a device for crew volume label '$label'; skipping"
103
+ continue
104
+ fi
105
+
106
+ # Format only if the device has no filesystem yet (first provision). An
107
+ # already-labelled device (reattached) is never reformatted.
108
+ if [ -z "$(blkid "$dev" 2>/dev/null)" ]; then
109
+ echo "formatting $dev as ext4 with label '$label' (first provision)"
110
+ mkfs.ext4 -F -L "$label" "$dev" || { echo "WARNING: mkfs failed for $dev"; continue; }
111
+ fi
112
+
113
+ # Persist by LABEL (stable), not by device name (renamed on Nitro).
114
+ if ! grep -q "LABEL=$label " /etc/fstab; then
115
+ echo "LABEL=$label $mount_path ext4 defaults,nofail 0 2" >> /etc/fstab
116
+ fi
117
+ mount "$mount_path" 2>/dev/null || mount -L "$label" "$mount_path" || \
118
+ echo "WARNING: could not mount label '$label' at $mount_path"
119
+ echo "crew volume '$label' -> $mount_path"
120
+ done
121
+
122
+ # --- 2. Host NAT for the crew task ENIs: enable IP forwarding and MASQUERADE
123
+ # egress out of the primary interface. The construct disables source/dest check
124
+ # on the host ENI so the kernel may forward packets not addressed to the host.
125
+ echo "--- enabling host NAT (ip_forward + iptables MASQUERADE) ---"
126
+ cat > /etc/sysctl.d/99-kirocrew-nat.conf <<'SYSCTL'
127
+ net.ipv4.ip_forward = 1
128
+ SYSCTL
129
+ sysctl --system >/dev/null 2>&1 || sysctl -w net.ipv4.ip_forward=1 || true
130
+
131
+ # Resolve the primary egress interface (the one carrying the default route).
132
+ EGRESS_IF=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}')
133
+ [ -n "$EGRESS_IF" ] || EGRESS_IF=$(ls /sys/class/net | grep -E '^(eth0|ens|enp)' | head -1)
134
+ echo "egress interface: ${EGRESS_IF:-unknown}"
135
+
136
+ if [ -n "$EGRESS_IF" ]; then
137
+ # Idempotent: only add the MASQUERADE rule if it is not already present.
138
+ if ! iptables -t nat -C POSTROUTING -o "$EGRESS_IF" -j MASQUERADE 2>/dev/null; then
139
+ iptables -t nat -A POSTROUTING -o "$EGRESS_IF" -j MASQUERADE || true
140
+ fi
141
+ # Persist the rules across reboot. iptables-services provides the save path;
142
+ # fall back to a boot-time restore unit if the package is unavailable.
143
+ if command -v iptables-save >/dev/null 2>&1; then
144
+ mkdir -p /etc/kirocrew
145
+ iptables-save > /etc/kirocrew/nat.rules || true
146
+ cat > /etc/systemd/system/kirocrew-nat-restore.service <<'UNIT'
147
+ [Unit]
148
+ Description=Restore KiroCrew host NAT iptables rules
149
+ After=network-online.target
150
+ Wants=network-online.target
151
+ Before=ecs.service
152
+
153
+ [Service]
154
+ Type=oneshot
155
+ ExecStart=/bin/sh -c '/sbin/iptables-restore < /etc/kirocrew/nat.rules'
156
+ RemainAfterExit=yes
157
+
158
+ [Install]
159
+ WantedBy=multi-user.target
160
+ UNIT
161
+ systemctl daemon-reload
162
+ systemctl enable kirocrew-nat-restore.service || true
163
+ fi
164
+ fi
165
+
166
+ # --- 2b. Disable source/dest check on THIS instance so the kernel forwards
167
+ # packets not addressed to the host (the ENI-level half of NAT; ip_forward is
168
+ # the kernel half). CloudFormation cannot set this on an ASG-launched instance,
169
+ # so the host does it to itself via IMDSv2 identity + modify-instance-attribute
170
+ # (the host role is scoped to KiroCrew ECS hosts by a tag condition).
171
+ echo "--- disabling source/dest check on the host ENI ---"
172
+ TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
173
+ -H "X-aws-ec2-metadata-token-ttl-seconds: 300" 2>/dev/null || true)
174
+ IID=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
175
+ http://169.254.169.254/latest/meta-data/instance-id 2>/dev/null || true)
176
+ AZ=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
177
+ http://169.254.169.254/latest/meta-data/placement/availability-zone 2>/dev/null || true)
178
+ REGION="${AZ%[a-z]}"
179
+ if [ -n "$IID" ] && [ -n "$REGION" ]; then
180
+ aws ec2 modify-instance-attribute --no-source-dest-check \
181
+ --instance-id "$IID" --region "$REGION" \
182
+ || echo "WARNING: could not disable source/dest check (NAT forwarding may drop task egress)"
183
+ else
184
+ echo "WARNING: could not resolve instance-id/region from IMDS; source/dest check left unchanged"
185
+ fi
186
+
187
+ # --- 3. Join the ECS cluster: write /etc/ecs/ecs.config with the cluster name
188
+ # and enable awsvpc task networking (ENI trunking raises the per-instance task
189
+ # ENI budget on smaller sizes). The ECS agent (preinstalled on the ECS AMI)
190
+ # reads this on start.
191
+ echo "--- joining ECS cluster '$ECS_CLUSTER' ---"
192
+ mkdir -p /etc/ecs
193
+ # Append only the keys we own; do not clobber an AMI-provided config.
194
+ {
195
+ echo "ECS_CLUSTER=$ECS_CLUSTER"
196
+ echo "ECS_ENABLE_TASK_ENI=true"
197
+ echo "ECS_AWSVPC_BLOCK_IMDS=true"
198
+ echo "ECS_ENABLE_AWSVPC_TRUNKING=true"
199
+ } >> /etc/ecs/ecs.config
200
+
201
+ # Start (or restart) the agent so it picks up the config.
202
+ systemctl enable --now ecs.service 2>/dev/null || systemctl restart ecs 2>/dev/null || true
203
+
204
+ echo "=== KiroCrew ECS-host bootstrap complete $(date -u) ==="
205
+ touch "$SETUP_DONE"
@@ -0,0 +1,175 @@
1
+ import { Duration } from 'aws-cdk-lib';
2
+ import * as ec2 from 'aws-cdk-lib/aws-ec2';
3
+ import * as iam from 'aws-cdk-lib/aws-iam';
4
+ import * as lambda from 'aws-cdk-lib/aws-lambda';
5
+ import * as sns from 'aws-cdk-lib/aws-sns';
6
+ import * as subs from 'aws-cdk-lib/aws-sns-subscriptions';
7
+ import { Construct } from 'constructs';
8
+ import { EcsCrewHost } from './ecs-crew-host';
9
+
10
+ const DEFAULT_CREW_PORT = 5476;
11
+
12
+ /**
13
+ * Properties for {@link CrewWebhookIngress}.
14
+ */
15
+ export interface CrewWebhookIngressProps {
16
+ /**
17
+ * VPC to place the ingress Lambda in. Must be the same VPC as the crew tasks
18
+ * so the Lambda can reach a task's private ENI IP directly with NO VPC
19
+ * endpoint.
20
+ */
21
+ readonly vpc: ec2.IVpc;
22
+
23
+ /**
24
+ * The crew host whose tasks receive webhooks. The task security group is
25
+ * granted a scoped inbound rule from this ingress Lambda's SG on the crew
26
+ * port only — the one controlled inbound exception to the egress-only model.
27
+ */
28
+ readonly host: EcsCrewHost;
29
+
30
+ /**
31
+ * ARN of an IAM permissions boundary applied to the Lambda's execution role.
32
+ * The ingress Lambda runs consumer-triggered code, so it carries a boundary
33
+ * matching the rest of the construct.
34
+ */
35
+ readonly permissionsBoundaryArn: string;
36
+
37
+ /**
38
+ * The code the ingress Lambda runs. The consumer supplies it: this construct
39
+ * wires the plumbing (SNS -> in-VPC Lambda -> task private IP) but does not
40
+ * ship an opinion about how a webhook payload maps to a crew.
41
+ */
42
+ readonly code: lambda.Code;
43
+
44
+ /**
45
+ * The Lambda handler entry point.
46
+ *
47
+ * @default 'index.handler'
48
+ */
49
+ readonly handler?: string;
50
+
51
+ /**
52
+ * The Lambda runtime. Must be an arm64-compatible runtime to match the
53
+ * arm64 default host.
54
+ *
55
+ * @default lambda.Runtime.NODEJS_20_X
56
+ */
57
+ readonly runtime?: lambda.Runtime;
58
+
59
+ /**
60
+ * Lambda architecture. Defaults to arm64 to match the Graviton host.
61
+ *
62
+ * @default lambda.Architecture.ARM_64
63
+ */
64
+ readonly architecture?: lambda.Architecture;
65
+
66
+ /**
67
+ * Subnets to place the ingress Lambda's ENIs in. Should be the same private
68
+ * subnet the crew tasks run in so the Lambda reaches task IPs directly.
69
+ *
70
+ * @default - the VPC's private-with-egress subnets
71
+ */
72
+ readonly vpcSubnets?: ec2.SubnetSelection;
73
+
74
+ /**
75
+ * TCP port on the crew task the Lambda reaches (the gateway/webhook port).
76
+ *
77
+ * @default 5476
78
+ */
79
+ readonly crewPort?: number;
80
+
81
+ /**
82
+ * An existing SNS topic to subscribe the Lambda to. Omit to create one.
83
+ *
84
+ * @default - a new topic is created
85
+ */
86
+ readonly topic?: sns.ITopic;
87
+
88
+ /**
89
+ * Lambda timeout.
90
+ *
91
+ * @default Duration.seconds(30)
92
+ */
93
+ readonly timeout?: Duration;
94
+ }
95
+
96
+ /**
97
+ * OPTIONAL, composable webhook-ingress path for {@link EcsCrewHost}, mirroring
98
+ * the optional {@link CrewBackupBucket} shape. Nothing is created unless the
99
+ * consumer instantiates it, so an existing consumer gains no new resources.
100
+ *
101
+ * Because each crew task runs in awsvpc mode with a real private VPC IP, an
102
+ * in-VPC Lambda can POST to a crew's `POST /api/hooks/agent` DIRECTLY — no VPC
103
+ * endpoint, no PrivateLink, no ALB. This construct wires: an SNS topic, an
104
+ * in-VPC Lambda under the permissions boundary, and a scoped ingress rule on
105
+ * the crew task security group from the Lambda's SG on the crew port ONLY.
106
+ * That ingress rule is the single controlled inbound exception to the
107
+ * egress-only task model.
108
+ *
109
+ * Greenfield: the construct wires the plumbing but ships no webhook-to-crew
110
+ * routing opinion — the consumer supplies the Lambda {@link CrewWebhookIngressProps.code}.
111
+ */
112
+ export class CrewWebhookIngress extends Construct {
113
+ /** The SNS topic that fans events into the ingress Lambda. */
114
+ public readonly topic: sns.ITopic;
115
+ /** The in-VPC ingress Lambda. */
116
+ public readonly function: lambda.Function;
117
+ /** The ingress Lambda's security group (the source of the scoped task ingress rule). */
118
+ public readonly securityGroup: ec2.SecurityGroup;
119
+
120
+ constructor(scope: Construct, id: string, props: CrewWebhookIngressProps) {
121
+ super(scope, id);
122
+
123
+ const crewPort = props.crewPort ?? DEFAULT_CREW_PORT;
124
+
125
+ this.securityGroup = new ec2.SecurityGroup(this, 'IngressLambdaSg', {
126
+ vpc: props.vpc,
127
+ description: 'KiroCrew webhook-ingress Lambda - reaches crew task private IPs',
128
+ allowAllOutbound: true,
129
+ });
130
+
131
+ const executionRole = new iam.Role(this, 'IngressLambdaRole', {
132
+ assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
133
+ permissionsBoundary: iam.ManagedPolicy.fromManagedPolicyArn(
134
+ this,
135
+ 'Boundary',
136
+ props.permissionsBoundaryArn,
137
+ ),
138
+ managedPolicies: [
139
+ // The Lambda runs in a VPC, so it needs the ENI-management actions.
140
+ iam.ManagedPolicy.fromAwsManagedPolicyName(
141
+ 'service-role/AWSLambdaVPCAccessExecutionRole',
142
+ ),
143
+ ],
144
+ });
145
+
146
+ this.function = new lambda.Function(this, 'IngressLambda', {
147
+ runtime: props.runtime ?? lambda.Runtime.NODEJS_20_X,
148
+ architecture: props.architecture ?? lambda.Architecture.ARM_64,
149
+ handler: props.handler ?? 'index.handler',
150
+ code: props.code,
151
+ role: executionRole,
152
+ vpc: props.vpc,
153
+ vpcSubnets: props.vpcSubnets ?? {
154
+ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
155
+ },
156
+ securityGroups: [this.securityGroup],
157
+ timeout: props.timeout ?? Duration.seconds(30),
158
+ environment: {
159
+ CREW_PORT: String(crewPort),
160
+ },
161
+ });
162
+
163
+ this.topic = props.topic ?? new sns.Topic(this, 'IngressTopic');
164
+ this.topic.addSubscription(new subs.LambdaSubscription(this.function));
165
+
166
+ // The one controlled inbound exception to the egress-only task model: the
167
+ // crew task SG accepts the crew port FROM the ingress Lambda SG only, never
168
+ // a CIDR. Documented in the README.
169
+ props.host.base.securityGroup.addIngressRule(
170
+ ec2.Peer.securityGroupId(this.securityGroup.securityGroupId),
171
+ ec2.Port.tcp(crewPort),
172
+ 'Webhook ingress from the in-VPC ingress Lambda SG only (no CIDR)',
173
+ );
174
+ }
175
+ }