@reventlessdev/reventless-seed-aws 1.0.0-alpha.2 → 1.0.0-alpha.4

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,700 @@
1
+ // AWS "reset" for the seed harness: truncate a deployed stack's durable stores
2
+ // — every DynamoDB table (EventLog, DcbEventLog, QueryDb) and every S3 bucket
3
+ // (tasks, served images) the framework created for the stack — leaving the
4
+ // infrastructure in place so the stack reads empty and is re-seedable. It is the
5
+ // exact inverse of seeding: `Seed.Runner.assertStoreEmpty` refuses to seed a
6
+ // non-empty store; this makes a non-empty store empty again.
7
+ //
8
+ // A wipe is irreversible, so every step is fail-closed and production must be
9
+ // unreachable at several at once (the separate-AWS-account guarantee is the one
10
+ // deferred layer — see docs/plans/seed-reset-and-fresh-store-guard.md):
11
+ //
12
+ // 1. name allowlist — the stack name must match ^(alpha|dev|pr-.+)$
13
+ // 2. wipeable flag — the target's own `Pulumi.<stack>.yaml` must declare
14
+ // `reventless:wipeable: true` (read via `pulumi config`)
15
+ // 3. tag-scoped discovery — targets are found ONLY through the framework's
16
+ // `reventless:platform=<project>` + `environment=<stack>`
17
+ // tags (Resource Groups Tagging API). Scoping on BOTH
18
+ // matters: `environment` is the stack NAME alone, so two
19
+ // projects sharing a stack name (e.g. `alpha`) in one
20
+ // account collide on it; `platform` (the Pulumi project)
21
+ // keeps a wipe inside the one project. No name globbing.
22
+ // 4. per-resource tag re-check — every discovered resource is re-verified to
23
+ // carry both tags before a single delete is issued
24
+ // 5. dry-run default + typed confirm — lists what it would empty and stops.
25
+ // Interactively, re-typing the exact stack name confirms
26
+ // a real wipe (no env var). Without a TTY (CI),
27
+ // REVENTLESS_WIPE_CONFIRM=<stack> is the equivalent.
28
+ //
29
+ // Gates 1 and 2 are AND-ed: a stray `wipeable: true` on an unlisted name is
30
+ // still refused, and a listed name without the flag is still refused. Production
31
+ // stacks satisfy neither.
32
+ //
33
+ // A deployment is usually several Pulumi projects sharing a stack name — the
34
+ // platform project plus one per domain plugin, each its own `reventless:platform`.
35
+ // The caller passes those projects as `targets`; the operator picks a scope
36
+ // (`domain` = all plugins, a single plugin, `platform`, or `everything`), and
37
+ // gate 2 + discovery run per selected project. Wiping domain data alone leaves
38
+ // the platform's plugin registry intact, so the store stays re-seedable.
39
+ //
40
+ // Stack resolution reuses `ReventlessSeedAws` (same `pulumi` subprocess, same
41
+ // backend pinning). AWS credentials come from the ambient chain (env / profile /
42
+ // SSO); only the region is resolved explicitly and pinned on the environment, so
43
+ // every SDK client targets the same account/region the tags were read from.
44
+
45
+ open ReventlessSeed
46
+
47
+ module Ddb = AwsSdk.DynamoDb
48
+ module Rgt = AwsSdk.ResourceGroupsTaggingApi
49
+ module S3 = AwsSdk.S3
50
+
51
+ @scope("process") @val external exit: int => unit = "exit"
52
+ @scope("process") @val external processEnv: dict<string> = "env"
53
+ @module("node:fs") external readFileSync: (string, string) => string = "readFileSync"
54
+
55
+ type stream
56
+ @scope("process") @val external stdin: stream = "stdin"
57
+ // `process.stdin.isTTY` is `true` interactively and `undefined` under a pipe/CI.
58
+ @get external isTTY: stream => option<bool> = "isTTY"
59
+
60
+ // The Pulumi project name, which the framework stamps as `reventless:platform` on
61
+ // every resource (`Plugin.res`: `platformName = getProjectName()`). Discovery
62
+ // MUST scope on this as well as the stack: `reventless:environment` carries only
63
+ // the stack *name*, so two different Pulumi projects deployed with the same stack
64
+ // name (e.g. `alpha`) in one account/region share that tag. Filtering by platform
65
+ // too keeps a wipe inside the one project the operator is standing in. Read from
66
+ // the target project's Pulumi.yaml — the same dir the pulumi subprocess uses.
67
+ let projectName = (~projectDir: string): string => {
68
+ let path = `${projectDir}/Pulumi.yaml`
69
+ let raw = try readFileSync(path, "utf8") catch {
70
+ | _ => throw(Seed.Failed(`could not read ${path} to scope the wipe to this Pulumi project.`))
71
+ }
72
+ switch raw
73
+ ->String.split("\n")
74
+ ->Array.find(line => line->String.trim->String.startsWith("name:")) {
75
+ | Some(line) =>
76
+ let trimmed = line->String.trim
77
+ trimmed
78
+ ->String.slice(~start=String.length("name:"), ~end=String.length(trimmed))
79
+ ->String.trim
80
+ ->String.replaceRegExp(%re("/^[\"']|[\"']$/g"), "")
81
+ | None => throw(Seed.Failed(`could not find a \`name:\` field in ${path}.`))
82
+ }
83
+ }
84
+
85
+ // A read of a JSON object field — items come back from the document client as
86
+ // already-unmarshalled JSON values, so a delete key is just the key attributes
87
+ // picked back out.
88
+ let field = (json: JSON.t, key: string): option<JSON.t> =>
89
+ switch json {
90
+ | Object(obj) => obj->Dict.get(key)
91
+ | _ => None
92
+ }
93
+
94
+ let asString = (json: JSON.t): option<string> =>
95
+ switch json {
96
+ | String(s) => Some(s)
97
+ | _ => None
98
+ }
99
+
100
+ let chunk = (arr: array<'a>, size: int): array<array<'a>> => {
101
+ let out = []
102
+ let i = ref(0)
103
+ while i.contents < arr->Array.length {
104
+ out->Array.push(arr->Array.slice(~start=i.contents, ~end=i.contents + size))
105
+ i := i.contents + size
106
+ }
107
+ out
108
+ }
109
+
110
+ // ── Gates ───────────────────────────────────────────────────────────────────
111
+
112
+ // Fail-closed name allowlist. A denylist ("everything except prod") fails open
113
+ // the day a new prod-like stack is added and forgotten; this fails closed.
114
+ let nameAllowlist = %re("/^(alpha|dev|pr-.+)$/")
115
+
116
+ // The stack's fully-resolved Pulumi config, read once. Reading it whole (rather
117
+ // than `pulumi config get <key>`, which exits non-zero for BOTH a missing key
118
+ // and a missing/unreachable stack) lets a refusal say *which* it was — an
119
+ // unreadable config is a pulumi/backend problem; an absent flag is a missing
120
+ // opt-in. `pulumi config --json` maps each key to `{value, secret}`.
121
+ let readStackConfig = (~projectDir, ~backend, ~stack): result<dict<JSON.t>, string> =>
122
+ switch (
123
+ try Some(ReventlessSeedAws.pulumi(~projectDir, ~backend, ["config", "--json", "--stack", stack])) catch {
124
+ | _ => None
125
+ }
126
+ ) {
127
+ | None =>
128
+ Error(
129
+ `could not read the Pulumi config for stack "${stack}" — is pulumi logged in to the right backend, and is the stack deployed?`,
130
+ )
131
+ | Some(raw) =>
132
+ switch (
133
+ try Some(JSON.parseOrThrow(raw)) catch {
134
+ | _ => None
135
+ }
136
+ ) {
137
+ | Some(Object(obj)) => Ok(obj)
138
+ | _ => Error(`could not parse \`pulumi config --json\` output for stack "${stack}".`)
139
+ }
140
+ }
141
+
142
+ let configValue = (cfg: dict<JSON.t>, key: string): option<string> =>
143
+ cfg->Dict.get(key)->Option.flatMap(v => v->field("value"))->Option.flatMap(asString)
144
+
145
+ // ── Discovery ─────────────────────────────────────────────────────────────────
146
+
147
+ type resource =
148
+ | Table(string)
149
+ | Bucket(string)
150
+ | Other
151
+
152
+ // arn:aws:dynamodb:<region>:<acct>:table/<name> | arn:aws:s3:::<bucket>
153
+ let classify = (arn: string): resource =>
154
+ if arn->String.startsWith("arn:aws:dynamodb:") {
155
+ switch arn->String.split(":table/") {
156
+ | [_, name] => Table(name)
157
+ | _ => Other
158
+ }
159
+ } else if arn->String.startsWith("arn:aws:s3:::") {
160
+ Bucket(arn->String.slice(~start=String.length("arn:aws:s3:::"), ~end=String.length(arn)))
161
+ } else {
162
+ Other
163
+ }
164
+
165
+ let tagValue = (tags: array<Rgt.GetResourcesCommand.tag>, key: string): option<string> =>
166
+ tags->Array.findMap(t => t.key == key ? Some(t.value) : None)
167
+
168
+ // Discovery is scoped by BOTH tags (platform AND environment) — the Tagging API
169
+ // ANDs multiple TagFilters — so it only ever sees the target project's own stack,
170
+ // never a same-named stack from another project. Each returned resource is then
171
+ // re-checked to carry both tags (a mismatch aborts the whole run rather than
172
+ // being skipped) as a second, per-resource guard.
173
+ let discover = async (~region, ~stack, ~platform): (array<string>, array<string>) => {
174
+ let client = Rgt.client(~region, ())
175
+ let tables = []
176
+ let buckets = []
177
+ let rec loop = async (token: option<string>): unit => {
178
+ let out = await Rgt.GetResourcesCommand.send(
179
+ client,
180
+ Rgt.GetResourcesCommand.make({
181
+ tagFilters: [
182
+ {key: "reventless:platform", values: [platform]},
183
+ {key: "reventless:environment", values: [stack]},
184
+ ],
185
+ resourceTypeFilters: ["dynamodb:table", "s3"],
186
+ resourcesPerPage: 100,
187
+ paginationToken: ?token,
188
+ }),
189
+ )
190
+ out.resourceTagMappingList
191
+ ->Option.getOr([])
192
+ ->Array.forEach(m => {
193
+ let tags = m.tags->Option.getOr([])
194
+ let assertTag = (key, expected) =>
195
+ switch tagValue(tags, key) {
196
+ | Some(v) if v == expected => ()
197
+ | other =>
198
+ throw(
199
+ Seed.Failed(
200
+ `refusing: discovered resource ${m.resourceARN} carries ${key}=${other->Option.getOr(
201
+ "<none>",
202
+ )}, not "${expected}".`,
203
+ ),
204
+ )
205
+ }
206
+ assertTag("reventless:platform", platform)
207
+ assertTag("reventless:environment", stack)
208
+ switch classify(m.resourceARN) {
209
+ | Table(name) => tables->Array.push(name)
210
+ | Bucket(name) => buckets->Array.push(name)
211
+ | Other => ()
212
+ }
213
+ })
214
+ switch out.paginationToken {
215
+ | Some(t) if t != "" => await loop(Some(t))
216
+ | _ => ()
217
+ }
218
+ }
219
+ await loop(None)
220
+ (tables, buckets)
221
+ }
222
+
223
+ // ── Counting (dry-run) ─────────────────────────────────────────────────────────
224
+
225
+ let countTable = async (table: string): int => {
226
+ let rec loop = async (start: option<dict<JSON.t>>, acc: int): int => {
227
+ let out = await Ddb.DocumentClient.ScanCommand.send(
228
+ Ddb.DocumentClient.ScanCommand.make({
229
+ tableName: table,
230
+ select: #COUNT,
231
+ exclusiveStartKey: ?start,
232
+ }),
233
+ )
234
+ let acc = acc + out.count->Option.getOr(0)
235
+ switch out.lastEvaluatedKey {
236
+ | Some(k) => await loop(Some(k), acc)
237
+ | None => acc
238
+ }
239
+ }
240
+ await loop(None, 0)
241
+ }
242
+
243
+ let countBucket = async (bucket: string): int => {
244
+ let rec loop = async (keyMarker, versionMarker, acc): int => {
245
+ let out = await S3.ListObjectVersionsCommand.send(
246
+ S3.ListObjectVersionsCommand.make({
247
+ bucket,
248
+ keyMarker: ?keyMarker,
249
+ versionIdMarker: ?versionMarker,
250
+ }),
251
+ )
252
+ let n =
253
+ out.versions->Option.getOr([])->Array.length +
254
+ out.deleteMarkers->Option.getOr([])->Array.length
255
+ if out.isTruncated->Option.getOr(false) {
256
+ await loop(out.nextKeyMarker, out.nextVersionIdMarker, acc + n)
257
+ } else {
258
+ acc + n
259
+ }
260
+ }
261
+ await loop(None, None, 0)
262
+ }
263
+
264
+ // ── Wiping ──────────────────────────────────────────────────────────────────
265
+
266
+ // BatchWrite takes ≤ 25 requests and may return some UnprocessedItems under
267
+ // throttling; resend those. Capped so a persistently-failing table surfaces as
268
+ // an error rather than looping forever.
269
+ let rec sendBatch = async (
270
+ table: string,
271
+ requests: array<Ddb.DocumentClient.BatchWriteCommand.writeRequest>,
272
+ ~attempt: int,
273
+ ): unit =>
274
+ if requests->Array.length > 0 {
275
+ if attempt > 8 {
276
+ throw(Seed.Failed(`table ${table}: ${(requests->Array.length)->Int.toString} item(s) still unprocessed after 8 retries.`))
277
+ }
278
+ let out = await Ddb.DocumentClient.BatchWriteCommand.send(
279
+ Ddb.DocumentClient.BatchWriteCommand.make({
280
+ requestItems: Dict.fromArray([(table, requests)]),
281
+ }),
282
+ )
283
+ let unprocessed =
284
+ out.unprocessedItems->Option.flatMap(d => d->Dict.get(table))->Option.getOr([])
285
+ if unprocessed->Array.length > 0 {
286
+ await sendBatch(table, unprocessed, ~attempt=attempt + 1)
287
+ }
288
+ }
289
+
290
+ let truncateTable = async (table: string): unit => {
291
+ let desc = await Ddb.DynamoDb.DescribeTableCommand.send(
292
+ Ddb.DynamoDb.DescribeTableCommand.make({tableName: table}),
293
+ )
294
+ let keyAttrs =
295
+ desc.table->Option.flatMap(t => t.keySchema)->Option.getOr([])->Array.map(k => k.attributeName)
296
+ if keyAttrs->Array.length == 0 {
297
+ throw(Seed.Failed(`could not read a key schema for table ${table}.`))
298
+ }
299
+ // Project only the key attributes (aliased to dodge reserved words) so the
300
+ // scan carries just what a delete needs.
301
+ let names = keyAttrs->Array.mapWithIndex((name, i) => (`#k${i->Int.toString}`, name))
302
+ let projection = names->Array.map(((alias, _)) => alias)->Array.join(", ")
303
+ let rec loop = async (start: option<dict<JSON.t>>): unit => {
304
+ let out = await Ddb.DocumentClient.ScanCommand.send(
305
+ Ddb.DocumentClient.ScanCommand.make({
306
+ tableName: table,
307
+ exclusiveStartKey: ?start,
308
+ projectionExpression: projection,
309
+ expressionAttributeNames: names->Dict.fromArray,
310
+ }),
311
+ )
312
+ let requests =
313
+ out.items
314
+ ->Option.getOr([])
315
+ ->Array.map(item => {
316
+ let key =
317
+ keyAttrs->Array.filterMap(attr => item->field(attr)->Option.map(v => (attr, v)))->Dict.fromArray
318
+ ({deleteRequest: {key: key}}: Ddb.DocumentClient.BatchWriteCommand.writeRequest)
319
+ })
320
+ let batches = chunk(requests, Ddb.DocumentClient.BatchWriteCommand.maxBatchSize)
321
+ for i in 0 to batches->Array.length - 1 {
322
+ switch batches->Array.get(i) {
323
+ | Some(b) => await sendBatch(table, b, ~attempt=1)
324
+ | None => ()
325
+ }
326
+ }
327
+ switch out.lastEvaluatedKey {
328
+ | Some(k) => await loop(Some(k))
329
+ | None => ()
330
+ }
331
+ }
332
+ await loop(None)
333
+ }
334
+
335
+ // One ListObjectVersions page returns ≤ 1000 entries (versions + delete
336
+ // markers), and DeleteObjects takes ≤ 1000, so one list page maps to one delete.
337
+ let emptyBucket = async (bucket: string): unit => {
338
+ let rec loop = async (keyMarker, versionMarker): unit => {
339
+ let out = await S3.ListObjectVersionsCommand.send(
340
+ S3.ListObjectVersionsCommand.make({
341
+ bucket,
342
+ keyMarker: ?keyMarker,
343
+ versionIdMarker: ?versionMarker,
344
+ }),
345
+ )
346
+ let ids =
347
+ Array.concat(out.versions->Option.getOr([]), out.deleteMarkers->Option.getOr([]))->Array.map(v => (
348
+ {key: v.key, versionId: v.versionId}: S3.DeleteObjectsCommand.objectIdentifier
349
+ ))
350
+ if ids->Array.length > 0 {
351
+ let res = await S3.DeleteObjectsCommand.send(
352
+ S3.DeleteObjectsCommand.make({
353
+ bucket,
354
+ delete: {objects: ids, quiet: true},
355
+ }),
356
+ )
357
+ switch res.errors {
358
+ | Some(errs) if errs->Array.length > 0 =>
359
+ throw(
360
+ Seed.Failed(
361
+ `failed to delete ${(errs->Array.length)->Int.toString} object(s) from ${bucket}: ${errs
362
+ ->Array.get(0)
363
+ ->Option.flatMap(e => e.message)
364
+ ->Option.getOr("unknown")}`,
365
+ ),
366
+ )
367
+ | _ => ()
368
+ }
369
+ }
370
+ if out.isTruncated->Option.getOr(false) {
371
+ await loop(out.nextKeyMarker, out.nextVersionIdMarker)
372
+ }
373
+ }
374
+ await loop(None, None)
375
+ }
376
+
377
+ // ── Targets & scope ───────────────────────────────────────────────────────────
378
+
379
+ // A deployment is several Pulumi projects sharing a stack name — the platform
380
+ // project plus one per domain plugin — each a separate `reventless:platform`. A
381
+ // target names one such project by the directory its `Pulumi.<stack>.yaml` lives
382
+ // in (relative to the seed cwd), plus a menu label and whether it holds domain
383
+ // data or platform bookkeeping. The caller declares them; the reset never guesses
384
+ // the topology.
385
+ type group =
386
+ | Domain
387
+ | Platform
388
+
389
+ type target = {
390
+ projectDir: string,
391
+ label: string,
392
+ group: group,
393
+ }
394
+
395
+ // A target resolved to its discovered, counted stores, ready to report and wipe.
396
+ type resolved = {
397
+ target: target,
398
+ platform: string,
399
+ tables: array<string>,
400
+ tableCounts: array<int>,
401
+ bucketCounts: array<(string, int)>,
402
+ }
403
+
404
+ // Picks which targets to wipe. `domain` (every domain plugin) leads and is the
405
+ // default; each single domain plugin follows so one plugin's data can be wiped
406
+ // alone; then `platform`; then `everything`. `SEED_RESET_SCOPE` (domain |
407
+ // platform | everything | a plugin label) selects non-interactively.
408
+ let chooseScope = async (~targets: array<target>): array<target> => {
409
+ let domain = targets->Array.filter(t => t.group == Domain)
410
+ let platform = targets->Array.filter(t => t.group == Platform)
411
+ let labelsOf = ts => ts->Array.map(t => t.label)->Array.join(", ")
412
+ switch Seed.Prompt.envValue("SEED_RESET_SCOPE")->Option.map(String.toLowerCase) {
413
+ | Some("domain") => domain
414
+ | Some("platform") => platform
415
+ | Some("all") | Some("everything") | Some("both") => targets
416
+ | Some(other) =>
417
+ switch targets->Array.find(t => t.label->String.toLowerCase == other) {
418
+ | Some(t) => [t]
419
+ | None =>
420
+ throw(
421
+ Seed.Failed(
422
+ `SEED_RESET_SCOPE="${other}" is not a scope — use domain, platform, everything, or a plugin label (${labelsOf(
423
+ domain,
424
+ )}).`,
425
+ ),
426
+ )
427
+ }
428
+ | None =>
429
+ let options = []
430
+ if domain->Array.length > 0 {
431
+ options->Array.push((`domain — ${labelsOf(domain)}`, domain))
432
+ // Single-plugin entries only when there is more than one, else they just
433
+ // duplicate the `domain` entry.
434
+ if domain->Array.length > 1 {
435
+ domain->Array.forEach(t => options->Array.push((t.label, [t])))
436
+ }
437
+ }
438
+ if platform->Array.length > 0 {
439
+ options->Array.push((`platform — ${labelsOf(platform)}`, platform))
440
+ }
441
+ if domain->Array.length > 0 && platform->Array.length > 0 {
442
+ options->Array.push((`everything — ${labelsOf(targets)}`, targets))
443
+ }
444
+ await Seed.Prompt.select(~title="Reset scope:", ~options)
445
+ }
446
+ }
447
+
448
+ // ── Orchestration ─────────────────────────────────────────────────────────────
449
+
450
+ // Gate 2 per target: its own `Pulumi.<stack>.yaml` must declare wipeable, and
451
+ // must resolve a region. Reasons are distinct (config unreadable vs opt-in
452
+ // missing vs region missing), each saying what to fix. Returns the target's
453
+ // region so the caller can insist every selected target shares one.
454
+ let gateTarget = (~target: target, ~backend, ~stack): string => {
455
+ let cfg = switch readStackConfig(~projectDir=target.projectDir, ~backend, ~stack) {
456
+ | Ok(c) => c
457
+ | Error(message) => throw(Seed.Failed(message))
458
+ }
459
+ switch configValue(cfg, "reventless:wipeable")->Option.map(v => v->String.trim->String.toLowerCase) {
460
+ | Some("true") => ()
461
+ | _ =>
462
+ throw(
463
+ Seed.Failed(
464
+ `${target.label}: stack "${stack}" does not declare \`reventless:wipeable: "true"\` in its Pulumi.${stack}.yaml — refusing. Add that line only on disposable dev stacks (see the alpha example configs).`,
465
+ ),
466
+ )
467
+ }
468
+ switch Seed.Prompt.envValue("AWS_REGION") {
469
+ | Some(r) if r != "" => r
470
+ | _ =>
471
+ switch configValue(cfg, "aws:region") {
472
+ | Some(r) if r != "" => r
473
+ | _ =>
474
+ throw(
475
+ Seed.Failed(
476
+ `${target.label}: could not resolve the AWS region — set AWS_REGION or \`aws:region\` in the stack config.`,
477
+ ),
478
+ )
479
+ }
480
+ }
481
+ }
482
+
483
+ let reportAll = (resolvedList: array<resolved>, ~stack, ~region): int => {
484
+ Seed.Runner.heading(`Reset target: stack "${stack}" in ${region}`)
485
+ let total = ref(0)
486
+ resolvedList->Array.forEach(r => {
487
+ Console.log("")
488
+ Console.log(` ${r.target.label} (${r.platform})`)
489
+ Console.log(" DynamoDB tables:")
490
+ r.tables->Array.forEachWithIndex((t, i) => {
491
+ let c = r.tableCounts->Array.get(i)->Option.getOr(0)
492
+ total := total.contents + c
493
+ Console.log(` ${c->Int.toString->String.padStart(8, " ")} ${t}`)
494
+ })
495
+ if r.tables->Array.length == 0 {
496
+ Console.log(" (none)")
497
+ }
498
+ Console.log(" S3 buckets:")
499
+ r.bucketCounts->Array.forEach(((b, c)) => {
500
+ total := total.contents + c
501
+ Console.log(` ${c->Int.toString->String.padStart(8, " ")} ${b}`)
502
+ })
503
+ if r.bucketCounts->Array.length == 0 {
504
+ Console.log(" (none)")
505
+ }
506
+ })
507
+ total.contents
508
+ }
509
+
510
+ /**
511
+ * Reset a deployed stack across one or more of its Pulumi projects. Resolves the
512
+ * shared stack name, refuses unless it is on the name allowlist, lets the
513
+ * operator pick a scope (domain / a single plugin / platform / everything), then
514
+ * for each chosen project: refuses unless it declares itself wipeable, discovers
515
+ * its stores by `platform`+`environment` tag, and reports what it would empty.
516
+ * Only on a matching `REVENTLESS_WIPE_CONFIRM` plus a re-typed stack name does it
517
+ * truncate every table and empty every bucket, then verify empty.
518
+ *
519
+ * `targets` are the deployment's projects (the caller knows the topology);
520
+ * `stack` and `backend` mirror `ReventlessSeedAws.connect`.
521
+ */
522
+ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
523
+ let go = async () => {
524
+ try {
525
+ if targets->Array.length == 0 {
526
+ throw(Seed.Failed("no targets were declared — nothing to reset."))
527
+ }
528
+ let backend = switch Seed.Prompt.envValue("SEED_PULUMI_BACKEND") {
529
+ | Some(url) => Some(url)
530
+ | None => backend
531
+ }
532
+ // Any target's dir lists the shared stack; prefer the platform project.
533
+ let baseTarget =
534
+ targets->Array.find(t => t.group == Platform)->Option.getOr(targets->Array.getUnsafe(0))
535
+ let stack = await ReventlessSeedAws.resolveStack(
536
+ ~projectDir=baseTarget.projectDir,
537
+ ~backend,
538
+ ~stack,
539
+ )
540
+
541
+ if !(nameAllowlist->RegExp.test(stack)) {
542
+ throw(
543
+ Seed.Failed(
544
+ `stack "${stack}" is not on the wipe name-allowlist (alpha, dev, pr-*) — refusing.`,
545
+ ),
546
+ )
547
+ }
548
+
549
+ let selected = await chooseScope(~targets)
550
+
551
+ // Gate every selected target and collect its region; all must agree, since
552
+ // the DynamoDB/S3 clients read one region from the environment.
553
+ let regions = selected->Array.map(t => gateTarget(~target=t, ~backend, ~stack))
554
+ let region = regions->Array.getUnsafe(0)
555
+ if regions->Array.some(r => r != region) {
556
+ throw(
557
+ Seed.Failed(
558
+ `selected targets span more than one region (${regions->Array.join(
559
+ ", ",
560
+ )}) — reset them one region at a time.`,
561
+ ),
562
+ )
563
+ }
564
+ processEnv->Dict.set("AWS_REGION", region)
565
+
566
+ // Discover + count each target, scoped to its own project via the platform
567
+ // tag so a same-named stack from another project is never touched.
568
+ let resolvedList = []
569
+ for i in 0 to selected->Array.length - 1 {
570
+ switch selected->Array.get(i) {
571
+ | Some(target) =>
572
+ let platform = projectName(~projectDir=target.projectDir)
573
+ let (tables, buckets) = await discover(~region, ~stack, ~platform)
574
+ let tables = tables->Array.toSorted(String.compare)
575
+ let buckets = buckets->Array.toSorted(String.compare)
576
+ let tableCounts = []
577
+ for j in 0 to tables->Array.length - 1 {
578
+ switch tables->Array.get(j) {
579
+ | Some(t) => tableCounts->Array.push(await countTable(t))
580
+ | None => ()
581
+ }
582
+ }
583
+ let bucketCounts = []
584
+ for j in 0 to buckets->Array.length - 1 {
585
+ switch buckets->Array.get(j) {
586
+ | Some(b) => bucketCounts->Array.push((b, await countBucket(b)))
587
+ | None => ()
588
+ }
589
+ }
590
+ resolvedList->Array.push({target, platform, tables, tableCounts, bucketCounts})
591
+ | None => ()
592
+ }
593
+ }
594
+
595
+ let total = reportAll(resolvedList, ~stack, ~region)
596
+ if total == 0 {
597
+ Seed.Prompt.close()
598
+ Console.log("")
599
+ Console.log(`Nothing to reset — the selected scope already reads empty in ${region}.`)
600
+ exit(0)
601
+ }
602
+
603
+ // Confirmation. Interactively, re-typing the exact stack name IS the
604
+ // confirmation — the validation above (allowlist + wipeable + tag checks)
605
+ // plus a deliberate keystroke is enough, so no env var is needed. Without a
606
+ // TTY (CI/scripts) there is nothing to type into, so
607
+ // REVENTLESS_WIPE_CONFIRM=<stack> is the equivalent opt-in. Dry-run is the
608
+ // default either way.
609
+ let interactive = stdin->isTTY->Option.getOr(false)
610
+ let confirmed = if interactive {
611
+ let typed = await Seed.Prompt.ask(
612
+ `About to permanently empty ${total->Int.toString} item(s)/object(s) across the selected scope of "${stack}". ` ++
613
+ `Type the stack name to confirm, or press Enter to keep this a dry run: `,
614
+ )
615
+ typed->String.trim == stack
616
+ } else {
617
+ Seed.Prompt.envValue("REVENTLESS_WIPE_CONFIRM") == Some(stack)
618
+ }
619
+ Seed.Prompt.close()
620
+ if !confirmed {
621
+ Console.log("")
622
+ Console.log(
623
+ interactive
624
+ ? "Dry run — nothing was deleted."
625
+ : `Dry run — nothing was deleted. Set REVENTLESS_WIPE_CONFIRM=${stack} to empty this scope non-interactively.`,
626
+ )
627
+ exit(0)
628
+ }
629
+
630
+ for i in 0 to resolvedList->Array.length - 1 {
631
+ switch resolvedList->Array.get(i) {
632
+ | Some(r) =>
633
+ Seed.Runner.heading(`Emptying ${r.target.label} …`)
634
+ for j in 0 to r.tables->Array.length - 1 {
635
+ switch r.tables->Array.get(j) {
636
+ | Some(t) =>
637
+ await truncateTable(t)
638
+ Console.log(` truncated ${t}`)
639
+ | None => ()
640
+ }
641
+ }
642
+ for j in 0 to r.bucketCounts->Array.length - 1 {
643
+ switch r.bucketCounts->Array.get(j) {
644
+ | Some((b, _)) =>
645
+ await emptyBucket(b)
646
+ Console.log(` emptied ${b}`)
647
+ | None => ()
648
+ }
649
+ }
650
+ | None => ()
651
+ }
652
+ }
653
+
654
+ // Prove empty — the inverse of the post-seed verify.
655
+ let remaining = ref(0)
656
+ for i in 0 to resolvedList->Array.length - 1 {
657
+ switch resolvedList->Array.get(i) {
658
+ | Some(r) =>
659
+ for j in 0 to r.tables->Array.length - 1 {
660
+ switch r.tables->Array.get(j) {
661
+ | Some(t) => remaining := remaining.contents + (await countTable(t))
662
+ | None => ()
663
+ }
664
+ }
665
+ for j in 0 to r.bucketCounts->Array.length - 1 {
666
+ switch r.bucketCounts->Array.get(j) {
667
+ | Some((b, _)) => remaining := remaining.contents + (await countBucket(b))
668
+ | None => ()
669
+ }
670
+ }
671
+ | None => ()
672
+ }
673
+ }
674
+ if remaining.contents != 0 {
675
+ throw(
676
+ Seed.Failed(
677
+ `${remaining.contents->Int.toString} item(s)/object(s) remain after the wipe — re-run to finish.`,
678
+ ),
679
+ )
680
+ }
681
+
682
+ Console.log("")
683
+ Console.log(`Reset complete — the selected scope of "${stack}" reads empty and is re-seedable.`)
684
+ exit(0)
685
+ } catch {
686
+ | Seed.Failed(message) =>
687
+ Seed.Prompt.close()
688
+ Console.error("")
689
+ Console.error(`Reset aborted — ${message}`)
690
+ exit(1)
691
+ | exn =>
692
+ Seed.Prompt.close()
693
+ Console.error("")
694
+ Console.error("Reset aborted with an unexpected error:")
695
+ Console.error(exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"))
696
+ exit(1)
697
+ }
698
+ }
699
+ go()->ignore
700
+ }