@reventlessdev/reventless-aws 3.0.0-alpha.346 → 3.0.0-alpha.348

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 (80) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +17 -13
  3. package/rescript.json +2 -1
  4. package/run-bake-manifest.mjs +3 -0
  5. package/run-deploy-app.mjs +3 -0
  6. package/scripts/BakeManifest.res +395 -0
  7. package/scripts/BakeManifest.res.mjs +487 -0
  8. package/scripts/DeployApp.res +708 -0
  9. package/scripts/DeployApp.res.mjs +1015 -0
  10. package/scripts/DeployManifest.res +84 -0
  11. package/scripts/DeployManifest.res.mjs +112 -0
  12. package/scripts/ProvisionAccounts.res +60 -46
  13. package/scripts/ProvisionAccounts.res.mjs +51 -37
  14. package/scripts/PulumiCli.res +43 -0
  15. package/scripts/PulumiCli.res.mjs +106 -0
  16. package/src/Platform.res +19 -2
  17. package/src/Platform.res.mjs +12 -4
  18. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +1 -5
  19. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +1 -1
  20. package/src/adapter/Api/Platform_UIFragments_Lambda.res +1 -5
  21. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +1 -2
  22. package/src/adapter/Auth/Auth_ActiveRoleStore.res +1 -5
  23. package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +1 -2
  24. package/src/adapter/Auth/Auth_ActiveRoleTrigger.res +1 -5
  25. package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +1 -2
  26. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +52 -31
  27. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +75 -44
  28. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +1 -5
  29. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +1 -2
  30. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res +1 -5
  31. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res.mjs +1 -2
  32. package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +5 -4
  33. package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res.mjs +1 -1
  34. package/src/adapter/Postgres/PgMigration_Builder.res +1 -1
  35. package/src/adapter/Postgres/PgMigration_Builder.res.mjs +1 -2
  36. package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res +2 -0
  37. package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res.mjs +2 -2
  38. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +15 -6
  39. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +51 -30
  40. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res.mjs +31 -14
  41. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +1 -5
  42. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +1 -1
  43. package/src/adapter/StateTopic/StateTopic_AppSync.res +1 -5
  44. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +1 -1
  45. package/src/adapter/Task/TaskBucket_S3.res +1 -3
  46. package/src/adapter/Task/TaskBucket_S3.res.mjs +1 -1
  47. package/src/adapter/Upload/Upload_Claim_S3.res +1 -5
  48. package/src/adapter/Upload/Upload_Claim_S3.res.mjs +1 -1
  49. package/src/adapter/Upload/Upload_Presign_S3.res +1 -5
  50. package/src/adapter/Upload/Upload_Presign_S3.res.mjs +1 -2
  51. package/src/capability/Capability_Geocoding_AwsLocation.res +5 -1
  52. package/src/capability/Capability_Geocoding_AwsLocation.res.mjs +7 -1
  53. package/src/components/Api/AppSync_SdlDecorate.res +4 -2
  54. package/src/components/Api/AppSync_SdlDecorate.res.mjs +1 -1
  55. package/src/plugin/cloner/ClonerRunner_Fargate.res +1 -5
  56. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +1 -1
  57. package/src/plugin/stack/Plugin_Stack.res +4 -0
  58. package/src/plugin/stack/Plugin_Stack.res.mjs +2 -0
  59. package/src/util/Util_DeadLetterQueue.res +1 -5
  60. package/src/util/Util_DeadLetterQueue.res.mjs +1 -2
  61. package/src/util/Util_StoreLayout.res +15 -2
  62. package/src/util/Util_StoreLayout.res.mjs +11 -3
  63. package/tests/BakeManifestTest.res +204 -0
  64. package/tests/BakeManifestTest.res.mjs +259 -0
  65. package/tests/DcbColdStartPartitionTest.res +32 -0
  66. package/tests/DcbColdStartPartitionTest.res.mjs +33 -0
  67. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +88 -0
  68. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +112 -0
  69. package/tests/DcbUnresolvedPartitionSlice.res +28 -0
  70. package/tests/DcbUnresolvedPartitionSlice.res.mjs +38 -0
  71. package/tests/DeployAppTest.res +90 -0
  72. package/tests/DeployAppTest.res.mjs +133 -0
  73. package/tests/LambdaLayerLookupTest.res +104 -0
  74. package/tests/LambdaLayerLookupTest.res.mjs +117 -0
  75. package/tests/ProvisionAccountsTest.res +26 -0
  76. package/tests/ProvisionAccountsTest.res.mjs +31 -0
  77. package/tests/Util_StoreLayoutTest.res +6 -0
  78. package/tests/Util_StoreLayoutTest.res.mjs +7 -4
  79. package/tests/dcbColdStartPartition.mjs +66 -0
  80. package/tests/setup/layerArn.cjs +5 -0
@@ -0,0 +1,708 @@
1
+ /***
2
+ Deploy an app to AWS — its platform stack and every plugin stack its
3
+ `deploy-manifest.yaml` lists — or remove it again.
4
+
5
+ ```
6
+ pnpm exec deploy-app up # deploy, bake the manifest, create the demo accounts
7
+ pnpm exec deploy-app down # remove everything `up` created
8
+ ```
9
+
10
+ Pulumi is driven through its Automation API, so it never stops to ask a
11
+ question. Every step of `up` can run again: a stack that exists is updated, a
12
+ layer already published for this version is reused, an account that exists is
13
+ adopted.
14
+
15
+ 🚨 **Only try-out stacks.** Every stack `up` creates declares
16
+ `reventless:disposable: "true"`, which is also what lets `pulumi destroy` remove
17
+ its storage. `up` refuses to touch an existing stack without that setting, and
18
+ `down` refuses to remove one, so neither can reach `alpha` or a real deployment.
19
+ */
20
+
21
+ module Automation = Pulumi.Automation
22
+ module Lambda = AwsSdk.Lambda
23
+ module Ssm = AwsSdk.SSM
24
+ module Sts = AwsSdk.STS
25
+ module Manifest = Reventless.AccountsManifest
26
+
27
+ // ── Arguments ────────────────────────────────────────────────────────────────
28
+
29
+ type command = Up | Down
30
+
31
+ type args = {
32
+ command: option<command>,
33
+ manifest: option<string>,
34
+ stack: string,
35
+ help: bool,
36
+ }
37
+
38
+ let defaultStack = "dev"
39
+
40
+ let parseArgs = (argv: array<string>): result<args, string> => {
41
+ let acc = ref(Ok({command: None, manifest: None, stack: defaultStack, help: false}))
42
+ let i = ref(0)
43
+ let count = argv->Array.length
44
+ while i.contents < count {
45
+ let flag = argv->Array.getUnsafe(i.contents)
46
+ let value = argv->Array.get(i.contents + 1)
47
+ switch (acc.contents, flag, value) {
48
+ | (Error(_), _, _) => i := count
49
+ | (Ok(a), "up", _) if a.command == None =>
50
+ acc := Ok({...a, command: Some(Up)})
51
+ i := i.contents + 1
52
+ | (Ok(a), "down", _) if a.command == None =>
53
+ acc := Ok({...a, command: Some(Down)})
54
+ i := i.contents + 1
55
+ | (Ok(a), "--manifest", Some(v)) =>
56
+ acc := Ok({...a, manifest: Some(v)})
57
+ i := i.contents + 2
58
+ | (Ok(a), "--stack", Some(v)) =>
59
+ acc := Ok({...a, stack: v})
60
+ i := i.contents + 2
61
+ | (Ok(a), "--help", _) | (Ok(a), "-h", _) =>
62
+ acc := Ok({...a, help: true})
63
+ i := i.contents + 1
64
+ | (Ok(_), "--manifest", None) | (Ok(_), "--stack", None) =>
65
+ acc := Error(`${flag} needs a value`)
66
+ | (Ok(_), unknown, _) => acc := Error(`unknown argument "${unknown}"`)
67
+ }
68
+ }
69
+ acc.contents
70
+ }
71
+
72
+ let usage = `
73
+ Deploy an app to AWS, or remove it again.
74
+
75
+ deploy-app up Deploy the platform and every plugin, bake the component
76
+ manifest, create the accounts in .reventless/users.<stack>.yaml,
77
+ and print the web address and the sign-ins.
78
+ deploy-app down Remove every stack, and the Lambda layer and SSM parameter
79
+ \`up\` created.
80
+
81
+ --manifest <path> The deploy manifest. Defaults to ${DeployManifest.defaultFile}
82
+ in the working directory.
83
+ --stack <name> The try-out stack. Defaults to "${defaultStack}".
84
+
85
+ Region and credentials come from the environment, as for any AWS call. Only
86
+ stacks declaring reventless:disposable: "true" are touched; \`up\` sets it on
87
+ every stack it creates.
88
+ `
89
+
90
+ // ── The projects ─────────────────────────────────────────────────────────────
91
+
92
+ /** A stack folder, as `Pulumi.yaml` names it. `deploy-manifest.yaml`'s own
93
+ `platform.name` need not match the real project name, so it is not used. */
94
+ type project = {
95
+ label: string,
96
+ dir: string,
97
+ projectName: string,
98
+ program: string,
99
+ stackDefaults: dict<string>,
100
+ }
101
+
102
+ @module("yaml") external parseYaml: string => JSON.t = "parse"
103
+
104
+ let projectOf = (p: DeployManifest.project): result<project, string> => {
105
+ let file = NodePath.join([p.dir, "Pulumi.yaml"])
106
+ switch try Some(NodeFs.readFileSync(file)->parseYaml) catch {
107
+ | _ => None
108
+ } {
109
+ | None => Error(`${file} cannot be read — is ${p.dir} a Pulumi project?`)
110
+ | Some(json) =>
111
+ let field = name =>
112
+ json
113
+ ->JSON.Decode.object
114
+ ->Option.flatMap(o => o->Dict.get(name))
115
+ ->Option.flatMap(JSON.Decode.string)
116
+ switch field("name") {
117
+ | None => Error(`${file} names no project`)
118
+ | Some(projectName) =>
119
+ Ok({
120
+ label: p.name,
121
+ dir: p.dir,
122
+ projectName,
123
+ program: NodePath.join([p.dir, field("main")->Option.getOr("index.js")]),
124
+ stackDefaults: p.stackDefaults,
125
+ })
126
+ }
127
+ }
128
+ }
129
+
130
+ let projectsOf = (manifest: DeployManifest.resolved): result<(project, array<project>), string> =>
131
+ switch projectOf(manifest.platform) {
132
+ | Error(_) as e => e
133
+ | Ok(platform) =>
134
+ let plugins = manifest.plugins->Array.map(projectOf)
135
+ switch plugins->Array.find(Result.isError) {
136
+ | Some(Error(message)) => Error(message)
137
+ | _ => Ok((platform, plugins->Array.filterMap(r => r->Result.mapOr(None, p => Some(p)))))
138
+ }
139
+ }
140
+
141
+ // ── Checks before anything is created ────────────────────────────────────────
142
+
143
+ let requiredNodeMajor = 22
144
+
145
+ let checkNode = (version: string): option<string> =>
146
+ switch version->String.split(".")->Array.get(0)->Option.flatMap(v => Int.fromString(v)) {
147
+ | Some(major) if major >= requiredNodeMajor => None
148
+ | _ =>
149
+ Some(
150
+ `Reventless needs Node ${requiredNodeMajor->Int.toString} or later, and this is Node ${version}. Switch to the version in .node-version (for example \`fnm use\` or \`nvm use\`).`,
151
+ )
152
+ }
153
+
154
+ /** A backend other than Pulumi Cloud keeps stack secrets with a passphrase, and
155
+ asks for it on a terminal nobody is watching. */
156
+ let backendNeedsPassphrase = (~url: option<string>, ~env: dict<string>): bool =>
157
+ switch url {
158
+ | Some(url) if !(url->String.startsWith("https://")) =>
159
+ env->Dict.get("PULUMI_CONFIG_PASSPHRASE") == None &&
160
+ env->Dict.get("PULUMI_CONFIG_PASSPHRASE_FILE") == None
161
+ | _ => false
162
+ }
163
+
164
+ let unbuilt = (projects: array<project>): array<project> =>
165
+ projects->Array.filter(p => !NodeFs.existsSync(p.program))
166
+
167
+ type ready = {region: string, account: string}
168
+
169
+ let check = async (~projects: array<project>): result<ready, array<string>> => {
170
+ let problems = []
171
+ checkNode(NodeProcess.versions->Dict.get("node")->Option.getOr("0"))->Option.forEach(p =>
172
+ problems->Array.push(p)
173
+ )
174
+ let sts = Sts.client()
175
+ let region = switch await (sts->Sts.config).region() {
176
+ | region => Some(region)
177
+ | exception _ =>
178
+ problems->Array.push(
179
+ "No AWS region is set. Set AWS_REGION (for example `export AWS_REGION=eu-west-1`) or give your AWS profile a region.",
180
+ )
181
+ None
182
+ }
183
+ let account = switch region {
184
+ | None => None
185
+ | Some(region) =>
186
+ switch await Sts.client(~region, ())->Sts.GetCallerIdentityCommand.send(
187
+ Sts.GetCallerIdentityCommand.make(Dict.make()),
188
+ ) {
189
+ | identity => identity.account
190
+ | exception exn =>
191
+ problems->Array.push(
192
+ `The AWS credentials do not work (${Util_AwsError.describe(
193
+ exn,
194
+ )}). Sign in — \`aws sso login\`, or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY — and try again.`,
195
+ )
196
+ None
197
+ }
198
+ }
199
+ switch projects->Array.get(0) {
200
+ | None => ()
201
+ | Some(platform) =>
202
+ switch await Automation.create({workDir: platform.dir}) {
203
+ | workspace =>
204
+ switch await workspace->Automation.whoAmI {
205
+ | who =>
206
+ if backendNeedsPassphrase(~url=who.url, ~env=NodeProcess.env) {
207
+ problems->Array.push(
208
+ `Pulumi is logged in to ${who.url->Option.getOr(
209
+ "a self-managed backend",
210
+ )}, which protects stack secrets with a passphrase. Set PULUMI_CONFIG_PASSPHRASE (an empty value is allowed) and try again.`,
211
+ )
212
+ }
213
+ | exception _ =>
214
+ problems->Array.push(
215
+ "Pulumi is not logged in. Run `pulumi login` (Pulumi Cloud) or `pulumi login --local`, and try again.",
216
+ )
217
+ }
218
+ | exception _ =>
219
+ problems->Array.push(
220
+ "The Pulumi CLI could not be run. Install it — https://www.pulumi.com/docs/iac/download-install/ — and try again.",
221
+ )
222
+ }
223
+ }
224
+ unbuilt(projects)->Array.forEach(p =>
225
+ problems->Array.push(
226
+ `${p.label} is not built: ${p.program} is missing. Run \`pnpm run build\` and try again.`,
227
+ )
228
+ )
229
+ switch (problems, region, account) {
230
+ | ([], Some(region), Some(account)) => Ok({region, account})
231
+ | ([], _, _) => Error(["The AWS account could not be identified."])
232
+ | (problems, _, _) => Error(problems)
233
+ }
234
+ }
235
+
236
+ // ── The Lambda layer ─────────────────────────────────────────────────────────
237
+
238
+ let layerName = stack => `reventless-aws-${stack}`
239
+
240
+ /** What marks a layer version as one `up` published, and for which release. */
241
+ let layerDescriptionPrefix = "reventless-aws@"
242
+ let layerDescription = version => layerDescriptionPrefix ++ version
243
+
244
+ let releaseUrl = version =>
245
+ `https://github.com/ReventlessDev/reventless-core/releases/download/${encodeURIComponent(
246
+ "@reventlessdev/reventless-aws@" ++ version,
247
+ )}/reventless-layer.zip`
248
+
249
+ /** The version of this package — the one the app installed, so the one its
250
+ functions import. */
251
+ let installedVersion = (): string =>
252
+ NodePath.join([NodePath.dirname(NodeUrl.fileURLToPath(NodeImportMeta.url)), "..", "package.json"])
253
+ ->NodeFs.readFileSync
254
+ ->JSON.parseOrThrow
255
+ ->JSON.Decode.object
256
+ ->Option.flatMap(o => o->Dict.get("version"))
257
+ ->Option.flatMap(JSON.Decode.string)
258
+ ->Option.getOr("unknown")
259
+
260
+ let errorName = (exn: exn): option<string> =>
261
+ exn
262
+ ->JsExn.fromException
263
+ ->Option.flatMap(e => e->JsExn.name)
264
+
265
+ let storedLayerArn = async (~ssm: Ssm.client, ~parameter: string): option<string> =>
266
+ switch await ssm->Ssm.GetParameterCommand.send(Ssm.GetParameterCommand.make({name: parameter})) {
267
+ | output => output.parameter->Option.flatMap(p => p.value)
268
+ | exception exn if errorName(exn) == Some(Ssm.parameterNotFound) => None
269
+ }
270
+
271
+ let layerVersionDescription = async (~lambda: Lambda.client, ~arn: string): option<string> =>
272
+ switch await lambda->Lambda.GetLayerVersionByArnCommand.send(
273
+ Lambda.GetLayerVersionByArnCommand.make({arn: arn}),
274
+ ) {
275
+ | output => output.description
276
+ | exception _ => None
277
+ }
278
+
279
+ /** The layer this deploy attaches: `REVENTLESS_LAYER_ARN` when set, the one
280
+ already stored for this version, or the release download published now. */
281
+ let ensureLayer = async (~stack: string, ~region: string, ~version: string): result<
282
+ string,
283
+ string,
284
+ > =>
285
+ switch NodeProcess.env->Dict.get("REVENTLESS_LAYER_ARN") {
286
+ | Some(arn) if arn->String.trim != "" =>
287
+ Console.log(`layer ${arn} (from REVENTLESS_LAYER_ARN)`)
288
+ Ok(arn->String.trim)
289
+ | _ =>
290
+ let ssm = Ssm.client(~region, ())
291
+ let lambda = Lambda.client(~region, ())
292
+ let parameter = PulumiAws.Lambda.layerParameter(stack)
293
+ let stored = await storedLayerArn(~ssm, ~parameter)
294
+ let current = switch stored {
295
+ | None => None
296
+ | Some(arn) =>
297
+ (await layerVersionDescription(~lambda, ~arn)) == Some(layerDescription(version))
298
+ ? Some(arn)
299
+ : None
300
+ }
301
+ switch current {
302
+ | Some(arn) =>
303
+ Console.log(`layer ${arn} (already published for ${version})`)
304
+ Ok(arn)
305
+ | None =>
306
+ let url = releaseUrl(version)
307
+ Console.log(`layer downloading ${url}`)
308
+ switch await Web.Fetch.fetch(url, {}) {
309
+ | response if response->Web.Fetch.status == 404 =>
310
+ Error(
311
+ `No Lambda layer was released for reventless-aws@${version} — this checkout sits between two releases. Build one with \`REVENTLESS_AWS_VERSION=${version} pnpm run build\` in reventless/layer-builder/, publish it, and set REVENTLESS_LAYER_ARN.`,
312
+ )
313
+ | response if !(response->Web.Fetch.ok) =>
314
+ Error(`downloading ${url} failed with HTTP ${response->Web.Fetch.status->Int.toString}`)
315
+ | response =>
316
+ let zip = Uint8Array.fromBuffer(await response->Web.Fetch.arrayBuffer)
317
+ let published = await lambda->Lambda.PublishLayerVersionCommand.send(
318
+ Lambda.PublishLayerVersionCommand.make({
319
+ layerName: layerName(stack),
320
+ description: layerDescription(version),
321
+ content: {zipFile: zip},
322
+ compatibleRuntimes: ["nodejs20.x", "nodejs22.x"],
323
+ }),
324
+ )
325
+ switch published.layerVersionArn {
326
+ | None => Error(`publishing the layer ${layerName(stack)} returned no ARN`)
327
+ | Some(arn) =>
328
+ let _ = await ssm->Ssm.PutParameterCommand.send(
329
+ Ssm.PutParameterCommand.make({
330
+ name: parameter,
331
+ value: arn,
332
+ type_: "String",
333
+ overwrite: true,
334
+ }),
335
+ )
336
+ Console.log(`layer ${arn} (published, stored in ${parameter})`)
337
+ Ok(arn)
338
+ }
339
+ | exception exn => Error(`downloading ${url} failed: ${Util_AwsError.describe(exn)}`)
340
+ }
341
+ }
342
+ }
343
+
344
+ /** Removes the layer versions `up` published for this stack, and the parameter
345
+ when it points at one of them. A layer published by anyone else is left. */
346
+ let removeLayer = async (~stack: string, ~region: string) => {
347
+ let lambda = Lambda.client(~region, ())
348
+ let ssm = Ssm.client(~region, ())
349
+ let name = layerName(stack)
350
+ let versions = []
351
+ let marker = ref(None)
352
+ let more = ref(true)
353
+ while more.contents {
354
+ switch await lambda->Lambda.ListLayerVersionsCommand.send(
355
+ Lambda.ListLayerVersionsCommand.make({layerName: name, marker: ?marker.contents}),
356
+ ) {
357
+ | page =>
358
+ page.layerVersions->Option.forEach(page => versions->Array.pushMany(page))
359
+ marker := page.nextMarker
360
+ more := page.nextMarker != None
361
+ | exception exn if errorName(exn) == Some("ResourceNotFoundException") => more := false
362
+ }
363
+ }
364
+ for index in 0 to versions->Array.length - 1 {
365
+ let v = versions->Array.getUnsafe(index)
366
+ switch (v.version, v.description) {
367
+ | (Some(version), Some(description))
368
+ if description->String.startsWith(layerDescriptionPrefix) =>
369
+ let _ = await lambda->Lambda.DeleteLayerVersionCommand.send(
370
+ Lambda.DeleteLayerVersionCommand.make({layerName: name, versionNumber: version}),
371
+ )
372
+ Console.log(`layer removed ${name}:${version->Int.toString}`)
373
+ | _ => ()
374
+ }
375
+ }
376
+ let parameter = PulumiAws.Lambda.layerParameter(stack)
377
+ switch await storedLayerArn(~ssm, ~parameter) {
378
+ | Some(arn) if arn->String.includes(`:layer:${name}:`) =>
379
+ let _ = await ssm->Ssm.DeleteParameterCommand.send(
380
+ Ssm.DeleteParameterCommand.make({name: parameter}),
381
+ )
382
+ Console.log(`layer removed ${parameter}`)
383
+ | _ => ()
384
+ }
385
+ }
386
+
387
+ // ── Stacks ───────────────────────────────────────────────────────────────────
388
+
389
+ let disposableKey = "reventless:disposable"
390
+
391
+ let isDisposable = async (stack: Automation.stack): bool =>
392
+ switch await stack->Automation.getConfig(disposableKey) {
393
+ | {value} => value == "true"
394
+ | exception _ => false
395
+ }
396
+
397
+ let existingStack = async (~project: project, ~stack: string, ~envVars) =>
398
+ switch await Automation.selectStack(
399
+ {stackName: stack, workDir: project.dir},
400
+ {envVars: envVars},
401
+ ) {
402
+ | found => Some(found)
403
+ | exception _ => None
404
+ }
405
+
406
+ /** The settings a stack `up` creates starts with: the app's defaults for the
407
+ folder, then the region and the try-out flag, which the app cannot override. */
408
+ let newStackConfig = (~project: project, ~region: string): array<(string, string)> =>
409
+ project.stackDefaults
410
+ ->Dict.toArray
411
+ ->Array.concat([("aws:region", region), ("aws-native:region", region), (disposableKey, "true")])
412
+
413
+ /** Opens the stack for `up`: creates it with its settings, or selects it when
414
+ it exists and is a try-out stack. `extra` is set either way. */
415
+ let openForUp = async (
416
+ ~project: project,
417
+ ~stack: string,
418
+ ~region: string,
419
+ ~envVars: dict<string>,
420
+ ~extra: array<(string, string)>,
421
+ ): result<Automation.stack, string> => {
422
+ let setAll = async (s, settings) =>
423
+ for index in 0 to settings->Array.length - 1 {
424
+ let (key, value) = settings->Array.getUnsafe(index)
425
+ await s->Automation.setConfig(key, {value: value})
426
+ }
427
+ switch await existingStack(~project, ~stack, ~envVars) {
428
+ | Some(existing) =>
429
+ if await isDisposable(existing) {
430
+ await setAll(existing, extra)
431
+ Ok(existing)
432
+ } else {
433
+ Error(
434
+ `${project.projectName}/${stack} already exists and does not declare ${disposableKey}: "true". deploy-app only deploys try-out stacks it created; pick another --stack.`,
435
+ )
436
+ }
437
+ | None =>
438
+ let created = await Automation.createOrSelectStack(
439
+ {stackName: stack, workDir: project.dir},
440
+ {envVars: envVars},
441
+ )
442
+ await setAll(created, newStackConfig(~project, ~region)->Array.concat(extra))
443
+ Console.log(`stack ${project.projectName}/${stack} (created)`)
444
+ Ok(created)
445
+ }
446
+ }
447
+
448
+ let stream = (text: string) => NodeProcess.stdout->NodeProcess.write(text)
449
+
450
+ /** The `error:` lines of a failed Pulumi command. A failure before the update
451
+ starts — a missing program, a locked stack — streams nothing, so without
452
+ these the run would end without a reason. */
453
+ let failureDetail = (exn: exn): string => {
454
+ let message = Util_AwsError.describe(exn)
455
+ let errors = []
456
+ message
457
+ ->String.split("\n")
458
+ ->Array.map(String.trim)
459
+ ->Array.forEach(line =>
460
+ if line->String.startsWith("error:") && !(errors->Array.includes(line)) {
461
+ errors->Array.push(line)
462
+ }
463
+ )
464
+ errors->Array.length > 0 ? errors->Array.join("\n") : message
465
+ }
466
+
467
+ /** Deploys one stack, streaming Pulumi's output as it goes. */
468
+ let deploy = async (~project: project, ~stack: Automation.stack, ~name: string): result<
469
+ unit,
470
+ string,
471
+ > => {
472
+ Console.log(`\n── Deploying ${project.label} (${project.projectName}/${name}) ──\n`)
473
+ switch await stack->Automation.up({onOutput: stream}) {
474
+ | _ => Ok()
475
+ | exception exn =>
476
+ Error(
477
+ `deploying ${project.projectName}/${name} failed. Fix the cause and run up again; what was created is kept and reused.\n${failureDetail(
478
+ exn,
479
+ )}`,
480
+ )
481
+ }
482
+ }
483
+
484
+ let outputString = (outputs: dict<Automation.outputValue>, name: string): option<string> =>
485
+ outputs->Dict.get(name)->Option.flatMap(o => o.value->JSON.Decode.string)
486
+
487
+ // ── The accounts ─────────────────────────────────────────────────────────────
488
+
489
+ /** The stack's own accounts file beside the platform, started from the app's
490
+ template, or `None` when the app has no template — then there is nothing to
491
+ create. Per stack because the file records the ids one pool minted: sharing
492
+ `users.yaml` would overwrite another deployment's record of its accounts. */
493
+ let accountsFileName = stack => `users.${stack}.yaml`
494
+
495
+ let accountsFile = (~platformDir: string, ~stack: string): option<string> => {
496
+ let file = NodePath.join([platformDir, ".reventless", accountsFileName(stack)])
497
+ let template = NodePath.join([platformDir, Manifest.templateName])
498
+ switch (NodeFs.existsSync(file), NodeFs.existsSync(template)) {
499
+ | (false, false) => None
500
+ | (true, _) => Some(file)
501
+ | (false, true) =>
502
+ NodeFs.mkdirSync(NodePath.dirname(file), {recursive: true})
503
+ NodeFs.cpSync(template, file, {})
504
+ Console.log(`manifest ${file} (new, copied from ${template})`)
505
+ Some(file)
506
+ }
507
+ }
508
+
509
+ let signInLines = (entries: array<Manifest.entry>): array<string> => {
510
+ let width = entries->Array.reduce(0, (w, e) => Math.Int.max(w, e.username->String.length))
511
+ entries->Array.map(e =>
512
+ ` ${e.username->String.padEnd(width, " ")} [${e.groups->Array.join(", ")}]`
513
+ )
514
+ }
515
+
516
+ // ── up / down ────────────────────────────────────────────────────────────────
517
+
518
+ let up = async (~manifest: DeployManifest.resolved, ~stack: string): result<unit, string> =>
519
+ switch projectsOf(manifest) {
520
+ | Error(_) as e => e
521
+ | Ok((platform, plugins)) =>
522
+ switch await check(~projects=[platform]->Array.concat(plugins)) {
523
+ | Error(problems) =>
524
+ Error(`not ready to deploy:\n${problems->Array.map(p => ` - ${p}`)->Array.join("\n")}`)
525
+ | Ok({region, account}) =>
526
+ Console.log(`account ${account}, region ${region}, stack ${stack}`)
527
+ let startedAt = Date.make()->Date.toISOString
528
+ switch await ensureLayer(~stack, ~region, ~version=installedVersion()) {
529
+ | Error(_) as e => e
530
+ | Ok(layerArn) =>
531
+ let envVars = Dict.fromArray([("REVENTLESS_LAYER_ARN", layerArn)])
532
+ switch await openForUp(~project=platform, ~stack, ~region, ~envVars, ~extra=[]) {
533
+ | Error(_) as e => e
534
+ | Ok(platformStack) =>
535
+ switch await deploy(~project=platform, ~stack=platformStack, ~name=stack) {
536
+ | Error(_) as e => e
537
+ | Ok() =>
538
+ let platformOutputs = await platformStack->Automation.outputs
539
+ switch platformOutputs->outputString("platformStack") {
540
+ | None =>
541
+ Error(`the platform stack exports no platformStack — its reventless-aws is older than this command`)
542
+ | Some(platformRef) =>
543
+ let rec deployPlugins = async index =>
544
+ switch plugins->Array.get(index) {
545
+ | None => Ok()
546
+ | Some(plugin) =>
547
+ switch await openForUp(
548
+ ~project=plugin,
549
+ ~stack,
550
+ ~region,
551
+ ~envVars,
552
+ ~extra=[("platform:stack", platformRef)],
553
+ ) {
554
+ | Error(_) as e => e
555
+ | Ok(pluginStack) =>
556
+ switch await deploy(~project=plugin, ~stack=pluginStack, ~name=stack) {
557
+ | Error(_) as e => e
558
+ | Ok() => await deployPlugins(index + 1)
559
+ }
560
+ }
561
+ }
562
+ switch await deployPlugins(0) {
563
+ | Error(_) as e => e
564
+ | Ok() =>
565
+ Console.log("\n── Baking the component manifest ──\n")
566
+ switch await BakeManifest.bake(~manifest, ~stack, ~since=Some(startedAt)) {
567
+ | Error(_) as e => e
568
+ | Ok() =>
569
+ let accounts = switch (
570
+ accountsFile(~platformDir=platform.dir, ~stack),
571
+ platformOutputs->outputString("identityProviderId"),
572
+ ) {
573
+ | (None, _) => Ok(None)
574
+ | (Some(_), None) =>
575
+ Error("the platform stack exports no identityProviderId to create accounts in")
576
+ | (Some(file), Some(providerId)) =>
577
+ Console.log("\n── Creating the accounts ──\n")
578
+ switch await ProvisionAccounts.provision(~file, ~providerId) {
579
+ | Error(_) as e => e
580
+ | Ok() => Ok(Some(file))
581
+ }
582
+ }
583
+ switch accounts {
584
+ | Error(_) as e => e
585
+ | Ok(file) =>
586
+ Console.log("\n── Ready ──\n")
587
+ switch platformOutputs->outputString("hostShellUrl") {
588
+ | Some(url) => Console.log(` Open: ${url}`)
589
+ | None => Console.log(" This platform deploys no web app.")
590
+ }
591
+ file->Option.forEach(file =>
592
+ switch Manifest.parseFile(file) {
593
+ | Ok(entries) if entries->Array.length > 0 =>
594
+ Console.log(" Sign in:")
595
+ signInLines(entries)->Array.forEach(line => Console.log(line))
596
+ Console.log(` The passwords are in ${file}.`)
597
+ | _ => ()
598
+ }
599
+ )
600
+ Console.log(` Remove it all again: deploy-app down --stack ${stack}`)
601
+ Ok()
602
+ }
603
+ }
604
+ }
605
+ }
606
+ }
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ let down = async (~manifest: DeployManifest.resolved, ~stack: string): result<unit, string> =>
613
+ switch projectsOf(manifest) {
614
+ | Error(_) as e => e
615
+ | Ok((platform, plugins)) =>
616
+ let sts = Sts.client()
617
+ switch await (sts->Sts.config).region() {
618
+ | exception _ => Error("No AWS region is set. Set AWS_REGION and try again.")
619
+ | region =>
620
+ let envVars = Dict.make()
621
+ // Reverse deploy order: the plugins read the platform's outputs.
622
+ let ordered = plugins->Array.toReversed->Array.concat([platform])
623
+ let found = []
624
+ let refused = []
625
+ for index in 0 to ordered->Array.length - 1 {
626
+ let project = ordered->Array.getUnsafe(index)
627
+ switch await existingStack(~project, ~stack, ~envVars) {
628
+ | None => Console.log(`stack ${project.projectName}/${stack} (not deployed)`)
629
+ | Some(s) =>
630
+ if await isDisposable(s) {
631
+ found->Array.push((project, s))
632
+ } else {
633
+ refused->Array.push(`${project.projectName}/${stack}`)
634
+ }
635
+ }
636
+ }
637
+ if refused->Array.length > 0 {
638
+ Error(
639
+ `nothing was removed: ${refused->Array.join(", ")} ${refused->Array.length == 1
640
+ ? "does"
641
+ : "do"} not declare ${disposableKey}: "true", and deploy-app only removes try-out stacks it created.`,
642
+ )
643
+ } else {
644
+ let rec removeStacks = async index =>
645
+ switch found->Array.get(index) {
646
+ | None => Ok()
647
+ | Some((project, s)) =>
648
+ Console.log(
649
+ `\n── Removing ${project.label} (${project.projectName}/${stack}) ──\n`,
650
+ )
651
+ switch await s->Automation.destroy({onOutput: stream}) {
652
+ | _ =>
653
+ await s->Automation.workspace->Automation.removeStack(stack)
654
+ await removeStacks(index + 1)
655
+ | exception exn =>
656
+ Error(
657
+ `removing ${project.projectName}/${stack} failed. Fix the cause and run down again.\n${failureDetail(
658
+ exn,
659
+ )}`,
660
+ )
661
+ }
662
+ }
663
+ switch await removeStacks(0) {
664
+ | Error(_) as e => e
665
+ | Ok() =>
666
+ await removeLayer(~stack, ~region)
667
+ Console.log(
668
+ found->Array.length == 0
669
+ ? `\nNo stack was deployed as ${stack}.`
670
+ : `\nRemoved ${stack}.`,
671
+ )
672
+ Ok()
673
+ }
674
+ }
675
+ }
676
+ }
677
+
678
+ let run = async (): result<unit, string> =>
679
+ switch parseArgs(NodeProcess.argv->Array.slice(~start=2, ~end=NodeProcess.argv->Array.length)) {
680
+ | Error(_) as e => e
681
+ | Ok(args) if args.help =>
682
+ Console.log(usage)
683
+ Ok()
684
+ | Ok({command: None}) => Error("say `up` or `down` (--help for more)")
685
+ | Ok({command: Some(command), manifest, stack}) =>
686
+ switch DeployManifest.load(manifest->Option.getOr(DeployManifest.defaultFile)) {
687
+ | Error(_) as e => e
688
+ | Ok(manifest) =>
689
+ switch command {
690
+ | Up => await up(~manifest, ~stack)
691
+ | Down => await down(~manifest, ~stack)
692
+ }
693
+ }
694
+ }
695
+
696
+ let main = async () =>
697
+ switch await run() {
698
+ | Ok() => ()
699
+ | Error(message) =>
700
+ Console.error(`deploy-app: ${message}`)
701
+ NodeProcess.exit(1)
702
+ | exception exn =>
703
+ Console.error(`deploy-app: ${Util_AwsError.describe(exn)}`)
704
+ NodeProcess.exit(1)
705
+ }
706
+
707
+ // No top-level call: `../run-deploy-app.mjs` invokes [main], so a test can import
708
+ // this module.