@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,248 @@
1
+ import { Aws, Tags } from 'aws-cdk-lib';
2
+ import * as iam from 'aws-cdk-lib/aws-iam';
3
+ import * as logs from 'aws-cdk-lib/aws-logs';
4
+ import { Construct } from 'constructs';
5
+ import { ICrewBackupBucket } from './remote-crew-instance-props';
6
+
7
+ // Mirrors _CREW_RE in the upstream cloud/fargate/identity.py exactly. A
8
+ // trailing hyphen would make the derived role end in "--exec"; a leading one is
9
+ // not a legal start for the derived resource names.
10
+ const CREW_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
11
+ // Matches the upstream template's PermissionsBoundaryArn AllowedPattern.
12
+ const BOUNDARY_RE =
13
+ /^arn:aws(-[a-z0-9]+)*:iam::[0-9]{12}:policy\/kirocrew-crew-boundary$/;
14
+ const ECR_REPO_RE =
15
+ /^arn:aws(-[a-z0-9]+)*:ecr:[a-z0-9-]{1,32}:[0-9]{12}:repository\/[a-zA-Z0-9._/-]+$/;
16
+
17
+ /** CloudWatch Logs retention values the upstream template permits. */
18
+ const ALLOWED_RETENTION_DAYS = [1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365];
19
+
20
+ /**
21
+ * Properties for {@link FargateCrew}.
22
+ */
23
+ export interface FargateCrewProps {
24
+ /**
25
+ * Crew name: 1–32 chars, lower-case alphanumeric with inner hyphens, never
26
+ * leading or trailing. Every resource name is DERIVED from it — the task
27
+ * definition rebuilds the role ARNs and log-group name from the crew name
28
+ * and refuses a document whose ARNs disagree, so a rename here is a launch
29
+ * refusal, not a silent mismatch.
30
+ */
31
+ readonly crew: string;
32
+
33
+ /**
34
+ * Days a crew's task logs are kept before CloudWatch expires them. Must be
35
+ * one of the CloudWatch retention values.
36
+ *
37
+ * @default 30
38
+ */
39
+ readonly logRetentionDays?: number;
40
+
41
+ /**
42
+ * ARN of the pre-created shared crew permissions boundary
43
+ * (`arn:aws:iam::<account>:policy/kirocrew-crew-boundary`).
44
+ *
45
+ * Optional by design: this is a DECLARED degraded mode. Unlike the EC2 lane
46
+ * (whose boundary is created once by launcher code), no creator exists for
47
+ * the crew boundary yet, so rather than reference a policy nothing creates
48
+ * this is omitted until that creator lands. When set, it caps what these
49
+ * roles can ever do regardless of attached policies.
50
+ *
51
+ * @default - no boundary (declared degraded mode)
52
+ */
53
+ readonly permissionsBoundaryArn?: string;
54
+
55
+ /**
56
+ * ARN of the private ECR repository holding the crew image. Leave unset when
57
+ * the image is pulled from a public registry (the decided delivery, ECR
58
+ * Public), which needs no execution-role pull grant. When set, the pull grant
59
+ * is scoped to this one repository.
60
+ *
61
+ * @default - public registry; no pull grant
62
+ */
63
+ readonly ecrRepositoryArn?: string;
64
+
65
+ /**
66
+ * An S3 backup bucket to grant the TASK role write access to, so the running
67
+ * crew container can push `kirocrew snapshot` bundles off-box on its own
68
+ * schedule. The task role (not the execution role) gets this, because the
69
+ * push runs inside the container. Omit to disable off-box backup.
70
+ *
71
+ * @default - no off-box backup grant
72
+ */
73
+ readonly backupBucket?: ICrewBackupBucket;
74
+ }
75
+
76
+ /**
77
+ * Per-crew Fargate scaffolding for ONE KiroCrew remote crew: the two roles a
78
+ * task carries and the log group it writes to.
79
+ *
80
+ * One per crew, alongside the shared {@link FargateCrewBase}. Deleting this
81
+ * removes exactly one crew's identity and logs and leaves the cluster and its
82
+ * siblings untouched. Port of the upstream `kirocrew-fargate-crew` template.
83
+ *
84
+ * Security invariants preserved from upstream:
85
+ * - The **task role** (the running container's identity, and the blast radius
86
+ * one agent turn reaches) is created with NO policies and MUST NEVER be
87
+ * granted `secretsmanager:GetSecretValue` — the model credential is already
88
+ * in the container env, so the grant buys nothing while letting one turn read
89
+ * every crew's secret.
90
+ * - The **execution role** reads secrets scoped to `kirocrew/crew/<crew>/*`
91
+ * only, with individually-listed actions (no prefix wildcards).
92
+ * - Both assume-role policies carry an `aws:SourceAccount` condition so the
93
+ * roles are not assumable on behalf of an unrelated stack's task.
94
+ */
95
+ export class FargateCrew extends Construct {
96
+ /** Role ECS assumes BEFORE the container starts (secret fetch + log stream). */
97
+ public readonly executionRole: iam.Role;
98
+ /** Identity the RUNNING container carries. Created with no policies. */
99
+ public readonly taskRole: iam.Role;
100
+ /** The crew's log group (`/kirocrew/crew/<crew>`). */
101
+ public readonly logGroup: logs.LogGroup;
102
+ /** The crew this construct scaffolds. */
103
+ public readonly crew: string;
104
+ /** The one secret ARN pattern the execution role may read. */
105
+ public readonly secretArnPattern: string;
106
+
107
+ constructor(scope: Construct, id: string, props: FargateCrewProps) {
108
+ super(scope, id);
109
+
110
+ const crew = props.crew;
111
+ if (!CREW_RE.test(crew)) {
112
+ throw new Error(
113
+ 'crew must be 1-32 chars, lower-case alphanumeric with inner hyphens ' +
114
+ `(never leading/trailing); got '${crew}'`,
115
+ );
116
+ }
117
+ const retention = props.logRetentionDays ?? 30;
118
+ if (!ALLOWED_RETENTION_DAYS.includes(retention)) {
119
+ throw new Error(
120
+ `logRetentionDays must be one of ${ALLOWED_RETENTION_DAYS.join(', ')}`,
121
+ );
122
+ }
123
+ if (props.permissionsBoundaryArn && !BOUNDARY_RE.test(props.permissionsBoundaryArn)) {
124
+ throw new Error(
125
+ 'permissionsBoundaryArn must be a kirocrew-crew-boundary policy ARN',
126
+ );
127
+ }
128
+ if (props.ecrRepositoryArn && !ECR_REPO_RE.test(props.ecrRepositoryArn)) {
129
+ throw new Error('ecrRepositoryArn must be a valid ECR repository ARN');
130
+ }
131
+ this.crew = crew;
132
+
133
+ const boundary = props.permissionsBoundaryArn
134
+ ? iam.ManagedPolicy.fromManagedPolicyArn(this, 'Boundary', props.permissionsBoundaryArn)
135
+ : undefined;
136
+
137
+ // Log group. Name is FIXED (logConfiguration is one of the four fields
138
+ // RunTask cannot override), derived as /kirocrew/crew/<crew>.
139
+ this.logGroup = new logs.LogGroup(this, 'LogGroup', {
140
+ logGroupName: `/kirocrew/crew/${crew}`,
141
+ retention: retentionToEnum(retention),
142
+ });
143
+
144
+ const secretArnPattern = `arn:${Aws.PARTITION}:secretsmanager:${Aws.REGION}:${Aws.ACCOUNT_ID}:secret:kirocrew/crew/${crew}/*`;
145
+ this.secretArnPattern = secretArnPattern;
146
+ const logGroupArn = `arn:${Aws.PARTITION}:logs:${Aws.REGION}:${Aws.ACCOUNT_ID}:log-group:/kirocrew/crew/${crew}:*`;
147
+
148
+ const assumedBy = new iam.ServicePrincipal('ecs-tasks.amazonaws.com', {
149
+ conditions: {
150
+ // Without this the role is assumable on behalf of any task in the
151
+ // account, including one an unrelated stack registered.
152
+ StringEquals: { 'aws:SourceAccount': Aws.ACCOUNT_ID },
153
+ },
154
+ });
155
+
156
+ // Execution role: what ECS assumes BEFORE the container starts, to fetch
157
+ // secrets and open the log stream. The container never holds this role,
158
+ // which is why the secret read lives here and not on the task role.
159
+ this.executionRole = new iam.Role(this, 'ExecutionRole', {
160
+ roleName: `kirocrew-crew-${crew}-exec`,
161
+ assumedBy,
162
+ permissionsBoundary: boundary,
163
+ });
164
+ this.executionRole.addToPolicy(
165
+ new iam.PolicyStatement({
166
+ // Scoped to THIS crew's secret namespace. The trailing wildcard covers
167
+ // only the six-char suffix Secrets Manager appends; it does not widen
168
+ // the crew segment, so this role cannot read a sibling's secret.
169
+ actions: ['secretsmanager:GetSecretValue'],
170
+ resources: [secretArnPattern],
171
+ }),
172
+ );
173
+ this.executionRole.addToPolicy(
174
+ new iam.PolicyStatement({
175
+ // CreateLogGroup is absent on purpose: the group is created by this
176
+ // construct, so a task that could create one could write outside the
177
+ // namespace it is retained and deleted under.
178
+ actions: ['logs:CreateLogStream', 'logs:PutLogEvents'],
179
+ resources: [logGroupArn],
180
+ }),
181
+ );
182
+ if (props.ecrRepositoryArn) {
183
+ // GetAuthorizationToken is account-wide by the service's own design (it
184
+ // names no repository); the layer/image reads are scoped to the repo.
185
+ this.executionRole.addToPolicy(
186
+ new iam.PolicyStatement({
187
+ actions: ['ecr:GetAuthorizationToken'],
188
+ resources: ['*'],
189
+ }),
190
+ );
191
+ this.executionRole.addToPolicy(
192
+ new iam.PolicyStatement({
193
+ actions: [
194
+ 'ecr:BatchCheckLayerAvailability',
195
+ 'ecr:BatchGetImage',
196
+ 'ecr:GetDownloadUrlForLayer',
197
+ ],
198
+ resources: [props.ecrRepositoryArn],
199
+ }),
200
+ );
201
+ }
202
+
203
+ // Task role: the identity the RUNNING container carries. Created with NO
204
+ // policies at all — the intended starting state. It must NEVER be granted
205
+ // secretsmanager:GetSecretValue (see class docstring).
206
+ this.taskRole = new iam.Role(this, 'TaskRole', {
207
+ roleName: `kirocrew-crew-${crew}-task`,
208
+ assumedBy,
209
+ permissionsBoundary: boundary,
210
+ });
211
+
212
+ // Off-box backup: the RUNNING container pushes snapshots, so the grant goes
213
+ // on the task role. It stays scoped to the backup bucket + its KMS key —
214
+ // this does not reintroduce any secret-read the task role is denied.
215
+ if (props.backupBucket) {
216
+ props.backupBucket.grantWrite(this.taskRole);
217
+ props.backupBucket.grantRead(this.taskRole);
218
+ }
219
+
220
+ for (const taggable of [this.logGroup, this.executionRole, this.taskRole]) {
221
+ Tags.of(taggable).add('kirocrew:managed', 'true');
222
+ Tags.of(taggable).add('kirocrew:crew', crew);
223
+ }
224
+ }
225
+
226
+ /** Namespace a crew secret must be created under for the exec role to read it. */
227
+ public get secretNamePrefix(): string {
228
+ return `kirocrew/crew/${this.crew}/`;
229
+ }
230
+ }
231
+
232
+ function retentionToEnum(days: number): logs.RetentionDays {
233
+ const map: Record<number, logs.RetentionDays> = {
234
+ 1: logs.RetentionDays.ONE_DAY,
235
+ 3: logs.RetentionDays.THREE_DAYS,
236
+ 5: logs.RetentionDays.FIVE_DAYS,
237
+ 7: logs.RetentionDays.ONE_WEEK,
238
+ 14: logs.RetentionDays.TWO_WEEKS,
239
+ 30: logs.RetentionDays.ONE_MONTH,
240
+ 60: logs.RetentionDays.TWO_MONTHS,
241
+ 90: logs.RetentionDays.THREE_MONTHS,
242
+ 120: logs.RetentionDays.FOUR_MONTHS,
243
+ 150: logs.RetentionDays.FIVE_MONTHS,
244
+ 180: logs.RetentionDays.SIX_MONTHS,
245
+ 365: logs.RetentionDays.ONE_YEAR,
246
+ };
247
+ return map[days];
248
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './remote-crew-instance';
2
+ export * from './remote-crew-instance-props';
3
+ export * from './fargate-crew-base';
4
+ export * from './fargate-crew';
5
+ export * from './crew-backup-bucket';
@@ -0,0 +1,316 @@
1
+ import * as ec2 from 'aws-cdk-lib/aws-ec2';
2
+ import * as iam from 'aws-cdk-lib/aws-iam';
3
+ import * as s3 from 'aws-cdk-lib/aws-s3';
4
+
5
+ /**
6
+ * CPU architecture for the KiroCrew EC2 instance. Selects the matching
7
+ * Amazon Linux 2023 AMI and the pinned Node.js / kiro-cli download.
8
+ */
9
+ export enum CrewArchitecture {
10
+ /** 64-bit ARM (Graviton). The upstream default. */
11
+ ARM64 = 'arm64',
12
+ /** 64-bit x86. */
13
+ X86_64 = 'x86_64',
14
+ }
15
+
16
+ /**
17
+ * How the KiroCrew source is delivered to the instance at first boot.
18
+ *
19
+ * Exactly one mode is active. When {@link sourceBucket} is set the instance
20
+ * downloads a source tarball from S3 (and is granted read on just that one
21
+ * object); otherwise it shallow-clones {@link kirocrewRef} from
22
+ * {@link kirocrewRepo}.
23
+ */
24
+ export interface CrewSource {
25
+ /**
26
+ * S3 bucket holding the source tarball (`kirocrew-src.tar.gz`). When set,
27
+ * the instance role is granted `s3:GetObject` on this object ONLY. Leave
28
+ * unset to clone from git instead.
29
+ *
30
+ * @default - clone from git (see kirocrewRepo / kirocrewRef)
31
+ */
32
+ readonly sourceBucket?: string;
33
+
34
+ /**
35
+ * S3 key of the source tarball within {@link sourceBucket}. Required when
36
+ * `sourceBucket` is set; ignored otherwise.
37
+ *
38
+ * @default - none
39
+ */
40
+ readonly sourceKey?: string;
41
+
42
+ /**
43
+ * Git repository to clone when no S3 source is configured.
44
+ *
45
+ * @default 'https://github.com/kirodotdev/KiroCrew.git'
46
+ */
47
+ readonly kirocrewRepo?: string;
48
+
49
+ /**
50
+ * Git ref (branch or tag) to install when cloning.
51
+ *
52
+ * Pin this to a released tag for reproducible, version-controlled deploys —
53
+ * the upstream launcher defaults to `main`, which drifts. This construct
54
+ * defaults to `main` only to match upstream; SET IT to a tag in production.
55
+ *
56
+ * @default 'main'
57
+ */
58
+ readonly kirocrewRef?: string;
59
+ }
60
+
61
+ /**
62
+ * Exposes the gateway's webhook port to ONE source security group.
63
+ *
64
+ * There is deliberately no CIDR form: the brain box is never internet-
65
+ * reachable by contract. The named source SG (e.g. an ingest Lambda's SG, or a
66
+ * reverse proxy that fronts the loopback gateway) is the only peer allowed to
67
+ * reach the port.
68
+ *
69
+ * NOTE: the KiroCrew gateway binds loopback (`127.0.0.1`) only — it exposes no
70
+ * routable listener. This rule opens the security group so a consumer-owned
71
+ * reverse proxy / tunnel on the box can be reached from the source SG; actually
72
+ * serving the webhook on a routable interface is the consumer's concern (see
73
+ * README "Private dual-stack brain" and the webhook Decisions-for-review).
74
+ */
75
+ export interface WebhookIngress {
76
+ /**
77
+ * Imported security group allowed to reach the webhook port. Passed as an
78
+ * `ISecurityGroup` (imported) — this construct never creates it.
79
+ */
80
+ readonly source: ec2.ISecurityGroup;
81
+
82
+ /**
83
+ * TCP port the ingress rule opens. Defaults to the dashboard/gateway port so
84
+ * a reverse proxy fronting the loopback gateway is reachable; override to
85
+ * target a consumer proxy on a different port.
86
+ *
87
+ * @default - the resolved dashboardPort (5476)
88
+ */
89
+ readonly port?: number;
90
+ }
91
+
92
+ /**
93
+ * Always-on runtime settings for the hosted crew, threaded into the crew
94
+ * `config.json` at boot. All fields are optional and default to the current
95
+ * gateway behaviour; omitting {@link RemoteCrewInstanceProps.crewRuntime}
96
+ * entirely reproduces today's `kirocrew setup --agent-only` + `kirocrew
97
+ * gateway` exactly.
98
+ *
99
+ * Verified against KiroCrew v0.6.0: autopilot maps to `agent.approval_mode`,
100
+ * idle-close maps to `session.timeout_secs`, and the conductor roster ships
101
+ * with `setup --agent-only` (custom members are source-delivered JSON under
102
+ * `~/.kiro/agents/`).
103
+ */
104
+ export interface CrewRuntime {
105
+ /**
106
+ * Enable Autopilot: the hosted crew auto-approves tool calls that pass its
107
+ * security checks (deny rules and sensitive-path blocks still apply). Sets
108
+ * `agent.approval_mode` to `"auto"` in config.json.
109
+ *
110
+ * @default false (interactive; gateway default)
111
+ */
112
+ readonly autopilot?: boolean;
113
+
114
+ /**
115
+ * Keep the 24/7 brain session alive between events by disabling the idle
116
+ * session sweep. Sets `session.timeout_secs` to `0` (documented: "0 disables
117
+ * the idle sweep").
118
+ *
119
+ * @default false (default 3600s idle timeout applies)
120
+ */
121
+ readonly disableIdleClose?: boolean;
122
+ }
123
+
124
+ /**
125
+ * Properties for {@link RemoteCrewInstance}.
126
+ */
127
+ export interface RemoteCrewInstanceProps {
128
+ /**
129
+ * VPC to launch the instance into.
130
+ */
131
+ readonly vpc: ec2.IVpc;
132
+
133
+ /**
134
+ * ARN of an IAM permissions boundary applied to the instance role.
135
+ *
136
+ * The instance runs a prompt-injectable agent that executes arbitrary
137
+ * tools, so its role MUST carry a boundary that caps blast radius. This is
138
+ * required, not optional.
139
+ */
140
+ readonly permissionsBoundaryArn: string;
141
+
142
+ /**
143
+ * Subnet selection for the instance. A public (IGW-routed) subnet needs a
144
+ * public IP for egress; a private (NAT-routed) subnet does not — see
145
+ * {@link associatePublicIp}.
146
+ *
147
+ * @default - one public subnet in the VPC
148
+ */
149
+ readonly vpcSubnets?: ec2.SubnetSelection;
150
+
151
+ /**
152
+ * EC2 instance type.
153
+ *
154
+ * @default - m7g.2xlarge (arm64) / m7i.2xlarge (x86_64), matching the
155
+ * upstream "Development" size tier
156
+ */
157
+ readonly instanceType?: ec2.InstanceType;
158
+
159
+ /**
160
+ * CPU architecture. Must match {@link instanceType} when that is set.
161
+ *
162
+ * @default CrewArchitecture.ARM64
163
+ */
164
+ readonly architecture?: CrewArchitecture;
165
+
166
+ /**
167
+ * gp3 root volume size in GiB (20–1000). The volume is always encrypted.
168
+ *
169
+ * @default 60
170
+ */
171
+ readonly volumeSizeGb?: number;
172
+
173
+ /**
174
+ * Attach a public IP to the instance ENI. Required for egress on IGW-only
175
+ * subnets; leave off (false) for NAT-routed private subnets, where it is
176
+ * unused attack surface.
177
+ *
178
+ * @default true
179
+ */
180
+ readonly associatePublicIp?: boolean;
181
+
182
+ /**
183
+ * Assign an IPv6 address to the instance's primary ENI and permit IPv6
184
+ * egress on the security group.
185
+ *
186
+ * Enables a dual-stack posture: combined with `associatePublicIp: false` and
187
+ * a private, IPv6-capable subnet, the instance egresses over IPv6 (via the
188
+ * VPC's Egress-Only Internet Gateway) with no public IPv4. CDK's
189
+ * `allowAllOutbound` renders IPv4 `0.0.0.0/0` egress only, so this also adds
190
+ * an explicit all-traffic IPv6 egress rule.
191
+ *
192
+ * This construct does NOT provision subnet IPv6 CIDRs, an Egress-Only
193
+ * Internet Gateway, or any route — those are the consumer VPC's
194
+ * responsibility. The selected subnet(s) MUST already carry IPv6 CIDRs.
195
+ *
196
+ * @default false
197
+ */
198
+ readonly enableIpv6?: boolean;
199
+
200
+ /**
201
+ * Discovery tag value written as `kirocrew:instance`. Must match
202
+ * `[a-zA-Z0-9-]{1,51}`.
203
+ *
204
+ * @default 'kirocrew'
205
+ */
206
+ readonly stackTag?: string;
207
+
208
+ /**
209
+ * TCP port the gateway serves the dashboard on (loopback only; reached via
210
+ * SSM port-forward). Recorded in the instance registry as the remote port.
211
+ *
212
+ * @default 5476
213
+ */
214
+ readonly dashboardPort?: number;
215
+
216
+ /**
217
+ * Optional SSH ingress CIDR. When set (and no wider than /16), opens tcp/22
218
+ * from that CIDR as a fallback. Omit for SSM-only access (recommended).
219
+ *
220
+ * @default - no inbound; SSM-only
221
+ */
222
+ readonly allowSshCidr?: string;
223
+
224
+ /**
225
+ * Open the gateway/webhook port to ONE source security group only (never a
226
+ * CIDR). Independent of {@link allowSshCidr} — both, either, or neither may
227
+ * be set; unset leaves the SG no-inbound (the default).
228
+ *
229
+ * @default - no webhook ingress
230
+ */
231
+ readonly webhookIngress?: WebhookIngress;
232
+
233
+ /**
234
+ * Secrets Manager ARN of the Bearer token that authenticates the native
235
+ * webhook (`POST /api/hooks/agent`). At boot the instance fetches the secret
236
+ * and writes it as `hooks.webhook_token` in the crew `config.json` — the
237
+ * token is never baked into userData, env literals, or code. The instance
238
+ * role is granted `secretsmanager:GetSecretValue` on THIS ARN only.
239
+ *
240
+ * Required when {@link webhookIngress} is set: a reachable webhook with no
241
+ * auth is a defect, not a default, so synth fails if ingress is opened
242
+ * without a token.
243
+ *
244
+ * NOTE: the KiroCrew gateway binds loopback (`127.0.0.1`) only and exposes no
245
+ * routable webhook listener — see the webhook Decisions-for-review in the PR.
246
+ * This wires the AUTH (token-in-config); routable exposure of the loopback
247
+ * route is a consumer reverse-proxy / tunnel concern.
248
+ *
249
+ * @default - webhook auth not configured (loopback / SSM only)
250
+ */
251
+ readonly webhookTokenSecretArn?: string;
252
+
253
+ /**
254
+ * Always-on runtime settings (Autopilot, no-idle-close) for the hosted crew,
255
+ * threaded into config.json at boot. Omit for the current gateway defaults.
256
+ *
257
+ * @default - current gateway behaviour (interactive, 3600s idle timeout)
258
+ */
259
+ readonly crewRuntime?: CrewRuntime;
260
+
261
+ /**
262
+ * How the KiroCrew source reaches the instance (S3 tarball or git clone).
263
+ *
264
+ * @default - clone kirodotdev/KiroCrew@main
265
+ */
266
+ readonly source?: CrewSource;
267
+
268
+ /**
269
+ * Minutes to wait for the gateway to become healthy before the stack fails
270
+ * and rolls back (cold boot + dnf + Node + vite build + pip).
271
+ *
272
+ * @default 25
273
+ */
274
+ readonly bootstrapTimeoutMinutes?: number;
275
+
276
+ /**
277
+ * An S3 backup bucket to push crew snapshots to on a schedule. When set, the
278
+ * instance role is granted write, a systemd timer runs
279
+ * `kirocrew snapshot --purpose backup` and uploads the newest (redaction-
280
+ * scrubbed) bundle, and a `kirocrew-restore-from-s3` helper is installed for
281
+ * rebuilding a replacement instance. Omit to disable off-box backup.
282
+ *
283
+ * @default - no off-box backup
284
+ */
285
+ readonly backupBucket?: ICrewBackupBucket;
286
+
287
+ /**
288
+ * systemd OnCalendar expression for the backup timer (see
289
+ * `man systemd.time`). Only used when {@link backupBucket} is set.
290
+ *
291
+ * @default 'daily'
292
+ */
293
+ readonly backupSchedule?: string;
294
+
295
+ /**
296
+ * S3 key prefix under which snapshots are stored in the backup bucket.
297
+ * Only used when {@link backupBucket} is set. A trailing slash is added if
298
+ * absent.
299
+ *
300
+ * @default 'crew-snapshots/'
301
+ */
302
+ readonly backupPrefix?: string;
303
+ }
304
+
305
+ /**
306
+ * The subset of {@link CrewBackupBucket} the EC2/Fargate constructs need. Kept
307
+ * as an interface so a consumer can pass their own bucket wrapper.
308
+ */
309
+ export interface ICrewBackupBucket {
310
+ /** The destination bucket name. */
311
+ readonly bucket: s3.IBucket;
312
+ /** Grant a principal write access to snapshots (bucket + KMS). */
313
+ grantWrite(grantee: iam.IGrantable): void;
314
+ /** Grant a principal read access to snapshots (bucket + KMS). */
315
+ grantRead(grantee: iam.IGrantable): void;
316
+ }