@reventlessdev/reventless-aws 3.0.0-alpha.345 → 3.0.0-alpha.347
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 +54 -0
- package/package.json +19 -12
- package/rescript.json +2 -1
- package/run-bake-manifest.mjs +3 -0
- package/run-deploy-app.mjs +3 -0
- package/run-provision-accounts.mjs +3 -0
- package/run-provision-admin.mjs +3 -0
- package/scripts/BakeManifest.res +395 -0
- package/scripts/BakeManifest.res.mjs +487 -0
- package/scripts/DeployApp.res +708 -0
- package/scripts/DeployApp.res.mjs +1015 -0
- package/scripts/DeployManifest.res +84 -0
- package/scripts/DeployManifest.res.mjs +112 -0
- package/scripts/ProvisionAccounts.res +339 -0
- package/scripts/ProvisionAccounts.res.mjs +351 -0
- package/scripts/ProvisionAdmin.res +275 -0
- package/scripts/ProvisionAdmin.res.mjs +289 -0
- package/scripts/ProvisionCognito.res +149 -0
- package/scripts/ProvisionCognito.res.mjs +98 -0
- package/scripts/ProvisionIdentity.res +76 -11
- package/scripts/ProvisionIdentity.res.mjs +72 -7
- package/scripts/ProvisionProvider.res +104 -0
- package/scripts/ProvisionProvider.res.mjs +123 -0
- package/scripts/PulumiCli.res +43 -0
- package/scripts/PulumiCli.res.mjs +106 -0
- package/src/Platform.res +51 -4
- package/src/Platform.res.mjs +32 -14
- package/src/Platform_Stack.res +119 -9
- package/src/Platform_Stack.res.mjs +42 -4
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +1 -5
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +1 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +1 -5
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +1 -2
- package/src/adapter/Auth/Auth_ActiveRoleStore.res +1 -5
- package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +1 -2
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res +1 -5
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +1 -2
- package/src/adapter/Auth/Auth_LoginIdentifier.res +66 -0
- package/src/adapter/Auth/Auth_LoginIdentifier.res.mjs +70 -0
- package/src/adapter/Auth/Auth_SignUpMode.res +78 -0
- package/src/adapter/Auth/Auth_SignUpMode.res.mjs +56 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +52 -31
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +75 -44
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +1 -5
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +1 -2
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res +1 -5
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res.mjs +1 -2
- package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +5 -4
- package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res.mjs +1 -1
- package/src/adapter/Postgres/PgMigration_Builder.res +1 -1
- package/src/adapter/Postgres/PgMigration_Builder.res.mjs +1 -2
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +5 -0
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +5 -1
- package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res +2 -0
- package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res.mjs +2 -2
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +15 -6
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +51 -30
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res.mjs +31 -14
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +1 -5
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +1 -1
- package/src/adapter/StateTopic/StateTopic_AppSync.res +1 -5
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +1 -1
- package/src/adapter/Task/TaskBucket_S3.res +1 -3
- package/src/adapter/Task/TaskBucket_S3.res.mjs +1 -1
- package/src/adapter/Upload/Upload_Claim_S3.res +1 -5
- package/src/adapter/Upload/Upload_Claim_S3.res.mjs +1 -1
- package/src/adapter/Upload/Upload_Presign_S3.res +1 -5
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +1 -2
- package/src/capability/Capability_Geocoding_AwsLocation.res +5 -1
- package/src/capability/Capability_Geocoding_AwsLocation.res.mjs +7 -1
- package/src/components/Api/AppSync_Adapter.res +56 -72
- package/src/components/Api/AppSync_Adapter.res.mjs +26 -60
- package/src/components/Api/AppSync_SdlDecorate.res +82 -1
- package/src/components/Api/AppSync_SdlDecorate.res.mjs +45 -0
- package/src/plugin/cloner/ClonerRunner_Fargate.res +1 -5
- package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +1 -1
- package/src/plugin/stack/Plugin_Stack.res +4 -0
- package/src/plugin/stack/Plugin_Stack.res.mjs +2 -0
- package/src/util/Util_DeadLetterQueue.res +1 -5
- package/src/util/Util_DeadLetterQueue.res.mjs +1 -2
- package/src/util/Util_StoreLayout.res +15 -2
- package/src/util/Util_StoreLayout.res.mjs +11 -3
- package/tests/AppSync_AdapterTest.res +83 -0
- package/tests/AppSync_AdapterTest.res.mjs +69 -0
- package/tests/Auth_LoginIdentifierTest.res +51 -0
- package/tests/Auth_LoginIdentifierTest.res.mjs +51 -0
- package/tests/Auth_SignUpModeTest.res +56 -0
- package/tests/Auth_SignUpModeTest.res.mjs +53 -0
- package/tests/BakeManifestTest.res +204 -0
- package/tests/BakeManifestTest.res.mjs +259 -0
- package/tests/DcbColdStartPartitionTest.res +32 -0
- package/tests/DcbColdStartPartitionTest.res.mjs +33 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +88 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +112 -0
- package/tests/DcbUnresolvedPartitionSlice.res +28 -0
- package/tests/DcbUnresolvedPartitionSlice.res.mjs +38 -0
- package/tests/DeployAppTest.res +90 -0
- package/tests/DeployAppTest.res.mjs +133 -0
- package/tests/LambdaLayerLookupTest.res +104 -0
- package/tests/LambdaLayerLookupTest.res.mjs +117 -0
- package/tests/ProvisionAccountsTest.res +168 -0
- package/tests/ProvisionAccountsTest.res.mjs +184 -0
- package/tests/ProvisionAdminTest.res +55 -0
- package/tests/ProvisionAdminTest.res.mjs +66 -0
- package/tests/ProvisionIdentityTest.res +89 -3
- package/tests/ProvisionIdentityTest.res.mjs +66 -1
- package/tests/Util_OwnerScopeEnvTest.res +25 -0
- package/tests/Util_OwnerScopeEnvTest.res.mjs +13 -0
- package/tests/Util_StoreLayoutTest.res +6 -0
- package/tests/Util_StoreLayoutTest.res.mjs +7 -4
- package/tests/dcbColdStartPartition.mjs +66 -0
- 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.
|