@reventlessdev/reventless-aws 3.0.0-alpha.371 → 3.0.0-alpha.373

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.373 (2026-10-05)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **deploy:** the bake waits for a review's plugins, and never follows a cancelled deploy ([84bdfbd](https://github.com/ReventlessDev/reventless-core/commit/84bdfbd13a892ceb9741a573e7928328e45f9d79))
11
+
12
+
13
+ # 3.0.0-alpha.372 (2026-10-05)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **deploy:** the pending-creates recovery runs from reventless-aws, and works ([b9e749e](https://github.com/ReventlessDev/reventless-core/commit/b9e749eb09354a559505fa6facc7f58e38afd2a9))
18
+ ### Features
19
+
20
+ * **deploy:** a plugin names its own region, and can stay out of review environments ([7edd318](https://github.com/ReventlessDev/reventless-core/commit/7edd3187f51f74e57c331b1cb417b17de8067b58))
21
+ * **deploy:** the deploy workflow names its state backend ([a370346](https://github.com/ReventlessDev/reventless-core/commit/a3703461bae9dc22c098315a09bbabcf0c5816b1))
22
+
23
+
6
24
  # 3.0.0-alpha.371 (2026-10-01)
7
25
 
8
26
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.371",
3
+ "version": "3.0.0-alpha.373",
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",
@@ -22,18 +23,18 @@
22
23
  "uuid": "^13.0.0",
23
24
  "yaml": "^2.8.3",
24
25
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.28",
26
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.11",
25
27
  "@reventlessdev/rescript-effect": "0.1.0-alpha.34",
26
28
  "@reventlessdev/rescript-node": "2.0.0-alpha.20",
27
- "@reventlessdev/rescript-jest": "1.0.0-alpha.11",
28
29
  "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.23",
29
30
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.23",
30
31
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
31
32
  "@reventlessdev/rescript-web": "1.0.0-alpha.7",
32
- "@reventlessdev/reventless-infra": "3.0.0-alpha.185",
33
33
  "@reventlessdev/reventless-core": "3.0.0-alpha.287",
34
34
  "@reventlessdev/reventless-postgres": "3.0.0-alpha.151",
35
- "@reventlessdev/reventless-spec": "3.0.0-alpha.157",
36
- "@reventlessdev/reventless-interop": "3.0.0-alpha.39"
35
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.185",
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()
@@ -27,21 +27,23 @@ type args = {
27
27
  manifest: option<string>,
28
28
  stack: option<string>,
29
29
  since: option<string>,
30
+ review: bool,
30
31
  help: bool,
31
32
  }
32
33
 
33
34
  let parseArgs = (argv: array<string>): result<args, string> =>
34
- Reventless.CliArgs.parse(~strings=["manifest", "stack", "since"], argv)
35
+ Reventless.CliArgs.parse(~strings=["manifest", "stack", "since"], ~bools=["review"], argv)
35
36
  ->Result.flatMap(Reventless.CliArgs.noPositionals)
36
37
  ->Result.map(a => {
37
38
  manifest: a->Reventless.CliArgs.string("manifest"),
38
39
  stack: a->Reventless.CliArgs.string("stack"),
39
40
  since: a->Reventless.CliArgs.string("since")->Option.flatMap(v => v == "" ? None : Some(v)),
41
+ review: a->Reventless.CliArgs.bool("review"),
40
42
  help: a->Reventless.CliArgs.help,
41
43
  })
42
44
 
43
45
  let usage = `
44
- Usage: bake-manifest [--manifest <path>] [--stack <name>] [--since <instant>]
46
+ Usage: bake-manifest [--manifest <path>] [--stack <name>] [--since <instant>] [--review]
45
47
 
46
48
  Bake the component manifest of a deployed platform.
47
49
 
@@ -51,6 +53,9 @@ Bake the component manifest of a deployed platform.
51
53
  platform's folder.
52
54
  --since <instant> When this deploy started (ISO 8601). Lets the report tell a
53
55
  plugin that re-registered from one that was unchanged.
56
+ --review The stack is a pull request's review environment. Its stacks'
57
+ settings are not in this checkout, so every plugin with a
58
+ review stack is waited for, not only those with a file here.
54
59
 
55
60
  Reads the bake function from the platform stack and each plugin's structure key
56
61
  from its stack, then asks the function to bake — again every
@@ -61,6 +66,17 @@ waiting, so that stops the run at once.
61
66
 
62
67
  // ── What the stacks say ──────────────────────────────────────────────────────
63
68
 
69
+ /** The plugins whose registration the bake waits for. A stack with no settings
70
+ file here was never deployed, except in a review environment, whose settings
71
+ exist only in the runner that created it: there it is every plugin with a
72
+ review stack. */
73
+ let pluginsToRead = (
74
+ plugins: array<DeployManifest.project>,
75
+ ~stack: string,
76
+ ~review: bool,
77
+ ~hasStackFile: (~dir: string, ~stack: string) => bool,
78
+ ) => plugins->Array.filter(p => review ? p.review : hasStackFile(~dir=p.dir, ~stack))
79
+
64
80
  type target = {functionName: string, bucket: string, key: string}
65
81
 
66
82
  /** `None` when the platform declares no bake. */
@@ -278,7 +294,12 @@ let invokeWith = (client: Lambda.client, ~functionName: string, ~payload: JSON.t
278
294
  | exception exn => Error(`could not invoke ${functionName}: ${Util_AwsError.describe(exn)}`)
279
295
  }
280
296
 
281
- let bake = async (~manifest: DeployManifest.resolved, ~stack: string, ~since: option<string>) =>
297
+ let bake = async (
298
+ ~manifest: DeployManifest.resolved,
299
+ ~stack: string,
300
+ ~since: option<string>,
301
+ ~review: bool,
302
+ ) =>
282
303
  switch PulumiCli.stackOutputs(~dir=manifest.platform.dir, ~stack) {
283
304
  | Error(_) as e => e
284
305
  | Ok(outputs) =>
@@ -288,12 +309,12 @@ let bake = async (~manifest: DeployManifest.resolved, ~stack: string, ~since: op
288
309
  Ok()
289
310
  | Some(target) =>
290
311
  let refs = []
291
- manifest.plugins->Array.forEach(p =>
292
- if PulumiCli.hasStackFile(~dir=p.dir, ~stack) {
293
- switch PulumiCli.stackOutputs(~dir=p.dir, ~stack) {
294
- | Ok(o) => o->structureRefOf->Option.forEach(r => refs->Array.push(r))
295
- | Error(message) => Console.error(`${p.name}: ${message} — not waited for`)
296
- }
312
+ manifest.plugins
313
+ ->pluginsToRead(~stack, ~review, ~hasStackFile=PulumiCli.hasStackFile)
314
+ ->Array.forEach(p =>
315
+ switch PulumiCli.stackOutputs(~dir=p.dir, ~stack) {
316
+ | Ok(o) => o->structureRefOf->Option.forEach(r => refs->Array.push(r))
317
+ | Error(message) => Console.error(`${p.name}: ${message} — not waited for`)
297
318
  }
298
319
  )
299
320
  let expect = refs->Dict.fromArray
@@ -352,7 +373,7 @@ let run = async (args: args): result<unit, string> =>
352
373
  | Ok(manifest) =>
353
374
  switch args.stack->Option.orElse(PulumiCli.selectedStack(~dir=manifest.platform.dir)) {
354
375
  | None => Error(`no stack selected in ${manifest.platform.dir} — pass --stack`)
355
- | Some(stack) => await bake(~manifest, ~stack, ~since=args.since)
376
+ | Some(stack) => await bake(~manifest, ~stack, ~since=args.since, ~review=args.review)
356
377
  }
357
378
  }
358
379
 
@@ -19,7 +19,7 @@ function parseArgs(argv) {
19
19
  "manifest",
20
20
  "stack",
21
21
  "since"
22
- ], undefined, undefined, undefined, argv), CliArgs$Reventless.noPositionals), a => ({
22
+ ], ["review"], undefined, undefined, argv), CliArgs$Reventless.noPositionals), a => ({
23
23
  manifest: CliArgs$Reventless.string(a, "manifest"),
24
24
  stack: CliArgs$Reventless.string(a, "stack"),
25
25
  since: Stdlib_Option.flatMap(CliArgs$Reventless.string(a, "since"), v => {
@@ -29,12 +29,13 @@ function parseArgs(argv) {
29
29
  return v;
30
30
  }
31
31
  }),
32
+ review: CliArgs$Reventless.bool(a, "review"),
32
33
  help: CliArgs$Reventless.help(a)
33
34
  }));
34
35
  }
35
36
 
36
37
  let usage = `
37
- Usage: bake-manifest [--manifest <path>] [--stack <name>] [--since <instant>]
38
+ Usage: bake-manifest [--manifest <path>] [--stack <name>] [--since <instant>] [--review]
38
39
 
39
40
  Bake the component manifest of a deployed platform.
40
41
 
@@ -44,6 +45,9 @@ Bake the component manifest of a deployed platform.
44
45
  platform's folder.
45
46
  --since <instant> When this deploy started (ISO 8601). Lets the report tell a
46
47
  plugin that re-registered from one that was unchanged.
48
+ --review The stack is a pull request's review environment. Its stacks'
49
+ settings are not in this checkout, so every plugin with a
50
+ review stack is waited for, not only those with a file here.
47
51
 
48
52
  Reads the bake function from the platform stack and each plugin's structure key
49
53
  from its stack, then asks the function to bake — again every
@@ -52,6 +56,16 @@ not arrived. A registration this deploy wrote with another key cannot arrive by
52
56
  waiting, so that stops the run at once.
53
57
  `;
54
58
 
59
+ function pluginsToRead(plugins, stack, review, hasStackFile) {
60
+ return plugins.filter(p => {
61
+ if (review) {
62
+ return p.review;
63
+ } else {
64
+ return hasStackFile(p.dir, stack);
65
+ }
66
+ });
67
+ }
68
+
55
69
  function targetOf(outputs) {
56
70
  let match = PulumiCli$ReventlessAws.stringOutput(outputs, "bakedManifestFunction");
57
71
  let match$1 = PulumiCli$ReventlessAws.stringOutput(outputs, "bakedManifestBucket");
@@ -269,7 +283,7 @@ function invokeWith(client, functionName, payload) {
269
283
  };
270
284
  }
271
285
 
272
- async function bake(manifest, stack, since) {
286
+ async function bake(manifest, stack, since, review) {
273
287
  let e = PulumiCli$ReventlessAws.stackOutputs(manifest.platform.dir, stack);
274
288
  if (e.TAG !== "Ok") {
275
289
  return e;
@@ -277,10 +291,7 @@ async function bake(manifest, stack, since) {
277
291
  let target = targetOf(e._0);
278
292
  if (target !== undefined) {
279
293
  let refs = [];
280
- manifest.plugins.forEach(p => {
281
- if (!PulumiCli$ReventlessAws.hasStackFile(p.dir, stack)) {
282
- return;
283
- }
294
+ pluginsToRead(manifest.plugins, stack, review, PulumiCli$ReventlessAws.hasStackFile).forEach(p => {
284
295
  let o = PulumiCli$ReventlessAws.stackOutputs(p.dir, stack);
285
296
  if (o.TAG === "Ok") {
286
297
  return Stdlib_Option.forEach(structureRefOf(o._0), r => {
@@ -336,7 +347,7 @@ async function run(args) {
336
347
  let manifest = e._0;
337
348
  let stack = Stdlib_Option.orElse(args.stack, PulumiCli$ReventlessAws.selectedStack(manifest.platform.dir));
338
349
  if (stack !== undefined) {
339
- return await bake(manifest, stack, args.since);
350
+ return await bake(manifest, stack, args.since, args.review);
340
351
  } else {
341
352
  return {
342
353
  TAG: "Error",
@@ -381,6 +392,7 @@ export {
381
392
  Lambda,
382
393
  parseArgs,
383
394
  usage,
395
+ pluginsToRead,
384
396
  targetOf,
385
397
  structureRefOf,
386
398
  payload,
@@ -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.`,
@@ -552,7 +570,12 @@ let up = async (~manifest: DeployManifest.resolved, ~stack: string): result<unit
552
570
  | Error(_) as e => e
553
571
  | Ok() =>
554
572
  Console.log("\n── Baking the component manifest ──\n")
555
- switch await BakeManifest.bake(~manifest, ~stack, ~since=Some(startedAt)) {
573
+ switch await BakeManifest.bake(
574
+ ~manifest,
575
+ ~stack,
576
+ ~since=Some(startedAt),
577
+ ~review=false,
578
+ ) {
556
579
  | Error(_) as e => e
557
580
  | Ok() =>
558
581
  let accounts = switch (
@@ -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
  });
@@ -703,7 +718,7 @@ async function up(manifest, stack) {
703
718
  return e$4;
704
719
  }
705
720
  console.log("\n── Baking the component manifest ──\n");
706
- let e$5 = await BakeManifest$ReventlessAws.bake(manifest, stack, startedAt);
721
+ let e$5 = await BakeManifest$ReventlessAws.bake(manifest, stack, startedAt, false);
707
722
  if (e$5.TAG !== "Ok") {
708
723
  return e$5;
709
724
  }
@@ -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,14 @@ 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 = {
42
+ name: string,
43
+ dir: string,
44
+ stackDefaults: dict<string>,
45
+ region: option<string>,
46
+ /** Whether a review environment deploys it: false only on a plugin marked so. */
47
+ review: bool,
48
+ }
38
49
 
39
50
  type resolved = {
40
51
  file: string,
@@ -60,6 +71,8 @@ let resolve = (manifest: t, ~file: string): resolved => {
60
71
  name: manifest.platform.name->Option.getOr("platform"),
61
72
  dir: NodePath.resolve([base, manifest.platform.path]),
62
73
  stackDefaults: manifest.platform.stackDefaults->Option.getOr(Dict.make()),
74
+ region: None,
75
+ review: true,
63
76
  },
64
77
  plugins: manifest.plugins
65
78
  ->Option.getOr([])
@@ -67,6 +80,8 @@ let resolve = (manifest: t, ~file: string): resolved => {
67
80
  name: p.name,
68
81
  dir: NodePath.resolve([base, p.path]),
69
82
  stackDefaults: p.stackDefaults->Option.getOr(Dict.make()),
83
+ region: p.region,
84
+ review: p.review->Option.getOr(true),
70
85
  }),
71
86
  }
72
87
  }
@@ -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,16 @@ 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,
64
+ review: true
61
65
  },
62
66
  plugins: Stdlib_Option.getOr(manifest.plugins, []).map(p => ({
63
67
  name: p.name,
64
68
  dir: Nodepath.resolve(base, p.path),
65
- stackDefaults: Stdlib_Option.getOr(p["stack-defaults"], {})
69
+ stackDefaults: Stdlib_Option.getOr(p["stack-defaults"], {}),
70
+ region: p.region,
71
+ review: Stdlib_Option.getOr(p.review, true)
66
72
  }))
67
73
  };
68
74
  }
@@ -21,6 +21,8 @@ plugins:
21
21
  depends-on: []
22
22
  - name: ordering
23
23
  path: ordering-aws
24
+ region: eu-central-1
25
+ review: false
24
26
  `
25
27
 
26
28
  testSync("resolves every folder against the manifest's own folder", () => {
@@ -36,10 +38,24 @@ plugins:
36
38
  name: "shop-platform",
37
39
  dir: "/repo/shop/platform-aws",
38
40
  stackDefaults: Dict.fromArray([("platform:messagingEmailProvider", "log")]),
41
+ region: None,
42
+ review: true,
39
43
  },
40
44
  plugins: [
41
- {name: "catalog", dir: "/repo/shop/catalog-aws", stackDefaults: Dict.make()},
42
- {name: "ordering", dir: "/repo/shop/ordering-aws", stackDefaults: Dict.make()},
45
+ {
46
+ name: "catalog",
47
+ dir: "/repo/shop/catalog-aws",
48
+ stackDefaults: Dict.make(),
49
+ region: None,
50
+ review: true,
51
+ },
52
+ {
53
+ name: "ordering",
54
+ dir: "/repo/shop/ordering-aws",
55
+ stackDefaults: Dict.make(),
56
+ region: Some("eu-central-1"),
57
+ review: false,
58
+ },
43
59
  ],
44
60
  }),
45
61
  )
@@ -66,6 +82,7 @@ describe("BakeManifest.parseArgs", () => {
66
82
  Bake.manifest: Some("m.yaml"),
67
83
  stack: Some("alpha"),
68
84
  since: Some("2026-09-16T10:00:00Z"),
85
+ review: false,
69
86
  help: false,
70
87
  }),
71
88
  )
@@ -76,11 +93,44 @@ describe("BakeManifest.parseArgs", () => {
76
93
  expect(Bake.parseArgs(["--since", ""])->Result.map(a => a.since))->toEqual(Ok(None))
77
94
  )
78
95
 
96
+ testSync("--review says the stack is a review environment", () =>
97
+ expect(
98
+ Bake.parseArgs(["--stack", "review-pr-7", "--review"])->Result.map(a => a.review),
99
+ )->toEqual(Ok(true))
100
+ )
101
+
79
102
  testSync("an unknown flag refuses", () =>
80
103
  expect(Bake.parseArgs(["--stak", "alpha"]))->toEqual(Error(`unknown argument "--stak"`))
81
104
  )
82
105
  })
83
106
 
107
+ describe("BakeManifest.pluginsToRead", () => {
108
+ let plugin = (name, ~review) => {
109
+ DeployManifest.name,
110
+ dir: `/repo/shop/${name}-aws`,
111
+ stackDefaults: Dict.make(),
112
+ region: None,
113
+ review,
114
+ }
115
+ let plugins = [plugin("catalog", ~review=true), plugin("ingest", ~review=false)]
116
+ let names = ps => ps->Array.map((p: DeployManifest.project) => p.name)
117
+
118
+ testSync("outside a review, the plugins whose stack has a settings file here", () => {
119
+ let hasStackFile = (~dir, ~stack as _) => dir == "/repo/shop/ingest-aws"
120
+ expect(
121
+ plugins->Bake.pluginsToRead(~stack="alpha", ~review=false, ~hasStackFile)->names,
122
+ )->toEqual(["ingest"])
123
+ })
124
+
125
+ // A review stack's settings exist only in the runner that created it.
126
+ testSync("in a review, every plugin with a review stack, files or not", () => {
127
+ let hasStackFile = (~dir as _, ~stack as _) => false
128
+ expect(
129
+ plugins->Bake.pluginsToRead(~stack="review-pr-7", ~review=true, ~hasStackFile)->names,
130
+ )->toEqual(["catalog"])
131
+ })
132
+ })
133
+
84
134
  describe("BakeManifest reads the stacks", () => {
85
135
  testSync("a platform without a bake function declares no bake", () =>
86
136
  expect(
@@ -25,6 +25,8 @@ plugins:
25
25
  depends-on: []
26
26
  - name: ordering
27
27
  path: ordering-aws
28
+ region: eu-central-1
29
+ review: false
28
30
  `), m => DeployManifest$ReventlessAws.resolve(m, "/repo/shop/deploy-manifest.yaml"));
29
31
  globalThis.expect(resolved).toEqual({
30
32
  TAG: "Ok",
@@ -37,18 +39,24 @@ plugins:
37
39
  stackDefaults: Object.fromEntries([[
38
40
  "platform:messagingEmailProvider",
39
41
  "log"
40
- ]])
42
+ ]]),
43
+ region: undefined,
44
+ review: true
41
45
  },
42
46
  plugins: [
43
47
  {
44
48
  name: "catalog",
45
49
  dir: "/repo/shop/catalog-aws",
46
- stackDefaults: {}
50
+ stackDefaults: {},
51
+ region: undefined,
52
+ review: true
47
53
  },
48
54
  {
49
55
  name: "ordering",
50
56
  dir: "/repo/shop/ordering-aws",
51
- stackDefaults: {}
57
+ stackDefaults: {},
58
+ region: "eu-central-1",
59
+ review: false
52
60
  }
53
61
  ]
54
62
  }
@@ -74,6 +82,7 @@ globalThis.describe("BakeManifest.parseArgs", () => {
74
82
  manifest: "m.yaml",
75
83
  stack: "alpha",
76
84
  since: "2026-09-16T10:00:00Z",
85
+ review: false,
77
86
  help: false
78
87
  }
79
88
  });
@@ -87,6 +96,16 @@ globalThis.describe("BakeManifest.parseArgs", () => {
87
96
  _0: undefined
88
97
  });
89
98
  });
99
+ globalThis.test("--review says the stack is a review environment", () => {
100
+ globalThis.expect(Stdlib_Result.map(BakeManifest$ReventlessAws.parseArgs([
101
+ "--stack",
102
+ "review-pr-7",
103
+ "--review"
104
+ ]), a => a.review)).toEqual({
105
+ TAG: "Ok",
106
+ _0: true
107
+ });
108
+ });
90
109
  globalThis.test("an unknown flag refuses", () => {
91
110
  globalThis.expect(BakeManifest$ReventlessAws.parseArgs([
92
111
  "--stak",
@@ -98,6 +117,30 @@ globalThis.describe("BakeManifest.parseArgs", () => {
98
117
  });
99
118
  });
100
119
 
120
+ globalThis.describe("BakeManifest.pluginsToRead", () => {
121
+ let plugin = (name, review) => ({
122
+ name: name,
123
+ dir: `/repo/shop/` + name + `-aws`,
124
+ stackDefaults: {},
125
+ region: undefined,
126
+ review: review
127
+ });
128
+ let plugins = [
129
+ plugin("catalog", true),
130
+ plugin("ingest", false)
131
+ ];
132
+ globalThis.test("outside a review, the plugins whose stack has a settings file here", () => {
133
+ let hasStackFile = (dir, param) => dir === "/repo/shop/ingest-aws";
134
+ let ps = BakeManifest$ReventlessAws.pluginsToRead(plugins, "alpha", false, hasStackFile);
135
+ globalThis.expect(ps.map(p => p.name)).toEqual(["ingest"]);
136
+ });
137
+ globalThis.test("in a review, every plugin with a review stack, files or not", () => {
138
+ let hasStackFile = (param, param$1) => false;
139
+ let ps = BakeManifest$ReventlessAws.pluginsToRead(plugins, "review-pr-7", true, hasStackFile);
140
+ globalThis.expect(ps.map(p => p.name)).toEqual(["catalog"]);
141
+ });
142
+ });
143
+
101
144
  globalThis.describe("BakeManifest reads the stacks", () => {
102
145
  globalThis.test("a platform without a bake function declares no bake", () => {
103
146
  globalThis.expect(BakeManifest$ReventlessAws.targetOf(Stdlib_Option.getOr(Stdlib_JSON.Decode.object(JSON.parse(`{"bakedManifestBucket": "b", "bakedManifestFunction": ""}`)), {}))).toEqual(undefined);
@@ -0,0 +1,93 @@
1
+ open JestGlobals
2
+
3
+ // Which pending operations the deploy workflow's recovery step drops and which
4
+ // stop it. The pulumi calls around them are checked against a real backend by
5
+ // hand; what is tested here is the decision.
6
+
7
+ module Clear = ClearPendingCreates
8
+
9
+ let export = (operations: string) =>
10
+ JSON.parseOrThrow(
11
+ `{"version": 3, "deployment": {"resources": [], "pending_operations": ${operations}}}`,
12
+ )
13
+
14
+ let operation = (~urn, ~type_) =>
15
+ `{"resource": {"urn": "${urn}", "type": "${type_}"}, "type": "creating"}`
16
+
17
+ let validation = operation(
18
+ ~urn="urn:pulumi:alpha::app::aws:acm/certificateValidation:CertificateValidation::cert",
19
+ ~type_="aws:acm/certificateValidation:CertificateValidation",
20
+ )
21
+ let lambda = operation(
22
+ ~urn="urn:pulumi:alpha::app::aws:lambda/function:Function::handler",
23
+ ~type_="aws:lambda/function:Function",
24
+ )
25
+
26
+ let refused = (state: JSON.t) =>
27
+ state
28
+ ->Clear.pendingOperations
29
+ ->Array.filter(p => !Clear.isSafe(p))
30
+ ->Array.map(p => p.resourceType)
31
+
32
+ describe("ClearPendingCreates.pendingOperations", () => {
33
+ testSync("a stack with none, or with no deployment yet, has none", () => {
34
+ expect(Clear.pendingOperations(export("[]")))->toEqual([])
35
+ expect(
36
+ Clear.pendingOperations(JSON.parseOrThrow(`{"version": 3, "deployment": {}}`)),
37
+ )->toEqual([])
38
+ expect(Clear.pendingOperations(JSON.parseOrThrow(`{"version": 3}`)))->toEqual([])
39
+ })
40
+
41
+ testSync("each operation with its resource and kind", () =>
42
+ expect(Clear.pendingOperations(export(`[${lambda}]`)))->toEqual([
43
+ {
44
+ Clear.urn: "urn:pulumi:alpha::app::aws:lambda/function:Function::handler",
45
+ resourceType: "aws:lambda/function:Function",
46
+ operation: "creating",
47
+ },
48
+ ])
49
+ )
50
+ })
51
+
52
+ describe("ClearPendingCreates.isSafe", () => {
53
+ testSync("a certificate validation is dropped", () =>
54
+ expect(refused(export(`[${validation}]`)))->toEqual([])
55
+ )
56
+
57
+ testSync("a resource that may exist in AWS stops the deploy", () =>
58
+ expect(refused(export(`[${lambda}]`)))->toEqual(["aws:lambda/function:Function"])
59
+ )
60
+
61
+ testSync("in a mix, only the unsafe one is refused", () =>
62
+ expect(refused(export(`[${validation}, ${lambda}]`)))->toEqual(["aws:lambda/function:Function"])
63
+ )
64
+
65
+ testSync("the type must match whole, not as a prefix", () =>
66
+ expect(
67
+ refused(
68
+ export(
69
+ `[${operation(~urn="u", ~type_="aws:acm/certificateValidation:CertificateValidationX")}]`,
70
+ ),
71
+ ),
72
+ )->toEqual(["aws:acm/certificateValidation:CertificateValidationX"])
73
+ )
74
+ })
75
+
76
+ describe("ClearPendingCreates.parseArgs", () => {
77
+ testSync("the stack, and the project directory", () =>
78
+ expect(Clear.parseArgs(["--stack", "alpha", "--cwd", "platform-aws"]))->toEqual(
79
+ Ok({Clear.stack: "alpha", cwd: "platform-aws"}),
80
+ )
81
+ )
82
+
83
+ testSync("the project directory defaults to the working directory", () =>
84
+ expect(Clear.parseArgs(["--stack", "alpha"])->Result.map(a => a.cwd))->toEqual(
85
+ Ok(NodeProcess.cwd()),
86
+ )
87
+ )
88
+
89
+ test("no stack is a usage mistake", async () => {
90
+ let observed = await Reventless.CliArgs.observe(Clear.cli, [])
91
+ expect((observed.exitCode, observed.reachedMain))->toEqual((Some(2), false))
92
+ })
93
+ })
@@ -0,0 +1,99 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
4
+ import * as CliArgs$Reventless from "@reventlessdev/reventless-spec/src/CliArgs.res.mjs";
5
+ import * as ClearPendingCreates$ReventlessAws from "../scripts/ClearPendingCreates.res.mjs";
6
+
7
+ function $$export(operations) {
8
+ return JSON.parse(`{"version": 3, "deployment": {"resources": [], "pending_operations": ` + operations + `}}`);
9
+ }
10
+
11
+ function operation(urn, type_) {
12
+ return `{"resource": {"urn": "` + urn + `", "type": "` + type_ + `"}, "type": "creating"}`;
13
+ }
14
+
15
+ let validation = `{"resource": {"urn": "` + "urn:pulumi:alpha::app::aws:acm/certificateValidation:CertificateValidation::cert" + `", "type": "` + "aws:acm/certificateValidation:CertificateValidation" + `"}, "type": "creating"}`;
16
+
17
+ let lambda = `{"resource": {"urn": "` + "urn:pulumi:alpha::app::aws:lambda/function:Function::handler" + `", "type": "` + "aws:lambda/function:Function" + `"}, "type": "creating"}`;
18
+
19
+ function refused(state) {
20
+ return ClearPendingCreates$ReventlessAws.pendingOperations(state).filter(p => !ClearPendingCreates$ReventlessAws.isSafe(p)).map(p => p.resourceType);
21
+ }
22
+
23
+ globalThis.describe("ClearPendingCreates.pendingOperations", () => {
24
+ globalThis.test("a stack with none, or with no deployment yet, has none", () => {
25
+ globalThis.expect(ClearPendingCreates$ReventlessAws.pendingOperations($$export("[]"))).toEqual([]);
26
+ globalThis.expect(ClearPendingCreates$ReventlessAws.pendingOperations(JSON.parse(`{"version": 3, "deployment": {}}`))).toEqual([]);
27
+ globalThis.expect(ClearPendingCreates$ReventlessAws.pendingOperations(JSON.parse(`{"version": 3}`))).toEqual([]);
28
+ });
29
+ globalThis.test("each operation with its resource and kind", () => {
30
+ globalThis.expect(ClearPendingCreates$ReventlessAws.pendingOperations($$export(`[` + lambda + `]`))).toEqual([{
31
+ urn: "urn:pulumi:alpha::app::aws:lambda/function:Function::handler",
32
+ resourceType: "aws:lambda/function:Function",
33
+ operation: "creating"
34
+ }]);
35
+ });
36
+ });
37
+
38
+ globalThis.describe("ClearPendingCreates.isSafe", () => {
39
+ globalThis.test("a certificate validation is dropped", () => {
40
+ globalThis.expect(refused($$export(`[` + validation + `]`))).toEqual([]);
41
+ });
42
+ globalThis.test("a resource that may exist in AWS stops the deploy", () => {
43
+ globalThis.expect(refused($$export(`[` + lambda + `]`))).toEqual(["aws:lambda/function:Function"]);
44
+ });
45
+ globalThis.test("in a mix, only the unsafe one is refused", () => {
46
+ globalThis.expect(refused($$export(`[` + validation + `, ` + lambda + `]`))).toEqual(["aws:lambda/function:Function"]);
47
+ });
48
+ globalThis.test("the type must match whole, not as a prefix", () => {
49
+ globalThis.expect(refused($$export(`[` + (`{"resource": {"urn": "` + "u" + `", "type": "` + "aws:acm/certificateValidation:CertificateValidationX" + `"}, "type": "creating"}`) + `]`))).toEqual(["aws:acm/certificateValidation:CertificateValidationX"]);
50
+ });
51
+ });
52
+
53
+ globalThis.describe("ClearPendingCreates.parseArgs", () => {
54
+ globalThis.test("the stack, and the project directory", () => {
55
+ globalThis.expect(ClearPendingCreates$ReventlessAws.parseArgs([
56
+ "--stack",
57
+ "alpha",
58
+ "--cwd",
59
+ "platform-aws"
60
+ ])).toEqual({
61
+ TAG: "Ok",
62
+ _0: {
63
+ stack: "alpha",
64
+ cwd: "platform-aws"
65
+ }
66
+ });
67
+ });
68
+ globalThis.test("the project directory defaults to the working directory", () => {
69
+ globalThis.expect(Stdlib_Result.map(ClearPendingCreates$ReventlessAws.parseArgs([
70
+ "--stack",
71
+ "alpha"
72
+ ]), a => a.cwd)).toEqual({
73
+ TAG: "Ok",
74
+ _0: process.cwd()
75
+ });
76
+ });
77
+ globalThis.test("no stack is a usage mistake", async () => {
78
+ let observed = await CliArgs$Reventless.observe(ClearPendingCreates$ReventlessAws.cli, []);
79
+ globalThis.expect([
80
+ observed.exitCode,
81
+ observed.reachedMain
82
+ ]).toEqual([
83
+ 2,
84
+ false
85
+ ]);
86
+ });
87
+ });
88
+
89
+ let Clear;
90
+
91
+ export {
92
+ Clear,
93
+ $$export,
94
+ operation,
95
+ validation,
96
+ lambda,
97
+ refused,
98
+ }
99
+ /* Not a pure module */
@@ -55,8 +55,19 @@ describe("DeployApp stacks and layer", () => {
55
55
  projectName: "shop-platform-aws",
56
56
  program: "/app/platform-aws/src/Main.res.mjs",
57
57
  stackDefaults: Dict.fromArray([("aws:region", "us-east-1"), ("platform:x", "y")]),
58
+ region: None,
58
59
  }
59
60
 
61
+ testSync("a stack the manifest puts in another region is refused", () => {
62
+ let ingest = {...project, label: "ingest", region: Some("eu-central-1")}
63
+ expect(
64
+ DeployApp.regionProblems(~projects=[project, ingest], ~region="eu-west-1")->Array.length,
65
+ )->toBe(1)
66
+ expect(
67
+ DeployApp.regionProblems(~projects=[project, ingest], ~region="eu-central-1"),
68
+ )->toEqual([])
69
+ })
70
+
60
71
  // The region and the try-out flag come last, so an app default cannot turn a
61
72
  // try-out into a stack `down` refuses to remove.
62
73
  testSync("a new stack gets the app's defaults, then the region and the try-out flag", () =>
@@ -79,8 +79,29 @@ globalThis.describe("DeployApp stacks and layer", () => {
79
79
  dir: "/app/platform-aws",
80
80
  projectName: "shop-platform-aws",
81
81
  program: "/app/platform-aws/src/Main.res.mjs",
82
- stackDefaults: project_stackDefaults
82
+ stackDefaults: project_stackDefaults,
83
+ region: undefined
83
84
  };
85
+ globalThis.test("a stack the manifest puts in another region is refused", () => {
86
+ let ingest_stackDefaults = project_stackDefaults;
87
+ let ingest_region = "eu-central-1";
88
+ let ingest = {
89
+ label: "ingest",
90
+ dir: "/app/platform-aws",
91
+ projectName: "shop-platform-aws",
92
+ program: "/app/platform-aws/src/Main.res.mjs",
93
+ stackDefaults: ingest_stackDefaults,
94
+ region: ingest_region
95
+ };
96
+ globalThis.expect(DeployApp$ReventlessAws.regionProblems([
97
+ project,
98
+ ingest
99
+ ], "eu-west-1").length).toBe(1);
100
+ globalThis.expect(DeployApp$ReventlessAws.regionProblems([
101
+ project,
102
+ ingest
103
+ ], "eu-central-1")).toEqual([]);
104
+ });
84
105
  globalThis.test("a new stack gets the app's defaults, then the region and the try-out flag", () => {
85
106
  globalThis.expect(DeployApp$ReventlessAws.newStackConfig(project, "eu-west-1")).toEqual([
86
107
  [