@reventlessdev/reventless-seed-aws 1.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +202 -0
  3. package/lib/bs/.compiler.log +2 -0
  4. package/lib/bs/.sourcedirs.json +1 -0
  5. package/lib/bs/build.ninja +0 -0
  6. package/lib/bs/compiler-info.json +8 -0
  7. package/lib/bs/src/ReventlessSeedAws.ast +0 -0
  8. package/lib/bs/src/ReventlessSeedAws.cmi +0 -0
  9. package/lib/bs/src/ReventlessSeedAws.cmj +0 -0
  10. package/lib/bs/src/ReventlessSeedAws.cmt +0 -0
  11. package/lib/bs/src/ReventlessSeedAws.res +328 -0
  12. package/lib/bs/src/ReventlessSeedAws.res.mjs +309 -0
  13. package/lib/bs/src/ReventlessSeedAws_Reconcile.ast +0 -0
  14. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmi +0 -0
  15. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmj +0 -0
  16. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmt +0 -0
  17. package/lib/bs/src/ReventlessSeedAws_Reconcile.res +389 -0
  18. package/lib/bs/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
  19. package/lib/bs/src/ReventlessSeedAws_Reset.ast +0 -0
  20. package/lib/bs/src/ReventlessSeedAws_Reset.cmi +0 -0
  21. package/lib/bs/src/ReventlessSeedAws_Reset.cmj +0 -0
  22. package/lib/bs/src/ReventlessSeedAws_Reset.cmt +0 -0
  23. package/lib/bs/src/ReventlessSeedAws_Reset.res +948 -0
  24. package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +861 -0
  25. package/lib/bs/tests/EndpointResolutionTest.ast +0 -0
  26. package/lib/bs/tests/EndpointResolutionTest.cmi +0 -0
  27. package/lib/bs/tests/EndpointResolutionTest.cmj +0 -0
  28. package/lib/bs/tests/EndpointResolutionTest.cmt +0 -0
  29. package/lib/bs/tests/EndpointResolutionTest.res +83 -0
  30. package/lib/bs/tests/EndpointResolutionTest.res.mjs +115 -0
  31. package/lib/bs/tests/ObjectStoreReconcileTest.ast +0 -0
  32. package/lib/bs/tests/ObjectStoreReconcileTest.cmi +0 -0
  33. package/lib/bs/tests/ObjectStoreReconcileTest.cmj +0 -0
  34. package/lib/bs/tests/ObjectStoreReconcileTest.cmt +0 -0
  35. package/lib/bs/tests/ObjectStoreReconcileTest.res +84 -0
  36. package/lib/bs/tests/ObjectStoreReconcileTest.res.mjs +102 -0
  37. package/lib/bs/tests/ObjectStoreResetTest.ast +0 -0
  38. package/lib/bs/tests/ObjectStoreResetTest.cmi +0 -0
  39. package/lib/bs/tests/ObjectStoreResetTest.cmj +0 -0
  40. package/lib/bs/tests/ObjectStoreResetTest.cmt +0 -0
  41. package/lib/bs/tests/ObjectStoreResetTest.res +170 -0
  42. package/lib/bs/tests/ObjectStoreResetTest.res.mjs +211 -0
  43. package/lib/ocaml/.compiler.log +2 -0
  44. package/lib/ocaml/EndpointResolutionTest.ast +0 -0
  45. package/lib/ocaml/EndpointResolutionTest.cmi +0 -0
  46. package/lib/ocaml/EndpointResolutionTest.cmj +0 -0
  47. package/lib/ocaml/EndpointResolutionTest.cmt +0 -0
  48. package/lib/ocaml/EndpointResolutionTest.res +83 -0
  49. package/lib/ocaml/ObjectStoreReconcileTest.ast +0 -0
  50. package/lib/ocaml/ObjectStoreReconcileTest.cmi +0 -0
  51. package/lib/ocaml/ObjectStoreReconcileTest.cmj +0 -0
  52. package/lib/ocaml/ObjectStoreReconcileTest.cmt +0 -0
  53. package/lib/ocaml/ObjectStoreReconcileTest.res +84 -0
  54. package/lib/ocaml/ObjectStoreResetTest.ast +0 -0
  55. package/lib/ocaml/ObjectStoreResetTest.cmi +0 -0
  56. package/lib/ocaml/ObjectStoreResetTest.cmj +0 -0
  57. package/lib/ocaml/ObjectStoreResetTest.cmt +0 -0
  58. package/lib/ocaml/ObjectStoreResetTest.res +170 -0
  59. package/lib/ocaml/ReventlessSeedAws.ast +0 -0
  60. package/lib/ocaml/ReventlessSeedAws.cmi +0 -0
  61. package/lib/ocaml/ReventlessSeedAws.cmj +0 -0
  62. package/lib/ocaml/ReventlessSeedAws.cmt +0 -0
  63. package/lib/ocaml/ReventlessSeedAws.res +328 -0
  64. package/lib/ocaml/ReventlessSeedAws_Reconcile.ast +0 -0
  65. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmi +0 -0
  66. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmj +0 -0
  67. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmt +0 -0
  68. package/lib/ocaml/ReventlessSeedAws_Reconcile.res +389 -0
  69. package/lib/ocaml/ReventlessSeedAws_Reset.ast +0 -0
  70. package/lib/ocaml/ReventlessSeedAws_Reset.cmi +0 -0
  71. package/lib/ocaml/ReventlessSeedAws_Reset.cmj +0 -0
  72. package/lib/ocaml/ReventlessSeedAws_Reset.cmt +0 -0
  73. package/lib/ocaml/ReventlessSeedAws_Reset.res +948 -0
  74. package/package.json +32 -0
  75. package/rescript.json +29 -0
  76. package/src/ReventlessSeedAws.res +328 -0
  77. package/src/ReventlessSeedAws.res.mjs +309 -0
  78. package/src/ReventlessSeedAws_Reconcile.res +389 -0
  79. package/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
  80. package/src/ReventlessSeedAws_Reset.res +948 -0
  81. package/src/ReventlessSeedAws_Reset.res.mjs +861 -0
  82. package/tests/EndpointResolutionTest.res +83 -0
  83. package/tests/EndpointResolutionTest.res.mjs +115 -0
  84. package/tests/ObjectStoreReconcileTest.res +84 -0
  85. package/tests/ObjectStoreReconcileTest.res.mjs +102 -0
  86. package/tests/ObjectStoreResetTest.res +170 -0
  87. package/tests/ObjectStoreResetTest.res.mjs +211 -0
@@ -0,0 +1,328 @@
1
+ // AWS `connect` for the seed harness: resolve a deployed stack, read its
2
+ // endpoints, and log in with Cognito — returning the same provider-agnostic
3
+ // `Seed.connection` a local run produces.
4
+ //
5
+ // Stack discovery reads the sibling Pulumi project's `Pulumi.<stack>.yaml`
6
+ // files (no `pulumi login` needed to list) and calls `pulumi stack output` for
7
+ // the chosen one. Endpoints come from the host-shell `config.json` when the
8
+ // stack publishes a `hostShellUrl` (the hybrid host-shell shape), or from the
9
+ // stack outputs directly otherwise. Login is Cognito USER_PASSWORD_AUTH over a
10
+ // plain `fetch` — the host UI client has no secret, so there is no SigV4 and no
11
+ // AWS SDK dependency.
12
+
13
+ open ReventlessSeed
14
+
15
+ // ── Node bindings ─────────────────────────────────────────────────────────────
16
+
17
+
18
+ type fetchInit = {method: string, headers: dict<string>, body?: string}
19
+ type response
20
+ @val external fetch: (string, fetchInit) => promise<response> = "fetch"
21
+ @send external responseJson: response => promise<JSON.t> = "json"
22
+ @get external responseOk: response => bool = "ok"
23
+ @get external responseStatus: response => int = "status"
24
+
25
+ // ── JSON helpers ──────────────────────────────────────────────────────────────
26
+
27
+ let field = (json: JSON.t, key: string): option<JSON.t> =>
28
+ switch json {
29
+ | Object(obj) => obj->Dict.get(key)
30
+ | _ => None
31
+ }
32
+
33
+ let asString = (json: JSON.t): option<string> =>
34
+ switch json {
35
+ | String(s) => Some(s)
36
+ | _ => None
37
+ }
38
+
39
+ // ── Stack discovery ───────────────────────────────────────────────────────────
40
+
41
+ // Which Pulumi backend the `pulumi` subprocess reads from. When `backend` is
42
+ // set it is passed as `PULUMI_BACKEND_URL` on a *copy* of the environment, so
43
+ // the operator's persistent `pulumi login` is never mutated — each example pins
44
+ // its own backend (core → Pulumi Cloud, a self-hosted app → its S3/… bucket)
45
+ // regardless of which backend the CLI is currently logged into. When absent, the
46
+ // subprocess inherits the ambient login (or an operator-set PULUMI_BACKEND_URL).
47
+ let envForBackend = (backend: option<string>): option<dict<string>> =>
48
+ switch backend {
49
+ | None => None
50
+ | Some(url) =>
51
+ let copy = NodeProcess.env->Dict.toArray->Dict.fromArray
52
+ copy->Dict.set("PULUMI_BACKEND_URL", url)
53
+ Some(copy)
54
+ }
55
+
56
+ let pulumi = (~projectDir: string, ~backend: option<string>, args: array<string>): string =>
57
+ NodeChildProcess.execFileSync(
58
+ "pulumi",
59
+ args,
60
+ {cwd: projectDir, encoding: "utf8", env: ?envForBackend(backend)},
61
+ )
62
+
63
+ // Stacks that actually exist in the Pulumi backend for this project — the names
64
+ // `pulumi stack output --stack <name>` accepts. A `Pulumi.<stack>.yaml` config
65
+ // file alone is NOT a deployed stack, so discovery must ask the backend (via
66
+ // `pulumi stack ls`), not the filesystem — otherwise it offers phantom stacks
67
+ // that fail with "no stack named …". Empty on any error (pulumi missing / not
68
+ // logged in / no stacks), which `resolveStack` reports as "none found".
69
+ let deployedStacks = (~projectDir: string, ~backend: option<string>): array<string> =>
70
+ try {
71
+ switch JSON.parseOrThrow(pulumi(~projectDir, ~backend, ["stack", "ls", "--json"])) {
72
+ | JSON.Array(items) => items->Array.filterMap(it => it->field("name")->Option.flatMap(asString))
73
+ | _ => []
74
+ }
75
+ } catch {
76
+ | _ => []
77
+ }
78
+
79
+ let stackOutputs = (~projectDir: string, ~backend: option<string>, stack: string): JSON.t => {
80
+ let raw = try pulumi(~projectDir, ~backend, ["stack", "output", "--stack", stack, "--json"]) catch {
81
+ | _ =>
82
+ throw(
83
+ Seed.Failed(
84
+ `pulumi stack output --stack ${stack} failed — is pulumi installed, logged in, ` ++
85
+ "and the stack deployed?",
86
+ ),
87
+ )
88
+ }
89
+ try JSON.parseOrThrow(raw) catch {
90
+ | _ => throw(Seed.Failed(`could not parse pulumi stack output for "${stack}"`))
91
+ }
92
+ }
93
+
94
+ let backendNote = (~backend: option<string>): string =>
95
+ switch backend {
96
+ | Some(url) => ` (backend: ${url})`
97
+ | None => ""
98
+ }
99
+
100
+ let resolveStack = async (~projectDir: string, ~backend: option<string>, ~stack: option<string>): string =>
101
+ switch stack {
102
+ | Some(s) => s
103
+ | None =>
104
+ switch Seed.Prompt.envValue("SEED_STACK") {
105
+ | Some(s) => s
106
+ | None =>
107
+ let stacks = deployedStacks(~projectDir, ~backend)
108
+ if stacks->Array.length == 0 {
109
+ throw(
110
+ Seed.Failed(
111
+ `no deployed Pulumi stacks for this project (\`pulumi stack ls\` is empty in ${projectDir})${backendNote(
112
+ ~backend,
113
+ )}. ` ++
114
+ "A Pulumi.<stack>.yaml config file alone is not a deployed stack — run `pulumi up` " ++
115
+ "first, log in to the backend that holds the stack, or set SEED_STACK to target one.",
116
+ ),
117
+ )
118
+ }
119
+ await Seed.Prompt.select(~title="Stack:", ~options=stacks->Array.map(s => (s, s)))
120
+ }
121
+ }
122
+
123
+ // ── Endpoints ─────────────────────────────────────────────────────────────────
124
+
125
+ let fetchConfig = async (hostShellUrl: string): JSON.t => {
126
+ let base = hostShellUrl->String.replaceRegExp(%re("/\/+$/g"), "")
127
+ let url = `${base}/config.json`
128
+ let res = try await fetch(url, {method: "GET", headers: Dict.make()}) catch {
129
+ | _ => throw(Seed.Failed(`cannot reach ${url}`))
130
+ }
131
+ if !(res->responseOk) {
132
+ throw(Seed.Failed(`GET ${url} → HTTP ${(res->responseStatus)->Int.toString}`))
133
+ }
134
+ await res->responseJson
135
+ }
136
+
137
+ // Prefer an explicit env override, else the discovered value, else fail naming
138
+ // what is missing.
139
+ let resolveField = (~envKey: string, ~fromSource: option<string>, ~human: string): string =>
140
+ switch Seed.Prompt.envValue(envKey) {
141
+ | Some(v) => v
142
+ | None =>
143
+ switch fromSource {
144
+ | Some(v) => v
145
+ | None => throw(Seed.Failed(`deployment is missing ${human} (and ${envKey} is unset)`))
146
+ }
147
+ }
148
+
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) =>
173
+ let fromCfg = key => cfg->field(key)->Option.flatMap(asString)
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) =>
192
+ let out = key => outputs->field(key)->Option.flatMap(asString)
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
+ ),
217
+ }
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)
231
+ }
232
+
233
+ // ── Cognito login ─────────────────────────────────────────────────────────────
234
+
235
+ let cognito = (~region: string, ~clientId: string) => async (
236
+ ~username: string,
237
+ ~password: string,
238
+ ): string => {
239
+ let body = JSON.stringify(
240
+ JSON.Encode.object(
241
+ Dict.fromArray([
242
+ ("AuthFlow", JSON.Encode.string("USER_PASSWORD_AUTH")),
243
+ ("ClientId", JSON.Encode.string(clientId)),
244
+ (
245
+ "AuthParameters",
246
+ JSON.Encode.object(
247
+ Dict.fromArray([
248
+ ("USERNAME", JSON.Encode.string(username)),
249
+ ("PASSWORD", JSON.Encode.string(password)),
250
+ ]),
251
+ ),
252
+ ),
253
+ ]),
254
+ ),
255
+ )
256
+ let headers = Dict.fromArray([
257
+ ("content-type", "application/x-amz-json-1.1"),
258
+ ("x-amz-target", "AWSCognitoIdentityProviderService.InitiateAuth"),
259
+ ])
260
+ let res = try await fetch(`https://cognito-idp.${region}.amazonaws.com/`, {
261
+ method: "POST",
262
+ headers,
263
+ body,
264
+ }) catch {
265
+ | _ => throw(Seed.Failed(`cannot reach Cognito in ${region}`))
266
+ }
267
+ let json = await res->responseJson
268
+ if !(res->responseOk) {
269
+ let detail =
270
+ json->field("message")->Option.flatMap(asString)->Option.getOr(JSON.stringify(json))
271
+ throw(
272
+ Seed.Failed(
273
+ `Cognito InitiateAuth failed (HTTP ${(res->responseStatus)->Int.toString}): ${detail}`,
274
+ ),
275
+ )
276
+ }
277
+ switch json->field("ChallengeName")->Option.flatMap(asString) {
278
+ | Some(challenge) =>
279
+ throw(
280
+ Seed.Failed(
281
+ `Cognito returned challenge ${challenge} — set a permanent password first ` ++
282
+ "(aws cognito-idp admin-set-user-password … --permanent).",
283
+ ),
284
+ )
285
+ | None => ()
286
+ }
287
+ switch json
288
+ ->field("AuthenticationResult")
289
+ ->Option.flatMap(ar => ar->field("IdToken"))
290
+ ->Option.flatMap(asString) {
291
+ | Some(token) => token
292
+ | None => throw(Seed.Failed(`Cognito response carried no IdToken: ${JSON.stringify(json)}`))
293
+ }
294
+ }
295
+
296
+ // ── connect ───────────────────────────────────────────────────────────────────
297
+
298
+ /**
299
+ * A `connect` thunk for a deployed AWS stack, ready to pass to `Seed.Runner.seed`.
300
+ *
301
+ * `projectDir` is where the Pulumi stacks live (relative to the seed's cwd);
302
+ * `stack` fixes the stack, else `SEED_STACK` or a menu chooses it. Resolves the
303
+ * endpoints, then prompts credentials and mints a Cognito id token.
304
+ *
305
+ * `backend` pins the Pulumi backend the stack lives in — Pulumi Cloud
306
+ * (`https://api.pulumi.com`, needs `PULUMI_ACCESS_TOKEN`) for a CI-deployed
307
+ * example, or a self-hosted store (`s3://…?region=…`, needs AWS creds) for a
308
+ * self-hosted app. It is passed to the pulumi subprocess as `PULUMI_BACKEND_URL`
309
+ * on a copy of the environment, so the operator's persistent `pulumi login` is
310
+ * untouched and each example seeds its own backend regardless of the current
311
+ * login. `SEED_PULUMI_BACKEND` overrides it; omit both to use the ambient login.
312
+ */
313
+ let connect = (~projectDir: string=".", ~stack=?, ~backend=?, ()): (
314
+ unit => promise<Seed.connection>
315
+ ) =>
316
+ async () => {
317
+ let backend = switch Seed.Prompt.envValue("SEED_PULUMI_BACKEND") {
318
+ | Some(url) => Some(url)
319
+ | None => backend
320
+ }
321
+ let stackName = await resolveStack(~projectDir, ~backend, ~stack)
322
+ let eps = await resolveEndpoints(~projectDir, ~backend, ~stack=stackName)
323
+ await Seed.Connect.make(
324
+ ~label=stackName,
325
+ ~endpoint=eps.graphql,
326
+ ~login=cognito(~region=eps.cognitoRegion, ~clientId=eps.cognitoClientId),
327
+ )
328
+ }