@reventlessdev/reventless-seed-aws 1.0.0-alpha.10
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.
- package/CHANGELOG.md +69 -0
- package/LICENSE +202 -0
- package/lib/bs/.compiler.log +2 -0
- package/lib/bs/.sourcedirs.json +1 -0
- package/lib/bs/build.ninja +0 -0
- package/lib/bs/compiler-info.json +8 -0
- package/lib/bs/src/ReventlessSeedAws.ast +0 -0
- package/lib/bs/src/ReventlessSeedAws.cmi +0 -0
- package/lib/bs/src/ReventlessSeedAws.cmj +0 -0
- package/lib/bs/src/ReventlessSeedAws.cmt +0 -0
- package/lib/bs/src/ReventlessSeedAws.res +328 -0
- package/lib/bs/src/ReventlessSeedAws.res.mjs +309 -0
- package/lib/bs/src/ReventlessSeedAws_Reconcile.ast +0 -0
- package/lib/bs/src/ReventlessSeedAws_Reconcile.cmi +0 -0
- package/lib/bs/src/ReventlessSeedAws_Reconcile.cmj +0 -0
- package/lib/bs/src/ReventlessSeedAws_Reconcile.cmt +0 -0
- package/lib/bs/src/ReventlessSeedAws_Reconcile.res +389 -0
- package/lib/bs/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
- package/lib/bs/src/ReventlessSeedAws_Reset.ast +0 -0
- package/lib/bs/src/ReventlessSeedAws_Reset.cmi +0 -0
- package/lib/bs/src/ReventlessSeedAws_Reset.cmj +0 -0
- package/lib/bs/src/ReventlessSeedAws_Reset.cmt +0 -0
- package/lib/bs/src/ReventlessSeedAws_Reset.res +948 -0
- package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +861 -0
- package/lib/bs/tests/EndpointResolutionTest.ast +0 -0
- package/lib/bs/tests/EndpointResolutionTest.cmi +0 -0
- package/lib/bs/tests/EndpointResolutionTest.cmj +0 -0
- package/lib/bs/tests/EndpointResolutionTest.cmt +0 -0
- package/lib/bs/tests/EndpointResolutionTest.res +83 -0
- package/lib/bs/tests/EndpointResolutionTest.res.mjs +115 -0
- package/lib/bs/tests/ObjectStoreReconcileTest.ast +0 -0
- package/lib/bs/tests/ObjectStoreReconcileTest.cmi +0 -0
- package/lib/bs/tests/ObjectStoreReconcileTest.cmj +0 -0
- package/lib/bs/tests/ObjectStoreReconcileTest.cmt +0 -0
- package/lib/bs/tests/ObjectStoreReconcileTest.res +84 -0
- package/lib/bs/tests/ObjectStoreReconcileTest.res.mjs +102 -0
- package/lib/bs/tests/ObjectStoreResetTest.ast +0 -0
- package/lib/bs/tests/ObjectStoreResetTest.cmi +0 -0
- package/lib/bs/tests/ObjectStoreResetTest.cmj +0 -0
- package/lib/bs/tests/ObjectStoreResetTest.cmt +0 -0
- package/lib/bs/tests/ObjectStoreResetTest.res +170 -0
- package/lib/bs/tests/ObjectStoreResetTest.res.mjs +211 -0
- package/lib/ocaml/.compiler.log +2 -0
- package/lib/ocaml/EndpointResolutionTest.ast +0 -0
- package/lib/ocaml/EndpointResolutionTest.cmi +0 -0
- package/lib/ocaml/EndpointResolutionTest.cmj +0 -0
- package/lib/ocaml/EndpointResolutionTest.cmt +0 -0
- package/lib/ocaml/EndpointResolutionTest.res +83 -0
- package/lib/ocaml/ObjectStoreReconcileTest.ast +0 -0
- package/lib/ocaml/ObjectStoreReconcileTest.cmi +0 -0
- package/lib/ocaml/ObjectStoreReconcileTest.cmj +0 -0
- package/lib/ocaml/ObjectStoreReconcileTest.cmt +0 -0
- package/lib/ocaml/ObjectStoreReconcileTest.res +84 -0
- package/lib/ocaml/ObjectStoreResetTest.ast +0 -0
- package/lib/ocaml/ObjectStoreResetTest.cmi +0 -0
- package/lib/ocaml/ObjectStoreResetTest.cmj +0 -0
- package/lib/ocaml/ObjectStoreResetTest.cmt +0 -0
- package/lib/ocaml/ObjectStoreResetTest.res +170 -0
- package/lib/ocaml/ReventlessSeedAws.ast +0 -0
- package/lib/ocaml/ReventlessSeedAws.cmi +0 -0
- package/lib/ocaml/ReventlessSeedAws.cmj +0 -0
- package/lib/ocaml/ReventlessSeedAws.cmt +0 -0
- package/lib/ocaml/ReventlessSeedAws.res +328 -0
- package/lib/ocaml/ReventlessSeedAws_Reconcile.ast +0 -0
- package/lib/ocaml/ReventlessSeedAws_Reconcile.cmi +0 -0
- package/lib/ocaml/ReventlessSeedAws_Reconcile.cmj +0 -0
- package/lib/ocaml/ReventlessSeedAws_Reconcile.cmt +0 -0
- package/lib/ocaml/ReventlessSeedAws_Reconcile.res +389 -0
- package/lib/ocaml/ReventlessSeedAws_Reset.ast +0 -0
- package/lib/ocaml/ReventlessSeedAws_Reset.cmi +0 -0
- package/lib/ocaml/ReventlessSeedAws_Reset.cmj +0 -0
- package/lib/ocaml/ReventlessSeedAws_Reset.cmt +0 -0
- package/lib/ocaml/ReventlessSeedAws_Reset.res +948 -0
- package/package.json +32 -0
- package/rescript.json +29 -0
- package/src/ReventlessSeedAws.res +328 -0
- package/src/ReventlessSeedAws.res.mjs +309 -0
- package/src/ReventlessSeedAws_Reconcile.res +389 -0
- package/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
- package/src/ReventlessSeedAws_Reset.res +948 -0
- package/src/ReventlessSeedAws_Reset.res.mjs +861 -0
- package/tests/EndpointResolutionTest.res +83 -0
- package/tests/EndpointResolutionTest.res.mjs +115 -0
- package/tests/ObjectStoreReconcileTest.res +84 -0
- package/tests/ObjectStoreReconcileTest.res.mjs +102 -0
- package/tests/ObjectStoreResetTest.res +170 -0
- package/tests/ObjectStoreResetTest.res.mjs +211 -0
|
@@ -0,0 +1,948 @@
|
|
|
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
|
+
|
|
52
|
+
|
|
53
|
+
// The Pulumi project name, which the framework stamps as `reventless:platform` on
|
|
54
|
+
// every resource (`Plugin.res`: `platformName = getProjectName()`). Discovery
|
|
55
|
+
// MUST scope on this as well as the stack: `reventless:environment` carries only
|
|
56
|
+
// the stack *name*, so two different Pulumi projects deployed with the same stack
|
|
57
|
+
// name (e.g. `alpha`) in one account/region share that tag. Filtering by platform
|
|
58
|
+
// too keeps a wipe inside the one project the operator is standing in. Read from
|
|
59
|
+
// the target project's Pulumi.yaml — the same dir the pulumi subprocess uses.
|
|
60
|
+
let projectName = (~projectDir: string): string => {
|
|
61
|
+
let path = `${projectDir}/Pulumi.yaml`
|
|
62
|
+
let raw = try NodeFs.readFileSync(path) catch {
|
|
63
|
+
| _ => throw(Seed.Failed(`could not read ${path} to scope the wipe to this Pulumi project.`))
|
|
64
|
+
}
|
|
65
|
+
switch raw
|
|
66
|
+
->String.split("\n")
|
|
67
|
+
->Array.find(line => line->String.trim->String.startsWith("name:")) {
|
|
68
|
+
| Some(line) =>
|
|
69
|
+
let trimmed = line->String.trim
|
|
70
|
+
trimmed
|
|
71
|
+
->String.slice(~start=String.length("name:"), ~end=String.length(trimmed))
|
|
72
|
+
->String.trim
|
|
73
|
+
->String.replaceRegExp(%re("/^[\"']|[\"']$/g"), "")
|
|
74
|
+
| None => throw(Seed.Failed(`could not find a \`name:\` field in ${path}.`))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A read of a JSON object field — items come back from the document client as
|
|
79
|
+
// already-unmarshalled JSON values, so a delete key is just the key attributes
|
|
80
|
+
// picked back out.
|
|
81
|
+
let field = (json: JSON.t, key: string): option<JSON.t> =>
|
|
82
|
+
switch json {
|
|
83
|
+
| Object(obj) => obj->Dict.get(key)
|
|
84
|
+
| _ => None
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let asString = (json: JSON.t): option<string> =>
|
|
88
|
+
switch json {
|
|
89
|
+
| String(s) => Some(s)
|
|
90
|
+
| _ => None
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let chunk = (arr: array<'a>, size: int): array<array<'a>> => {
|
|
94
|
+
let out = []
|
|
95
|
+
let i = ref(0)
|
|
96
|
+
while i.contents < arr->Array.length {
|
|
97
|
+
out->Array.push(arr->Array.slice(~start=i.contents, ~end=i.contents + size))
|
|
98
|
+
i := i.contents + size
|
|
99
|
+
}
|
|
100
|
+
out
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Gates ───────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
// Fail-closed name allowlist. A denylist ("everything except prod") fails open
|
|
106
|
+
// the day a new prod-like stack is added and forgotten; this fails closed.
|
|
107
|
+
let nameAllowlist = %re("/^(alpha|dev|pr-.+)$/")
|
|
108
|
+
|
|
109
|
+
// The stack's fully-resolved Pulumi config, read once. Reading it whole (rather
|
|
110
|
+
// than `pulumi config get <key>`, which exits non-zero for BOTH a missing key
|
|
111
|
+
// and a missing/unreachable stack) lets a refusal say *which* it was — an
|
|
112
|
+
// unreadable config is a pulumi/backend problem; an absent flag is a missing
|
|
113
|
+
// opt-in. `pulumi config --json` maps each key to `{value, secret}`.
|
|
114
|
+
let readStackConfig = (~projectDir, ~backend, ~stack): result<dict<JSON.t>, string> =>
|
|
115
|
+
switch (
|
|
116
|
+
try Some(ReventlessSeedAws.pulumi(~projectDir, ~backend, ["config", "--json", "--stack", stack])) catch {
|
|
117
|
+
| _ => None
|
|
118
|
+
}
|
|
119
|
+
) {
|
|
120
|
+
| None =>
|
|
121
|
+
Error(
|
|
122
|
+
`could not read the Pulumi config for stack "${stack}" — is pulumi logged in to the right backend, and is the stack deployed?`,
|
|
123
|
+
)
|
|
124
|
+
| Some(raw) =>
|
|
125
|
+
switch (
|
|
126
|
+
try Some(JSON.parseOrThrow(raw)) catch {
|
|
127
|
+
| _ => None
|
|
128
|
+
}
|
|
129
|
+
) {
|
|
130
|
+
| Some(Object(obj)) => Ok(obj)
|
|
131
|
+
| _ => Error(`could not parse \`pulumi config --json\` output for stack "${stack}".`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let configValue = (cfg: dict<JSON.t>, key: string): option<string> =>
|
|
136
|
+
cfg->Dict.get(key)->Option.flatMap(v => v->field("value"))->Option.flatMap(asString)
|
|
137
|
+
|
|
138
|
+
// ── Discovery ─────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
type resource =
|
|
141
|
+
| Table(string)
|
|
142
|
+
| Bucket(string)
|
|
143
|
+
| Other
|
|
144
|
+
|
|
145
|
+
// arn:aws:dynamodb:<region>:<acct>:table/<name> | arn:aws:s3:::<bucket>
|
|
146
|
+
let classify = (arn: string): resource =>
|
|
147
|
+
if arn->String.startsWith("arn:aws:dynamodb:") {
|
|
148
|
+
switch arn->String.split(":table/") {
|
|
149
|
+
| [_, name] => Table(name)
|
|
150
|
+
| _ => Other
|
|
151
|
+
}
|
|
152
|
+
} else if arn->String.startsWith("arn:aws:s3:::") {
|
|
153
|
+
Bucket(arn->String.slice(~start=String.length("arn:aws:s3:::"), ~end=String.length(arn)))
|
|
154
|
+
} else {
|
|
155
|
+
Other
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let tagValue = (tags: array<Rgt.GetResourcesCommand.tag>, key: string): option<string> =>
|
|
159
|
+
tags->Array.findMap(t => t.key == key ? Some(t.value) : None)
|
|
160
|
+
|
|
161
|
+
// Discovery is scoped by BOTH tags (platform AND environment) — the Tagging API
|
|
162
|
+
// ANDs multiple TagFilters — so it only ever sees the target project's own stack,
|
|
163
|
+
// never a same-named stack from another project. Each returned resource is then
|
|
164
|
+
// re-checked to carry both tags (a mismatch aborts the whole run rather than
|
|
165
|
+
// being skipped) as a second, per-resource guard.
|
|
166
|
+
let discover = async (~region, ~stack, ~platform): (array<string>, array<string>) => {
|
|
167
|
+
let client = Rgt.client(~region, ())
|
|
168
|
+
let tables = []
|
|
169
|
+
let buckets = []
|
|
170
|
+
let rec loop = async (token: option<string>): unit => {
|
|
171
|
+
let out = await Rgt.GetResourcesCommand.send(
|
|
172
|
+
client,
|
|
173
|
+
Rgt.GetResourcesCommand.make({
|
|
174
|
+
tagFilters: [
|
|
175
|
+
{key: "reventless:platform", values: [platform]},
|
|
176
|
+
{key: "reventless:environment", values: [stack]},
|
|
177
|
+
],
|
|
178
|
+
resourceTypeFilters: ["dynamodb:table", "s3"],
|
|
179
|
+
resourcesPerPage: 100,
|
|
180
|
+
paginationToken: ?token,
|
|
181
|
+
}),
|
|
182
|
+
)
|
|
183
|
+
out.resourceTagMappingList
|
|
184
|
+
->Option.getOr([])
|
|
185
|
+
->Array.forEach(m => {
|
|
186
|
+
let tags = m.tags->Option.getOr([])
|
|
187
|
+
let assertTag = (key, expected) =>
|
|
188
|
+
switch tagValue(tags, key) {
|
|
189
|
+
| Some(v) if v == expected => ()
|
|
190
|
+
| other =>
|
|
191
|
+
throw(
|
|
192
|
+
Seed.Failed(
|
|
193
|
+
`refusing: discovered resource ${m.resourceARN} carries ${key}=${other->Option.getOr(
|
|
194
|
+
"<none>",
|
|
195
|
+
)}, not "${expected}".`,
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
assertTag("reventless:platform", platform)
|
|
200
|
+
assertTag("reventless:environment", stack)
|
|
201
|
+
switch classify(m.resourceARN) {
|
|
202
|
+
| Table(name) => tables->Array.push(name)
|
|
203
|
+
| Bucket(name) => buckets->Array.push(name)
|
|
204
|
+
| Other => ()
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
switch out.paginationToken {
|
|
208
|
+
| Some(t) if t != "" => await loop(Some(t))
|
|
209
|
+
| _ => ()
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
await loop(None)
|
|
213
|
+
(tables, buckets)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Declared object stores ────────────────────────────────────────────────────
|
|
217
|
+
//
|
|
218
|
+
// A store declared by a `@storageRef` field is provisioned by the PLATFORM
|
|
219
|
+
// deploy — the serving CDN and the presign services live there, and a
|
|
220
|
+
// shared-layout bucket holds several plugins' stores, so no plugin stack can own
|
|
221
|
+
// it. Its bucket therefore carries the platform project's `reventless:platform`
|
|
222
|
+
// tag, which makes it invisible to a domain-scoped tag discovery and, worse,
|
|
223
|
+
// wholesale-emptiable by a platform-scoped one.
|
|
224
|
+
//
|
|
225
|
+
// Tag discovery cannot fix that: the tag says which project *built* the bucket,
|
|
226
|
+
// and the answer needed here is which plugin's data is *in* it — at prefix
|
|
227
|
+
// granularity, since one bucket holds several plugins' stores. The platform
|
|
228
|
+
// already publishes exactly that mapping as its `objectStores` stack output, so
|
|
229
|
+
// the reset reads ownership from the declaration rather than inferring it.
|
|
230
|
+
|
|
231
|
+
/** One provisioned store: which plugin owns it, and where its objects live. */
|
|
232
|
+
type objectStore = {
|
|
233
|
+
qualified: string,
|
|
234
|
+
plugin: string,
|
|
235
|
+
store: string,
|
|
236
|
+
bucketName: string,
|
|
237
|
+
keyPrefix: string,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A store's key is `{plugin}.{store}`, split at the FIRST dot: a registered
|
|
241
|
+
// plugin name cannot contain one, a store name could in principle.
|
|
242
|
+
let splitQualified = (key: string): option<(string, string)> =>
|
|
243
|
+
key
|
|
244
|
+
->String.indexOfOpt(".")
|
|
245
|
+
->Option.map(i => (
|
|
246
|
+
key->String.slice(~start=0, ~end=i),
|
|
247
|
+
key->String.slice(~start=i + 1, ~end=key->String.length),
|
|
248
|
+
))
|
|
249
|
+
|
|
250
|
+
/** Parse the platform stack's `objectStores` output. Absent is normal — a
|
|
251
|
+
platform may declare no stores — so only a malformed entry is an error, and
|
|
252
|
+
it is an error rather than a skip because a store the reset cannot read is a
|
|
253
|
+
store it would silently leave behind. */
|
|
254
|
+
let parseObjectStores = (json: option<JSON.t>): result<array<objectStore>, string> =>
|
|
255
|
+
switch json {
|
|
256
|
+
| None => Ok([])
|
|
257
|
+
| Some(Object(entries)) =>
|
|
258
|
+
entries
|
|
259
|
+
->Dict.toArray
|
|
260
|
+
->Array.reduce(Ok([]), (acc, (qualified, entry)) =>
|
|
261
|
+
switch acc {
|
|
262
|
+
| Error(_) as failed => failed
|
|
263
|
+
| Ok(stores) =>
|
|
264
|
+
switch (
|
|
265
|
+
splitQualified(qualified),
|
|
266
|
+
entry->field("bucketName")->Option.flatMap(asString),
|
|
267
|
+
entry->field("keyPrefix")->Option.flatMap(asString),
|
|
268
|
+
) {
|
|
269
|
+
| (Some((plugin, store)), Some(bucketName), Some(keyPrefix)) =>
|
|
270
|
+
Ok(Array.concat(stores, [{qualified, plugin, store, bucketName, keyPrefix}]))
|
|
271
|
+
| _ =>
|
|
272
|
+
Error(
|
|
273
|
+
`the platform stack's \`objectStores\` output has a malformed entry for "${qualified}" — ` ++
|
|
274
|
+
`expected a {plugin}.{store} key carrying bucketName and keyPrefix.`,
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
| Some(_) => Error("the platform stack's `objectStores` output is not an object.")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Refuse any store set a prefix-scoped wipe cannot separate.
|
|
283
|
+
|
|
284
|
+
Equality is the cross-plugin collision: two plugins declaring one store name
|
|
285
|
+
land on one prefix inside a shared bucket, and nothing distinguishes their
|
|
286
|
+
objects. Containment is the same problem one level up — the delete prefix is
|
|
287
|
+
`{keyPrefix}/`, so a store rooted at `a` encloses one rooted at `a/b`.
|
|
288
|
+
Comparing containment rather than equality is what keeps this correct once a
|
|
289
|
+
prefix carries path structure.
|
|
290
|
+
|
|
291
|
+
Fail-closed, and before anything is counted: this is the tool that destroys
|
|
292
|
+
data, so it refuses rather than skipping, and it does not assume an upstream
|
|
293
|
+
deploy-time check ran. */
|
|
294
|
+
let validateStores = (stores: array<objectStore>): result<unit, string> =>
|
|
295
|
+
switch stores->Array.find(s => s.keyPrefix == "" || s.store->String.includes("/")) {
|
|
296
|
+
| Some(s) =>
|
|
297
|
+
Error(
|
|
298
|
+
`store "${s.qualified}" has an unusable key prefix ("${s.keyPrefix}") — ` ++
|
|
299
|
+
`a store name may not be empty or contain "/".`,
|
|
300
|
+
)
|
|
301
|
+
| None =>
|
|
302
|
+
switch stores->Array.findMap(a =>
|
|
303
|
+
stores->Array.findMap(b =>
|
|
304
|
+
if a.qualified == b.qualified || a.bucketName != b.bucketName {
|
|
305
|
+
None
|
|
306
|
+
} else if a.keyPrefix == b.keyPrefix {
|
|
307
|
+
Some(
|
|
308
|
+
`stores "${a.qualified}" and "${b.qualified}" both live at ` ++
|
|
309
|
+
`${a.bucketName}/${a.keyPrefix}/ — a prefix-scoped wipe cannot tell their objects ` ++
|
|
310
|
+
`apart. Rename one store, or qualify the \`@storageRef\` annotation if they were ` ++
|
|
311
|
+
`meant to be one shared store.`,
|
|
312
|
+
)
|
|
313
|
+
} else if b.keyPrefix->String.startsWith(a.keyPrefix ++ "/") {
|
|
314
|
+
Some(
|
|
315
|
+
`store "${a.qualified}" (${a.bucketName}/${a.keyPrefix}/) encloses "${b.qualified}" ` ++
|
|
316
|
+
`(${b.keyPrefix}/) — wiping the first would delete the second's objects. Rename one.`,
|
|
317
|
+
)
|
|
318
|
+
} else {
|
|
319
|
+
None
|
|
320
|
+
}
|
|
321
|
+
)
|
|
322
|
+
) {
|
|
323
|
+
| Some(message) => Error(message)
|
|
324
|
+
| None => Ok()
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Counting (dry-run) ─────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
let countTable = async (table: string): int => {
|
|
331
|
+
let rec loop = async (start: option<dict<JSON.t>>, acc: int): int => {
|
|
332
|
+
let out = await Ddb.DocumentClient.ScanCommand.send(
|
|
333
|
+
Ddb.DocumentClient.ScanCommand.make({
|
|
334
|
+
tableName: table,
|
|
335
|
+
select: #COUNT,
|
|
336
|
+
exclusiveStartKey: ?start,
|
|
337
|
+
}),
|
|
338
|
+
)
|
|
339
|
+
let acc = acc + out.count->Option.getOr(0)
|
|
340
|
+
switch out.lastEvaluatedKey {
|
|
341
|
+
| Some(k) => await loop(Some(k), acc)
|
|
342
|
+
| None => acc
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
await loop(None, 0)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// `~prefix` narrows the count to one declared store inside a shared bucket;
|
|
349
|
+
// omitted, it counts the whole bucket.
|
|
350
|
+
let countBucket = async (bucket: string, ~prefix: option<string>=?): int => {
|
|
351
|
+
let rec loop = async (keyMarker, versionMarker, acc): int => {
|
|
352
|
+
let out = await S3.ListObjectVersionsCommand.send(
|
|
353
|
+
S3.ListObjectVersionsCommand.make({
|
|
354
|
+
bucket,
|
|
355
|
+
prefix: ?prefix,
|
|
356
|
+
keyMarker: ?keyMarker,
|
|
357
|
+
versionIdMarker: ?versionMarker,
|
|
358
|
+
}),
|
|
359
|
+
)
|
|
360
|
+
let n =
|
|
361
|
+
out.versions->Option.getOr([])->Array.length +
|
|
362
|
+
out.deleteMarkers->Option.getOr([])->Array.length
|
|
363
|
+
if out.isTruncated->Option.getOr(false) {
|
|
364
|
+
await loop(out.nextKeyMarker, out.nextVersionIdMarker, acc + n)
|
|
365
|
+
} else {
|
|
366
|
+
acc + n
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
await loop(None, None, 0)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── Wiping ──────────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
// BatchWrite takes ≤ 25 requests and may return some UnprocessedItems under
|
|
375
|
+
// throttling; resend those. Capped so a persistently-failing table surfaces as
|
|
376
|
+
// an error rather than looping forever.
|
|
377
|
+
let rec sendBatch = async (
|
|
378
|
+
table: string,
|
|
379
|
+
requests: array<Ddb.DocumentClient.BatchWriteCommand.writeRequest>,
|
|
380
|
+
~attempt: int,
|
|
381
|
+
): unit =>
|
|
382
|
+
if requests->Array.length > 0 {
|
|
383
|
+
if attempt > 8 {
|
|
384
|
+
throw(Seed.Failed(`table ${table}: ${(requests->Array.length)->Int.toString} item(s) still unprocessed after 8 retries.`))
|
|
385
|
+
}
|
|
386
|
+
let out = await Ddb.DocumentClient.BatchWriteCommand.send(
|
|
387
|
+
Ddb.DocumentClient.BatchWriteCommand.make({
|
|
388
|
+
requestItems: Dict.fromArray([(table, requests)]),
|
|
389
|
+
}),
|
|
390
|
+
)
|
|
391
|
+
let unprocessed =
|
|
392
|
+
out.unprocessedItems->Option.flatMap(d => d->Dict.get(table))->Option.getOr([])
|
|
393
|
+
if unprocessed->Array.length > 0 {
|
|
394
|
+
await sendBatch(table, unprocessed, ~attempt=attempt + 1)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let truncateTable = async (table: string): unit => {
|
|
399
|
+
let desc = await Ddb.DynamoDb.DescribeTableCommand.send(
|
|
400
|
+
Ddb.DynamoDb.DescribeTableCommand.make({tableName: table}),
|
|
401
|
+
)
|
|
402
|
+
let keyAttrs =
|
|
403
|
+
desc.table->Option.flatMap(t => t.keySchema)->Option.getOr([])->Array.map(k => k.attributeName)
|
|
404
|
+
if keyAttrs->Array.length == 0 {
|
|
405
|
+
throw(Seed.Failed(`could not read a key schema for table ${table}.`))
|
|
406
|
+
}
|
|
407
|
+
// Project only the key attributes (aliased to dodge reserved words) so the
|
|
408
|
+
// scan carries just what a delete needs.
|
|
409
|
+
let names = keyAttrs->Array.mapWithIndex((name, i) => (`#k${i->Int.toString}`, name))
|
|
410
|
+
let projection = names->Array.map(((alias, _)) => alias)->Array.join(", ")
|
|
411
|
+
let rec loop = async (start: option<dict<JSON.t>>): unit => {
|
|
412
|
+
let out = await Ddb.DocumentClient.ScanCommand.send(
|
|
413
|
+
Ddb.DocumentClient.ScanCommand.make({
|
|
414
|
+
tableName: table,
|
|
415
|
+
exclusiveStartKey: ?start,
|
|
416
|
+
projectionExpression: projection,
|
|
417
|
+
expressionAttributeNames: names->Dict.fromArray,
|
|
418
|
+
}),
|
|
419
|
+
)
|
|
420
|
+
let requests =
|
|
421
|
+
out.items
|
|
422
|
+
->Option.getOr([])
|
|
423
|
+
->Array.map(item => {
|
|
424
|
+
let key =
|
|
425
|
+
keyAttrs->Array.filterMap(attr => item->field(attr)->Option.map(v => (attr, v)))->Dict.fromArray
|
|
426
|
+
({deleteRequest: {key: key}}: Ddb.DocumentClient.BatchWriteCommand.writeRequest)
|
|
427
|
+
})
|
|
428
|
+
let batches = chunk(requests, Ddb.DocumentClient.BatchWriteCommand.maxBatchSize)
|
|
429
|
+
for i in 0 to batches->Array.length - 1 {
|
|
430
|
+
switch batches->Array.get(i) {
|
|
431
|
+
| Some(b) => await sendBatch(table, b, ~attempt=1)
|
|
432
|
+
| None => ()
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
switch out.lastEvaluatedKey {
|
|
436
|
+
| Some(k) => await loop(Some(k))
|
|
437
|
+
| None => ()
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
await loop(None)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// One ListObjectVersions page returns ≤ 1000 entries (versions + delete
|
|
444
|
+
// markers), and DeleteObjects takes ≤ 1000, so one list page maps to one delete.
|
|
445
|
+
let emptyBucket = async (bucket: string, ~prefix: option<string>=?): unit => {
|
|
446
|
+
let rec loop = async (keyMarker, versionMarker): unit => {
|
|
447
|
+
let out = await S3.ListObjectVersionsCommand.send(
|
|
448
|
+
S3.ListObjectVersionsCommand.make({
|
|
449
|
+
bucket,
|
|
450
|
+
prefix: ?prefix,
|
|
451
|
+
keyMarker: ?keyMarker,
|
|
452
|
+
versionIdMarker: ?versionMarker,
|
|
453
|
+
}),
|
|
454
|
+
)
|
|
455
|
+
let ids =
|
|
456
|
+
Array.concat(out.versions->Option.getOr([]), out.deleteMarkers->Option.getOr([]))->Array.map(v => (
|
|
457
|
+
{key: v.key, versionId: v.versionId}: S3.DeleteObjectsCommand.objectIdentifier
|
|
458
|
+
))
|
|
459
|
+
if ids->Array.length > 0 {
|
|
460
|
+
let res = await S3.DeleteObjectsCommand.send(
|
|
461
|
+
S3.DeleteObjectsCommand.make({
|
|
462
|
+
bucket,
|
|
463
|
+
delete: {objects: ids, quiet: true},
|
|
464
|
+
}),
|
|
465
|
+
)
|
|
466
|
+
switch res.errors {
|
|
467
|
+
| Some(errs) if errs->Array.length > 0 =>
|
|
468
|
+
throw(
|
|
469
|
+
Seed.Failed(
|
|
470
|
+
`failed to delete ${(errs->Array.length)->Int.toString} object(s) from ${bucket}: ${errs
|
|
471
|
+
->Array.get(0)
|
|
472
|
+
->Option.flatMap(e => e.message)
|
|
473
|
+
->Option.getOr("unknown")}`,
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
| _ => ()
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if out.isTruncated->Option.getOr(false) {
|
|
480
|
+
await loop(out.nextKeyMarker, out.nextVersionIdMarker)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
await loop(None, None)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── Targets & scope ───────────────────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
// A deployment is several Pulumi projects sharing a stack name — the platform
|
|
489
|
+
// project plus one per domain plugin — each a separate `reventless:platform`. A
|
|
490
|
+
// target names one such project by the directory its `Pulumi.<stack>.yaml` lives
|
|
491
|
+
// in (relative to the seed cwd), plus a menu label and whether it holds domain
|
|
492
|
+
// data or platform bookkeeping. The caller declares them; the reset never guesses
|
|
493
|
+
// the topology.
|
|
494
|
+
type group =
|
|
495
|
+
| Domain
|
|
496
|
+
| Platform
|
|
497
|
+
|
|
498
|
+
type target = {
|
|
499
|
+
projectDir: string,
|
|
500
|
+
label: string,
|
|
501
|
+
group: group,
|
|
502
|
+
// The name this project's plugin REGISTERS, when it differs from the
|
|
503
|
+
// operator-facing `label` (`Catalog` against `catalog`). It is what the
|
|
504
|
+
// platform's `objectStores` keys are qualified by, so it is how a declared
|
|
505
|
+
// store is attributed to a target. Declared rather than case-folded from the
|
|
506
|
+
// label: the caller states the topology, the reset never guesses it.
|
|
507
|
+
plugin?: string,
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let pluginOf = (t: target): string => t.plugin->Option.getOr(t.label)
|
|
511
|
+
|
|
512
|
+
// A target resolved to its discovered, counted stores, ready to report and wipe.
|
|
513
|
+
type resolved = {
|
|
514
|
+
target: target,
|
|
515
|
+
platform: string,
|
|
516
|
+
tables: array<string>,
|
|
517
|
+
tableCounts: array<int>,
|
|
518
|
+
bucketCounts: array<(string, int)>,
|
|
519
|
+
storeCounts: array<(objectStore, int)>,
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Picks which targets to wipe. `domain` (every domain plugin) leads and is the
|
|
523
|
+
// default; each single domain plugin follows so one plugin's data can be wiped
|
|
524
|
+
// alone; then `platform`; then `everything`. `SEED_RESET_SCOPE` (domain |
|
|
525
|
+
// platform | everything | a plugin label) selects non-interactively.
|
|
526
|
+
let chooseScope = async (~targets: array<target>): array<target> => {
|
|
527
|
+
let domain = targets->Array.filter(t => t.group == Domain)
|
|
528
|
+
let platform = targets->Array.filter(t => t.group == Platform)
|
|
529
|
+
let labelsOf = ts => ts->Array.map(t => t.label)->Array.join(", ")
|
|
530
|
+
switch Seed.Prompt.envValue("SEED_RESET_SCOPE")->Option.map(String.toLowerCase) {
|
|
531
|
+
| Some("domain") => domain
|
|
532
|
+
| Some("platform") => platform
|
|
533
|
+
| Some("all") | Some("everything") | Some("both") => targets
|
|
534
|
+
| Some(other) =>
|
|
535
|
+
switch targets->Array.find(t => t.label->String.toLowerCase == other) {
|
|
536
|
+
| Some(t) => [t]
|
|
537
|
+
| None =>
|
|
538
|
+
throw(
|
|
539
|
+
Seed.Failed(
|
|
540
|
+
`SEED_RESET_SCOPE="${other}" is not a scope — use domain, platform, everything, or a plugin label (${labelsOf(
|
|
541
|
+
domain,
|
|
542
|
+
)}).`,
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
| None =>
|
|
547
|
+
let options = []
|
|
548
|
+
if domain->Array.length > 0 {
|
|
549
|
+
options->Array.push((`domain — ${labelsOf(domain)}`, domain))
|
|
550
|
+
// Single-plugin entries only when there is more than one, else they just
|
|
551
|
+
// duplicate the `domain` entry.
|
|
552
|
+
if domain->Array.length > 1 {
|
|
553
|
+
domain->Array.forEach(t => options->Array.push((t.label, [t])))
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if platform->Array.length > 0 {
|
|
557
|
+
options->Array.push((`platform — ${labelsOf(platform)}`, platform))
|
|
558
|
+
}
|
|
559
|
+
if domain->Array.length > 0 && platform->Array.length > 0 {
|
|
560
|
+
options->Array.push((`everything — ${labelsOf(targets)}`, targets))
|
|
561
|
+
}
|
|
562
|
+
await Seed.Prompt.select(~title="Reset scope:", ~options)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ── Orchestration ─────────────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
// Gate 2 per target: its own `Pulumi.<stack>.yaml` must declare wipeable, and
|
|
569
|
+
// must resolve a region. Reasons are distinct (config unreadable vs opt-in
|
|
570
|
+
// missing vs region missing), each saying what to fix. Returns the target's
|
|
571
|
+
// region so the caller can insist every selected target shares one.
|
|
572
|
+
let gateTarget = (~target: target, ~backend, ~stack): string => {
|
|
573
|
+
let cfg = switch readStackConfig(~projectDir=target.projectDir, ~backend, ~stack) {
|
|
574
|
+
| Ok(c) => c
|
|
575
|
+
| Error(message) => throw(Seed.Failed(message))
|
|
576
|
+
}
|
|
577
|
+
switch configValue(cfg, "reventless:wipeable")->Option.map(v => v->String.trim->String.toLowerCase) {
|
|
578
|
+
| Some("true") => ()
|
|
579
|
+
| _ =>
|
|
580
|
+
throw(
|
|
581
|
+
Seed.Failed(
|
|
582
|
+
`${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).`,
|
|
583
|
+
),
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
switch Seed.Prompt.envValue("AWS_REGION") {
|
|
587
|
+
| Some(r) if r != "" => r
|
|
588
|
+
| _ =>
|
|
589
|
+
switch configValue(cfg, "aws:region") {
|
|
590
|
+
| Some(r) if r != "" => r
|
|
591
|
+
| _ =>
|
|
592
|
+
throw(
|
|
593
|
+
Seed.Failed(
|
|
594
|
+
`${target.label}: could not resolve the AWS region — set AWS_REGION or \`aws:region\` in the stack config.`,
|
|
595
|
+
),
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let reportAll = (resolvedList: array<resolved>, ~stack, ~region): int => {
|
|
602
|
+
Seed.Runner.heading(`Reset target: stack "${stack}" in ${region}`)
|
|
603
|
+
let total = ref(0)
|
|
604
|
+
resolvedList->Array.forEach(r => {
|
|
605
|
+
Console.log("")
|
|
606
|
+
Console.log(` ${r.target.label} (${r.platform})`)
|
|
607
|
+
Console.log(" DynamoDB tables:")
|
|
608
|
+
r.tables->Array.forEachWithIndex((t, i) => {
|
|
609
|
+
let c = r.tableCounts->Array.get(i)->Option.getOr(0)
|
|
610
|
+
total := total.contents + c
|
|
611
|
+
Console.log(` ${c->Int.toString->String.padStart(8, " ")} ${t}`)
|
|
612
|
+
})
|
|
613
|
+
if r.tables->Array.length == 0 {
|
|
614
|
+
Console.log(" (none)")
|
|
615
|
+
}
|
|
616
|
+
Console.log(" S3 buckets:")
|
|
617
|
+
r.bucketCounts->Array.forEach(((b, c)) => {
|
|
618
|
+
total := total.contents + c
|
|
619
|
+
Console.log(` ${c->Int.toString->String.padStart(8, " ")} ${b}`)
|
|
620
|
+
})
|
|
621
|
+
if r.bucketCounts->Array.length == 0 {
|
|
622
|
+
Console.log(" (none)")
|
|
623
|
+
}
|
|
624
|
+
// Declared stores get their own section rather than being folded in with the
|
|
625
|
+
// plain buckets: the unit is a prefix inside a bucket that other plugins also
|
|
626
|
+
// write to, and the operator needs to see which is which before confirming.
|
|
627
|
+
if r.storeCounts->Array.length > 0 {
|
|
628
|
+
Console.log(" Object stores:")
|
|
629
|
+
r.storeCounts->Array.forEach(((s, c)) => {
|
|
630
|
+
total := total.contents + c
|
|
631
|
+
Console.log(
|
|
632
|
+
` ${c->Int.toString->String.padStart(8, " ")} ${s.qualified} ` ++
|
|
633
|
+
`${s.bucketName}/${s.keyPrefix}/`,
|
|
634
|
+
)
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
})
|
|
638
|
+
total.contents
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Reset a deployed stack across one or more of its Pulumi projects. Resolves the
|
|
643
|
+
* shared stack name, refuses unless it is on the name allowlist, lets the
|
|
644
|
+
* operator pick a scope (domain / a single plugin / platform / everything), then
|
|
645
|
+
* for each chosen project: refuses unless it declares itself wipeable, discovers
|
|
646
|
+
* its stores by `platform`+`environment` tag, and reports what it would empty.
|
|
647
|
+
* Only on a matching `REVENTLESS_WIPE_CONFIRM` plus a re-typed stack name does it
|
|
648
|
+
* truncate every table and empty every bucket, then verify empty.
|
|
649
|
+
*
|
|
650
|
+
* `targets` are the deployment's projects (the caller knows the topology);
|
|
651
|
+
* `stack` and `backend` mirror `ReventlessSeedAws.connect`.
|
|
652
|
+
*/
|
|
653
|
+
let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
654
|
+
let go = async () => {
|
|
655
|
+
try {
|
|
656
|
+
if targets->Array.length == 0 {
|
|
657
|
+
throw(Seed.Failed("no targets were declared — nothing to reset."))
|
|
658
|
+
}
|
|
659
|
+
let backend = switch Seed.Prompt.envValue("SEED_PULUMI_BACKEND") {
|
|
660
|
+
| Some(url) => Some(url)
|
|
661
|
+
| None => backend
|
|
662
|
+
}
|
|
663
|
+
// Any target's dir lists the shared stack; prefer the platform project.
|
|
664
|
+
let baseTarget =
|
|
665
|
+
targets->Array.find(t => t.group == Platform)->Option.getOr(targets->Array.getUnsafe(0))
|
|
666
|
+
let stack = await ReventlessSeedAws.resolveStack(
|
|
667
|
+
~projectDir=baseTarget.projectDir,
|
|
668
|
+
~backend,
|
|
669
|
+
~stack,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
if !(nameAllowlist->RegExp.test(stack)) {
|
|
673
|
+
throw(
|
|
674
|
+
Seed.Failed(
|
|
675
|
+
`stack "${stack}" is not on the wipe name-allowlist (alpha, dev, pr-*) — refusing.`,
|
|
676
|
+
),
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
let selected = await chooseScope(~targets)
|
|
681
|
+
|
|
682
|
+
// Declared object stores live in a bucket the PLATFORM project owns, so
|
|
683
|
+
// resolving them reads the platform's stack output whichever scope was
|
|
684
|
+
// picked. Reading is side-effect free; the gates below decide whether
|
|
685
|
+
// anything may be deleted from it.
|
|
686
|
+
let platformTarget = targets->Array.find(t => t.group == Platform)
|
|
687
|
+
let allStores = switch platformTarget {
|
|
688
|
+
| None =>
|
|
689
|
+
// Not silent: a topology with no platform target is exactly the case
|
|
690
|
+
// where declared stores would be missed, and missing them is the bug
|
|
691
|
+
// this resolution exists to fix.
|
|
692
|
+
Console.log("")
|
|
693
|
+
Console.log(
|
|
694
|
+
"Note: no `platform` target is declared, so declared object stores could not be " ++
|
|
695
|
+
"resolved — any uploaded objects will be left in place.",
|
|
696
|
+
)
|
|
697
|
+
[]
|
|
698
|
+
| Some(pt) =>
|
|
699
|
+
let output = ReventlessSeedAws.stackOutputs(~projectDir=pt.projectDir, ~backend, stack)
|
|
700
|
+
switch parseObjectStores(output->field("objectStores")) {
|
|
701
|
+
| Ok(stores) => stores
|
|
702
|
+
| Error(message) => throw(Seed.Failed(message))
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
switch validateStores(allStores) {
|
|
706
|
+
| Ok() => ()
|
|
707
|
+
| Error(message) => throw(Seed.Failed(`refusing: ${message}`))
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Every bucket that holds a declared store, selected or not. These are
|
|
711
|
+
// excluded from the plain per-target bucket lists below so a store is
|
|
712
|
+
// reachable ONLY through the plugin that declared it — otherwise the
|
|
713
|
+
// platform scope would empty every plugin's objects wholesale.
|
|
714
|
+
let storeBucketNames = allStores->Array.map(s => s.bucketName)
|
|
715
|
+
let selectedStores =
|
|
716
|
+
allStores->Array.filter(s => selected->Array.some(t => pluginOf(t) == s.plugin))
|
|
717
|
+
|
|
718
|
+
// Gate every selected target and collect its region; all must agree, since
|
|
719
|
+
// the DynamoDB/S3 clients read one region from the environment. When a
|
|
720
|
+
// store is in scope, the platform's own stack must also declare itself
|
|
721
|
+
// wipeable — the objects belong to a plugin, but the bucket is the
|
|
722
|
+
// platform's, and both consents are needed to delete from it.
|
|
723
|
+
let gated = switch (selectedStores->Array.length > 0, platformTarget) {
|
|
724
|
+
| (true, Some(pt)) if !(selected->Array.some(t => t.projectDir == pt.projectDir)) =>
|
|
725
|
+
Array.concat(selected, [pt])
|
|
726
|
+
| _ => selected
|
|
727
|
+
}
|
|
728
|
+
let regions = gated->Array.map(t => gateTarget(~target=t, ~backend, ~stack))
|
|
729
|
+
let region = regions->Array.getUnsafe(0)
|
|
730
|
+
if regions->Array.some(r => r != region) {
|
|
731
|
+
throw(
|
|
732
|
+
Seed.Failed(
|
|
733
|
+
`selected targets span more than one region (${regions->Array.join(
|
|
734
|
+
", ",
|
|
735
|
+
)}) — reset them one region at a time.`,
|
|
736
|
+
),
|
|
737
|
+
)
|
|
738
|
+
}
|
|
739
|
+
NodeProcess.env->Dict.set("AWS_REGION", region)
|
|
740
|
+
|
|
741
|
+
// A store bucket arrives by stack output, not by tag discovery, so the
|
|
742
|
+
// per-resource tag re-check has to be applied to it explicitly: confirm it
|
|
743
|
+
// carries the platform project's own `platform`+`environment` tags before
|
|
744
|
+
// anything is deleted from it. One extra tagging-API call, and only when a
|
|
745
|
+
// store is actually in scope.
|
|
746
|
+
if selectedStores->Array.length > 0 {
|
|
747
|
+
switch platformTarget {
|
|
748
|
+
| Some(pt) =>
|
|
749
|
+
let platformProject = projectName(~projectDir=pt.projectDir)
|
|
750
|
+
let (_, platformBuckets) = await discover(~region, ~stack, ~platform=platformProject)
|
|
751
|
+
selectedStores->Array.forEach(s =>
|
|
752
|
+
if !(platformBuckets->Array.includes(s.bucketName)) {
|
|
753
|
+
throw(
|
|
754
|
+
Seed.Failed(
|
|
755
|
+
`refusing: store "${s.qualified}" names bucket ${s.bucketName}, which does not ` ++
|
|
756
|
+
`carry reventless:platform=${platformProject} + reventless:environment=${stack}.`,
|
|
757
|
+
),
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
)
|
|
761
|
+
| None => ()
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Discover + count each target, scoped to its own project via the platform
|
|
766
|
+
// tag so a same-named stack from another project is never touched.
|
|
767
|
+
let resolvedList = []
|
|
768
|
+
for i in 0 to selected->Array.length - 1 {
|
|
769
|
+
switch selected->Array.get(i) {
|
|
770
|
+
| Some(target) =>
|
|
771
|
+
let platform = projectName(~projectDir=target.projectDir)
|
|
772
|
+
let (tables, buckets) = await discover(~region, ~stack, ~platform)
|
|
773
|
+
let tables = tables->Array.toSorted(String.compare)
|
|
774
|
+
let buckets =
|
|
775
|
+
buckets
|
|
776
|
+
->Array.filter(b => !(storeBucketNames->Array.includes(b)))
|
|
777
|
+
->Array.toSorted(String.compare)
|
|
778
|
+
let tableCounts = []
|
|
779
|
+
for j in 0 to tables->Array.length - 1 {
|
|
780
|
+
switch tables->Array.get(j) {
|
|
781
|
+
| Some(t) => tableCounts->Array.push(await countTable(t))
|
|
782
|
+
| None => ()
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
let bucketCounts = []
|
|
786
|
+
for j in 0 to buckets->Array.length - 1 {
|
|
787
|
+
switch buckets->Array.get(j) {
|
|
788
|
+
| Some(b) => bucketCounts->Array.push((b, await countBucket(b)))
|
|
789
|
+
| None => ()
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
let stores = selectedStores->Array.filter(s => s.plugin == pluginOf(target))
|
|
793
|
+
let storeCounts = []
|
|
794
|
+
for j in 0 to stores->Array.length - 1 {
|
|
795
|
+
switch stores->Array.get(j) {
|
|
796
|
+
| Some(s) =>
|
|
797
|
+
storeCounts->Array.push((
|
|
798
|
+
s,
|
|
799
|
+
await countBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`),
|
|
800
|
+
))
|
|
801
|
+
| None => ()
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
resolvedList->Array.push({
|
|
805
|
+
target,
|
|
806
|
+
platform,
|
|
807
|
+
tables,
|
|
808
|
+
tableCounts,
|
|
809
|
+
bucketCounts,
|
|
810
|
+
storeCounts,
|
|
811
|
+
})
|
|
812
|
+
| None => ()
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
let total = reportAll(resolvedList, ~stack, ~region)
|
|
817
|
+
if total == 0 {
|
|
818
|
+
Seed.Prompt.close()
|
|
819
|
+
Console.log("")
|
|
820
|
+
Console.log(`Nothing to reset — the selected scope already reads empty in ${region}.`)
|
|
821
|
+
NodeProcess.exit(0)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Confirmation. Interactively, re-typing the exact stack name IS the
|
|
825
|
+
// confirmation — the validation above (allowlist + wipeable + tag checks)
|
|
826
|
+
// plus a deliberate keystroke is enough, so no env var is needed. Without a
|
|
827
|
+
// TTY (CI/scripts) there is nothing to type into, so
|
|
828
|
+
// REVENTLESS_WIPE_CONFIRM=<stack> is the equivalent opt-in. Dry-run is the
|
|
829
|
+
// default either way.
|
|
830
|
+
let interactive = NodeProcess.stdin->NodeProcess.isTTY->Option.getOr(false)
|
|
831
|
+
let confirmed = if interactive {
|
|
832
|
+
let typed = await Seed.Prompt.ask(
|
|
833
|
+
`About to permanently empty ${total->Int.toString} item(s)/object(s) across the selected scope of "${stack}". ` ++
|
|
834
|
+
`Type the stack name to confirm, or press Enter to keep this a dry run: `,
|
|
835
|
+
)
|
|
836
|
+
typed->String.trim == stack
|
|
837
|
+
} else {
|
|
838
|
+
Seed.Prompt.envValue("REVENTLESS_WIPE_CONFIRM") == Some(stack)
|
|
839
|
+
}
|
|
840
|
+
Seed.Prompt.close()
|
|
841
|
+
if !confirmed {
|
|
842
|
+
Console.log("")
|
|
843
|
+
Console.log(
|
|
844
|
+
interactive
|
|
845
|
+
? "Dry run — nothing was deleted."
|
|
846
|
+
: `Dry run — nothing was deleted. Set REVENTLESS_WIPE_CONFIRM=${stack} to empty this scope non-interactively.`,
|
|
847
|
+
)
|
|
848
|
+
NodeProcess.exit(0)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
for i in 0 to resolvedList->Array.length - 1 {
|
|
852
|
+
switch resolvedList->Array.get(i) {
|
|
853
|
+
| Some(r) =>
|
|
854
|
+
Seed.Runner.heading(`Emptying ${r.target.label} …`)
|
|
855
|
+
for j in 0 to r.tables->Array.length - 1 {
|
|
856
|
+
switch r.tables->Array.get(j) {
|
|
857
|
+
| Some(t) =>
|
|
858
|
+
await truncateTable(t)
|
|
859
|
+
Console.log(` truncated ${t}`)
|
|
860
|
+
| None => ()
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
for j in 0 to r.bucketCounts->Array.length - 1 {
|
|
864
|
+
switch r.bucketCounts->Array.get(j) {
|
|
865
|
+
| Some((b, _)) =>
|
|
866
|
+
await emptyBucket(b)
|
|
867
|
+
Console.log(` emptied ${b}`)
|
|
868
|
+
| None => ()
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// Report the count with the store, not just in the run total: "how
|
|
872
|
+
// many images went" is the question an operator is actually asking.
|
|
873
|
+
for j in 0 to r.storeCounts->Array.length - 1 {
|
|
874
|
+
switch r.storeCounts->Array.get(j) {
|
|
875
|
+
| Some((s, count)) =>
|
|
876
|
+
await emptyBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`)
|
|
877
|
+
Console.log(
|
|
878
|
+
` emptied ${s.qualified} — ${count->Int.toString} object(s) removed from ` ++
|
|
879
|
+
`${s.bucketName}/${s.keyPrefix}/`,
|
|
880
|
+
)
|
|
881
|
+
| None => ()
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
| None => ()
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Prove empty — the inverse of the post-seed verify.
|
|
889
|
+
let remaining = ref(0)
|
|
890
|
+
for i in 0 to resolvedList->Array.length - 1 {
|
|
891
|
+
switch resolvedList->Array.get(i) {
|
|
892
|
+
| Some(r) =>
|
|
893
|
+
for j in 0 to r.tables->Array.length - 1 {
|
|
894
|
+
switch r.tables->Array.get(j) {
|
|
895
|
+
| Some(t) => remaining := remaining.contents + (await countTable(t))
|
|
896
|
+
| None => ()
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
for j in 0 to r.bucketCounts->Array.length - 1 {
|
|
900
|
+
switch r.bucketCounts->Array.get(j) {
|
|
901
|
+
| Some((b, _)) => remaining := remaining.contents + (await countBucket(b))
|
|
902
|
+
| None => ()
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
// Per-store confirmation, so "every uploaded object is gone" is an
|
|
906
|
+
// observed fact rather than an inference from a global total.
|
|
907
|
+
for j in 0 to r.storeCounts->Array.length - 1 {
|
|
908
|
+
switch r.storeCounts->Array.get(j) {
|
|
909
|
+
| Some((s, _)) =>
|
|
910
|
+
let left = await countBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`)
|
|
911
|
+
remaining := remaining.contents + left
|
|
912
|
+
Console.log(
|
|
913
|
+
` verified empty: ${s.qualified} — ${left->Int.toString} object(s) remain under ` ++
|
|
914
|
+
`${s.bucketName}/${s.keyPrefix}/`,
|
|
915
|
+
)
|
|
916
|
+
| None => ()
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
| None => ()
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if remaining.contents != 0 {
|
|
923
|
+
throw(
|
|
924
|
+
Seed.Failed(
|
|
925
|
+
`${remaining.contents->Int.toString} item(s)/object(s) remain after the wipe — re-run to finish.`,
|
|
926
|
+
),
|
|
927
|
+
)
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
Console.log("")
|
|
931
|
+
Console.log(`Reset complete — the selected scope of "${stack}" reads empty and is re-seedable.`)
|
|
932
|
+
NodeProcess.exit(0)
|
|
933
|
+
} catch {
|
|
934
|
+
| Seed.Failed(message) =>
|
|
935
|
+
Seed.Prompt.close()
|
|
936
|
+
Console.error("")
|
|
937
|
+
Console.error(`Reset aborted — ${message}`)
|
|
938
|
+
NodeProcess.exit(1)
|
|
939
|
+
| exn =>
|
|
940
|
+
Seed.Prompt.close()
|
|
941
|
+
Console.error("")
|
|
942
|
+
Console.error("Reset aborted with an unexpected error:")
|
|
943
|
+
Console.error(exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"))
|
|
944
|
+
NodeProcess.exit(1)
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
go()->ignore
|
|
948
|
+
}
|