@reventlessdev/reventless-seed-aws 1.0.0-alpha.4 → 1.0.0-alpha.6
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 +15 -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 +131 -75
- package/lib/bs/src/ReventlessSeedAws.res.mjs +63 -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.cmt +0 -0
- package/lib/bs/src/ReventlessSeedAws_Reset.res +8 -15
- 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 +180 -0
- package/lib/bs/tests/EndpointResolutionTest.res.mjs +208 -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 +180 -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 +131 -75
- package/lib/ocaml/ReventlessSeedAws_Reset.ast +0 -0
- package/lib/ocaml/ReventlessSeedAws_Reset.cmi +0 -0
- package/lib/ocaml/ReventlessSeedAws_Reset.cmt +0 -0
- package/lib/ocaml/ReventlessSeedAws_Reset.res +8 -15
- package/package.json +6 -4
- package/rescript.json +8 -1
- package/src/ReventlessSeedAws.res +131 -75
- package/src/ReventlessSeedAws.res.mjs +63 -30
- package/src/ReventlessSeedAws_Reset.res +8 -15
- package/tests/EndpointResolutionTest.res +180 -0
- package/tests/EndpointResolutionTest.res.mjs +208 -0
|
@@ -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
|
|
@@ -41,6 +36,18 @@ let asString = (json: JSON.t): option<string> =>
|
|
|
41
36
|
| _ => None
|
|
42
37
|
}
|
|
43
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
|
+
|
|
44
51
|
// ── Stack discovery ───────────────────────────────────────────────────────────
|
|
45
52
|
|
|
46
53
|
// Which Pulumi backend the `pulumi` subprocess reads from. When `backend` is
|
|
@@ -53,13 +60,17 @@ let envForBackend = (backend: option<string>): option<dict<string>> =>
|
|
|
53
60
|
switch backend {
|
|
54
61
|
| None => None
|
|
55
62
|
| Some(url) =>
|
|
56
|
-
let copy =
|
|
63
|
+
let copy = NodeProcess.env->Dict.toArray->Dict.fromArray
|
|
57
64
|
copy->Dict.set("PULUMI_BACKEND_URL", url)
|
|
58
65
|
Some(copy)
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
let pulumi = (~projectDir: string, ~backend: option<string>, args: array<string>): string =>
|
|
62
|
-
execFileSync(
|
|
69
|
+
NodeChildProcess.execFileSync(
|
|
70
|
+
"pulumi",
|
|
71
|
+
args,
|
|
72
|
+
{cwd: projectDir, encoding: "utf8", env: ?envForBackend(backend)},
|
|
73
|
+
)
|
|
63
74
|
|
|
64
75
|
// Stacks that actually exist in the Pulumi backend for this project — the names
|
|
65
76
|
// `pulumi stack output --stack <name>` accepts. A `Pulumi.<stack>.yaml` config
|
|
@@ -147,70 +158,118 @@ let resolveField = (~envKey: string, ~fromSource: option<string>, ~human: string
|
|
|
147
158
|
}
|
|
148
159
|
}
|
|
149
160
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
+
// Which document publishes the deployment's endpoints. A stack that serves a
|
|
172
|
+
// host shell publishes them in the shell's `config.json` under the client's key
|
|
173
|
+
// names; one that does not publishes them as stack outputs under Pulumi's. The
|
|
174
|
+
// two arms name different keys for the same values, which is why this is a
|
|
175
|
+
// variant and not a merged lookup.
|
|
176
|
+
type source = HostShellConfig(JSON.t) | StackOutputs(JSON.t)
|
|
177
|
+
|
|
178
|
+
type endpoints = {
|
|
179
|
+
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
|
+
cognitoRegion: string,
|
|
186
|
+
cognitoClientId: string,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The endpoints a source document publishes — pure, so the branch selection and
|
|
191
|
+
* every key it reads can be exercised with synthetic documents.
|
|
192
|
+
*
|
|
193
|
+
* `uploadEndpoints` is read on **both** arms. It was read on neither: the
|
|
194
|
+
* host-shell arm looked only at the singular `uploadEndpoint`, and the
|
|
195
|
+
* stack-output arm had no upload lookup at all, so a platform that publishes its
|
|
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.
|
|
202
|
+
*/
|
|
203
|
+
let endpointsFrom = (~stack: string, source: source): endpoints =>
|
|
204
|
+
switch source {
|
|
205
|
+
| HostShellConfig(cfg) =>
|
|
163
206
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
207
|
+
{
|
|
208
|
+
graphql: resolveField(
|
|
209
|
+
~envKey="REVENTLESS_GRAPHQL_ENDPOINT",
|
|
210
|
+
~fromSource=fromCfg("apiEndpoint"),
|
|
211
|
+
~human="apiEndpoint",
|
|
212
|
+
),
|
|
213
|
+
uploadEndpoint: optionalField(
|
|
214
|
+
~envKey="REVENTLESS_UPLOAD_ENDPOINT",
|
|
215
|
+
~fromSource=fromCfg("uploadEndpoint"),
|
|
216
|
+
),
|
|
217
|
+
uploadEndpoints: cfg->field("uploadEndpoints")->Option.mapOr(Dict.make(), asStringDict),
|
|
218
|
+
cognitoRegion: resolveField(
|
|
219
|
+
~envKey="AWS_REGION",
|
|
220
|
+
~fromSource=fromCfg("region"),
|
|
221
|
+
~human="region",
|
|
222
|
+
),
|
|
223
|
+
cognitoClientId: resolveField(
|
|
224
|
+
~envKey="COGNITO_CLIENT_ID",
|
|
225
|
+
~fromSource=fromCfg("cognitoClientId"),
|
|
226
|
+
~human="cognitoClientId",
|
|
227
|
+
),
|
|
228
|
+
}
|
|
229
|
+
| StackOutputs(outputs) =>
|
|
186
230
|
let out = key => outputs->field(key)->Option.flatMap(asString)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
231
|
+
{
|
|
232
|
+
graphql: switch (
|
|
233
|
+
Seed.Prompt.envValue("REVENTLESS_GRAPHQL_ENDPOINT"),
|
|
234
|
+
out("domainMergedApiEndpoint"),
|
|
235
|
+
out("domainApiEndpoint"),
|
|
236
|
+
) {
|
|
237
|
+
| (Some(v), _, _) | (_, Some(v), _) | (_, _, Some(v)) => v
|
|
238
|
+
| _ =>
|
|
239
|
+
throw(
|
|
240
|
+
Seed.Failed(
|
|
241
|
+
`stack "${stack}" exports neither domainMergedApiEndpoint nor domainApiEndpoint`,
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
},
|
|
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
|
+
cognitoRegion: resolveField(
|
|
250
|
+
~envKey="AWS_REGION",
|
|
251
|
+
~fromSource=out("cognitoRegion"),
|
|
252
|
+
~human="cognitoRegion",
|
|
253
|
+
),
|
|
254
|
+
cognitoClientId: resolveField(
|
|
255
|
+
~envKey="COGNITO_CLIENT_ID",
|
|
256
|
+
~fromSource=out("cognitoUserPoolClientId"),
|
|
257
|
+
~human="cognitoUserPoolClientId",
|
|
258
|
+
),
|
|
199
259
|
}
|
|
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
260
|
}
|
|
261
|
+
|
|
262
|
+
let resolveEndpoints = async (
|
|
263
|
+
~projectDir: string,
|
|
264
|
+
~backend: option<string>,
|
|
265
|
+
~stack: string,
|
|
266
|
+
): endpoints => {
|
|
267
|
+
let outputs = stackOutputs(~projectDir, ~backend, stack)
|
|
268
|
+
let source = switch outputs->field("hostShellUrl")->Option.flatMap(asString) {
|
|
269
|
+
| Some(hostShellUrl) => HostShellConfig(await fetchConfig(hostShellUrl))
|
|
270
|
+
| None => StackOutputs(outputs)
|
|
271
|
+
}
|
|
272
|
+
endpointsFrom(~stack, source)
|
|
214
273
|
}
|
|
215
274
|
|
|
216
275
|
// ── Cognito login ─────────────────────────────────────────────────────────────
|
|
@@ -302,15 +361,12 @@ let connect = (~projectDir: string=".", ~stack=?, ~backend=?, ()): (
|
|
|
302
361
|
| None => backend
|
|
303
362
|
}
|
|
304
363
|
let stackName = await resolveStack(~projectDir, ~backend, ~stack)
|
|
305
|
-
let
|
|
306
|
-
~projectDir,
|
|
307
|
-
~backend,
|
|
308
|
-
~stack=stackName,
|
|
309
|
-
)
|
|
364
|
+
let eps = await resolveEndpoints(~projectDir, ~backend, ~stack=stackName)
|
|
310
365
|
await Seed.Connect.make(
|
|
311
366
|
~label=stackName,
|
|
312
|
-
~endpoint,
|
|
313
|
-
~uploadEndpoint,
|
|
314
|
-
~
|
|
367
|
+
~endpoint=eps.graphql,
|
|
368
|
+
~uploadEndpoint=eps.uploadEndpoint,
|
|
369
|
+
~uploadEndpoints=eps.uploadEndpoints,
|
|
370
|
+
~login=cognito(~region=eps.cognitoRegion, ~clientId=eps.cognitoClientId),
|
|
315
371
|
)
|
|
316
372
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -48,14 +48,7 @@ module Ddb = AwsSdk.DynamoDb
|
|
|
48
48
|
module Rgt = AwsSdk.ResourceGroupsTaggingApi
|
|
49
49
|
module S3 = AwsSdk.S3
|
|
50
50
|
|
|
51
|
-
@scope("process") @val external exit: int => unit = "exit"
|
|
52
|
-
@scope("process") @val external processEnv: dict<string> = "env"
|
|
53
|
-
@module("node:fs") external readFileSync: (string, string) => string = "readFileSync"
|
|
54
51
|
|
|
55
|
-
type stream
|
|
56
|
-
@scope("process") @val external stdin: stream = "stdin"
|
|
57
|
-
// `process.stdin.isTTY` is `true` interactively and `undefined` under a pipe/CI.
|
|
58
|
-
@get external isTTY: stream => option<bool> = "isTTY"
|
|
59
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
|
|
@@ -561,7 +554,7 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
561
554
|
),
|
|
562
555
|
)
|
|
563
556
|
}
|
|
564
|
-
|
|
557
|
+
NodeProcess.env->Dict.set("AWS_REGION", region)
|
|
565
558
|
|
|
566
559
|
// Discover + count each target, scoped to its own project via the platform
|
|
567
560
|
// tag so a same-named stack from another project is never touched.
|
|
@@ -597,7 +590,7 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
597
590
|
Seed.Prompt.close()
|
|
598
591
|
Console.log("")
|
|
599
592
|
Console.log(`Nothing to reset — the selected scope already reads empty in ${region}.`)
|
|
600
|
-
exit(0)
|
|
593
|
+
NodeProcess.exit(0)
|
|
601
594
|
}
|
|
602
595
|
|
|
603
596
|
// Confirmation. Interactively, re-typing the exact stack name IS the
|
|
@@ -606,7 +599,7 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
606
599
|
// TTY (CI/scripts) there is nothing to type into, so
|
|
607
600
|
// REVENTLESS_WIPE_CONFIRM=<stack> is the equivalent opt-in. Dry-run is the
|
|
608
601
|
// default either way.
|
|
609
|
-
let interactive = stdin->isTTY->Option.getOr(false)
|
|
602
|
+
let interactive = NodeProcess.stdin->NodeProcess.isTTY->Option.getOr(false)
|
|
610
603
|
let confirmed = if interactive {
|
|
611
604
|
let typed = await Seed.Prompt.ask(
|
|
612
605
|
`About to permanently empty ${total->Int.toString} item(s)/object(s) across the selected scope of "${stack}". ` ++
|
|
@@ -624,7 +617,7 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
624
617
|
? "Dry run — nothing was deleted."
|
|
625
618
|
: `Dry run — nothing was deleted. Set REVENTLESS_WIPE_CONFIRM=${stack} to empty this scope non-interactively.`,
|
|
626
619
|
)
|
|
627
|
-
exit(0)
|
|
620
|
+
NodeProcess.exit(0)
|
|
628
621
|
}
|
|
629
622
|
|
|
630
623
|
for i in 0 to resolvedList->Array.length - 1 {
|
|
@@ -681,19 +674,19 @@ let run = (~stack=?, ~backend=?, ~targets: array<target>, ()): unit => {
|
|
|
681
674
|
|
|
682
675
|
Console.log("")
|
|
683
676
|
Console.log(`Reset complete — the selected scope of "${stack}" reads empty and is re-seedable.`)
|
|
684
|
-
exit(0)
|
|
677
|
+
NodeProcess.exit(0)
|
|
685
678
|
} catch {
|
|
686
679
|
| Seed.Failed(message) =>
|
|
687
680
|
Seed.Prompt.close()
|
|
688
681
|
Console.error("")
|
|
689
682
|
Console.error(`Reset aborted — ${message}`)
|
|
690
|
-
exit(1)
|
|
683
|
+
NodeProcess.exit(1)
|
|
691
684
|
| exn =>
|
|
692
685
|
Seed.Prompt.close()
|
|
693
686
|
Console.error("")
|
|
694
687
|
Console.error("Reset aborted with an unexpected error:")
|
|
695
688
|
Console.error(exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown"))
|
|
696
|
-
exit(1)
|
|
689
|
+
NodeProcess.exit(1)
|
|
697
690
|
}
|
|
698
691
|
}
|
|
699
692
|
go()->ignore
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-seed-aws",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.6",
|
|
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
|
-
"@reventlessdev/rescript-aws-sdk": "
|
|
8
|
-
"@reventlessdev/
|
|
7
|
+
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.0",
|
|
8
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.0",
|
|
9
|
+
"@reventlessdev/reventless-seed": "1.0.0-alpha.6"
|
|
9
10
|
},
|
|
10
11
|
"devDependencies": {
|
|
11
|
-
"rescript": "12.3.0"
|
|
12
|
+
"rescript": "12.3.0",
|
|
13
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10"
|
|
12
14
|
},
|
|
13
15
|
"peerDependencies": {
|
|
14
16
|
"rescript": "12.3.0"
|
package/rescript.json
CHANGED
|
@@ -11,11 +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
22
|
"@reventlessdev/reventless-seed",
|
|
18
|
-
"@reventlessdev/rescript-aws-sdk"
|
|
23
|
+
"@reventlessdev/rescript-aws-sdk",
|
|
24
|
+
"@reventlessdev/rescript-node",
|
|
25
|
+
"@reventlessdev/rescript-jest"
|
|
19
26
|
],
|
|
20
27
|
"compiler-flags": [],
|
|
21
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
|
|
@@ -41,6 +36,18 @@ let asString = (json: JSON.t): option<string> =>
|
|
|
41
36
|
| _ => None
|
|
42
37
|
}
|
|
43
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
|
+
|
|
44
51
|
// ── Stack discovery ───────────────────────────────────────────────────────────
|
|
45
52
|
|
|
46
53
|
// Which Pulumi backend the `pulumi` subprocess reads from. When `backend` is
|
|
@@ -53,13 +60,17 @@ let envForBackend = (backend: option<string>): option<dict<string>> =>
|
|
|
53
60
|
switch backend {
|
|
54
61
|
| None => None
|
|
55
62
|
| Some(url) =>
|
|
56
|
-
let copy =
|
|
63
|
+
let copy = NodeProcess.env->Dict.toArray->Dict.fromArray
|
|
57
64
|
copy->Dict.set("PULUMI_BACKEND_URL", url)
|
|
58
65
|
Some(copy)
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
let pulumi = (~projectDir: string, ~backend: option<string>, args: array<string>): string =>
|
|
62
|
-
execFileSync(
|
|
69
|
+
NodeChildProcess.execFileSync(
|
|
70
|
+
"pulumi",
|
|
71
|
+
args,
|
|
72
|
+
{cwd: projectDir, encoding: "utf8", env: ?envForBackend(backend)},
|
|
73
|
+
)
|
|
63
74
|
|
|
64
75
|
// Stacks that actually exist in the Pulumi backend for this project — the names
|
|
65
76
|
// `pulumi stack output --stack <name>` accepts. A `Pulumi.<stack>.yaml` config
|
|
@@ -147,70 +158,118 @@ let resolveField = (~envKey: string, ~fromSource: option<string>, ~human: string
|
|
|
147
158
|
}
|
|
148
159
|
}
|
|
149
160
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
+
// Which document publishes the deployment's endpoints. A stack that serves a
|
|
172
|
+
// host shell publishes them in the shell's `config.json` under the client's key
|
|
173
|
+
// names; one that does not publishes them as stack outputs under Pulumi's. The
|
|
174
|
+
// two arms name different keys for the same values, which is why this is a
|
|
175
|
+
// variant and not a merged lookup.
|
|
176
|
+
type source = HostShellConfig(JSON.t) | StackOutputs(JSON.t)
|
|
177
|
+
|
|
178
|
+
type endpoints = {
|
|
179
|
+
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
|
+
cognitoRegion: string,
|
|
186
|
+
cognitoClientId: string,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The endpoints a source document publishes — pure, so the branch selection and
|
|
191
|
+
* every key it reads can be exercised with synthetic documents.
|
|
192
|
+
*
|
|
193
|
+
* `uploadEndpoints` is read on **both** arms. It was read on neither: the
|
|
194
|
+
* host-shell arm looked only at the singular `uploadEndpoint`, and the
|
|
195
|
+
* stack-output arm had no upload lookup at all, so a platform that publishes its
|
|
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.
|
|
202
|
+
*/
|
|
203
|
+
let endpointsFrom = (~stack: string, source: source): endpoints =>
|
|
204
|
+
switch source {
|
|
205
|
+
| HostShellConfig(cfg) =>
|
|
163
206
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
207
|
+
{
|
|
208
|
+
graphql: resolveField(
|
|
209
|
+
~envKey="REVENTLESS_GRAPHQL_ENDPOINT",
|
|
210
|
+
~fromSource=fromCfg("apiEndpoint"),
|
|
211
|
+
~human="apiEndpoint",
|
|
212
|
+
),
|
|
213
|
+
uploadEndpoint: optionalField(
|
|
214
|
+
~envKey="REVENTLESS_UPLOAD_ENDPOINT",
|
|
215
|
+
~fromSource=fromCfg("uploadEndpoint"),
|
|
216
|
+
),
|
|
217
|
+
uploadEndpoints: cfg->field("uploadEndpoints")->Option.mapOr(Dict.make(), asStringDict),
|
|
218
|
+
cognitoRegion: resolveField(
|
|
219
|
+
~envKey="AWS_REGION",
|
|
220
|
+
~fromSource=fromCfg("region"),
|
|
221
|
+
~human="region",
|
|
222
|
+
),
|
|
223
|
+
cognitoClientId: resolveField(
|
|
224
|
+
~envKey="COGNITO_CLIENT_ID",
|
|
225
|
+
~fromSource=fromCfg("cognitoClientId"),
|
|
226
|
+
~human="cognitoClientId",
|
|
227
|
+
),
|
|
228
|
+
}
|
|
229
|
+
| StackOutputs(outputs) =>
|
|
186
230
|
let out = key => outputs->field(key)->Option.flatMap(asString)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
231
|
+
{
|
|
232
|
+
graphql: switch (
|
|
233
|
+
Seed.Prompt.envValue("REVENTLESS_GRAPHQL_ENDPOINT"),
|
|
234
|
+
out("domainMergedApiEndpoint"),
|
|
235
|
+
out("domainApiEndpoint"),
|
|
236
|
+
) {
|
|
237
|
+
| (Some(v), _, _) | (_, Some(v), _) | (_, _, Some(v)) => v
|
|
238
|
+
| _ =>
|
|
239
|
+
throw(
|
|
240
|
+
Seed.Failed(
|
|
241
|
+
`stack "${stack}" exports neither domainMergedApiEndpoint nor domainApiEndpoint`,
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
},
|
|
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
|
+
cognitoRegion: resolveField(
|
|
250
|
+
~envKey="AWS_REGION",
|
|
251
|
+
~fromSource=out("cognitoRegion"),
|
|
252
|
+
~human="cognitoRegion",
|
|
253
|
+
),
|
|
254
|
+
cognitoClientId: resolveField(
|
|
255
|
+
~envKey="COGNITO_CLIENT_ID",
|
|
256
|
+
~fromSource=out("cognitoUserPoolClientId"),
|
|
257
|
+
~human="cognitoUserPoolClientId",
|
|
258
|
+
),
|
|
199
259
|
}
|
|
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
260
|
}
|
|
261
|
+
|
|
262
|
+
let resolveEndpoints = async (
|
|
263
|
+
~projectDir: string,
|
|
264
|
+
~backend: option<string>,
|
|
265
|
+
~stack: string,
|
|
266
|
+
): endpoints => {
|
|
267
|
+
let outputs = stackOutputs(~projectDir, ~backend, stack)
|
|
268
|
+
let source = switch outputs->field("hostShellUrl")->Option.flatMap(asString) {
|
|
269
|
+
| Some(hostShellUrl) => HostShellConfig(await fetchConfig(hostShellUrl))
|
|
270
|
+
| None => StackOutputs(outputs)
|
|
271
|
+
}
|
|
272
|
+
endpointsFrom(~stack, source)
|
|
214
273
|
}
|
|
215
274
|
|
|
216
275
|
// ── Cognito login ─────────────────────────────────────────────────────────────
|
|
@@ -302,15 +361,12 @@ let connect = (~projectDir: string=".", ~stack=?, ~backend=?, ()): (
|
|
|
302
361
|
| None => backend
|
|
303
362
|
}
|
|
304
363
|
let stackName = await resolveStack(~projectDir, ~backend, ~stack)
|
|
305
|
-
let
|
|
306
|
-
~projectDir,
|
|
307
|
-
~backend,
|
|
308
|
-
~stack=stackName,
|
|
309
|
-
)
|
|
364
|
+
let eps = await resolveEndpoints(~projectDir, ~backend, ~stack=stackName)
|
|
310
365
|
await Seed.Connect.make(
|
|
311
366
|
~label=stackName,
|
|
312
|
-
~endpoint,
|
|
313
|
-
~uploadEndpoint,
|
|
314
|
-
~
|
|
367
|
+
~endpoint=eps.graphql,
|
|
368
|
+
~uploadEndpoint=eps.uploadEndpoint,
|
|
369
|
+
~uploadEndpoints=eps.uploadEndpoints,
|
|
370
|
+
~login=cognito(~region=eps.cognitoRegion, ~clientId=eps.cognitoClientId),
|
|
315
371
|
)
|
|
316
372
|
}
|