@reventlessdev/reventless-aws 3.0.0-alpha.370 → 3.0.0-alpha.372

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 CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.372 (2026-10-05)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **deploy:** the pending-creates recovery runs from reventless-aws, and works ([b9e749e](https://github.com/ReventlessDev/reventless-core/commit/b9e749eb09354a559505fa6facc7f58e38afd2a9))
11
+ ### Features
12
+
13
+ * **deploy:** a plugin names its own region, and can stay out of review environments ([7edd318](https://github.com/ReventlessDev/reventless-core/commit/7edd3187f51f74e57c331b1cb417b17de8067b58))
14
+ * **deploy:** the deploy workflow names its state backend ([a370346](https://github.com/ReventlessDev/reventless-core/commit/a3703461bae9dc22c098315a09bbabcf0c5816b1))
15
+
16
+
17
+ # 3.0.0-alpha.371 (2026-10-01)
18
+
19
+ ### Bug Fixes
20
+
21
+ * check a plugin's roles against the user pool's groups ([ddb3c42](https://github.com/ReventlessDev/reventless-core/commit/ddb3c422b7226a19800e781abd5f03af18867007))
22
+
23
+
6
24
  # 3.0.0-alpha.370 (2026-10-01)
7
25
 
8
26
  * feat!: roles a plugin declares, groups a platform provides ([43daace](https://github.com/ReventlessDev/reventless-core/commit/43daace6ca588afe22562d079af1e6f1da81942e))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.370",
3
+ "version": "3.0.0-alpha.372",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "provision-accounts": "./run-provision-accounts.mjs",
10
10
  "bake-manifest": "./run-bake-manifest.mjs",
11
11
  "deploy-app": "./run-deploy-app.mjs",
12
- "resolve-environment": "./run-resolve-environment.mjs"
12
+ "resolve-environment": "./run-resolve-environment.mjs",
13
+ "clear-pending-creates": "./run-clear-pending-creates.mjs"
13
14
  },
14
15
  "dependencies": {
15
16
  "@aws-sdk/client-appsync": "3.970.0",
@@ -21,19 +22,19 @@
21
22
  "sury": "11.0.0",
22
23
  "uuid": "^13.0.0",
23
24
  "yaml": "^2.8.3",
24
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.27",
25
- "@reventlessdev/rescript-effect": "0.1.0-alpha.34",
25
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.28",
26
26
  "@reventlessdev/rescript-jest": "1.0.0-alpha.11",
27
27
  "@reventlessdev/rescript-node": "2.0.0-alpha.20",
28
- "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.22",
28
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.34",
29
+ "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.23",
30
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.23",
29
31
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
30
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.22",
31
- "@reventlessdev/reventless-core": "3.0.0-alpha.286",
32
32
  "@reventlessdev/rescript-web": "1.0.0-alpha.7",
33
- "@reventlessdev/reventless-interop": "3.0.0-alpha.38",
34
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.150",
35
- "@reventlessdev/reventless-spec": "3.0.0-alpha.156",
36
- "@reventlessdev/reventless-infra": "3.0.0-alpha.184"
33
+ "@reventlessdev/reventless-core": "3.0.0-alpha.287",
34
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.185",
35
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.151",
36
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.39",
37
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.157"
37
38
  },
38
39
  "devDependencies": {
39
40
  "rescript": "12.3.1",
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./scripts/ClearPendingCreates.res.mjs"
3
+ await main()
@@ -0,0 +1,142 @@
1
+ /***
2
+ Before `pulumi up`, recovers a stack an interrupted deploy left behind: releases a
3
+ stale lock, drops the pending creates known to have nothing in AWS behind them,
4
+ and refreshes. Any other pending operation stops it: `--clear-pending-creates`
5
+ drops them all, and would orphan a function or a role that does exist.
6
+ */
7
+
8
+ /** Resource types with nothing in AWS behind them, so a pending create of one is
9
+ safe to drop. Add one only when that holds; a refusal costs one look, an
10
+ orphan stays until someone finds it. */
11
+ let safeTypes = [
12
+ // ACM's wait for DNS validation, which gives up after 1h15m when the record is late.
13
+ "aws:acm/certificateValidation:CertificateValidation",
14
+ ]
15
+
16
+ type pending = {urn: string, resourceType: string, operation: string}
17
+
18
+ let pendingOperations = (export: JSON.t): array<pending> => {
19
+ let field = (json, name) => json->JSON.Decode.object->Option.flatMap(o => o->Dict.get(name))
20
+ let text = (json, name) => json->field(name)->Option.flatMap(JSON.Decode.string)->Option.getOr("")
21
+ export
22
+ ->field("deployment")
23
+ ->Option.flatMap(deployment => deployment->field("pending_operations"))
24
+ ->Option.flatMap(JSON.Decode.array)
25
+ ->Option.getOr([])
26
+ ->Array.map(operation => {
27
+ let resource = operation->field("resource")->Option.getOr(JSON.Null)
28
+ {
29
+ urn: resource->text("urn"),
30
+ resourceType: resource->text("type"),
31
+ operation: operation->text("type"),
32
+ }
33
+ })
34
+ }
35
+
36
+ let isSafe = (pending: pending) => safeTypes->Array.includes(pending.resourceType)
37
+
38
+ type args = {stack: string, cwd: string}
39
+
40
+ let parseArgs = (argv: array<string>): result<args, string> =>
41
+ Reventless.CliArgs.parse(~strings=["stack", "cwd"], argv)
42
+ ->Result.flatMap(Reventless.CliArgs.noPositionals)
43
+ ->Result.flatMap(a =>
44
+ switch a->Reventless.CliArgs.string("stack") {
45
+ | Some(stack) =>
46
+ Ok({stack, cwd: a->Reventless.CliArgs.string("cwd")->Option.getOr(NodeProcess.cwd())})
47
+ | None => Error("--stack names the stack to recover")
48
+ }
49
+ )
50
+
51
+ let usage = `
52
+ Usage: clear-pending-creates --stack <name> [--cwd <dir>]
53
+
54
+ Recovers a stack before \`pulumi up\`: cancels a stale update, drops the pending
55
+ creates of types with nothing in AWS behind them, and refreshes. Any other pending
56
+ operation fails the command, for someone to resolve by hand.
57
+
58
+ --stack <name> The stack.
59
+ --cwd <dir> The Pulumi project. Defaults to the working directory.
60
+ `
61
+
62
+ let pulumi = (args: args, command: array<string>) =>
63
+ NodeChildProcess.spawnSync(
64
+ "pulumi",
65
+ command->Array.concat(["--stack", args.stack, "--cwd", args.cwd, "--non-interactive"]),
66
+ {encoding: "utf8", maxBuffer: 256 * 1024 * 1024},
67
+ )
68
+
69
+ let succeeded = (result: NodeChildProcess.spawnSyncResult) =>
70
+ result.status->Nullable.toOption == Some(0)
71
+
72
+ let stderrOf = (result: NodeChildProcess.spawnSyncResult) =>
73
+ result.stderr->Nullable.toOption->Option.getOr("")->String.trim
74
+
75
+ let run = (args: args): result<unit, array<string>> => {
76
+ let cancel = pulumi(args, ["cancel", "--yes"])
77
+ switch stderrOf(cancel) {
78
+ | message
79
+ if !succeeded(cancel) &&
80
+ message != "" &&
81
+ !(message->String.toLowerCase->String.includes("no update in progress")) =>
82
+ Console.error(`::warning::pulumi cancel: ${message}`)
83
+ | _ => ()
84
+ }
85
+ let exported = pulumi(args, ["stack", "export"])
86
+ let state = switch exported.stdout->Nullable.toOption {
87
+ | Some(stdout) if succeeded(exported) =>
88
+ try Ok(JSON.parseOrThrow(stdout)) catch {
89
+ | _ => Error([`the export of stack ${args.stack} is not JSON`])
90
+ }
91
+ | _ => Error([`pulumi stack export failed: ${stderrOf(exported)}`])
92
+ }
93
+ state->Result.flatMap(state => {
94
+ let pending = pendingOperations(state)
95
+ let refused = pending->Array.filter(p => !isSafe(p))
96
+ if pending == [] {
97
+ Console.log(`No pending operations on stack ${args.stack}.`)
98
+ } else {
99
+ Console.log(
100
+ `${pending->Array.length->Int.toString} pending operation(s) on stack ${args.stack}.`,
101
+ )
102
+ }
103
+ if refused != [] {
104
+ Error(
105
+ refused->Array.map(p =>
106
+ `Pending ${p.operation} of ${p.urn} (${p.resourceType}): not cleared, because something may exist in AWS behind it. Resolve it by hand, or, if the type has nothing behind it, add it to ClearPendingCreates.safeTypes.`
107
+ ),
108
+ )
109
+ } else {
110
+ // Every pending operation left is safe, so the refresh may drop them all.
111
+ // `pulumi state delete` cannot: a pending create has no resource in the state.
112
+ pending->Array.forEach(p =>
113
+ Console.log(`::warning::Clearing pending ${p.operation} of ${p.urn} (${p.resourceType})`)
114
+ )
115
+ let clear = pending == [] ? [] : ["--clear-pending-creates"]
116
+ // A failed refresh is usually a stale provider serialization, which the
117
+ // `pulumi up` after this heals.
118
+ let refreshed = pulumi(args, ["refresh", "--yes", "--skip-preview"]->Array.concat(clear))
119
+ if !succeeded(refreshed) {
120
+ Console.error(
121
+ `::warning::pulumi refresh had errors; pulumi up runs anyway. ${stderrOf(refreshed)}`,
122
+ )
123
+ }
124
+ Ok()
125
+ }
126
+ })
127
+ }
128
+
129
+ let cli: Reventless.CliArgs.cli<args> = {bin: "clear-pending-creates", usage, parse: parseArgs}
130
+
131
+ let main = () =>
132
+ Reventless.CliArgs.run(cli, async args =>
133
+ switch run(args) {
134
+ | Ok() => ()
135
+ | Error(messages) =>
136
+ messages->Array.forEach(message => Console.error(`::error::${message}`))
137
+ NodeProcess.exit(1)
138
+ }
139
+ )
140
+
141
+ // No top-level call: `../run-clear-pending-creates.mjs` invokes [main], so a test
142
+ // can import this module.
@@ -0,0 +1,187 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
6
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
7
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
8
+ import * as CliArgs$Reventless from "@reventlessdev/reventless-spec/src/CliArgs.res.mjs";
9
+ import * as Nodechild_process from "node:child_process";
10
+
11
+ let safeTypes = ["aws:acm/certificateValidation:CertificateValidation"];
12
+
13
+ function pendingOperations($$export) {
14
+ let field = (json, name) => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => o[name]);
15
+ let text = (json, name) => Stdlib_Option.getOr(Stdlib_Option.flatMap(field(json, name), Stdlib_JSON.Decode.string), "");
16
+ return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(field($$export, "deployment"), deployment => field(deployment, "pending_operations")), Stdlib_JSON.Decode.array), []).map(operation => {
17
+ let resource = Stdlib_Option.getOr(field(operation, "resource"), null);
18
+ return {
19
+ urn: text(resource, "urn"),
20
+ resourceType: text(resource, "type"),
21
+ operation: text(operation, "type")
22
+ };
23
+ });
24
+ }
25
+
26
+ function isSafe(pending) {
27
+ return safeTypes.includes(pending.resourceType);
28
+ }
29
+
30
+ function parseArgs(argv) {
31
+ return Stdlib_Result.flatMap(Stdlib_Result.flatMap(CliArgs$Reventless.parse([
32
+ "stack",
33
+ "cwd"
34
+ ], undefined, undefined, undefined, argv), CliArgs$Reventless.noPositionals), a => {
35
+ let stack = CliArgs$Reventless.string(a, "stack");
36
+ if (stack !== undefined) {
37
+ return {
38
+ TAG: "Ok",
39
+ _0: {
40
+ stack: stack,
41
+ cwd: Stdlib_Option.getOr(CliArgs$Reventless.string(a, "cwd"), process.cwd())
42
+ }
43
+ };
44
+ } else {
45
+ return {
46
+ TAG: "Error",
47
+ _0: "--stack names the stack to recover"
48
+ };
49
+ }
50
+ });
51
+ }
52
+
53
+ let usage = `
54
+ Usage: clear-pending-creates --stack <name> [--cwd <dir>]
55
+
56
+ Recovers a stack before \`pulumi up\`: cancels a stale update, drops the pending
57
+ creates of types with nothing in AWS behind them, and refreshes. Any other pending
58
+ operation fails the command, for someone to resolve by hand.
59
+
60
+ --stack <name> The stack.
61
+ --cwd <dir> The Pulumi project. Defaults to the working directory.
62
+ `;
63
+
64
+ function pulumi(args, command) {
65
+ return Nodechild_process.spawnSync("pulumi", command.concat([
66
+ "--stack",
67
+ args.stack,
68
+ "--cwd",
69
+ args.cwd,
70
+ "--non-interactive"
71
+ ]), {
72
+ encoding: "utf8",
73
+ maxBuffer: 268435456
74
+ });
75
+ }
76
+
77
+ function succeeded(result) {
78
+ return Primitive_object.equal(Primitive_option.fromNullable(result.status), 0);
79
+ }
80
+
81
+ function stderrOf(result) {
82
+ return Stdlib_Option.getOr(Primitive_option.fromNullable(result.stderr), "").trim();
83
+ }
84
+
85
+ function run(args) {
86
+ let cancel = pulumi(args, [
87
+ "cancel",
88
+ "--yes"
89
+ ]);
90
+ let message = stderrOf(cancel);
91
+ if (!succeeded(cancel) && message !== "" && !message.toLowerCase().includes("no update in progress")) {
92
+ console.error(`::warning::pulumi cancel: ` + message);
93
+ }
94
+ let exported = pulumi(args, [
95
+ "stack",
96
+ "export"
97
+ ]);
98
+ let stdout = exported.stdout;
99
+ let state;
100
+ let exit = 0;
101
+ if ((stdout == null) || !succeeded(exported)) {
102
+ exit = 1;
103
+ } else {
104
+ try {
105
+ state = {
106
+ TAG: "Ok",
107
+ _0: JSON.parse(stdout)
108
+ };
109
+ } catch (exn) {
110
+ state = {
111
+ TAG: "Error",
112
+ _0: [`the export of stack ` + args.stack + ` is not JSON`]
113
+ };
114
+ }
115
+ }
116
+ if (exit === 1) {
117
+ state = {
118
+ TAG: "Error",
119
+ _0: [`pulumi stack export failed: ` + stderrOf(exported)]
120
+ };
121
+ }
122
+ return Stdlib_Result.flatMap(state, state => {
123
+ let pending = pendingOperations(state);
124
+ let refused = pending.filter(p => !safeTypes.includes(p.resourceType));
125
+ if (Primitive_object.equal(pending, [])) {
126
+ console.log(`No pending operations on stack ` + args.stack + `.`);
127
+ } else {
128
+ console.log(pending.length.toString() + ` pending operation(s) on stack ` + args.stack + `.`);
129
+ }
130
+ if (Primitive_object.notequal(refused, [])) {
131
+ return {
132
+ TAG: "Error",
133
+ _0: refused.map(p => `Pending ` + p.operation + ` of ` + p.urn + ` (` + p.resourceType + `): not cleared, because something may exist in AWS behind it. Resolve it by hand, or, if the type has nothing behind it, add it to ClearPendingCreates.safeTypes.`)
134
+ };
135
+ }
136
+ pending.forEach(p => {
137
+ console.log(`::warning::Clearing pending ` + p.operation + ` of ` + p.urn + ` (` + p.resourceType + `)`);
138
+ });
139
+ let clear = Primitive_object.equal(pending, []) ? [] : ["--clear-pending-creates"];
140
+ let refreshed = pulumi(args, [
141
+ "refresh",
142
+ "--yes",
143
+ "--skip-preview"
144
+ ].concat(clear));
145
+ if (!succeeded(refreshed)) {
146
+ console.error(`::warning::pulumi refresh had errors; pulumi up runs anyway. ` + stderrOf(refreshed));
147
+ }
148
+ return {
149
+ TAG: "Ok",
150
+ _0: undefined
151
+ };
152
+ });
153
+ }
154
+
155
+ let cli = {
156
+ bin: "clear-pending-creates",
157
+ usage: usage,
158
+ parse: parseArgs
159
+ };
160
+
161
+ function main() {
162
+ return CliArgs$Reventless.run(cli, undefined, undefined, async args => {
163
+ let messages = run(args);
164
+ if (messages.TAG === "Ok") {
165
+ return;
166
+ }
167
+ messages._0.forEach(message => {
168
+ console.error(`::error::` + message);
169
+ });
170
+ process.exit(1);
171
+ });
172
+ }
173
+
174
+ export {
175
+ safeTypes,
176
+ pendingOperations,
177
+ isSafe,
178
+ parseArgs,
179
+ usage,
180
+ pulumi,
181
+ succeeded,
182
+ stderrOf,
183
+ run,
184
+ cli,
185
+ main,
186
+ }
187
+ /* CliArgs-Reventless Not a pure module */
@@ -86,6 +86,7 @@ type project = {
86
86
  projectName: string,
87
87
  program: string,
88
88
  stackDefaults: dict<string>,
89
+ region: option<string>,
89
90
  }
90
91
 
91
92
  @module("yaml") external parseYaml: string => JSON.t = "parse"
@@ -111,6 +112,7 @@ let projectOf = (p: DeployManifest.project): result<project, string> => {
111
112
  projectName,
112
113
  program: NodePath.join([p.dir, field("main")->Option.getOr("index.js")]),
113
114
  stackDefaults: p.stackDefaults,
115
+ region: p.region,
114
116
  })
115
117
  }
116
118
  }
@@ -150,6 +152,19 @@ let backendNeedsPassphrase = (~url: option<string>, ~env: dict<string>): bool =>
150
152
  | _ => false
151
153
  }
152
154
 
155
+ /** A manifest that puts a stack in another region than the one `up` deploys
156
+ every stack to. */
157
+ let regionProblems = (~projects: array<project>, ~region: string): array<string> =>
158
+ projects->Array.filterMap(p =>
159
+ switch p.region {
160
+ | Some(own) if own != region =>
161
+ Some(
162
+ `${p.label} deploys to ${own} in the manifest, and deploy-app deploys every stack to one region, ${region}. Deploy it with the deploy workflow, or from a manifest without that region.`,
163
+ )
164
+ | _ => None
165
+ }
166
+ )
167
+
153
168
  let unbuilt = (projects: array<project>): array<project> =>
154
169
  projects->Array.filter(p => !NodeFs.existsSync(p.program))
155
170
 
@@ -201,7 +216,7 @@ let check = async (~projects: array<project>): result<ready, array<string>> => {
201
216
  }
202
217
  | exception _ =>
203
218
  problems->Array.push(
204
- "Pulumi is not logged in. Run `pulumi login` (Pulumi Cloud) or `pulumi login --local`, and try again.",
219
+ "Pulumi is not logged in. Run `pulumi login` (Pulumi Cloud), `pulumi login s3://<bucket>` (a bucket of your own) or `pulumi login --local`, and try again.",
205
220
  )
206
221
  }
207
222
  | exception _ =>
@@ -210,6 +225,9 @@ let check = async (~projects: array<project>): result<ready, array<string>> => {
210
225
  )
211
226
  }
212
227
  }
228
+ region->Option.forEach(region =>
229
+ regionProblems(~projects, ~region)->Array.forEach(p => problems->Array.push(p))
230
+ )
213
231
  unbuilt(projects)->Array.forEach(p =>
214
232
  problems->Array.push(
215
233
  `${p.label} is not built: ${p.program} is missing. Run \`pnpm run build\` and try again.`,
@@ -134,7 +134,8 @@ function projectOf(p) {
134
134
  dir: p.dir,
135
135
  projectName: projectName,
136
136
  program: Nodepath.join(p.dir, Stdlib_Option.getOr(field("main"), "index.js")),
137
- stackDefaults: p.stackDefaults
137
+ stackDefaults: p.stackDefaults,
138
+ region: p.region
138
139
  }
139
140
  };
140
141
  } else {
@@ -183,6 +184,15 @@ function backendNeedsPassphrase(url, env) {
183
184
  }
184
185
  }
185
186
 
187
+ function regionProblems(projects, region) {
188
+ return Stdlib_Array.filterMap(projects, p => {
189
+ let own = p.region;
190
+ if (own !== undefined && own !== region) {
191
+ return p.label + ` deploys to ` + own + ` in the manifest, and deploy-app deploys every stack to one region, ` + region + `. Deploy it with the deploy workflow, or from a manifest without that region.`;
192
+ }
193
+ });
194
+ }
195
+
186
196
  function unbuilt(projects) {
187
197
  return projects.filter(p => !Nodefs.existsSync(p.program));
188
198
  }
@@ -243,7 +253,7 @@ async function check(projects) {
243
253
  who = await workspace.whoAmI();
244
254
  exit$3 = 2;
245
255
  } catch (exn$3) {
246
- problems.push("Pulumi is not logged in. Run `pulumi login` (Pulumi Cloud) or `pulumi login --local`, and try again.");
256
+ problems.push("Pulumi is not logged in. Run `pulumi login` (Pulumi Cloud), `pulumi login s3://<bucket>` (a bucket of your own) or `pulumi login --local`, and try again.");
247
257
  }
248
258
  if (exit$3 === 2) {
249
259
  if (backendNeedsPassphrase(who.url, process.env)) {
@@ -252,6 +262,11 @@ async function check(projects) {
252
262
  }
253
263
  }
254
264
  }
265
+ Stdlib_Option.forEach(region, region => {
266
+ regionProblems(projects, region).forEach(p => {
267
+ problems.push(p);
268
+ });
269
+ });
255
270
  unbuilt(projects).forEach(p => {
256
271
  problems.push(p.label + ` is not built: ` + p.program + ` is missing. Run \`pnpm run build\` and try again.`);
257
272
  });
@@ -917,6 +932,7 @@ export {
917
932
  requiredNodeMajor,
918
933
  checkNode,
919
934
  backendNeedsPassphrase,
935
+ regionProblems,
920
936
  unbuilt,
921
937
  check,
922
938
  layerName,
@@ -7,6 +7,8 @@ it here. Paths in the file are relative to the file's own folder.
7
7
 
8
8
  `stack-defaults` on an entry are the settings `deploy-app` gives a stack it
9
9
  creates for that folder — what a new stack of this app cannot deploy without.
10
+ A plugin's `region` overrides the file's for that stack; `review: false` keeps it
11
+ out of a pull request's review environment.
10
12
  */
11
13
 
12
14
  @module("yaml") external parseYaml: string => JSON.t = "parse"
@@ -24,6 +26,8 @@ type plugin = {
24
26
  path: string,
25
27
  @as("depends-on") dependsOn?: array<string>,
26
28
  @as("stack-defaults") stackDefaults?: dict<string>,
29
+ region?: string,
30
+ review?: bool,
27
31
  }
28
32
 
29
33
  @schema
@@ -34,7 +38,7 @@ type t = {
34
38
  }
35
39
 
36
40
  /** One stack folder, with its path made absolute. */
37
- type project = {name: string, dir: string, stackDefaults: dict<string>}
41
+ type project = {name: string, dir: string, stackDefaults: dict<string>, region: option<string>}
38
42
 
39
43
  type resolved = {
40
44
  file: string,
@@ -60,6 +64,7 @@ let resolve = (manifest: t, ~file: string): resolved => {
60
64
  name: manifest.platform.name->Option.getOr("platform"),
61
65
  dir: NodePath.resolve([base, manifest.platform.path]),
62
66
  stackDefaults: manifest.platform.stackDefaults->Option.getOr(Dict.make()),
67
+ region: None,
63
68
  },
64
69
  plugins: manifest.plugins
65
70
  ->Option.getOr([])
@@ -67,6 +72,7 @@ let resolve = (manifest: t, ~file: string): resolved => {
67
72
  name: p.name,
68
73
  dir: NodePath.resolve([base, p.path]),
69
74
  stackDefaults: p.stackDefaults->Option.getOr(Dict.make()),
75
+ region: p.region,
70
76
  }),
71
77
  }
72
78
  }
@@ -18,7 +18,9 @@ let pluginSchema = Sury.$schema(s => ({
18
18
  name: s.m(Sury.string),
19
19
  path: s.m(Sury.string),
20
20
  "depends-on": s.m(Sury.$option(Sury.array(Sury.string))),
21
- "stack-defaults": s.m(Sury.$option(Sury.record(Sury.string)))
21
+ "stack-defaults": s.m(Sury.$option(Sury.record(Sury.string))),
22
+ region: s.m(Sury.$option(Sury.string)),
23
+ review: s.m(Sury.$option(Sury.boolean))
22
24
  }));
23
25
 
24
26
  let schema = Sury.$schema(s => ({
@@ -57,12 +59,14 @@ function resolve(manifest, file) {
57
59
  platform: {
58
60
  name: Stdlib_Option.getOr(manifest.platform.name, "platform"),
59
61
  dir: Nodepath.resolve(base, manifest.platform.path),
60
- stackDefaults: Stdlib_Option.getOr(manifest.platform["stack-defaults"], {})
62
+ stackDefaults: Stdlib_Option.getOr(manifest.platform["stack-defaults"], {}),
63
+ region: undefined
61
64
  },
62
65
  plugins: Stdlib_Option.getOr(manifest.plugins, []).map(p => ({
63
66
  name: p.name,
64
67
  dir: Nodepath.resolve(base, p.path),
65
- stackDefaults: Stdlib_Option.getOr(p["stack-defaults"], {})
68
+ stackDefaults: Stdlib_Option.getOr(p["stack-defaults"], {}),
69
+ region: p.region
66
70
  }))
67
71
  };
68
72
  }
package/src/Platform.res CHANGED
@@ -112,11 +112,34 @@ let messagingEmailProviderRef: ref<Pulumi.Output.t<string>> = ref(Pulumi.Output.
112
112
  resolves roles while it is built, and a mapping stated later is refused. */
113
113
  let roleGroups = Reventless.Role.setGroups
114
114
 
115
- /** The groups of a user pool this deployment did not create, which no accounts
116
- manifest names. They count as provided by the check that every role a plugin
117
- needs has a group. */
115
+ /** Groups a root says its identity provider has, for a provider the platform
116
+ cannot list. Where it can (`loadProvidedGroups`), the listing decides, and a
117
+ declared group the pool lacks fails the deploy. */
118
118
  let providedGroups = Reventless.Role.provideGroups
119
119
 
120
+ /** The platform stack a plugin stack deploys against (`platform:stack`), or
121
+ `None` in a program that is its own platform. One reference per program:
122
+ a second with the same name is a duplicate resource. */
123
+ let platformStackRefCache: ref<option<option<Pulumi.StackReference.t>>> = ref(None)
124
+
125
+ let platformStackReference = (): option<Pulumi.StackReference.t> =>
126
+ switch platformStackRefCache.contents {
127
+ | Some(stackRef) => stackRef
128
+ | None =>
129
+ let stackRef =
130
+ Pulumi.Config.make(Some("platform"))
131
+ ->Pulumi.Config.get("stack")
132
+ ->Option.map(stack => Pulumi.StackReference.make(stack))
133
+ platformStackRefCache := Some(stackRef)
134
+ stackRef
135
+ }
136
+
137
+ /** Ask the user pool which groups it has, so the role check compares each
138
+ plugin's roles with the pool. A plugin's deploy program calls `deployPlugin`
139
+ once this resolves; see [Platform_ProvidedGroups]. */
140
+ let loadProvidedGroups = (): promise<unit> =>
141
+ Platform_ProvidedGroups.load(~stackRef=platformStackReference())
142
+
120
143
  /** The group a role resolves to — the name this platform writes into its
121
144
  directives, its shell configuration and the elevated groups. For code that
122
145
  grants access by group, so it never spells one itself. */
@@ -164,10 +187,10 @@ module MakeWithConfig = (
164
187
  // [Reventless.OwnerScope.defaultElevatedGroups].
165
188
  Reventless.OwnerScope.defaultElevatedRoles([Reventless.Role.admin])
166
189
 
167
- // What this deployment provides for the roles its plugins need: the
168
- // administrator group the stack declares, and the groups of the accounts
169
- // provisioning creates. Read when each plugin is built, after the root has
170
- // mapped its roles.
190
+ // What this deployment provides for the roles its plugins need, where the pool
191
+ // was not listed (`loadProvidedGroups`): the administrator group the stack
192
+ // declares, and the groups of the accounts provisioning creates. Read when each
193
+ // plugin is built, after the root has mapped its roles.
171
194
  Reventless.Role.provideGroupsFrom(() =>
172
195
  [Reventless.Role.adminGroup()]->Array.concat(Reventless.AccountsManifest.declaredGroups())
173
196
  )
@@ -228,10 +251,7 @@ module MakeWithConfig = (
228
251
  // Determine API source based on platform:stack config.
229
252
  // - Platform/monolithic mode (no config): create a real AppSync API resource.
230
253
  // - Plugin mode (config set): reference the platform's shared API via StackReference.
231
- let platformStackRef =
232
- Pulumi.Config.make(Some("platform"))
233
- ->Pulumi.Config.get("stack")
234
- ->Option.map(stack => Pulumi.StackReference.make(stack))
254
+ let platformStackRef = platformStackReference()
235
255
 
236
256
  // The declared object stores, as the upload claimer needs them: qualified
237
257
  // name, physical bucket, served prefix.