@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,84 @@
|
|
|
1
|
+
/***
|
|
2
|
+
An app's `deploy-manifest.yaml`: the folder holding the platform stack, and the
|
|
3
|
+
folders holding the plugin stacks, in the order they deploy.
|
|
4
|
+
|
|
5
|
+
The reusable deploy workflow reads it with `yq`; the commands in this folder read
|
|
6
|
+
it here. Paths in the file are relative to the file's own folder.
|
|
7
|
+
|
|
8
|
+
`stack-defaults` on an entry are the settings `deploy-app` gives a stack it
|
|
9
|
+
creates for that folder — what a new stack of this app cannot deploy without.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
@module("yaml") external parseYaml: string => JSON.t = "parse"
|
|
13
|
+
|
|
14
|
+
@schema
|
|
15
|
+
type platform = {
|
|
16
|
+
path: string,
|
|
17
|
+
name?: string,
|
|
18
|
+
@as("stack-defaults") stackDefaults?: dict<string>,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@schema
|
|
22
|
+
type plugin = {
|
|
23
|
+
name: string,
|
|
24
|
+
path: string,
|
|
25
|
+
@as("depends-on") dependsOn?: array<string>,
|
|
26
|
+
@as("stack-defaults") stackDefaults?: dict<string>,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@schema
|
|
30
|
+
type t = {
|
|
31
|
+
region?: string,
|
|
32
|
+
platform: platform,
|
|
33
|
+
plugins?: array<plugin>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One stack folder, with its path made absolute. */
|
|
37
|
+
type project = {name: string, dir: string, stackDefaults: dict<string>}
|
|
38
|
+
|
|
39
|
+
type resolved = {
|
|
40
|
+
file: string,
|
|
41
|
+
region: option<string>,
|
|
42
|
+
platform: project,
|
|
43
|
+
plugins: array<project>,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let defaultFile = "deploy-manifest.yaml"
|
|
47
|
+
|
|
48
|
+
let parseString = (text: string): result<t, string> =>
|
|
49
|
+
try Ok(S.parseOrThrow(parseYaml(text), ~to=schema)) catch {
|
|
50
|
+
| JsExn(err) => Error(JsExn.message(err)->Option.getOr("not a deploy manifest"))
|
|
51
|
+
| _ => Error("not a deploy manifest")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let resolve = (manifest: t, ~file: string): resolved => {
|
|
55
|
+
let base = NodePath.dirname(NodePath.resolve([file]))
|
|
56
|
+
{
|
|
57
|
+
file,
|
|
58
|
+
region: manifest.region,
|
|
59
|
+
platform: {
|
|
60
|
+
name: manifest.platform.name->Option.getOr("platform"),
|
|
61
|
+
dir: NodePath.resolve([base, manifest.platform.path]),
|
|
62
|
+
stackDefaults: manifest.platform.stackDefaults->Option.getOr(Dict.make()),
|
|
63
|
+
},
|
|
64
|
+
plugins: manifest.plugins
|
|
65
|
+
->Option.getOr([])
|
|
66
|
+
->Array.map(p => {
|
|
67
|
+
name: p.name,
|
|
68
|
+
dir: NodePath.resolve([base, p.path]),
|
|
69
|
+
stackDefaults: p.stackDefaults->Option.getOr(Dict.make()),
|
|
70
|
+
}),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let load = (file: string): result<resolved, string> =>
|
|
75
|
+
switch try Ok(NodeFs.readFileSync(file)) catch {
|
|
76
|
+
| _ => Error(`cannot read ${file}`)
|
|
77
|
+
} {
|
|
78
|
+
| Error(_) as e => e
|
|
79
|
+
| Ok(text) =>
|
|
80
|
+
switch parseString(text) {
|
|
81
|
+
| Error(message) => Error(`${file}: ${message}`)
|
|
82
|
+
| Ok(manifest) => Ok(manifest->resolve(~file))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Sury from "sury";
|
|
5
|
+
import * as Yaml from "yaml";
|
|
6
|
+
import * as Nodefs from "node:fs";
|
|
7
|
+
import * as Nodepath from "node:path";
|
|
8
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
9
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
10
|
+
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
11
|
+
|
|
12
|
+
let platformSchema = Sury.$schema(s => ({
|
|
13
|
+
path: s.m(Sury.string),
|
|
14
|
+
name: s.m(Sury.$option(Sury.string)),
|
|
15
|
+
"stack-defaults": s.m(Sury.$option(Sury.dict(Sury.string)))
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
let pluginSchema = Sury.$schema(s => ({
|
|
19
|
+
name: s.m(Sury.string),
|
|
20
|
+
path: s.m(Sury.string),
|
|
21
|
+
"depends-on": s.m(Sury.$option(Sury.array(Sury.string))),
|
|
22
|
+
"stack-defaults": s.m(Sury.$option(Sury.dict(Sury.string)))
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
let schema = Sury.$schema(s => ({
|
|
26
|
+
region: s.m(Sury.$option(Sury.string)),
|
|
27
|
+
platform: s.m(platformSchema),
|
|
28
|
+
plugins: s.m(Sury.$option(Sury.array(pluginSchema)))
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
function parseString(text) {
|
|
32
|
+
try {
|
|
33
|
+
return {
|
|
34
|
+
TAG: "Ok",
|
|
35
|
+
_0: S.parseOrThrow(Yaml.parse(text), schema)
|
|
36
|
+
};
|
|
37
|
+
} catch (raw_err) {
|
|
38
|
+
let err = Primitive_exceptions.internalToException(raw_err);
|
|
39
|
+
if (err.RE_EXN_ID === "JsExn") {
|
|
40
|
+
return {
|
|
41
|
+
TAG: "Error",
|
|
42
|
+
_0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "not a deploy manifest")
|
|
43
|
+
};
|
|
44
|
+
} else {
|
|
45
|
+
return {
|
|
46
|
+
TAG: "Error",
|
|
47
|
+
_0: "not a deploy manifest"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolve(manifest, file) {
|
|
54
|
+
let base = Nodepath.dirname(Nodepath.resolve(file));
|
|
55
|
+
return {
|
|
56
|
+
file: file,
|
|
57
|
+
region: manifest.region,
|
|
58
|
+
platform: {
|
|
59
|
+
name: Stdlib_Option.getOr(manifest.platform.name, "platform"),
|
|
60
|
+
dir: Nodepath.resolve(base, manifest.platform.path),
|
|
61
|
+
stackDefaults: Stdlib_Option.getOr(manifest.platform["stack-defaults"], {})
|
|
62
|
+
},
|
|
63
|
+
plugins: Stdlib_Option.getOr(manifest.plugins, []).map(p => ({
|
|
64
|
+
name: p.name,
|
|
65
|
+
dir: Nodepath.resolve(base, p.path),
|
|
66
|
+
stackDefaults: Stdlib_Option.getOr(p["stack-defaults"], {})
|
|
67
|
+
}))
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function load(file) {
|
|
72
|
+
let e;
|
|
73
|
+
try {
|
|
74
|
+
e = {
|
|
75
|
+
TAG: "Ok",
|
|
76
|
+
_0: Nodefs.readFileSync(file, "utf8")
|
|
77
|
+
};
|
|
78
|
+
} catch (exn) {
|
|
79
|
+
e = {
|
|
80
|
+
TAG: "Error",
|
|
81
|
+
_0: `cannot read ` + file
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (e.TAG !== "Ok") {
|
|
85
|
+
return e;
|
|
86
|
+
}
|
|
87
|
+
let message = parseString(e._0);
|
|
88
|
+
if (message.TAG === "Ok") {
|
|
89
|
+
return {
|
|
90
|
+
TAG: "Ok",
|
|
91
|
+
_0: resolve(message._0, file)
|
|
92
|
+
};
|
|
93
|
+
} else {
|
|
94
|
+
return {
|
|
95
|
+
TAG: "Error",
|
|
96
|
+
_0: file + `: ` + message._0
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let defaultFile = "deploy-manifest.yaml";
|
|
102
|
+
|
|
103
|
+
export {
|
|
104
|
+
platformSchema,
|
|
105
|
+
pluginSchema,
|
|
106
|
+
schema,
|
|
107
|
+
defaultFile,
|
|
108
|
+
parseString,
|
|
109
|
+
resolve,
|
|
110
|
+
load,
|
|
111
|
+
}
|
|
112
|
+
/* platformSchema Not a pure module */
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/***
|
|
2
|
+
Turn a declared cast of accounts into working sign-ins.
|
|
3
|
+
|
|
4
|
+
`provision-admin` makes the *first* administrator, which is the account that
|
|
5
|
+
cannot be made from a signed-in session because there is no signed-in session yet.
|
|
6
|
+
This one makes a declared list of accounts instead.
|
|
7
|
+
|
|
8
|
+
🚨 **Neither needs the other to have run.** Both authenticate as the AWS caller,
|
|
9
|
+
not as a Cognito principal, so there is no bootstrap ordering between them: a
|
|
10
|
+
manifest declaring an entry in the administrator group stands a deployment up on
|
|
11
|
+
its own. The difference is where the credential ends up — `provision-admin` prints
|
|
12
|
+
one and keeps no copy, while this writes into the manifest, which is what
|
|
13
|
+
`pnpm run seed` reads. For a demo whose cast already names an administrator, this
|
|
14
|
+
one alone is enough.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
pnpm exec provision-accounts
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
It reads `.reventless/users.yaml`, and knows nothing about any particular
|
|
21
|
+
application's cast: the manifest belongs to whoever is deploying. A demo's four
|
|
22
|
+
roles and a company's twelve staff are the same run. Which pool it writes to is
|
|
23
|
+
[ProvisionProvider]'s question, and usually needs no argument either.
|
|
24
|
+
|
|
25
|
+
🚨 **Two halves, and only the second is AWS's.** [Reventless.AccountsManifest.prepare]
|
|
26
|
+
validates the manifest and mints a password into every empty field, on any
|
|
27
|
+
platform and with no credentials — locally that is the whole of provisioning,
|
|
28
|
+
because there the manifest *is* the user store. This script then applies the
|
|
29
|
+
prepared manifest to a Cognito pool. That first half is its own bin,
|
|
30
|
+
`prepare-accounts`, so a platform that never deploys to AWS can reach it without
|
|
31
|
+
depending on this package.
|
|
32
|
+
|
|
33
|
+
Re-running is safe and is the normal case — an operator adding one account to
|
|
34
|
+
four. A password already in the file is never replaced; see
|
|
35
|
+
[Reventless.AccountsManifest.applyFills] for why that rule is the whole of the
|
|
36
|
+
write-back's safety.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
open AwsSdk
|
|
40
|
+
|
|
41
|
+
module Cognito = CognitoIdentityServiceProvider
|
|
42
|
+
module Manifest = Reventless.AccountsManifest
|
|
43
|
+
|
|
44
|
+
// ── Arguments ────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
type args = {
|
|
47
|
+
providerId: option<string>,
|
|
48
|
+
stack: option<string>,
|
|
49
|
+
file: option<string>,
|
|
50
|
+
help: bool,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parsed rather than positional, and unknown flags are an error — the reason
|
|
54
|
+
[ProvisionAdmin.parseArgs] gives, which lands harder here: a typo'd
|
|
55
|
+
`--provider-id` would fall through to the environment and create the whole cast
|
|
56
|
+
in a *different pool* than the operator named. */
|
|
57
|
+
let parseArgs = (argv: array<string>): result<args, string> => {
|
|
58
|
+
let acc = ref(Ok({providerId: None, stack: None, file: None, help: false}))
|
|
59
|
+
let i = ref(0)
|
|
60
|
+
let count = argv->Array.length
|
|
61
|
+
while i.contents < count {
|
|
62
|
+
let flag = argv->Array.getUnsafe(i.contents)
|
|
63
|
+
let value = argv->Array.get(i.contents + 1)
|
|
64
|
+
switch (acc.contents, flag, value) {
|
|
65
|
+
| (Error(_), _, _) => i := count
|
|
66
|
+
| (Ok(a), "--provider-id", Some(v)) =>
|
|
67
|
+
acc := Ok({...a, providerId: Some(v)})
|
|
68
|
+
i := i.contents + 2
|
|
69
|
+
| (Ok(a), "--stack", Some(v)) =>
|
|
70
|
+
acc := Ok({...a, stack: Some(v)})
|
|
71
|
+
i := i.contents + 2
|
|
72
|
+
| (Ok(a), "--file", Some(v)) =>
|
|
73
|
+
acc := Ok({...a, file: Some(v)})
|
|
74
|
+
i := i.contents + 2
|
|
75
|
+
| (Ok(a), "--help", _) | (Ok(a), "-h", _) =>
|
|
76
|
+
acc := Ok({...a, help: true})
|
|
77
|
+
i := i.contents + 1
|
|
78
|
+
| (Ok(_), "--provider-id", None) | (Ok(_), "--file", None) | (Ok(_), "--stack", None) =>
|
|
79
|
+
acc := Error(`${flag} needs a value`)
|
|
80
|
+
| (Ok(_), unknown, _) => acc := Error(`unknown argument "${unknown}"`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
acc.contents
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let usage = `
|
|
87
|
+
Turn a declared cast of accounts into working sign-ins.
|
|
88
|
+
|
|
89
|
+
--provider-id <id> The identity provider to create the accounts in. Usually
|
|
90
|
+
omitted: it falls back to ${ProvisionProvider.envKey},
|
|
91
|
+
then to the identityProviderId exported by the selected
|
|
92
|
+
Pulumi stack — which every deployment exports, whether it
|
|
93
|
+
created the pool or was handed one.
|
|
94
|
+
--stack <name> Read that output from this stack instead of the selected
|
|
95
|
+
one. The run always names the stack it used.
|
|
96
|
+
--file <path> The manifest. Defaults to .reventless/users.yaml relative
|
|
97
|
+
to the working directory, which is where both platforms
|
|
98
|
+
keep it.
|
|
99
|
+
|
|
100
|
+
For every entry: ensures the groups it names, ensures the account, sets a
|
|
101
|
+
permanent password, and applies the memberships. Generated passwords and the ids
|
|
102
|
+
the pool mints are written back into the manifest, so the seed client can read
|
|
103
|
+
them without anyone retyping a UUID.
|
|
104
|
+
|
|
105
|
+
A password already in the manifest is never replaced. Region and credentials come
|
|
106
|
+
from the environment, as for any AWS SDK call.
|
|
107
|
+
`
|
|
108
|
+
|
|
109
|
+
// ── Preflight ────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
let looksLikeEmail = (username: string): bool =>
|
|
112
|
+
username->String.includes("@") && username->String.includes(".")
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
Refuse a pool this cast could never sign in to.
|
|
116
|
+
|
|
117
|
+
The mirror of [ProvisionAdmin.checkPoolAcceptsEmail], and it has to run the other
|
|
118
|
+
way round: that script is handed an address and asks whether the pool takes one,
|
|
119
|
+
while a manifest usually names plain usernames — `shopper`, `merch` — and the
|
|
120
|
+
question is whether the pool will accept *those*. A pool created with
|
|
121
|
+
`UsernameAttributes: [email]` will not, and the accounts it makes instead are
|
|
122
|
+
correctly created and unable to authenticate.
|
|
123
|
+
|
|
124
|
+
Checked for every entry before anything is created or written, so a manifest that
|
|
125
|
+
cannot work costs nothing rather than half a cast.
|
|
126
|
+
*/
|
|
127
|
+
let checkPoolAcceptsUsernames = async (~providerId: string, ~usernames: array<string>): result<
|
|
128
|
+
unit,
|
|
129
|
+
string,
|
|
130
|
+
> =>
|
|
131
|
+
switch await ProvisionCognito.usernameAttributes(~providerId) {
|
|
132
|
+
| Error(_) as e => e
|
|
133
|
+
| Ok(attributes) =>
|
|
134
|
+
let signsInOnEmail = attributes->Array.includes("email")
|
|
135
|
+
let unusable = signsInOnEmail ? usernames->Array.filter(u => !looksLikeEmail(u)) : []
|
|
136
|
+
if unusable->Array.length == 0 {
|
|
137
|
+
Console.log(
|
|
138
|
+
`pool signs in on ${attributes->Array.length == 0
|
|
139
|
+
? "username"
|
|
140
|
+
: attributes->Array.join(", ")}`,
|
|
141
|
+
)
|
|
142
|
+
Ok()
|
|
143
|
+
} else {
|
|
144
|
+
Error(
|
|
145
|
+
`pool "${providerId}" signs in on ${attributes->Array.join(
|
|
146
|
+
", ",
|
|
147
|
+
)}, so ${unusable->Array.join(
|
|
148
|
+
", ",
|
|
149
|
+
)} could never authenticate. The sign-in attribute is fixed at pool creation and no update can change it — either name these accounts by address in the manifest, or provision them in a pool that signs in on a username.`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Applying ─────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
let describe = (outcome: ProvisionCognito.outcome) =>
|
|
157
|
+
switch outcome {
|
|
158
|
+
| Created => "created"
|
|
159
|
+
| AlreadyPresent => "already present"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Every group the manifest names, made once rather than once per member.
|
|
163
|
+
|
|
164
|
+
The description says where the group came from because a pool outlives the
|
|
165
|
+
deployment that seeded it, and an operator meeting an undocumented group in the
|
|
166
|
+
console has no other way to find out. */
|
|
167
|
+
let ensureGroups = async (~providerId: string, ~entries: array<Manifest.entry>): unit => {
|
|
168
|
+
let named = []
|
|
169
|
+
entries->Array.forEach(entry =>
|
|
170
|
+
entry.groups->Array.forEach(group =>
|
|
171
|
+
if !(named->Array.includes(group)) {
|
|
172
|
+
named->Array.push(group)
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
for index in 0 to named->Array.length - 1 {
|
|
177
|
+
let group = named->Array.getUnsafe(index)
|
|
178
|
+
let outcome = await ProvisionCognito.ensureGroup(
|
|
179
|
+
~providerId,
|
|
180
|
+
~group,
|
|
181
|
+
~description="Declared in a Reventless accounts manifest",
|
|
182
|
+
)
|
|
183
|
+
Console.log(`group ${group} (${outcome->describe})`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
One account: the entry, applied.
|
|
189
|
+
|
|
190
|
+
The password is set unconditionally rather than only for an account this run
|
|
191
|
+
created — the same reason `provision-admin` does it, arriving at a manifest: the
|
|
192
|
+
file is the record of what the credential *is*, so an account whose password
|
|
193
|
+
drifted from the file is exactly the case a re-run is expected to fix.
|
|
194
|
+
|
|
195
|
+
Returns the id the pool minted, which is the value the manifest cannot know until
|
|
196
|
+
the account exists and which nobody should be pasting by hand.
|
|
197
|
+
*/
|
|
198
|
+
let applyEntry = async (~providerId: string, ~entry: Manifest.entry): option<string> => {
|
|
199
|
+
let attributes: array<Cognito.AdminCreateUserCommand.attributeType> = looksLikeEmail(
|
|
200
|
+
entry.username,
|
|
201
|
+
)
|
|
202
|
+
? [{name: "email", value: entry.username}, {name: "email_verified", value: "true"}]
|
|
203
|
+
: []
|
|
204
|
+
let outcome = await ProvisionCognito.ensureUser(
|
|
205
|
+
~providerId,
|
|
206
|
+
~username=entry.username,
|
|
207
|
+
~attributes,
|
|
208
|
+
)
|
|
209
|
+
Console.log(`user ${entry.username} (${outcome->describe})`)
|
|
210
|
+
await ProvisionCognito.setPassword(
|
|
211
|
+
~providerId,
|
|
212
|
+
~username=entry.username,
|
|
213
|
+
~password=entry.password,
|
|
214
|
+
)
|
|
215
|
+
for index in 0 to entry.groups->Array.length - 1 {
|
|
216
|
+
let group = entry.groups->Array.getUnsafe(index)
|
|
217
|
+
await ProvisionCognito.addToGroup(~providerId, ~username=entry.username, ~group)
|
|
218
|
+
Console.log(`member ${entry.username} in ${group}`)
|
|
219
|
+
}
|
|
220
|
+
await ProvisionCognito.subOf(~providerId, ~username=entry.username)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
🚨 **The passwords are in the manifest, not in this output, and that is the whole
|
|
227
|
+
point of the write-back.**
|
|
228
|
+
|
|
229
|
+
`provision-admin` prints its one credential because there is nowhere else for it
|
|
230
|
+
to live. A cast of four has somewhere: the file the seed client already reads to
|
|
231
|
+
sign in. Printing them here would put every account's password in scrollback and
|
|
232
|
+
in any build log, and buy nothing — whoever needs them can read the file they
|
|
233
|
+
already have.
|
|
234
|
+
*/
|
|
235
|
+
let manifestNote = (~file: string) =>
|
|
236
|
+
`
|
|
237
|
+
🚨 Generated passwords were written into ${file}. They are not printed here
|
|
238
|
+
because that file is where they are read from — by you and by \`pnpm run seed\`.
|
|
239
|
+
It is gitignored on every platform; keep it that way. These are bootstrap
|
|
240
|
+
credentials for a deployment, not secrets to reuse anywhere real.`
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
The manifest at `file`, applied to the pool `providerId`.
|
|
244
|
+
|
|
245
|
+
🚨 **The pool is checked before anything is written.** Preparing mints passwords
|
|
246
|
+
into the file; doing that first left a refused run with a manifest offering
|
|
247
|
+
accounts that exist nowhere, which the seed then tried to sign in as. `checkPool`
|
|
248
|
+
is a parameter so a test can refuse without a pool.
|
|
249
|
+
*/
|
|
250
|
+
let provision = async (
|
|
251
|
+
~file: string,
|
|
252
|
+
~providerId: string,
|
|
253
|
+
~checkPool=checkPoolAcceptsUsernames,
|
|
254
|
+
): result<unit, string> =>
|
|
255
|
+
switch Manifest.parseFile(file) {
|
|
256
|
+
| Error(message) => Error(`${file}: ${message}`)
|
|
257
|
+
| Ok([]) => Error(`${file} declares no accounts`)
|
|
258
|
+
| Ok(declared) =>
|
|
259
|
+
switch await checkPool(~providerId, ~usernames=declared->Array.map(e => e.username)) {
|
|
260
|
+
| Error(_) as e => e
|
|
261
|
+
| Ok() =>
|
|
262
|
+
switch Manifest.prepare(~path=file) {
|
|
263
|
+
| Error(message) => Error(`${file}: ${message}`)
|
|
264
|
+
| Ok(prepared) =>
|
|
265
|
+
let generated = prepared->Array.filter(p => p.passwordGenerated)->Array.length
|
|
266
|
+
Console.log(
|
|
267
|
+
`manifest ${file} (${prepared
|
|
268
|
+
->Array.length
|
|
269
|
+
->Int.toString} accounts, ${generated->Int.toString} password(s) generated)`,
|
|
270
|
+
)
|
|
271
|
+
let entries = prepared->Array.map(p => p.entry)
|
|
272
|
+
await ensureGroups(~providerId, ~entries)
|
|
273
|
+
let fills = []
|
|
274
|
+
for index in 0 to entries->Array.length - 1 {
|
|
275
|
+
let entry = entries->Array.getUnsafe(index)
|
|
276
|
+
let userId = await applyEntry(~providerId, ~entry)
|
|
277
|
+
fills->Array.push({Manifest.index, password: None, userId})
|
|
278
|
+
}
|
|
279
|
+
switch Manifest.fillFile(~path=file, ~fills) {
|
|
280
|
+
| Error(message) => Error(`${file}: ${message}`)
|
|
281
|
+
| Ok(report) =>
|
|
282
|
+
let corrected = report->Array.filter(r => r.userIdWritten)->Array.length
|
|
283
|
+
Console.log(
|
|
284
|
+
`manifest ${file} (${corrected->Int.toString} id(s) written back)${manifestNote(
|
|
285
|
+
~file,
|
|
286
|
+
)}`,
|
|
287
|
+
)
|
|
288
|
+
Ok()
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let run = async (): result<unit, string> =>
|
|
295
|
+
// argv[0] is node, argv[1] this script.
|
|
296
|
+
switch parseArgs(NodeProcess.argv->Array.slice(~start=2, ~end=NodeProcess.argv->Array.length)) {
|
|
297
|
+
| Error(_) as e => e
|
|
298
|
+
| Ok(args) if args.help =>
|
|
299
|
+
Console.log(usage)
|
|
300
|
+
Ok()
|
|
301
|
+
| Ok(args) =>
|
|
302
|
+
switch Manifest.locate(~given=?args.file, ()) {
|
|
303
|
+
| Error(_) as e => e
|
|
304
|
+
| Ok(located) =>
|
|
305
|
+
let file = located->Manifest.pathOf
|
|
306
|
+
switch located {
|
|
307
|
+
| SeededFrom(_, template) => Console.log(`manifest ${file} (new, copied from ${template})`)
|
|
308
|
+
| Declared(_) => ()
|
|
309
|
+
}
|
|
310
|
+
switch ProvisionProvider.resolve(~given=args.providerId, ~stack=args.stack) {
|
|
311
|
+
| Error(message) =>
|
|
312
|
+
Error(`${message}. To fill in the manifest without creating anything, run prepare-accounts`)
|
|
313
|
+
| Ok((providerId, source)) =>
|
|
314
|
+
Console.log(`provider ${providerId} (from ${source->ProvisionProvider.describe})`)
|
|
315
|
+
await provision(~file, ~providerId)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
🚨 **Catches thrown exceptions, not only `Error` results** — the reason
|
|
322
|
+
[ProvisionAdmin.main] gives. Without it an SDK failure escapes an async function
|
|
323
|
+
nothing awaits and Node reports `UnhandledPromiseRejection ... "#<Object>"`,
|
|
324
|
+
naming neither the call that failed nor why.
|
|
325
|
+
*/
|
|
326
|
+
let main = async () =>
|
|
327
|
+
switch await run() {
|
|
328
|
+
| Ok() => ()
|
|
329
|
+
| Error(message) =>
|
|
330
|
+
Console.error(`provision-accounts: ${message}`)
|
|
331
|
+
NodeProcess.exit(1)
|
|
332
|
+
| exception exn =>
|
|
333
|
+
Console.error(`provision-accounts: ${Util_AwsError.describe(exn)}`)
|
|
334
|
+
NodeProcess.exit(1)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 🚨 **No top-level call.** `../run-provision-accounts.mjs` invokes [main]; this
|
|
338
|
+
// module only defines it. A module that ran itself on import could not be
|
|
339
|
+
// imported by a test.
|