@reventlessdev/reventless-seed-aws 1.0.0-alpha.2 → 1.0.0-alpha.21
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 +146 -0
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/.sourcedirs.json +1 -0
- package/lib/bs/compiler-info.json +2 -2
- 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 +87 -75
- package/lib/bs/src/ReventlessSeedAws.res.mjs +34 -30
- 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 +287 -0
- package/lib/bs/src/ReventlessSeedAws_Quiesce.res.mjs +205 -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 +385 -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 +1098 -0
- package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +991 -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/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 +133 -0
- package/lib/bs/tests/QuiesceResetTest.res.mjs +141 -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 +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/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 +133 -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 +87 -75
- 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 +287 -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 +385 -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 +1098 -0
- package/package.json +7 -4
- package/rescript.json +9 -1
- package/src/ReventlessSeedAws.res +87 -75
- package/src/ReventlessSeedAws.res.mjs +34 -30
- package/src/ReventlessSeedAws_Quiesce.res +287 -0
- package/src/ReventlessSeedAws_Quiesce.res.mjs +205 -0
- package/src/ReventlessSeedAws_Reconcile.res +385 -0
- package/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
- package/src/ReventlessSeedAws_Reset.res +1098 -0
- package/src/ReventlessSeedAws_Reset.res.mjs +991 -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
- package/tests/QuiesceResetTest.res +133 -0
- package/tests/QuiesceResetTest.res.mjs +141 -0
package/package.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-seed-aws",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
4
|
-
"description": "AWS connect for the Reventless seed harness — stack discovery
|
|
3
|
+
"version": "1.0.0-alpha.21",
|
|
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
|
-
"@reventlessdev/
|
|
7
|
+
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
|
|
8
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.7",
|
|
9
|
+
"@reventlessdev/reventless-seed": "1.0.0-alpha.15"
|
|
8
10
|
},
|
|
9
11
|
"devDependencies": {
|
|
10
|
-
"rescript": "12.3.0"
|
|
12
|
+
"rescript": "12.3.0",
|
|
13
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10"
|
|
11
14
|
},
|
|
12
15
|
"peerDependencies": {
|
|
13
16
|
"rescript": "12.3.0"
|
package/rescript.json
CHANGED
|
@@ -11,10 +11,18 @@
|
|
|
11
11
|
{
|
|
12
12
|
"dir": "src",
|
|
13
13
|
"subdirs": true
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"dir": "tests",
|
|
17
|
+
"subdirs": true,
|
|
18
|
+
"type": "dev"
|
|
14
19
|
}
|
|
15
20
|
],
|
|
16
21
|
"dependencies": [
|
|
17
|
-
"@reventlessdev/reventless-seed"
|
|
22
|
+
"@reventlessdev/reventless-seed",
|
|
23
|
+
"@reventlessdev/rescript-aws-sdk",
|
|
24
|
+
"@reventlessdev/rescript-node",
|
|
25
|
+
"@reventlessdev/rescript-jest"
|
|
18
26
|
],
|
|
19
27
|
"compiler-flags": [],
|
|
20
28
|
"suffix": ".res.mjs"
|
|
@@ -14,11 +14,6 @@ open ReventlessSeed
|
|
|
14
14
|
|
|
15
15
|
// ── Node bindings ─────────────────────────────────────────────────────────────
|
|
16
16
|
|
|
17
|
-
@val @scope("process") external processEnv: dict<string> = "env"
|
|
18
|
-
|
|
19
|
-
type execOptions = {cwd: string, encoding: string, env?: dict<string>}
|
|
20
|
-
@module("node:child_process")
|
|
21
|
-
external execFileSync: (string, array<string>, execOptions) => string = "execFileSync"
|
|
22
17
|
|
|
23
18
|
type fetchInit = {method: string, headers: dict<string>, body?: string}
|
|
24
19
|
type response
|
|
@@ -53,13 +48,17 @@ let envForBackend = (backend: option<string>): option<dict<string>> =>
|
|
|
53
48
|
switch backend {
|
|
54
49
|
| None => None
|
|
55
50
|
| Some(url) =>
|
|
56
|
-
let copy =
|
|
51
|
+
let copy = NodeProcess.env->Dict.toArray->Dict.fromArray
|
|
57
52
|
copy->Dict.set("PULUMI_BACKEND_URL", url)
|
|
58
53
|
Some(copy)
|
|
59
54
|
}
|
|
60
55
|
|
|
61
56
|
let pulumi = (~projectDir: string, ~backend: option<string>, args: array<string>): string =>
|
|
62
|
-
execFileSync(
|
|
57
|
+
NodeChildProcess.execFileSync(
|
|
58
|
+
"pulumi",
|
|
59
|
+
args,
|
|
60
|
+
{cwd: projectDir, encoding: "utf8", env: ?envForBackend(backend)},
|
|
61
|
+
)
|
|
63
62
|
|
|
64
63
|
// Stacks that actually exist in the Pulumi backend for this project — the names
|
|
65
64
|
// `pulumi stack output --stack <name>` accepts. A `Pulumi.<stack>.yaml` config
|
|
@@ -147,70 +146,88 @@ let resolveField = (~envKey: string, ~fromSource: option<string>, ~human: string
|
|
|
147
146
|
}
|
|
148
147
|
}
|
|
149
148
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
149
|
+
// Which document publishes the deployment's endpoints. A stack that serves a
|
|
150
|
+
// host shell publishes them in the shell's `config.json` under the client's key
|
|
151
|
+
// names; one that does not publishes them as stack outputs under Pulumi's. The
|
|
152
|
+
// two arms name different keys for the same values, which is why this is a
|
|
153
|
+
// variant and not a merged lookup.
|
|
154
|
+
type source = HostShellConfig(JSON.t) | StackOutputs(JSON.t)
|
|
155
|
+
|
|
156
|
+
type endpoints = {
|
|
157
|
+
graphql: string,
|
|
158
|
+
cognitoRegion: string,
|
|
159
|
+
cognitoClientId: string,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The endpoints a source document publishes — pure, so the branch selection and
|
|
164
|
+
* every key it reads can be exercised with synthetic documents.
|
|
165
|
+
*
|
|
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.
|
|
169
|
+
*/
|
|
170
|
+
let endpointsFrom = (~stack: string, source: source): endpoints =>
|
|
171
|
+
switch source {
|
|
172
|
+
| HostShellConfig(cfg) =>
|
|
163
173
|
let fromCfg = key => cfg->field(key)->Option.flatMap(asString)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
~human="cognitoClientId",
|
|
183
|
-
)
|
|
184
|
-
(endpoint, uploadEndpoint, region, clientId)
|
|
185
|
-
| None =>
|
|
174
|
+
{
|
|
175
|
+
graphql: resolveField(
|
|
176
|
+
~envKey="REVENTLESS_GRAPHQL_ENDPOINT",
|
|
177
|
+
~fromSource=fromCfg("apiEndpoint"),
|
|
178
|
+
~human="apiEndpoint",
|
|
179
|
+
),
|
|
180
|
+
cognitoRegion: resolveField(
|
|
181
|
+
~envKey="AWS_REGION",
|
|
182
|
+
~fromSource=fromCfg("region"),
|
|
183
|
+
~human="region",
|
|
184
|
+
),
|
|
185
|
+
cognitoClientId: resolveField(
|
|
186
|
+
~envKey="COGNITO_CLIENT_ID",
|
|
187
|
+
~fromSource=fromCfg("cognitoClientId"),
|
|
188
|
+
~human="cognitoClientId",
|
|
189
|
+
),
|
|
190
|
+
}
|
|
191
|
+
| StackOutputs(outputs) =>
|
|
186
192
|
let out = key => outputs->field(key)->Option.flatMap(asString)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
193
|
+
{
|
|
194
|
+
graphql: switch (
|
|
195
|
+
Seed.Prompt.envValue("REVENTLESS_GRAPHQL_ENDPOINT"),
|
|
196
|
+
out("domainMergedApiEndpoint"),
|
|
197
|
+
out("domainApiEndpoint"),
|
|
198
|
+
) {
|
|
199
|
+
| (Some(v), _, _) | (_, Some(v), _) | (_, _, Some(v)) => v
|
|
200
|
+
| _ =>
|
|
201
|
+
throw(
|
|
202
|
+
Seed.Failed(
|
|
203
|
+
`stack "${stack}" exports neither domainMergedApiEndpoint nor domainApiEndpoint`,
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
},
|
|
207
|
+
cognitoRegion: resolveField(
|
|
208
|
+
~envKey="AWS_REGION",
|
|
209
|
+
~fromSource=out("cognitoRegion"),
|
|
210
|
+
~human="cognitoRegion",
|
|
211
|
+
),
|
|
212
|
+
cognitoClientId: resolveField(
|
|
213
|
+
~envKey="COGNITO_CLIENT_ID",
|
|
214
|
+
~fromSource=out("cognitoUserPoolClientId"),
|
|
215
|
+
~human="cognitoUserPoolClientId",
|
|
216
|
+
),
|
|
199
217
|
}
|
|
200
|
-
let region = resolveField(
|
|
201
|
-
~envKey="AWS_REGION",
|
|
202
|
-
~fromSource=out("cognitoRegion"),
|
|
203
|
-
~human="cognitoRegion",
|
|
204
|
-
)
|
|
205
|
-
let clientId = resolveField(
|
|
206
|
-
~envKey="COGNITO_CLIENT_ID",
|
|
207
|
-
~fromSource=out("cognitoUserPoolClientId"),
|
|
208
|
-
~human="cognitoUserPoolClientId",
|
|
209
|
-
)
|
|
210
|
-
// Absent → "" → the data set skips its upload phase.
|
|
211
|
-
let uploadEndpoint = Seed.Prompt.envValue("REVENTLESS_UPLOAD_ENDPOINT")->Option.getOr("")
|
|
212
|
-
(endpoint, uploadEndpoint, region, clientId)
|
|
213
218
|
}
|
|
219
|
+
|
|
220
|
+
let resolveEndpoints = async (
|
|
221
|
+
~projectDir: string,
|
|
222
|
+
~backend: option<string>,
|
|
223
|
+
~stack: string,
|
|
224
|
+
): endpoints => {
|
|
225
|
+
let outputs = stackOutputs(~projectDir, ~backend, stack)
|
|
226
|
+
let source = switch outputs->field("hostShellUrl")->Option.flatMap(asString) {
|
|
227
|
+
| Some(hostShellUrl) => HostShellConfig(await fetchConfig(hostShellUrl))
|
|
228
|
+
| None => StackOutputs(outputs)
|
|
229
|
+
}
|
|
230
|
+
endpointsFrom(~stack, source)
|
|
214
231
|
}
|
|
215
232
|
|
|
216
233
|
// ── Cognito login ─────────────────────────────────────────────────────────────
|
|
@@ -302,15 +319,10 @@ let connect = (~projectDir: string=".", ~stack=?, ~backend=?, ()): (
|
|
|
302
319
|
| None => backend
|
|
303
320
|
}
|
|
304
321
|
let stackName = await resolveStack(~projectDir, ~backend, ~stack)
|
|
305
|
-
let
|
|
306
|
-
~projectDir,
|
|
307
|
-
~backend,
|
|
308
|
-
~stack=stackName,
|
|
309
|
-
)
|
|
322
|
+
let eps = await resolveEndpoints(~projectDir, ~backend, ~stack=stackName)
|
|
310
323
|
await Seed.Connect.make(
|
|
311
324
|
~label=stackName,
|
|
312
|
-
~endpoint,
|
|
313
|
-
~
|
|
314
|
-
~login=cognito(~region, ~clientId),
|
|
325
|
+
~endpoint=eps.graphql,
|
|
326
|
+
~login=cognito(~region=eps.cognitoRegion, ~clientId=eps.cognitoClientId),
|
|
315
327
|
)
|
|
316
328
|
}
|
|
@@ -152,34 +152,28 @@ function resolveField(envKey, fromSource, human) {
|
|
|
152
152
|
};
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (hostShellUrl !== undefined) {
|
|
159
|
-
let cfg = await fetchConfig(hostShellUrl);
|
|
155
|
+
function endpointsFrom(stack, source) {
|
|
156
|
+
if (source.TAG === "HostShellConfig") {
|
|
157
|
+
let cfg = source._0;
|
|
160
158
|
let fromCfg = key => Stdlib_Option.flatMap(field(cfg, key), asString);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
endpoint,
|
|
167
|
-
uploadEndpoint,
|
|
168
|
-
region,
|
|
169
|
-
clientId
|
|
170
|
-
];
|
|
159
|
+
return {
|
|
160
|
+
graphql: resolveField("REVENTLESS_GRAPHQL_ENDPOINT", fromCfg("apiEndpoint"), "apiEndpoint"),
|
|
161
|
+
cognitoRegion: resolveField("AWS_REGION", fromCfg("region"), "region"),
|
|
162
|
+
cognitoClientId: resolveField("COGNITO_CLIENT_ID", fromCfg("cognitoClientId"), "cognitoClientId")
|
|
163
|
+
};
|
|
171
164
|
}
|
|
165
|
+
let outputs = source._0;
|
|
172
166
|
let out = key => Stdlib_Option.flatMap(field(outputs, key), asString);
|
|
173
167
|
let match = Seed_Prompt$ReventlessSeed.envValue("REVENTLESS_GRAPHQL_ENDPOINT");
|
|
174
168
|
let match$1 = out("domainMergedApiEndpoint");
|
|
175
169
|
let match$2 = out("domainApiEndpoint");
|
|
176
|
-
let
|
|
170
|
+
let tmp;
|
|
177
171
|
if (match !== undefined) {
|
|
178
|
-
|
|
172
|
+
tmp = match;
|
|
179
173
|
} else if (match$1 !== undefined) {
|
|
180
|
-
|
|
174
|
+
tmp = match$1;
|
|
181
175
|
} else if (match$2 !== undefined) {
|
|
182
|
-
|
|
176
|
+
tmp = match$2;
|
|
183
177
|
} else {
|
|
184
178
|
throw {
|
|
185
179
|
RE_EXN_ID: Seed$ReventlessSeed.Failed,
|
|
@@ -187,15 +181,24 @@ async function resolveEndpoints(projectDir, backend, stack) {
|
|
|
187
181
|
Error: new Error()
|
|
188
182
|
};
|
|
189
183
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
184
|
+
return {
|
|
185
|
+
graphql: tmp,
|
|
186
|
+
cognitoRegion: resolveField("AWS_REGION", out("cognitoRegion"), "cognitoRegion"),
|
|
187
|
+
cognitoClientId: resolveField("COGNITO_CLIENT_ID", out("cognitoUserPoolClientId"), "cognitoUserPoolClientId")
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function resolveEndpoints(projectDir, backend, stack) {
|
|
192
|
+
let outputs = stackOutputs(projectDir, backend, stack);
|
|
193
|
+
let hostShellUrl = Stdlib_Option.flatMap(field(outputs, "hostShellUrl"), asString);
|
|
194
|
+
let source = hostShellUrl !== undefined ? ({
|
|
195
|
+
TAG: "HostShellConfig",
|
|
196
|
+
_0: await fetchConfig(hostShellUrl)
|
|
197
|
+
}) : ({
|
|
198
|
+
TAG: "StackOutputs",
|
|
199
|
+
_0: outputs
|
|
200
|
+
});
|
|
201
|
+
return endpointsFrom(stack, source);
|
|
199
202
|
}
|
|
200
203
|
|
|
201
204
|
function cognito(region, clientId) {
|
|
@@ -282,8 +285,8 @@ function connect($staropt$star, stack, backend, param) {
|
|
|
282
285
|
let url = Seed_Prompt$ReventlessSeed.envValue("SEED_PULUMI_BACKEND");
|
|
283
286
|
let backend$1 = url !== undefined ? url : backend;
|
|
284
287
|
let stackName = await resolveStack(projectDir, backend$1, stack);
|
|
285
|
-
let
|
|
286
|
-
return await Seed_Connect$ReventlessSeed.make(stackName,
|
|
288
|
+
let eps = await resolveEndpoints(projectDir, backend$1, stackName);
|
|
289
|
+
return await Seed_Connect$ReventlessSeed.make(stackName, eps.graphql, cognito(eps.cognitoRegion, eps.cognitoClientId), undefined);
|
|
287
290
|
};
|
|
288
291
|
}
|
|
289
292
|
|
|
@@ -298,6 +301,7 @@ export {
|
|
|
298
301
|
resolveStack,
|
|
299
302
|
fetchConfig,
|
|
300
303
|
resolveField,
|
|
304
|
+
endpointsFrom,
|
|
301
305
|
resolveEndpoints,
|
|
302
306
|
cognito,
|
|
303
307
|
connect,
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Taking a deployed stack's runtimes out of service for the length of a wipe,
|
|
2
|
+
// and putting them back exactly as they were.
|
|
3
|
+
//
|
|
4
|
+
// Why a wipe needs this at all: a truncate is not durable while the runtimes
|
|
5
|
+
// that own the data are running. A slice runtime keeps its TODO list in a
|
|
6
|
+
// module-level dict for the lifetime of its execution environment and re-saves
|
|
7
|
+
// every row it holds at the end of each invocation — including the scheduled
|
|
8
|
+
// sweep, which carries no events. Emptying the table under a warm container
|
|
9
|
+
// therefore succeeds and is then undone, byte for byte, by the next sweep: the
|
|
10
|
+
// rows are not recomputed from anything, so their ids and timestamps come back
|
|
11
|
+
// identical. Emptying the upstream stores first does not help, because nothing
|
|
12
|
+
// upstream is read.
|
|
13
|
+
//
|
|
14
|
+
// The answer is to remove the contention rather than to outrun it, which takes
|
|
15
|
+
// two distinct steps, in this order:
|
|
16
|
+
//
|
|
17
|
+
// 1. HOLD — reserve 0 concurrency on every function in scope, so no new
|
|
18
|
+
// invocation can start while the wipe and its verification run. This alone
|
|
19
|
+
// is not enough: a reservation stops invocations, it does not discard the
|
|
20
|
+
// execution environment that holds the state.
|
|
21
|
+
// 2. RECYCLE — change each function's configuration once. A configuration
|
|
22
|
+
// change is the documented way to make Lambda discard existing execution
|
|
23
|
+
// environments, so whatever a warm container was holding is gone before it
|
|
24
|
+
// can be invoked again. Done while still held, so the container that
|
|
25
|
+
// eventually replaces it starts from the emptied store.
|
|
26
|
+
//
|
|
27
|
+
// The recycle is a round trip — a marker variable added, then the original map
|
|
28
|
+
// put back — so the function ends the run byte-identical to how it started and
|
|
29
|
+
// `pulumi preview` reports no drift. The second update recycles again, which is
|
|
30
|
+
// harmless: it happens while the hold is still on and the store is already
|
|
31
|
+
// empty.
|
|
32
|
+
//
|
|
33
|
+
// Everything acquired here is released on the failure path as well as the
|
|
34
|
+
// success path, and the release never throws: a reset that aborted mid-wipe
|
|
35
|
+
// must still hand the stack back. Release failures come back as warnings for
|
|
36
|
+
// the caller to print, because a function left at zero concurrency is a stack
|
|
37
|
+
// left switched off, and that has to be said out loud rather than raised as one
|
|
38
|
+
// more exception on top of whatever already went wrong.
|
|
39
|
+
|
|
40
|
+
module Lambda = AwsSdk.Lambda
|
|
41
|
+
|
|
42
|
+
/** The variable a recycle adds and then removes. Named for what it is, so an
|
|
43
|
+
operator who finds one left behind by a killed run knows it is inert and can
|
|
44
|
+
be deleted (or left for the next `pulumi up` to remove). */
|
|
45
|
+
let markerKey = "REVENTLESS_RESET_RECYCLE"
|
|
46
|
+
|
|
47
|
+
/** What one function looked like before the reset touched it. */
|
|
48
|
+
type held = {
|
|
49
|
+
functionName: string,
|
|
50
|
+
/** `None` = the function had no reservation at all. That is a different state
|
|
51
|
+
from a reservation of 0, and restoring it needs
|
|
52
|
+
`DeleteFunctionConcurrency` rather than a put. */
|
|
53
|
+
priorReserved: option<int>,
|
|
54
|
+
/** The variable map as it was, flattened: a function with no `Environment`
|
|
55
|
+
block at all reads as an empty map and is restored to an empty map. */
|
|
56
|
+
priorVariables: dict<string>,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The Lambda control plane is rate-limited well below the data plane, and a
|
|
60
|
+
reset touches every function in a project at once. Six at a time keeps a
|
|
61
|
+
forty-function stack under a minute per pass without risking a throttle. */
|
|
62
|
+
let concurrency = 6
|
|
63
|
+
|
|
64
|
+
let mapBounded = async (items: array<'a>, ~limit: int, f: 'a => promise<'b>): array<'b> => {
|
|
65
|
+
let results = Array.make(~length=items->Array.length, None)
|
|
66
|
+
let next = ref(0)
|
|
67
|
+
let worker = async () => {
|
|
68
|
+
let running = ref(true)
|
|
69
|
+
while running.contents {
|
|
70
|
+
let i = next.contents
|
|
71
|
+
if i >= items->Array.length {
|
|
72
|
+
running := false
|
|
73
|
+
} else {
|
|
74
|
+
next := i + 1
|
|
75
|
+
let r = await f(items->Array.getUnsafe(i))
|
|
76
|
+
results->Array.set(i, Some(r))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
let _ = await Array.fromInitializer(~length=Math.Int.min(limit, items->Array.length), _ =>
|
|
81
|
+
worker()
|
|
82
|
+
)->Promise.all
|
|
83
|
+
results->Array.filterMap(x => x)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let errorText = (exn: exn): string =>
|
|
87
|
+
exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error")
|
|
88
|
+
|
|
89
|
+
// An update is rejected while another is still settling, and a stack that was
|
|
90
|
+
// deployed moments ago can still be settling. Poll rather than assume.
|
|
91
|
+
let awaitSettled = async (~client, ~functionName: string): unit => {
|
|
92
|
+
let deadline = 60
|
|
93
|
+
let rec poll = async (attempt: int): unit =>
|
|
94
|
+
if attempt < deadline {
|
|
95
|
+
let out = await Lambda.GetFunctionConfigurationCommand.send(
|
|
96
|
+
client,
|
|
97
|
+
Lambda.GetFunctionConfigurationCommand.make({functionName: functionName}),
|
|
98
|
+
)
|
|
99
|
+
switch out.lastUpdateStatus {
|
|
100
|
+
| Some("InProgress") =>
|
|
101
|
+
await ReventlessSeed.Seed.Client.sleep(1000)
|
|
102
|
+
await poll(attempt + 1)
|
|
103
|
+
| _ => ()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await poll(0)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let updateVariables = async (~client, ~functionName: string, ~variables: dict<string>): unit => {
|
|
110
|
+
await awaitSettled(~client, ~functionName)
|
|
111
|
+
let _ = await Lambda.UpdateFunctionConfigurationCommand.send(
|
|
112
|
+
client,
|
|
113
|
+
Lambda.UpdateFunctionConfigurationCommand.make({
|
|
114
|
+
functionName,
|
|
115
|
+
environment: {variables: variables},
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
await awaitSettled(~client, ~functionName)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Put one function's reservation back exactly as it was — a value it had, or no
|
|
122
|
+
reservation at all. Never throws: a function left reserved at 0 is a function
|
|
123
|
+
left switched off, which has to be said rather than raised. */
|
|
124
|
+
let restoreConcurrency = async (~client, ~h: held): option<string> =>
|
|
125
|
+
switch await (
|
|
126
|
+
switch h.priorReserved {
|
|
127
|
+
| Some(n) =>
|
|
128
|
+
Lambda.PutFunctionConcurrencyCommand.send(
|
|
129
|
+
client,
|
|
130
|
+
Lambda.PutFunctionConcurrencyCommand.make({
|
|
131
|
+
functionName: h.functionName,
|
|
132
|
+
reservedConcurrentExecutions: n,
|
|
133
|
+
}),
|
|
134
|
+
)->Promise.thenResolve(_ => ())
|
|
135
|
+
| None =>
|
|
136
|
+
Lambda.DeleteFunctionConcurrencyCommand.send(
|
|
137
|
+
client,
|
|
138
|
+
Lambda.DeleteFunctionConcurrencyCommand.make({functionName: h.functionName}),
|
|
139
|
+
)->Promise.thenResolve(_ => ())
|
|
140
|
+
}
|
|
141
|
+
) {
|
|
142
|
+
| () => None
|
|
143
|
+
| exception exn =>
|
|
144
|
+
Some(
|
|
145
|
+
`LEFT SWITCHED OFF: could not restore concurrency on ${h.functionName} (${errorText(exn)}). ` ++
|
|
146
|
+
`It is still reserved at 0 and will not run until that is undone.`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
Hand the stack back: original environment first (which also drops the marker),
|
|
152
|
+
then the original concurrency.
|
|
153
|
+
|
|
154
|
+
That order matters. The environment restore is itself a configuration change, so
|
|
155
|
+
it recycles a second time; doing it while the function is still held means the
|
|
156
|
+
container that finally serves traffic is created after the restore, not during
|
|
157
|
+
it.
|
|
158
|
+
|
|
159
|
+
Never throws, for either step and for any function — this runs on the abort path
|
|
160
|
+
too, where an exception would replace the reason the reset failed with a
|
|
161
|
+
secondary one.
|
|
162
|
+
*/
|
|
163
|
+
let release = async (~client, ~held: array<held>): array<string> => {
|
|
164
|
+
let warnings = await held->mapBounded(~limit=concurrency, async h => {
|
|
165
|
+
let envWarning = switch await updateVariables(
|
|
166
|
+
~client,
|
|
167
|
+
~functionName=h.functionName,
|
|
168
|
+
~variables=h.priorVariables,
|
|
169
|
+
) {
|
|
170
|
+
| () => None
|
|
171
|
+
| exception exn =>
|
|
172
|
+
Some(
|
|
173
|
+
`could not restore the environment of ${h.functionName} (${errorText(exn)}) — it may still ` ++
|
|
174
|
+
`carry ${markerKey}, which is inert and is removed by the next deploy.`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
let concurrencyWarning = await restoreConcurrency(~client, ~h)
|
|
178
|
+
[envWarning, concurrencyWarning]->Array.filterMap(w => w)
|
|
179
|
+
})
|
|
180
|
+
warnings->Array.flat
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
Discard every held function's execution environments, so nothing that loaded
|
|
185
|
+
state before the wipe can write it back afterwards.
|
|
186
|
+
|
|
187
|
+
Never throws. A function that cannot be recycled is the one case where the reset
|
|
188
|
+
genuinely cannot promise the store stays empty, so it comes back as a warning
|
|
189
|
+
naming the function — the caller reports it rather than swallowing it.
|
|
190
|
+
*/
|
|
191
|
+
let recycle = async (~client, ~held: array<held>): array<string> => {
|
|
192
|
+
let warnings = await held->mapBounded(~limit=concurrency, async h => {
|
|
193
|
+
let marked = h.priorVariables->Dict.copy
|
|
194
|
+
marked->Dict.set(markerKey, "1")
|
|
195
|
+
switch await updateVariables(~client, ~functionName=h.functionName, ~variables=marked) {
|
|
196
|
+
| () => None
|
|
197
|
+
| exception exn =>
|
|
198
|
+
Some(
|
|
199
|
+
`could not recycle ${h.functionName} (${errorText(exn)}) — a warm container may still hold ` ++
|
|
200
|
+
`pre-wipe state and write it back. Redeploy or update that function to clear it.`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
warnings->Array.filterMap(w => w)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
Take every named function out of service, recording what to put back.
|
|
209
|
+
|
|
210
|
+
Throws if any function cannot be held — before a single delete has been issued,
|
|
211
|
+
so a reset that cannot hold the platform still has the option of not starting.
|
|
212
|
+
The message names the IAM actions involved, because a missing permission is much
|
|
213
|
+
the likeliest cause and "AccessDenied" on its own does not say which one.
|
|
214
|
+
|
|
215
|
+
Crucially, it hands back whatever it did manage to take before throwing. The
|
|
216
|
+
functions are held concurrently, so a failure part-way through the batch leaves
|
|
217
|
+
earlier ones already at zero — and a reset that aborted while silently switching
|
|
218
|
+
off half a stack would be a worse failure than the one it was reporting. Only the
|
|
219
|
+
reservations are rolled back: this runs before `recycle`, so no environment has
|
|
220
|
+
been touched yet.
|
|
221
|
+
*/
|
|
222
|
+
let hold = async (~client, ~functionNames: array<string>): array<held> => {
|
|
223
|
+
let attempts = await functionNames->mapBounded(~limit=concurrency, async functionName =>
|
|
224
|
+
switch await (
|
|
225
|
+
async () => {
|
|
226
|
+
let concurrencyOut = await Lambda.GetFunctionConcurrencyCommand.send(
|
|
227
|
+
client,
|
|
228
|
+
Lambda.GetFunctionConcurrencyCommand.make({functionName: functionName}),
|
|
229
|
+
)
|
|
230
|
+
let configOut = await Lambda.GetFunctionConfigurationCommand.send(
|
|
231
|
+
client,
|
|
232
|
+
Lambda.GetFunctionConfigurationCommand.make({functionName: functionName}),
|
|
233
|
+
)
|
|
234
|
+
let held = {
|
|
235
|
+
functionName,
|
|
236
|
+
priorReserved: concurrencyOut.reservedConcurrentExecutions,
|
|
237
|
+
priorVariables: configOut.environment
|
|
238
|
+
->Option.flatMap(e => e.variables)
|
|
239
|
+
->Option.getOr(Dict.make()),
|
|
240
|
+
}
|
|
241
|
+
let _ = await Lambda.PutFunctionConcurrencyCommand.send(
|
|
242
|
+
client,
|
|
243
|
+
Lambda.PutFunctionConcurrencyCommand.make({
|
|
244
|
+
functionName,
|
|
245
|
+
reservedConcurrentExecutions: 0,
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
held
|
|
249
|
+
}
|
|
250
|
+
)() {
|
|
251
|
+
| held => Ok(held)
|
|
252
|
+
| exception exn => Error((functionName, errorText(exn)))
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
let taken = attempts->Array.filterMap(a =>
|
|
257
|
+
switch a {
|
|
258
|
+
| Ok(h) => Some(h)
|
|
259
|
+
| Error(_) => None
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
switch attempts->Array.findMap(a =>
|
|
263
|
+
switch a {
|
|
264
|
+
| Error(failure) => Some(failure)
|
|
265
|
+
| Ok(_) => None
|
|
266
|
+
}
|
|
267
|
+
) {
|
|
268
|
+
| None => taken
|
|
269
|
+
| Some((functionName, reason)) =>
|
|
270
|
+
let rollback = await taken->mapBounded(~limit=concurrency, h => restoreConcurrency(~client, ~h))
|
|
271
|
+
let stranded = rollback->Array.filterMap(w => w)
|
|
272
|
+
throw(
|
|
273
|
+
ReventlessSeed.Seed.Failed(
|
|
274
|
+
`could not hold Lambda function ${functionName} at zero concurrency (${reason}) — nothing was ` ++
|
|
275
|
+
`deleted. The reset holds every runtime in scope for the length of the wipe, which needs ` ++
|
|
276
|
+
`lambda:GetFunctionConcurrency, lambda:GetFunctionConfiguration, lambda:PutFunctionConcurrency, ` ++
|
|
277
|
+
`lambda:DeleteFunctionConcurrency and lambda:UpdateFunctionConfiguration. Set ` ++
|
|
278
|
+
`SEED_RESET_NO_QUIESCE=1 to wipe without the hold — but a running runtime can then write back ` ++
|
|
279
|
+
`what the wipe removes.` ++
|
|
280
|
+
switch stranded {
|
|
281
|
+
| [] => ""
|
|
282
|
+
| messages => `\n\n ` ++ messages->Array.join("\n ")
|
|
283
|
+
},
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
}
|