@reventlessdev/reventless-seed-aws 1.0.0-alpha.6 → 1.0.0-alpha.8
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 +14 -0
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/compiler-info.json +1 -1
- 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 +3 -47
- package/lib/bs/src/ReventlessSeedAws.res.mjs +1 -30
- 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 +261 -6
- package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +211 -25
- 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 +12 -109
- package/lib/bs/tests/EndpointResolutionTest.res.mjs +4 -97
- 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 -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 +12 -109
- 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 +3 -47
- 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 +261 -6
- package/package.json +2 -2
- package/src/ReventlessSeedAws.res +3 -47
- package/src/ReventlessSeedAws.res.mjs +1 -30
- package/src/ReventlessSeedAws_Reset.res +261 -6
- package/src/ReventlessSeedAws_Reset.res.mjs +211 -25
- package/tests/EndpointResolutionTest.res +12 -109
- package/tests/EndpointResolutionTest.res.mjs +4 -97
- package/tests/ObjectStoreResetTest.res +170 -0
- package/tests/ObjectStoreResetTest.res.mjs +211 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// How the reset learns which uploaded objects belong to which plugin, and what
|
|
2
|
+
// it refuses.
|
|
3
|
+
//
|
|
4
|
+
// A declared store's bucket is built by the PLATFORM deploy, so its tags say
|
|
5
|
+
// which project built it, not whose data is in it. Ownership therefore comes
|
|
6
|
+
// from the platform's `objectStores` stack output, and these pin the pure half
|
|
7
|
+
// of that: parsing the output, and refusing any store set a prefix-scoped wipe
|
|
8
|
+
// could not separate. The impure half is a `pulumi stack output` subprocess and
|
|
9
|
+
// the S3 calls, and the decisions under test are in neither.
|
|
10
|
+
|
|
11
|
+
open JestGlobals
|
|
12
|
+
|
|
13
|
+
module Reset = ReventlessSeedAws_Reset
|
|
14
|
+
|
|
15
|
+
let obj = entries => JSON.Encode.object(entries->Dict.fromArray)
|
|
16
|
+
let str = JSON.Encode.string
|
|
17
|
+
|
|
18
|
+
let storeEntry = (~bucket, ~prefix) =>
|
|
19
|
+
obj([("bucketName", str(bucket)), ("keyPrefix", str(prefix))])
|
|
20
|
+
|
|
21
|
+
let output = entries => Some(obj(entries))
|
|
22
|
+
|
|
23
|
+
let store = (~qualified, ~bucket, ~prefix): Reset.objectStore => {
|
|
24
|
+
let (plugin, name) = Reset.splitQualified(qualified)->Option.getOr(("", qualified))
|
|
25
|
+
{qualified, plugin, store: name, bucketName: bucket, keyPrefix: prefix}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("parseObjectStores", () => {
|
|
29
|
+
testSync("splits the qualified key into plugin and store", () =>
|
|
30
|
+
switch Reset.parseObjectStores(
|
|
31
|
+
output([("Catalog.productImages", storeEntry(~bucket="alpha-stores", ~prefix="productImages"))]),
|
|
32
|
+
) {
|
|
33
|
+
| Ok([s]) =>
|
|
34
|
+
expect((s.plugin, s.store, s.bucketName, s.keyPrefix))->toEqual((
|
|
35
|
+
"Catalog",
|
|
36
|
+
"productImages",
|
|
37
|
+
"alpha-stores",
|
|
38
|
+
"productImages",
|
|
39
|
+
))
|
|
40
|
+
| other => fail(`expected one parsed store, got ${other->JSON.stringifyAny->Option.getOr("?")}`)
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// A store name may contain a dot; a registered plugin name may not — so the
|
|
45
|
+
// split is at the first one, matching how the key is composed.
|
|
46
|
+
testSync("splits at the first dot only", () =>
|
|
47
|
+
switch Reset.splitQualified("Catalog.product.images") {
|
|
48
|
+
| Some((plugin, name)) => expect((plugin, name))->toEqual(("Catalog", "product.images"))
|
|
49
|
+
| None => fail("expected a split")
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// A platform that declares no stores is ordinary, not an error.
|
|
54
|
+
testSync("absent output yields no stores", () =>
|
|
55
|
+
switch Reset.parseObjectStores(None) {
|
|
56
|
+
| Ok(stores) => expect(stores->Array.length)->toBe(0)
|
|
57
|
+
| Error(message) => fail(message)
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
// A store the reset cannot read is a store it would silently leave behind, so
|
|
62
|
+
// a malformed entry fails the run rather than being skipped.
|
|
63
|
+
testSync("a malformed entry is an error, not a skip", () =>
|
|
64
|
+
switch Reset.parseObjectStores(output([("Catalog.productImages", obj([("keyPrefix", str("x"))]))])) {
|
|
65
|
+
| Ok(_) => fail("expected a malformed entry to be refused")
|
|
66
|
+
| Error(message) => expect(message->String.includes("Catalog.productImages"))->toBe(true)
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
testSync("an unqualified key is an error", () =>
|
|
71
|
+
switch Reset.parseObjectStores(
|
|
72
|
+
output([("productImages", storeEntry(~bucket="alpha-stores", ~prefix="productImages"))]),
|
|
73
|
+
) {
|
|
74
|
+
| Ok(_) => fail("expected an unqualified key to be refused")
|
|
75
|
+
| Error(message) => expect(message->String.includes("productImages"))->toBe(true)
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe("validateStores", () => {
|
|
81
|
+
testSync("distinct prefixes in one bucket are fine", () =>
|
|
82
|
+
expect(
|
|
83
|
+
Reset.validateStores([
|
|
84
|
+
store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
|
|
85
|
+
store(~qualified="Ordering.labels", ~bucket="alpha-stores", ~prefix="labels"),
|
|
86
|
+
]),
|
|
87
|
+
)->toEqual(Ok())
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// The cross-plugin collision: one prefix, two owners, nothing to tell their
|
|
91
|
+
// objects apart.
|
|
92
|
+
testSync("two plugins on one prefix are refused", () =>
|
|
93
|
+
switch Reset.validateStores([
|
|
94
|
+
store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
|
|
95
|
+
store(~qualified="Ordering.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
|
|
96
|
+
]) {
|
|
97
|
+
| Ok() => fail("expected a collision to be refused")
|
|
98
|
+
| Error(message) =>
|
|
99
|
+
expect(
|
|
100
|
+
message->String.includes("Catalog.productImages") &&
|
|
101
|
+
message->String.includes("Ordering.productImages"),
|
|
102
|
+
)->toBe(true)
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// The same shape one level up: `Catalog` encloses `Catalog/productImages`, so
|
|
107
|
+
// wiping the first would delete the second's objects.
|
|
108
|
+
testSync("an enclosing prefix is refused", () =>
|
|
109
|
+
switch Reset.validateStores([
|
|
110
|
+
store(~qualified="Legacy.catalog", ~bucket="alpha-stores", ~prefix="Catalog"),
|
|
111
|
+
store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix="Catalog/productImages"),
|
|
112
|
+
]) {
|
|
113
|
+
| Ok() => fail("expected an enclosing prefix to be refused")
|
|
114
|
+
| Error(message) => expect(message->String.includes("encloses"))->toBe(true)
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
// Different buckets cannot reach each other, so an equal prefix there is not a
|
|
119
|
+
// collision — which is what the per-store layout produces in production.
|
|
120
|
+
testSync("one prefix in two different buckets is fine", () =>
|
|
121
|
+
expect(
|
|
122
|
+
Reset.validateStores([
|
|
123
|
+
store(~qualified="Catalog.productImages", ~bucket="catalog-productImages", ~prefix="productImages"),
|
|
124
|
+
store(~qualified="Ordering.productImages", ~bucket="ordering-productImages", ~prefix="productImages"),
|
|
125
|
+
]),
|
|
126
|
+
)->toEqual(Ok())
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// A shared prefix that merely starts with another's characters is not nesting:
|
|
130
|
+
// the delete prefix carries a trailing slash, so `images` cannot reach
|
|
131
|
+
// `images-archive`.
|
|
132
|
+
testSync("a sibling prefix sharing a character run is fine", () =>
|
|
133
|
+
expect(
|
|
134
|
+
Reset.validateStores([
|
|
135
|
+
store(~qualified="Catalog.images", ~bucket="alpha-stores", ~prefix="images"),
|
|
136
|
+
store(~qualified="Catalog.imagesArchive", ~bucket="alpha-stores", ~prefix="images-archive"),
|
|
137
|
+
]),
|
|
138
|
+
)->toEqual(Ok())
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
testSync("an empty or slash-bearing store name is refused", () => {
|
|
142
|
+
switch Reset.validateStores([
|
|
143
|
+
store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix=""),
|
|
144
|
+
]) {
|
|
145
|
+
| Ok() => fail("expected an empty prefix to be refused")
|
|
146
|
+
| Error(_) => ()
|
|
147
|
+
}
|
|
148
|
+
switch Reset.validateStores([
|
|
149
|
+
store(~qualified="Catalog.a/b", ~bucket="alpha-stores", ~prefix="a/b"),
|
|
150
|
+
]) {
|
|
151
|
+
| Ok() => fail("expected a store name containing a slash to be refused")
|
|
152
|
+
| Error(_) => ()
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe("pluginOf", () => {
|
|
158
|
+
// The `objectStores` keys carry the REGISTERED plugin name; the menu carries
|
|
159
|
+
// the operator-facing label. Attribution uses the declared name so the reset
|
|
160
|
+
// never has to guess the relation between the two.
|
|
161
|
+
testSync("prefers the declared plugin name over the label", () =>
|
|
162
|
+
expect(
|
|
163
|
+
Reset.pluginOf({projectDir: "../catalog-aws", label: "catalog", group: Domain, plugin: "Catalog"}),
|
|
164
|
+
)->toBe("Catalog")
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
testSync("falls back to the label when no plugin name is declared", () =>
|
|
168
|
+
expect(Reset.pluginOf({projectDir: ".", label: "platform", group: Platform}))->toBe("platform")
|
|
169
|
+
)
|
|
170
|
+
})
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -36,18 +36,6 @@ let asString = (json: JSON.t): option<string> =>
|
|
|
36
36
|
| _ => None
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// Non-string members are dropped rather than failing the whole map: one
|
|
40
|
-
// malformed entry should not cost a run every endpoint it could have used.
|
|
41
|
-
let asStringDict = (json: JSON.t): dict<string> =>
|
|
42
|
-
switch json {
|
|
43
|
-
| Object(obj) =>
|
|
44
|
-
obj
|
|
45
|
-
->Dict.toArray
|
|
46
|
-
->Array.filterMap(((k, v)) => v->asString->Option.map(s => (k, s)))
|
|
47
|
-
->Dict.fromArray
|
|
48
|
-
| _ => Dict.make()
|
|
49
|
-
}
|
|
50
|
-
|
|
51
39
|
// ── Stack discovery ───────────────────────────────────────────────────────────
|
|
52
40
|
|
|
53
41
|
// Which Pulumi backend the `pulumi` subprocess reads from. When `backend` is
|
|
@@ -158,16 +146,6 @@ let resolveField = (~envKey: string, ~fromSource: option<string>, ~human: string
|
|
|
158
146
|
}
|
|
159
147
|
}
|
|
160
148
|
|
|
161
|
-
// Same precedence, for a value whose absence is a legitimate answer rather than
|
|
162
|
-
// a broken deployment: a stack that declares no store publishes no upload
|
|
163
|
-
// endpoint, and that must stay a no-op at the data set instead of failing the
|
|
164
|
-
// run before a single command is sent.
|
|
165
|
-
let optionalField = (~envKey: string, ~fromSource: option<string>): string =>
|
|
166
|
-
switch Seed.Prompt.envValue(envKey) {
|
|
167
|
-
| Some(v) => v
|
|
168
|
-
| None => fromSource->Option.getOr("")
|
|
169
|
-
}
|
|
170
|
-
|
|
171
149
|
// Which document publishes the deployment's endpoints. A stack that serves a
|
|
172
150
|
// host shell publishes them in the shell's `config.json` under the client's key
|
|
173
151
|
// names; one that does not publishes them as stack outputs under Pulumi's. The
|
|
@@ -177,11 +155,6 @@ type source = HostShellConfig(JSON.t) | StackOutputs(JSON.t)
|
|
|
177
155
|
|
|
178
156
|
type endpoints = {
|
|
179
157
|
graphql: string,
|
|
180
|
-
// The legacy single presign service; "" when the deployment publishes none.
|
|
181
|
-
uploadEndpoint: string,
|
|
182
|
-
// Qualified `{plugin}.{store}` → that store's presign endpoint. Empty when the
|
|
183
|
-
// deployment declares no store.
|
|
184
|
-
uploadEndpoints: dict<string>,
|
|
185
158
|
cognitoRegion: string,
|
|
186
159
|
cognitoClientId: string,
|
|
187
160
|
}
|
|
@@ -190,15 +163,9 @@ type endpoints = {
|
|
|
190
163
|
* The endpoints a source document publishes — pure, so the branch selection and
|
|
191
164
|
* every key it reads can be exercised with synthetic documents.
|
|
192
165
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* stores and no host shell resolved "" and reported itself as serving no
|
|
197
|
-
* uploads.
|
|
198
|
-
*
|
|
199
|
-
* `REVENTLESS_UPLOAD_ENDPOINT` still overrides the singular endpoint and does
|
|
200
|
-
* not touch the map: one string cannot express a map, so it could only ever
|
|
201
|
-
* override one store and would need a naming convention to say which.
|
|
166
|
+
* Upload endpoints are no longer resolved here: under route B the seed mints through
|
|
167
|
+
* the domain API's `Upload_Presign` mutation on `graphql`, passing the store per asset,
|
|
168
|
+
* so there is no per-store URL to discover.
|
|
202
169
|
*/
|
|
203
170
|
let endpointsFrom = (~stack: string, source: source): endpoints =>
|
|
204
171
|
switch source {
|
|
@@ -210,11 +177,6 @@ let endpointsFrom = (~stack: string, source: source): endpoints =>
|
|
|
210
177
|
~fromSource=fromCfg("apiEndpoint"),
|
|
211
178
|
~human="apiEndpoint",
|
|
212
179
|
),
|
|
213
|
-
uploadEndpoint: optionalField(
|
|
214
|
-
~envKey="REVENTLESS_UPLOAD_ENDPOINT",
|
|
215
|
-
~fromSource=fromCfg("uploadEndpoint"),
|
|
216
|
-
),
|
|
217
|
-
uploadEndpoints: cfg->field("uploadEndpoints")->Option.mapOr(Dict.make(), asStringDict),
|
|
218
180
|
cognitoRegion: resolveField(
|
|
219
181
|
~envKey="AWS_REGION",
|
|
220
182
|
~fromSource=fromCfg("region"),
|
|
@@ -242,10 +204,6 @@ let endpointsFrom = (~stack: string, source: source): endpoints =>
|
|
|
242
204
|
),
|
|
243
205
|
)
|
|
244
206
|
},
|
|
245
|
-
// No stack output carries the legacy single service — it exists only in a
|
|
246
|
-
// host shell's config.json — so here the env override is its only source.
|
|
247
|
-
uploadEndpoint: optionalField(~envKey="REVENTLESS_UPLOAD_ENDPOINT", ~fromSource=None),
|
|
248
|
-
uploadEndpoints: outputs->field("uploadEndpoints")->Option.mapOr(Dict.make(), asStringDict),
|
|
249
207
|
cognitoRegion: resolveField(
|
|
250
208
|
~envKey="AWS_REGION",
|
|
251
209
|
~fromSource=out("cognitoRegion"),
|
|
@@ -365,8 +323,6 @@ let connect = (~projectDir: string=".", ~stack=?, ~backend=?, ()): (
|
|
|
365
323
|
await Seed.Connect.make(
|
|
366
324
|
~label=stackName,
|
|
367
325
|
~endpoint=eps.graphql,
|
|
368
|
-
~uploadEndpoint=eps.uploadEndpoint,
|
|
369
|
-
~uploadEndpoints=eps.uploadEndpoints,
|
|
370
326
|
~login=cognito(~region=eps.cognitoRegion, ~clientId=eps.cognitoClientId),
|
|
371
327
|
)
|
|
372
328
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -213,6 +213,118 @@ let discover = async (~region, ~stack, ~platform): (array<string>, array<string>
|
|
|
213
213
|
(tables, buckets)
|
|
214
214
|
}
|
|
215
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
|
+
|
|
216
328
|
// ── Counting (dry-run) ─────────────────────────────────────────────────────────
|
|
217
329
|
|
|
218
330
|
let countTable = async (table: string): int => {
|
|
@@ -233,11 +345,14 @@ let countTable = async (table: string): int => {
|
|
|
233
345
|
await loop(None, 0)
|
|
234
346
|
}
|
|
235
347
|
|
|
236
|
-
|
|
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 => {
|
|
237
351
|
let rec loop = async (keyMarker, versionMarker, acc): int => {
|
|
238
352
|
let out = await S3.ListObjectVersionsCommand.send(
|
|
239
353
|
S3.ListObjectVersionsCommand.make({
|
|
240
354
|
bucket,
|
|
355
|
+
prefix: ?prefix,
|
|
241
356
|
keyMarker: ?keyMarker,
|
|
242
357
|
versionIdMarker: ?versionMarker,
|
|
243
358
|
}),
|
|
@@ -327,11 +442,12 @@ let truncateTable = async (table: string): unit => {
|
|
|
327
442
|
|
|
328
443
|
// One ListObjectVersions page returns ≤ 1000 entries (versions + delete
|
|
329
444
|
// markers), and DeleteObjects takes ≤ 1000, so one list page maps to one delete.
|
|
330
|
-
let emptyBucket = async (bucket: string): unit => {
|
|
445
|
+
let emptyBucket = async (bucket: string, ~prefix: option<string>=?): unit => {
|
|
331
446
|
let rec loop = async (keyMarker, versionMarker): unit => {
|
|
332
447
|
let out = await S3.ListObjectVersionsCommand.send(
|
|
333
448
|
S3.ListObjectVersionsCommand.make({
|
|
334
449
|
bucket,
|
|
450
|
+
prefix: ?prefix,
|
|
335
451
|
keyMarker: ?keyMarker,
|
|
336
452
|
versionIdMarker: ?versionMarker,
|
|
337
453
|
}),
|
|
@@ -383,8 +499,16 @@ type target = {
|
|
|
383
499
|
projectDir: string,
|
|
384
500
|
label: string,
|
|
385
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,
|
|
386
508
|
}
|
|
387
509
|
|
|
510
|
+
let pluginOf = (t: target): string => t.plugin->Option.getOr(t.label)
|
|
511
|
+
|
|
388
512
|
// A target resolved to its discovered, counted stores, ready to report and wipe.
|
|
389
513
|
type resolved = {
|
|
390
514
|
target: target,
|
|
@@ -392,6 +516,7 @@ type resolved = {
|
|
|
392
516
|
tables: array<string>,
|
|
393
517
|
tableCounts: array<int>,
|
|
394
518
|
bucketCounts: array<(string, int)>,
|
|
519
|
+
storeCounts: array<(objectStore, int)>,
|
|
395
520
|
}
|
|
396
521
|
|
|
397
522
|
// Picks which targets to wipe. `domain` (every domain plugin) leads and is the
|
|
@@ -496,6 +621,19 @@ let reportAll = (resolvedList: array<resolved>, ~stack, ~region): int => {
|
|
|
496
621
|
if r.bucketCounts->Array.length == 0 {
|
|
497
622
|
Console.log(" (none)")
|
|
498
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
|
+
}
|
|
499
637
|
})
|
|
500
638
|
total.contents
|
|
501
639
|
}
|
|
@@ -541,9 +679,53 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
541
679
|
|
|
542
680
|
let selected = await chooseScope(~targets)
|
|
543
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
|
+
|
|
544
718
|
// Gate every selected target and collect its region; all must agree, since
|
|
545
|
-
// the DynamoDB/S3 clients read one region from the environment.
|
|
546
|
-
|
|
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))
|
|
547
729
|
let region = regions->Array.getUnsafe(0)
|
|
548
730
|
if regions->Array.some(r => r != region) {
|
|
549
731
|
throw(
|
|
@@ -556,6 +738,30 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
556
738
|
}
|
|
557
739
|
NodeProcess.env->Dict.set("AWS_REGION", region)
|
|
558
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
|
+
|
|
559
765
|
// Discover + count each target, scoped to its own project via the platform
|
|
560
766
|
// tag so a same-named stack from another project is never touched.
|
|
561
767
|
let resolvedList = []
|
|
@@ -565,7 +771,10 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
565
771
|
let platform = projectName(~projectDir=target.projectDir)
|
|
566
772
|
let (tables, buckets) = await discover(~region, ~stack, ~platform)
|
|
567
773
|
let tables = tables->Array.toSorted(String.compare)
|
|
568
|
-
let buckets =
|
|
774
|
+
let buckets =
|
|
775
|
+
buckets
|
|
776
|
+
->Array.filter(b => !(storeBucketNames->Array.includes(b)))
|
|
777
|
+
->Array.toSorted(String.compare)
|
|
569
778
|
let tableCounts = []
|
|
570
779
|
for j in 0 to tables->Array.length - 1 {
|
|
571
780
|
switch tables->Array.get(j) {
|
|
@@ -580,7 +789,26 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
580
789
|
| None => ()
|
|
581
790
|
}
|
|
582
791
|
}
|
|
583
|
-
|
|
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
|
+
})
|
|
584
812
|
| None => ()
|
|
585
813
|
}
|
|
586
814
|
}
|
|
@@ -640,6 +868,19 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
640
868
|
| None => ()
|
|
641
869
|
}
|
|
642
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
|
+
}
|
|
643
884
|
| None => ()
|
|
644
885
|
}
|
|
645
886
|
}
|
|
@@ -661,6 +902,20 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
661
902
|
| None => ()
|
|
662
903
|
}
|
|
663
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
|
+
}
|
|
664
919
|
| None => ()
|
|
665
920
|
}
|
|
666
921
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-seed-aws",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.8",
|
|
4
4
|
"description": "AWS connect + guarded reset for the Reventless seed harness — stack discovery, Cognito login, and a tag-scoped, allowlisted store wipe",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.0",
|
|
8
8
|
"@reventlessdev/rescript-node": "2.0.0-alpha.0",
|
|
9
|
-
"@reventlessdev/reventless-seed": "1.0.0-alpha.
|
|
9
|
+
"@reventlessdev/reventless-seed": "1.0.0-alpha.7"
|
|
10
10
|
},
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"rescript": "12.3.0",
|