@reventlessdev/reventless-seed-aws 1.0.0-alpha.4 → 1.0.0-alpha.40
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 +279 -0
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/.sourcedirs.json +1 -0
- package/lib/bs/compiler-info.json +4 -4
- 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 +198 -134
- package/lib/bs/src/ReventlessSeedAws.res.mjs +59 -33
- package/lib/bs/src/ReventlessSeedAws_Quiesce.ast +0 -0
- package/lib/bs/src/ReventlessSeedAws_Quiesce.cmi +0 -0
- package/lib/bs/src/ReventlessSeedAws_Quiesce.cmj +0 -0
- package/lib/bs/src/ReventlessSeedAws_Quiesce.cmt +0 -0
- package/lib/bs/src/ReventlessSeedAws_Quiesce.res +307 -0
- package/lib/bs/src/ReventlessSeedAws_Quiesce.res.mjs +217 -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 +391 -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 +497 -94
- package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +385 -69
- 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 +83 -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 +191 -0
- package/lib/bs/tests/ObjectStoreResetTest.res.mjs +211 -0
- package/lib/bs/tests/QuiesceResetTest.ast +0 -0
- package/lib/bs/tests/QuiesceResetTest.cmi +0 -0
- package/lib/bs/tests/QuiesceResetTest.cmj +0 -0
- package/lib/bs/tests/QuiesceResetTest.cmt +0 -0
- package/lib/bs/tests/QuiesceResetTest.res +169 -0
- package/lib/bs/tests/QuiesceResetTest.res.mjs +157 -0
- package/lib/ocaml/.compiler.log +2 -2
- 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 +83 -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 +191 -0
- package/lib/ocaml/QuiesceResetTest.ast +0 -0
- package/lib/ocaml/QuiesceResetTest.cmi +0 -0
- package/lib/ocaml/QuiesceResetTest.cmj +0 -0
- package/lib/ocaml/QuiesceResetTest.cmt +0 -0
- package/lib/ocaml/QuiesceResetTest.res +169 -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 +198 -134
- package/lib/ocaml/ReventlessSeedAws_Quiesce.ast +0 -0
- package/lib/ocaml/ReventlessSeedAws_Quiesce.cmi +0 -0
- package/lib/ocaml/ReventlessSeedAws_Quiesce.cmj +0 -0
- package/lib/ocaml/ReventlessSeedAws_Quiesce.cmt +0 -0
- package/lib/ocaml/ReventlessSeedAws_Quiesce.res +307 -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 +391 -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 +497 -94
- package/package.json +7 -5
- package/rescript.json +8 -1
- package/src/ReventlessSeedAws.res +198 -134
- package/src/ReventlessSeedAws.res.mjs +59 -33
- package/src/ReventlessSeedAws_Quiesce.res +307 -0
- package/src/ReventlessSeedAws_Quiesce.res.mjs +217 -0
- package/src/ReventlessSeedAws_Reconcile.res +391 -0
- package/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
- package/src/ReventlessSeedAws_Reset.res +497 -94
- package/src/ReventlessSeedAws_Reset.res.mjs +385 -69
- package/tests/EndpointResolutionTest.res +83 -0
- package/tests/EndpointResolutionTest.res.mjs +115 -0
- package/tests/ObjectStoreReconcileTest.res +83 -0
- package/tests/ObjectStoreReconcileTest.res.mjs +102 -0
- package/tests/ObjectStoreResetTest.res +191 -0
- package/tests/ObjectStoreResetTest.res.mjs +211 -0
- package/tests/QuiesceResetTest.res +169 -0
- package/tests/QuiesceResetTest.res.mjs +157 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// A wipe is irreversible, so every step is fail-closed and production must be
|
|
9
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):
|
|
10
|
+
// deferred layer — see docs/plans/done/seed-reset-and-fresh-store-guard.md):
|
|
11
11
|
//
|
|
12
12
|
// 1. name allowlist — the stack name must match ^(alpha|dev|pr-.+)$
|
|
13
13
|
// 2. wipeable flag — the target's own `Pulumi.<stack>.yaml` must declare
|
|
@@ -47,15 +47,8 @@ open ReventlessSeed
|
|
|
47
47
|
module Ddb = AwsSdk.DynamoDb
|
|
48
48
|
module Rgt = AwsSdk.ResourceGroupsTaggingApi
|
|
49
49
|
module S3 = AwsSdk.S3
|
|
50
|
-
|
|
51
|
-
|
|
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"
|
|
50
|
+
module Lambda = AwsSdk.Lambda
|
|
51
|
+
module Quiesce = ReventlessSeedAws_Quiesce
|
|
59
52
|
|
|
60
53
|
// The Pulumi project name, which the framework stamps as `reventless:platform` on
|
|
61
54
|
// every resource (`Plugin.res`: `platformName = getProjectName()`). Discovery
|
|
@@ -66,7 +59,7 @@ type stream
|
|
|
66
59
|
// the target project's Pulumi.yaml — the same dir the pulumi subprocess uses.
|
|
67
60
|
let projectName = (~projectDir: string): string => {
|
|
68
61
|
let path = `${projectDir}/Pulumi.yaml`
|
|
69
|
-
let raw = try readFileSync(path
|
|
62
|
+
let raw = try NodeFs.readFileSync(path) catch {
|
|
70
63
|
| _ => throw(Seed.Failed(`could not read ${path} to scope the wipe to this Pulumi project.`))
|
|
71
64
|
}
|
|
72
65
|
switch raw
|
|
@@ -77,7 +70,7 @@ let projectName = (~projectDir: string): string => {
|
|
|
77
70
|
trimmed
|
|
78
71
|
->String.slice(~start=String.length("name:"), ~end=String.length(trimmed))
|
|
79
72
|
->String.trim
|
|
80
|
-
->String.replaceRegExp(
|
|
73
|
+
->String.replaceRegExp(/^[\"']|[\"']$/g, "")
|
|
81
74
|
| None => throw(Seed.Failed(`could not find a \`name:\` field in ${path}.`))
|
|
82
75
|
}
|
|
83
76
|
}
|
|
@@ -111,7 +104,7 @@ let chunk = (arr: array<'a>, size: int): array<array<'a>> => {
|
|
|
111
104
|
|
|
112
105
|
// Fail-closed name allowlist. A denylist ("everything except prod") fails open
|
|
113
106
|
// the day a new prod-like stack is added and forgotten; this fails closed.
|
|
114
|
-
let nameAllowlist =
|
|
107
|
+
let nameAllowlist = /^(alpha|dev|pr-.+)$/
|
|
115
108
|
|
|
116
109
|
// The stack's fully-resolved Pulumi config, read once. Reading it whole (rather
|
|
117
110
|
// than `pulumi config get <key>`, which exits non-zero for BOTH a missing key
|
|
@@ -119,21 +112,19 @@ let nameAllowlist = %re("/^(alpha|dev|pr-.+)$/")
|
|
|
119
112
|
// unreadable config is a pulumi/backend problem; an absent flag is a missing
|
|
120
113
|
// opt-in. `pulumi config --json` maps each key to `{value, secret}`.
|
|
121
114
|
let readStackConfig = (~projectDir, ~backend, ~stack): result<dict<JSON.t>, string> =>
|
|
122
|
-
switch (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
115
|
+
switch try Some(
|
|
116
|
+
ReventlessSeedAws.pulumi(~projectDir, ~backend, ["config", "--json", "--stack", stack]),
|
|
117
|
+
) catch {
|
|
118
|
+
| _ => None
|
|
119
|
+
} {
|
|
127
120
|
| None =>
|
|
128
121
|
Error(
|
|
129
122
|
`could not read the Pulumi config for stack "${stack}" — is pulumi logged in to the right backend, and is the stack deployed?`,
|
|
130
123
|
)
|
|
131
124
|
| Some(raw) =>
|
|
132
|
-
switch (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
) {
|
|
125
|
+
switch try Some(JSON.parseOrThrow(raw)) catch {
|
|
126
|
+
| _ => None
|
|
127
|
+
} {
|
|
137
128
|
| Some(Object(obj)) => Ok(obj)
|
|
138
129
|
| _ => Error(`could not parse \`pulumi config --json\` output for stack "${stack}".`)
|
|
139
130
|
}
|
|
@@ -147,9 +138,16 @@ let configValue = (cfg: dict<JSON.t>, key: string): option<string> =>
|
|
|
147
138
|
type resource =
|
|
148
139
|
| Table(string)
|
|
149
140
|
| Bucket(string)
|
|
141
|
+
| Function(string)
|
|
150
142
|
| Other
|
|
151
143
|
|
|
152
144
|
// arn:aws:dynamodb:<region>:<acct>:table/<name> | arn:aws:s3:::<bucket>
|
|
145
|
+
// | arn:aws:lambda:<region>:<acct>:function:<name>
|
|
146
|
+
//
|
|
147
|
+
// A Lambda ARN can carry a trailing `:<version|alias>` qualifier. The tagging
|
|
148
|
+
// API returns the unqualified form, but taking the segment after `:function:`
|
|
149
|
+
// up to the next colon rather than to the end of the string means a qualified
|
|
150
|
+
// one still yields the function name instead of a name no API accepts.
|
|
153
151
|
let classify = (arn: string): resource =>
|
|
154
152
|
if arn->String.startsWith("arn:aws:dynamodb:") {
|
|
155
153
|
switch arn->String.split(":table/") {
|
|
@@ -158,6 +156,16 @@ let classify = (arn: string): resource =>
|
|
|
158
156
|
}
|
|
159
157
|
} else if arn->String.startsWith("arn:aws:s3:::") {
|
|
160
158
|
Bucket(arn->String.slice(~start=String.length("arn:aws:s3:::"), ~end=String.length(arn)))
|
|
159
|
+
} else if arn->String.startsWith("arn:aws:lambda:") {
|
|
160
|
+
switch arn->String.split(":function:") {
|
|
161
|
+
| [_, rest] =>
|
|
162
|
+
switch rest->String.split(":") {
|
|
163
|
+
| [name] => Function(name)
|
|
164
|
+
| [name, _qualifier] => Function(name)
|
|
165
|
+
| _ => Other
|
|
166
|
+
}
|
|
167
|
+
| _ => Other
|
|
168
|
+
}
|
|
161
169
|
} else {
|
|
162
170
|
Other
|
|
163
171
|
}
|
|
@@ -165,15 +173,29 @@ let classify = (arn: string): resource =>
|
|
|
165
173
|
let tagValue = (tags: array<Rgt.GetResourcesCommand.tag>, key: string): option<string> =>
|
|
166
174
|
tags->Array.findMap(t => t.key == key ? Some(t.value) : None)
|
|
167
175
|
|
|
176
|
+
/** What one project's tags resolve to: the stores to empty, and the runtimes
|
|
177
|
+
that have to be held still while that happens. */
|
|
178
|
+
type discovered = {
|
|
179
|
+
tables: array<string>,
|
|
180
|
+
buckets: array<string>,
|
|
181
|
+
functions: array<string>,
|
|
182
|
+
}
|
|
183
|
+
|
|
168
184
|
// Discovery is scoped by BOTH tags (platform AND environment) — the Tagging API
|
|
169
185
|
// ANDs multiple TagFilters — so it only ever sees the target project's own stack,
|
|
170
186
|
// never a same-named stack from another project. Each returned resource is then
|
|
171
187
|
// re-checked to carry both tags (a mismatch aborts the whole run rather than
|
|
172
188
|
// being skipped) as a second, per-resource guard.
|
|
173
|
-
|
|
189
|
+
//
|
|
190
|
+
// Lambda functions are discovered alongside the stores, under the same tags and
|
|
191
|
+
// the same per-resource re-check. They are never deleted from — they are what
|
|
192
|
+
// the wipe has to hold still, and the tag scope that decides what may be emptied
|
|
193
|
+
// is exactly the scope that decides what may be held.
|
|
194
|
+
let discover = async (~region, ~stack, ~platform): discovered => {
|
|
174
195
|
let client = Rgt.client(~region, ())
|
|
175
196
|
let tables = []
|
|
176
197
|
let buckets = []
|
|
198
|
+
let functions = []
|
|
177
199
|
let rec loop = async (token: option<string>): unit => {
|
|
178
200
|
let out = await Rgt.GetResourcesCommand.send(
|
|
179
201
|
client,
|
|
@@ -182,7 +204,7 @@ let discover = async (~region, ~stack, ~platform): (array<string>, array<string>
|
|
|
182
204
|
{key: "reventless:platform", values: [platform]},
|
|
183
205
|
{key: "reventless:environment", values: [stack]},
|
|
184
206
|
],
|
|
185
|
-
resourceTypeFilters: ["dynamodb:table", "s3"],
|
|
207
|
+
resourceTypeFilters: ["dynamodb:table", "s3", "lambda:function"],
|
|
186
208
|
resourcesPerPage: 100,
|
|
187
209
|
paginationToken: ?token,
|
|
188
210
|
}),
|
|
@@ -208,6 +230,7 @@ let discover = async (~region, ~stack, ~platform): (array<string>, array<string>
|
|
|
208
230
|
switch classify(m.resourceARN) {
|
|
209
231
|
| Table(name) => tables->Array.push(name)
|
|
210
232
|
| Bucket(name) => buckets->Array.push(name)
|
|
233
|
+
| Function(name) => functions->Array.push(name)
|
|
211
234
|
| Other => ()
|
|
212
235
|
}
|
|
213
236
|
})
|
|
@@ -217,9 +240,118 @@ let discover = async (~region, ~stack, ~platform): (array<string>, array<string>
|
|
|
217
240
|
}
|
|
218
241
|
}
|
|
219
242
|
await loop(None)
|
|
220
|
-
|
|
243
|
+
{tables, buckets, functions}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Declared object stores ────────────────────────────────────────────────────
|
|
247
|
+
//
|
|
248
|
+
// A store declared by a `@storageRef` field is provisioned by the PLATFORM
|
|
249
|
+
// deploy — the serving CDN and the presign services live there, and a
|
|
250
|
+
// shared-layout bucket holds several plugins' stores, so no plugin stack can own
|
|
251
|
+
// it. Its bucket therefore carries the platform project's `reventless:platform`
|
|
252
|
+
// tag, which makes it invisible to a domain-scoped tag discovery and, worse,
|
|
253
|
+
// wholesale-emptiable by a platform-scoped one.
|
|
254
|
+
//
|
|
255
|
+
// Tag discovery cannot fix that: the tag says which project *built* the bucket,
|
|
256
|
+
// and the answer needed here is which plugin's data is *in* it — at prefix
|
|
257
|
+
// granularity, since one bucket holds several plugins' stores. The platform
|
|
258
|
+
// already publishes exactly that mapping as its `objectStores` stack output, so
|
|
259
|
+
// the reset reads ownership from the declaration rather than inferring it.
|
|
260
|
+
|
|
261
|
+
/** One provisioned store: which plugin owns it, and where its objects live. */
|
|
262
|
+
type objectStore = {
|
|
263
|
+
qualified: string,
|
|
264
|
+
plugin: string,
|
|
265
|
+
store: string,
|
|
266
|
+
bucketName: string,
|
|
267
|
+
keyPrefix: string,
|
|
221
268
|
}
|
|
222
269
|
|
|
270
|
+
// A store's key is `{plugin}.{store}`, split at the FIRST dot: a registered
|
|
271
|
+
// plugin name cannot contain one, a store name could in principle.
|
|
272
|
+
let splitQualified = (key: string): option<(string, string)> =>
|
|
273
|
+
key
|
|
274
|
+
->String.indexOfOpt(".")
|
|
275
|
+
->Option.map(i => (
|
|
276
|
+
key->String.slice(~start=0, ~end=i),
|
|
277
|
+
key->String.slice(~start=i + 1, ~end=key->String.length),
|
|
278
|
+
))
|
|
279
|
+
|
|
280
|
+
/** Parse the platform stack's `objectStores` output. Absent is normal — a
|
|
281
|
+
platform may declare no stores — so only a malformed entry is an error, and
|
|
282
|
+
it is an error rather than a skip because a store the reset cannot read is a
|
|
283
|
+
store it would silently leave behind. */
|
|
284
|
+
let parseObjectStores = (json: option<JSON.t>): result<array<objectStore>, string> =>
|
|
285
|
+
switch json {
|
|
286
|
+
| None => Ok([])
|
|
287
|
+
| Some(Object(entries)) =>
|
|
288
|
+
entries
|
|
289
|
+
->Dict.toArray
|
|
290
|
+
->Array.reduce(Ok([]), (acc, (qualified, entry)) =>
|
|
291
|
+
switch acc {
|
|
292
|
+
| Error(_) as failed => failed
|
|
293
|
+
| Ok(stores) =>
|
|
294
|
+
switch (
|
|
295
|
+
splitQualified(qualified),
|
|
296
|
+
entry->field("bucketName")->Option.flatMap(asString),
|
|
297
|
+
entry->field("keyPrefix")->Option.flatMap(asString),
|
|
298
|
+
) {
|
|
299
|
+
| (Some((plugin, store)), Some(bucketName), Some(keyPrefix)) =>
|
|
300
|
+
Ok(Array.concat(stores, [{qualified, plugin, store, bucketName, keyPrefix}]))
|
|
301
|
+
| _ =>
|
|
302
|
+
Error(
|
|
303
|
+
`the platform stack's \`objectStores\` output has a malformed entry for "${qualified}" — ` ++ `expected a {plugin}.{store} key carrying bucketName and keyPrefix.`,
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
)
|
|
308
|
+
| Some(_) => Error("the platform stack's `objectStores` output is not an object.")
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Refuse any store set a prefix-scoped wipe cannot separate.
|
|
312
|
+
|
|
313
|
+
Equality is the cross-plugin collision: two plugins declaring one store name
|
|
314
|
+
land on one prefix inside a shared bucket, and nothing distinguishes their
|
|
315
|
+
objects. Containment is the same problem one level up — the delete prefix is
|
|
316
|
+
`{keyPrefix}/`, so a store rooted at `a` encloses one rooted at `a/b`.
|
|
317
|
+
Comparing containment rather than equality is what keeps this correct once a
|
|
318
|
+
prefix carries path structure.
|
|
319
|
+
|
|
320
|
+
Fail-closed, and before anything is counted: this is the tool that destroys
|
|
321
|
+
data, so it refuses rather than skipping, and it does not assume an upstream
|
|
322
|
+
deploy-time check ran. */
|
|
323
|
+
let validateStores = (stores: array<objectStore>): result<unit, string> =>
|
|
324
|
+
switch stores->Array.find(s => s.keyPrefix == "" || s.store->String.includes("/")) {
|
|
325
|
+
| Some(s) =>
|
|
326
|
+
Error(
|
|
327
|
+
`store "${s.qualified}" has an unusable key prefix ("${s.keyPrefix}") — ` ++ `a store name may not be empty or contain "/".`,
|
|
328
|
+
)
|
|
329
|
+
| None =>
|
|
330
|
+
switch stores->Array.findMap(a =>
|
|
331
|
+
stores->Array.findMap(b =>
|
|
332
|
+
if a.qualified == b.qualified || a.bucketName != b.bucketName {
|
|
333
|
+
None
|
|
334
|
+
} else if a.keyPrefix == b.keyPrefix {
|
|
335
|
+
Some(
|
|
336
|
+
`stores "${a.qualified}" and "${b.qualified}" both live at ` ++
|
|
337
|
+
`${a.bucketName}/${a.keyPrefix}/ — a prefix-scoped wipe cannot tell their objects ` ++
|
|
338
|
+
`apart. Rename one store, or qualify the \`@storageRef\` annotation if they were ` ++ `meant to be one shared store.`,
|
|
339
|
+
)
|
|
340
|
+
} else if b.keyPrefix->String.startsWith(a.keyPrefix ++ "/") {
|
|
341
|
+
Some(
|
|
342
|
+
`store "${a.qualified}" (${a.bucketName}/${a.keyPrefix}/) encloses "${b.qualified}" ` ++
|
|
343
|
+
`(${b.keyPrefix}/) — wiping the first would delete the second's objects. Rename one.`,
|
|
344
|
+
)
|
|
345
|
+
} else {
|
|
346
|
+
None
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
) {
|
|
350
|
+
| Some(message) => Error(message)
|
|
351
|
+
| None => Ok()
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
223
355
|
// ── Counting (dry-run) ─────────────────────────────────────────────────────────
|
|
224
356
|
|
|
225
357
|
let countTable = async (table: string): int => {
|
|
@@ -240,12 +372,15 @@ let countTable = async (table: string): int => {
|
|
|
240
372
|
await loop(None, 0)
|
|
241
373
|
}
|
|
242
374
|
|
|
243
|
-
|
|
375
|
+
// `~prefix` narrows the count to one declared store inside a shared bucket;
|
|
376
|
+
// omitted, it counts the whole bucket.
|
|
377
|
+
let countBucket = async (bucket: string, ~prefix: option<string>=?): int => {
|
|
244
378
|
let rec loop = async (keyMarker, versionMarker, acc): int => {
|
|
245
379
|
let out = await S3.ListObjectVersionsCommand.send(
|
|
246
380
|
S3.ListObjectVersionsCommand.make({
|
|
247
381
|
bucket,
|
|
248
|
-
|
|
382
|
+
?prefix,
|
|
383
|
+
?keyMarker,
|
|
249
384
|
versionIdMarker: ?versionMarker,
|
|
250
385
|
}),
|
|
251
386
|
)
|
|
@@ -273,7 +408,13 @@ let rec sendBatch = async (
|
|
|
273
408
|
): unit =>
|
|
274
409
|
if requests->Array.length > 0 {
|
|
275
410
|
if attempt > 8 {
|
|
276
|
-
throw(
|
|
411
|
+
throw(
|
|
412
|
+
Seed.Failed(
|
|
413
|
+
`table ${table}: ${requests
|
|
414
|
+
->Array.length
|
|
415
|
+
->Int.toString} item(s) still unprocessed after 8 retries.`,
|
|
416
|
+
),
|
|
417
|
+
)
|
|
277
418
|
}
|
|
278
419
|
let out = await Ddb.DocumentClient.BatchWriteCommand.send(
|
|
279
420
|
Ddb.DocumentClient.BatchWriteCommand.make({
|
|
@@ -314,7 +455,9 @@ let truncateTable = async (table: string): unit => {
|
|
|
314
455
|
->Option.getOr([])
|
|
315
456
|
->Array.map(item => {
|
|
316
457
|
let key =
|
|
317
|
-
keyAttrs
|
|
458
|
+
keyAttrs
|
|
459
|
+
->Array.filterMap(attr => item->field(attr)->Option.map(v => (attr, v)))
|
|
460
|
+
->Dict.fromArray
|
|
318
461
|
({deleteRequest: {key: key}}: Ddb.DocumentClient.BatchWriteCommand.writeRequest)
|
|
319
462
|
})
|
|
320
463
|
let batches = chunk(requests, Ddb.DocumentClient.BatchWriteCommand.maxBatchSize)
|
|
@@ -334,19 +477,20 @@ let truncateTable = async (table: string): unit => {
|
|
|
334
477
|
|
|
335
478
|
// One ListObjectVersions page returns ≤ 1000 entries (versions + delete
|
|
336
479
|
// markers), and DeleteObjects takes ≤ 1000, so one list page maps to one delete.
|
|
337
|
-
let emptyBucket = async (bucket: string): unit => {
|
|
480
|
+
let emptyBucket = async (bucket: string, ~prefix: option<string>=?): unit => {
|
|
338
481
|
let rec loop = async (keyMarker, versionMarker): unit => {
|
|
339
482
|
let out = await S3.ListObjectVersionsCommand.send(
|
|
340
483
|
S3.ListObjectVersionsCommand.make({
|
|
341
484
|
bucket,
|
|
342
|
-
|
|
485
|
+
?prefix,
|
|
486
|
+
?keyMarker,
|
|
343
487
|
versionIdMarker: ?versionMarker,
|
|
344
488
|
}),
|
|
345
489
|
)
|
|
346
490
|
let ids =
|
|
347
|
-
Array.concat(out.versions->Option.getOr([]), out.deleteMarkers->Option.getOr([]))->Array.map(
|
|
348
|
-
|
|
349
|
-
))
|
|
491
|
+
Array.concat(out.versions->Option.getOr([]), out.deleteMarkers->Option.getOr([]))->Array.map((
|
|
492
|
+
v
|
|
493
|
+
): S3.DeleteObjectsCommand.objectIdentifier => {key: v.key, versionId: v.versionId})
|
|
350
494
|
if ids->Array.length > 0 {
|
|
351
495
|
let res = await S3.DeleteObjectsCommand.send(
|
|
352
496
|
S3.DeleteObjectsCommand.make({
|
|
@@ -358,7 +502,7 @@ let emptyBucket = async (bucket: string): unit => {
|
|
|
358
502
|
| Some(errs) if errs->Array.length > 0 =>
|
|
359
503
|
throw(
|
|
360
504
|
Seed.Failed(
|
|
361
|
-
`failed to delete ${
|
|
505
|
+
`failed to delete ${errs->Array.length->Int.toString} object(s) from ${bucket}: ${errs
|
|
362
506
|
->Array.get(0)
|
|
363
507
|
->Option.flatMap(e => e.message)
|
|
364
508
|
->Option.getOr("unknown")}`,
|
|
@@ -390,8 +534,16 @@ type target = {
|
|
|
390
534
|
projectDir: string,
|
|
391
535
|
label: string,
|
|
392
536
|
group: group,
|
|
537
|
+
// The name this project's plugin REGISTERS, when it differs from the
|
|
538
|
+
// operator-facing `label` (`Catalog` against `catalog`). It is what the
|
|
539
|
+
// platform's `objectStores` keys are qualified by, so it is how a declared
|
|
540
|
+
// store is attributed to a target. Declared rather than case-folded from the
|
|
541
|
+
// label: the caller states the topology, the reset never guesses it.
|
|
542
|
+
plugin?: string,
|
|
393
543
|
}
|
|
394
544
|
|
|
545
|
+
let pluginOf = (t: target): string => t.plugin->Option.getOr(t.label)
|
|
546
|
+
|
|
395
547
|
// A target resolved to its discovered, counted stores, ready to report and wipe.
|
|
396
548
|
type resolved = {
|
|
397
549
|
target: target,
|
|
@@ -399,6 +551,9 @@ type resolved = {
|
|
|
399
551
|
tables: array<string>,
|
|
400
552
|
tableCounts: array<int>,
|
|
401
553
|
bucketCounts: array<(string, int)>,
|
|
554
|
+
storeCounts: array<(objectStore, int)>,
|
|
555
|
+
/** The project's Lambda functions — not wiped, held still while it is. */
|
|
556
|
+
functions: array<string>,
|
|
402
557
|
}
|
|
403
558
|
|
|
404
559
|
// Picks which targets to wipe. `domain` (every domain plugin) leads and is the
|
|
@@ -429,6 +584,7 @@ let chooseScope = async (~targets: array<target>): array<target> => {
|
|
|
429
584
|
let options = []
|
|
430
585
|
if domain->Array.length > 0 {
|
|
431
586
|
options->Array.push((`domain — ${labelsOf(domain)}`, domain))
|
|
587
|
+
|
|
432
588
|
// Single-plugin entries only when there is more than one, else they just
|
|
433
589
|
// duplicate the `domain` entry.
|
|
434
590
|
if domain->Array.length > 1 {
|
|
@@ -456,7 +612,9 @@ let gateTarget = (~target: target, ~backend, ~stack): string => {
|
|
|
456
612
|
| Ok(c) => c
|
|
457
613
|
| Error(message) => throw(Seed.Failed(message))
|
|
458
614
|
}
|
|
459
|
-
switch configValue(cfg, "reventless:wipeable")->Option.map(v =>
|
|
615
|
+
switch configValue(cfg, "reventless:wipeable")->Option.map(v =>
|
|
616
|
+
v->String.trim->String.toLowerCase
|
|
617
|
+
) {
|
|
460
618
|
| Some("true") => ()
|
|
461
619
|
| _ =>
|
|
462
620
|
throw(
|
|
@@ -480,6 +638,47 @@ let gateTarget = (~target: target, ~backend, ~stack): string => {
|
|
|
480
638
|
}
|
|
481
639
|
}
|
|
482
640
|
|
|
641
|
+
/** Opt out of holding the platform still for the wipe. The hold needs Lambda
|
|
642
|
+
permissions the reset did not previously ask for, so credentials that lack
|
|
643
|
+
them have a way through — at the cost of the emptiness guarantee, which the
|
|
644
|
+
note printed alongside it says plainly. */
|
|
645
|
+
let noQuiesce = (): bool =>
|
|
646
|
+
switch Seed.Prompt.envValue("SEED_RESET_NO_QUIESCE")->Option.map(v =>
|
|
647
|
+
v->String.trim->String.toLowerCase
|
|
648
|
+
) {
|
|
649
|
+
| Some("1") | Some("true") | Some("yes") => true
|
|
650
|
+
| _ => false
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** What to say when a store is not empty after the wipe.
|
|
654
|
+
|
|
655
|
+
The old message reported only a total and advised re-running. That advice was
|
|
656
|
+
wrong in exactly the case that occurs: a runtime holding its state in memory
|
|
657
|
+
rewrites the store on every invocation, so every re-run reproduces the same
|
|
658
|
+
failure and the message sends the operator round the loop again.
|
|
659
|
+
|
|
660
|
+
So name the stores, and separate the two situations that are actually
|
|
661
|
+
different. Under a hold, nothing could start during the wipe, so the only
|
|
662
|
+
possible writer is an invocation that was already in flight — and a re-run
|
|
663
|
+
does clear that. Without a hold there is no such bound, and re-running is the
|
|
664
|
+
wrong advice. */
|
|
665
|
+
let refillMessage = (~refilled: array<(string, int)>, ~heldCount: int): string => {
|
|
666
|
+
let total = refilled->Array.reduce(0, (acc, (_, n)) => acc + n)
|
|
667
|
+
let lines =
|
|
668
|
+
refilled
|
|
669
|
+
->Array.map(((label, n)) => `\n ${n->Int.toString->String.padStart(8, " ")} ${label}`)
|
|
670
|
+
->Array.join("")
|
|
671
|
+
let diagnosis = if heldCount > 0 {
|
|
672
|
+
`\n\n The wipe deleted these and something wrote them back. All ${heldCount->Int.toString} runtime(s) ` ++
|
|
673
|
+
`in scope were held at zero concurrency for the whole wipe, so the writer was an invocation already ` ++ `in flight when the hold took effect. Re-running clears it.`
|
|
674
|
+
} else {
|
|
675
|
+
`\n\n The wipe deleted these and a running runtime wrote them back. The stack was NOT held ` ++
|
|
676
|
+
`(SEED_RESET_NO_QUIESCE), so nothing stopped it, and re-running will not help: the writer keeps the ` ++
|
|
677
|
+
`state in memory and restores it on every invocation, identical each time. Re-run without ` ++ `SEED_RESET_NO_QUIESCE so the runtimes are held and recycled.`
|
|
678
|
+
}
|
|
679
|
+
`${total->Int.toString} item(s)/object(s) came back after the wipe:${lines}${diagnosis}`
|
|
680
|
+
}
|
|
681
|
+
|
|
483
682
|
let reportAll = (resolvedList: array<resolved>, ~stack, ~region): int => {
|
|
484
683
|
Seed.Runner.heading(`Reset target: stack "${stack}" in ${region}`)
|
|
485
684
|
let total = ref(0)
|
|
@@ -503,6 +702,20 @@ let reportAll = (resolvedList: array<resolved>, ~stack, ~region): int => {
|
|
|
503
702
|
if r.bucketCounts->Array.length == 0 {
|
|
504
703
|
Console.log(" (none)")
|
|
505
704
|
}
|
|
705
|
+
|
|
706
|
+
// Declared stores get their own section rather than being folded in with the
|
|
707
|
+
// plain buckets: the unit is a prefix inside a bucket that other plugins also
|
|
708
|
+
// write to, and the operator needs to see which is which before confirming.
|
|
709
|
+
if r.storeCounts->Array.length > 0 {
|
|
710
|
+
Console.log(" Object stores:")
|
|
711
|
+
r.storeCounts->Array.forEach(((s, c)) => {
|
|
712
|
+
total := total.contents + c
|
|
713
|
+
Console.log(
|
|
714
|
+
` ${c->Int.toString->String.padStart(8, " ")} ${s.qualified} ` ++
|
|
715
|
+
`${s.bucketName}/${s.keyPrefix}/`,
|
|
716
|
+
)
|
|
717
|
+
})
|
|
718
|
+
}
|
|
506
719
|
})
|
|
507
720
|
total.contents
|
|
508
721
|
}
|
|
@@ -548,9 +761,52 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
548
761
|
|
|
549
762
|
let selected = await chooseScope(~targets)
|
|
550
763
|
|
|
764
|
+
// Declared object stores live in a bucket the PLATFORM project owns, so
|
|
765
|
+
// resolving them reads the platform's stack output whichever scope was
|
|
766
|
+
// picked. Reading is side-effect free; the gates below decide whether
|
|
767
|
+
// anything may be deleted from it.
|
|
768
|
+
let platformTarget = targets->Array.find(t => t.group == Platform)
|
|
769
|
+
let allStores = switch platformTarget {
|
|
770
|
+
| None =>
|
|
771
|
+
// Not silent: a topology with no platform target is exactly the case
|
|
772
|
+
// where declared stores would be missed, and missing them is the bug
|
|
773
|
+
// this resolution exists to fix.
|
|
774
|
+
Console.log("")
|
|
775
|
+
Console.log(
|
|
776
|
+
"Note: no `platform` target is declared, so declared object stores could not be " ++ "resolved — any uploaded objects will be left in place.",
|
|
777
|
+
)
|
|
778
|
+
[]
|
|
779
|
+
| Some(pt) =>
|
|
780
|
+
let output = ReventlessSeedAws.stackOutputs(~projectDir=pt.projectDir, ~backend, stack)
|
|
781
|
+
switch parseObjectStores(output->field("objectStores")) {
|
|
782
|
+
| Ok(stores) => stores
|
|
783
|
+
| Error(message) => throw(Seed.Failed(message))
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
switch validateStores(allStores) {
|
|
787
|
+
| Ok() => ()
|
|
788
|
+
| Error(message) => throw(Seed.Failed(`refusing: ${message}`))
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Every bucket that holds a declared store, selected or not. These are
|
|
792
|
+
// excluded from the plain per-target bucket lists below so a store is
|
|
793
|
+
// reachable ONLY through the plugin that declared it — otherwise the
|
|
794
|
+
// platform scope would empty every plugin's objects wholesale.
|
|
795
|
+
let storeBucketNames = allStores->Array.map(s => s.bucketName)
|
|
796
|
+
let selectedStores =
|
|
797
|
+
allStores->Array.filter(s => selected->Array.some(t => pluginOf(t) == s.plugin))
|
|
798
|
+
|
|
551
799
|
// Gate every selected target and collect its region; all must agree, since
|
|
552
|
-
// the DynamoDB/S3 clients read one region from the environment.
|
|
553
|
-
|
|
800
|
+
// the DynamoDB/S3 clients read one region from the environment. When a
|
|
801
|
+
// store is in scope, the platform's own stack must also declare itself
|
|
802
|
+
// wipeable — the objects belong to a plugin, but the bucket is the
|
|
803
|
+
// platform's, and both consents are needed to delete from it.
|
|
804
|
+
let gated = switch (selectedStores->Array.length > 0, platformTarget) {
|
|
805
|
+
| (true, Some(pt)) if !(selected->Array.some(t => t.projectDir == pt.projectDir)) =>
|
|
806
|
+
Array.concat(selected, [pt])
|
|
807
|
+
| _ => selected
|
|
808
|
+
}
|
|
809
|
+
let regions = gated->Array.map(t => gateTarget(~target=t, ~backend, ~stack))
|
|
554
810
|
let region = regions->Array.getUnsafe(0)
|
|
555
811
|
if regions->Array.some(r => r != region) {
|
|
556
812
|
throw(
|
|
@@ -561,7 +817,31 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
561
817
|
),
|
|
562
818
|
)
|
|
563
819
|
}
|
|
564
|
-
|
|
820
|
+
NodeProcess.env->Dict.set("AWS_REGION", region)
|
|
821
|
+
|
|
822
|
+
// A store bucket arrives by stack output, not by tag discovery, so the
|
|
823
|
+
// per-resource tag re-check has to be applied to it explicitly: confirm it
|
|
824
|
+
// carries the platform project's own `platform`+`environment` tags before
|
|
825
|
+
// anything is deleted from it. One extra tagging-API call, and only when a
|
|
826
|
+
// store is actually in scope.
|
|
827
|
+
if selectedStores->Array.length > 0 {
|
|
828
|
+
switch platformTarget {
|
|
829
|
+
| Some(pt) =>
|
|
830
|
+
let platformProject = projectName(~projectDir=pt.projectDir)
|
|
831
|
+
let platformFound = await discover(~region, ~stack, ~platform=platformProject)
|
|
832
|
+
selectedStores->Array.forEach(s =>
|
|
833
|
+
if !(platformFound.buckets->Array.includes(s.bucketName)) {
|
|
834
|
+
throw(
|
|
835
|
+
Seed.Failed(
|
|
836
|
+
`refusing: store "${s.qualified}" names bucket ${s.bucketName}, which does not ` ++
|
|
837
|
+
`carry reventless:platform=${platformProject} + reventless:environment=${stack}.`,
|
|
838
|
+
),
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
)
|
|
842
|
+
| None => ()
|
|
843
|
+
}
|
|
844
|
+
}
|
|
565
845
|
|
|
566
846
|
// Discover + count each target, scoped to its own project via the platform
|
|
567
847
|
// tag so a same-named stack from another project is never touched.
|
|
@@ -570,9 +850,12 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
570
850
|
switch selected->Array.get(i) {
|
|
571
851
|
| Some(target) =>
|
|
572
852
|
let platform = projectName(~projectDir=target.projectDir)
|
|
573
|
-
let
|
|
574
|
-
let tables = tables->Array.toSorted(String.compare)
|
|
575
|
-
let buckets =
|
|
853
|
+
let found = await discover(~region, ~stack, ~platform)
|
|
854
|
+
let tables = found.tables->Array.toSorted(String.compare)
|
|
855
|
+
let buckets =
|
|
856
|
+
found.buckets
|
|
857
|
+
->Array.filter(b => !(storeBucketNames->Array.includes(b)))
|
|
858
|
+
->Array.toSorted(String.compare)
|
|
576
859
|
let tableCounts = []
|
|
577
860
|
for j in 0 to tables->Array.length - 1 {
|
|
578
861
|
switch tables->Array.get(j) {
|
|
@@ -587,7 +870,27 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
587
870
|
| None => ()
|
|
588
871
|
}
|
|
589
872
|
}
|
|
590
|
-
|
|
873
|
+
let stores = selectedStores->Array.filter(s => s.plugin == pluginOf(target))
|
|
874
|
+
let storeCounts = []
|
|
875
|
+
for j in 0 to stores->Array.length - 1 {
|
|
876
|
+
switch stores->Array.get(j) {
|
|
877
|
+
| Some(s) =>
|
|
878
|
+
storeCounts->Array.push((
|
|
879
|
+
s,
|
|
880
|
+
await countBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`),
|
|
881
|
+
))
|
|
882
|
+
| None => ()
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
resolvedList->Array.push({
|
|
886
|
+
target,
|
|
887
|
+
platform,
|
|
888
|
+
tables,
|
|
889
|
+
tableCounts,
|
|
890
|
+
bucketCounts,
|
|
891
|
+
storeCounts,
|
|
892
|
+
functions: found.functions->Array.toSorted(String.compare),
|
|
893
|
+
})
|
|
591
894
|
| None => ()
|
|
592
895
|
}
|
|
593
896
|
}
|
|
@@ -597,7 +900,7 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
597
900
|
Seed.Prompt.close()
|
|
598
901
|
Console.log("")
|
|
599
902
|
Console.log(`Nothing to reset — the selected scope already reads empty in ${region}.`)
|
|
600
|
-
exit(0)
|
|
903
|
+
NodeProcess.exit(0)
|
|
601
904
|
}
|
|
602
905
|
|
|
603
906
|
// Confirmation. Interactively, re-typing the exact stack name IS the
|
|
@@ -606,11 +909,10 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
606
909
|
// TTY (CI/scripts) there is nothing to type into, so
|
|
607
910
|
// REVENTLESS_WIPE_CONFIRM=<stack> is the equivalent opt-in. Dry-run is the
|
|
608
911
|
// default either way.
|
|
609
|
-
let interactive = stdin->isTTY->Option.getOr(false)
|
|
912
|
+
let interactive = NodeProcess.stdin->NodeProcess.isTTY->Option.getOr(false)
|
|
610
913
|
let confirmed = if interactive {
|
|
611
914
|
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: `,
|
|
915
|
+
`About to permanently empty ${total->Int.toString} item(s)/object(s) across the selected scope of "${stack}". ` ++ `Type the stack name to confirm, or press Enter to keep this a dry run: `,
|
|
614
916
|
)
|
|
615
917
|
typed->String.trim == stack
|
|
616
918
|
} else {
|
|
@@ -624,76 +926,177 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
624
926
|
? "Dry run — nothing was deleted."
|
|
625
927
|
: `Dry run — nothing was deleted. Set REVENTLESS_WIPE_CONFIRM=${stack} to empty this scope non-interactively.`,
|
|
626
928
|
)
|
|
627
|
-
exit(0)
|
|
929
|
+
NodeProcess.exit(0)
|
|
628
930
|
}
|
|
629
931
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
932
|
+
// Hold the platform still before the first delete. A truncate is not
|
|
933
|
+
// durable while the runtimes that own the data are running — see
|
|
934
|
+
// ReventlessSeedAws_Quiesce for the mechanism and for why emptying the
|
|
935
|
+
// upstream stores first does not substitute for this.
|
|
936
|
+
let functionNames =
|
|
937
|
+
resolvedList->Array.reduce([], (acc, r) =>
|
|
938
|
+
Array.concat(acc, r.functions->Array.filter(f => !(acc->Array.includes(f))))
|
|
939
|
+
)
|
|
940
|
+
let quiesce = !noQuiesce() && functionNames->Array.length > 0
|
|
941
|
+
let lambdaClient = Lambda.client(~region, ())
|
|
942
|
+
let held = if quiesce {
|
|
943
|
+
Seed.Runner.heading(
|
|
944
|
+
`Holding ${functionNames->Array.length->Int.toString} runtime(s) at zero concurrency …`,
|
|
945
|
+
)
|
|
946
|
+
await Quiesce.hold(~client=lambdaClient, ~functionNames)
|
|
947
|
+
} else {
|
|
948
|
+
if functionNames->Array.length > 0 {
|
|
949
|
+
Console.log("")
|
|
950
|
+
Console.log(
|
|
951
|
+
"Note: SEED_RESET_NO_QUIESCE is set — the stack's runtimes keep running through the wipe. " ++ "A runtime that holds state across invocations can write it straight back over an emptied store.",
|
|
952
|
+
)
|
|
953
|
+
}
|
|
954
|
+
[]
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// From here the reset owns the platform's concurrency, so every exit has
|
|
958
|
+
// to run through the release below — including the abort path. ReScript
|
|
959
|
+
// has no `finally`, so the wipe's outcome is captured as a value and
|
|
960
|
+
// re-raised after the stack has been handed back.
|
|
961
|
+
let outcome = try {
|
|
962
|
+
for i in 0 to resolvedList->Array.length - 1 {
|
|
963
|
+
switch resolvedList->Array.get(i) {
|
|
964
|
+
| Some(r) =>
|
|
965
|
+
Seed.Runner.heading(`Emptying ${r.target.label} …`)
|
|
966
|
+
for j in 0 to r.tables->Array.length - 1 {
|
|
967
|
+
switch r.tables->Array.get(j) {
|
|
968
|
+
| Some(t) =>
|
|
969
|
+
await truncateTable(t)
|
|
970
|
+
Console.log(` truncated ${t}`)
|
|
971
|
+
| None => ()
|
|
972
|
+
}
|
|
640
973
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
974
|
+
for j in 0 to r.bucketCounts->Array.length - 1 {
|
|
975
|
+
switch r.bucketCounts->Array.get(j) {
|
|
976
|
+
| Some((b, _)) =>
|
|
977
|
+
await emptyBucket(b)
|
|
978
|
+
Console.log(` emptied ${b}`)
|
|
979
|
+
| None => ()
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
// Report the count with the store, not just in the run total: "how
|
|
983
|
+
// many images went" is the question an operator is actually asking.
|
|
984
|
+
for j in 0 to r.storeCounts->Array.length - 1 {
|
|
985
|
+
switch r.storeCounts->Array.get(j) {
|
|
986
|
+
| Some((s, count)) =>
|
|
987
|
+
await emptyBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`)
|
|
988
|
+
Console.log(
|
|
989
|
+
` emptied ${s.qualified} — ${count->Int.toString} object(s) removed from ` ++
|
|
990
|
+
`${s.bucketName}/${s.keyPrefix}/`,
|
|
991
|
+
)
|
|
992
|
+
| None => ()
|
|
993
|
+
}
|
|
648
994
|
}
|
|
995
|
+
| None => ()
|
|
649
996
|
}
|
|
650
|
-
| None => ()
|
|
651
997
|
}
|
|
652
|
-
}
|
|
653
998
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
999
|
+
// Prove empty — the inverse of the post-seed verify. Every store that
|
|
1000
|
+
// still reads non-zero is named, because "12 items remain" without
|
|
1001
|
+
// saying where is the difference between a diagnosis and a mystery.
|
|
1002
|
+
let refilled = []
|
|
1003
|
+
for i in 0 to resolvedList->Array.length - 1 {
|
|
1004
|
+
switch resolvedList->Array.get(i) {
|
|
1005
|
+
| Some(r) =>
|
|
1006
|
+
for j in 0 to r.tables->Array.length - 1 {
|
|
1007
|
+
switch r.tables->Array.get(j) {
|
|
1008
|
+
| Some(t) =>
|
|
1009
|
+
let left = await countTable(t)
|
|
1010
|
+
if left != 0 {
|
|
1011
|
+
refilled->Array.push((t, left))
|
|
1012
|
+
}
|
|
1013
|
+
| None => ()
|
|
1014
|
+
}
|
|
663
1015
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
1016
|
+
for j in 0 to r.bucketCounts->Array.length - 1 {
|
|
1017
|
+
switch r.bucketCounts->Array.get(j) {
|
|
1018
|
+
| Some((b, _)) =>
|
|
1019
|
+
let left = await countBucket(b)
|
|
1020
|
+
if left != 0 {
|
|
1021
|
+
refilled->Array.push((b, left))
|
|
1022
|
+
}
|
|
1023
|
+
| None => ()
|
|
1024
|
+
}
|
|
669
1025
|
}
|
|
1026
|
+
// Per-store confirmation, so "every uploaded object is gone" is an
|
|
1027
|
+
// observed fact rather than an inference from a global total.
|
|
1028
|
+
for j in 0 to r.storeCounts->Array.length - 1 {
|
|
1029
|
+
switch r.storeCounts->Array.get(j) {
|
|
1030
|
+
| Some((s, _)) =>
|
|
1031
|
+
let left = await countBucket(s.bucketName, ~prefix=`${s.keyPrefix}/`)
|
|
1032
|
+
if left != 0 {
|
|
1033
|
+
refilled->Array.push((`${s.qualified} (${s.bucketName}/${s.keyPrefix}/)`, left))
|
|
1034
|
+
}
|
|
1035
|
+
Console.log(
|
|
1036
|
+
` verified empty: ${s.qualified} — ${left->Int.toString} object(s) remain under ` ++
|
|
1037
|
+
`${s.bucketName}/${s.keyPrefix}/`,
|
|
1038
|
+
)
|
|
1039
|
+
| None => ()
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
| None => ()
|
|
670
1043
|
}
|
|
671
|
-
| None => ()
|
|
672
1044
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1045
|
+
Ok(refilled)
|
|
1046
|
+
} catch {
|
|
1047
|
+
| Seed.Failed(message) => Error(message)
|
|
1048
|
+
| exn =>
|
|
1049
|
+
Error(
|
|
1050
|
+
`the wipe failed: ${exn
|
|
1051
|
+
->JsExn.fromException
|
|
1052
|
+
->Option.flatMap(JsExn.message)
|
|
1053
|
+
->Option.getOr("unknown error")}`,
|
|
679
1054
|
)
|
|
680
1055
|
}
|
|
681
1056
|
|
|
1057
|
+
// Recycle while still held, so the container that eventually serves
|
|
1058
|
+
// traffic is created after the stores were emptied — then hand the stack
|
|
1059
|
+
// back. Both are best-effort and report rather than raise: they run on the
|
|
1060
|
+
// abort path too, where throwing would bury the reason the reset failed.
|
|
1061
|
+
let warnings = if held->Array.length > 0 {
|
|
1062
|
+
Seed.Runner.heading(`Recycling ${held->Array.length->Int.toString} runtime(s) …`)
|
|
1063
|
+
let recycled = await Quiesce.recycle(~client=lambdaClient, ~held)
|
|
1064
|
+
let released = await Quiesce.release(~client=lambdaClient, ~held)
|
|
1065
|
+
Array.concat(recycled, released)
|
|
1066
|
+
} else {
|
|
1067
|
+
[]
|
|
1068
|
+
}
|
|
1069
|
+
if warnings->Array.length > 0 {
|
|
1070
|
+
Console.error("")
|
|
1071
|
+
warnings->Array.forEach(w => Console.error(` warning: ${w}`))
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
switch outcome {
|
|
1075
|
+
| Error(message) => throw(Seed.Failed(message))
|
|
1076
|
+
| Ok(refilled) if refilled->Array.length > 0 =>
|
|
1077
|
+
throw(Seed.Failed(refillMessage(~refilled, ~heldCount=held->Array.length)))
|
|
1078
|
+
| Ok(_) => ()
|
|
1079
|
+
}
|
|
1080
|
+
|
|
682
1081
|
Console.log("")
|
|
683
|
-
Console.log(
|
|
684
|
-
|
|
1082
|
+
Console.log(
|
|
1083
|
+
`Reset complete — the selected scope of "${stack}" reads empty and is re-seedable.`,
|
|
1084
|
+
)
|
|
1085
|
+
NodeProcess.exit(0)
|
|
685
1086
|
} catch {
|
|
686
1087
|
| Seed.Failed(message) =>
|
|
687
1088
|
Seed.Prompt.close()
|
|
688
1089
|
Console.error("")
|
|
689
1090
|
Console.error(`Reset aborted — ${message}`)
|
|
690
|
-
exit(1)
|
|
1091
|
+
NodeProcess.exit(1)
|
|
691
1092
|
| exn =>
|
|
692
1093
|
Seed.Prompt.close()
|
|
693
1094
|
Console.error("")
|
|
694
1095
|
Console.error("Reset aborted with an unexpected error:")
|
|
695
|
-
Console.error(
|
|
696
|
-
|
|
1096
|
+
Console.error(
|
|
1097
|
+
exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"),
|
|
1098
|
+
)
|
|
1099
|
+
NodeProcess.exit(1)
|
|
697
1100
|
}
|
|
698
1101
|
}
|
|
699
1102
|
go()->ignore
|