@intentius/chant-lexicon-pulumi 0.124.57 → 0.124.59

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.
@@ -16,8 +16,11 @@ import {
16
16
  diyStackLockIds,
17
17
  gatedStackCancel,
18
18
  gatedStackExport,
19
+ gatedStackAction,
19
20
  gatedStackMove,
20
21
  moveLeftovers,
22
+ PulumiStackActionRefused,
23
+ stackActionScope,
21
24
  pulumiStackLockIds,
22
25
  PULUMI_STACK_OPS_OP,
23
26
  type GatedRequestApi,
@@ -284,3 +287,112 @@ describe("the stack lock reader (chant#4615)", () => {
284
287
  expect(await pulumiStackLockIds({ dir, stack: "dev", engine: "plunge", env: {} })).toEqual({ checked: true, ids: [] });
285
288
  });
286
289
  });
290
+
291
+ describe("action (chant#4654)", () => {
292
+ const rotate = "urn:pulumi:prod-us::shop::command:local:Command::rotate";
293
+ const bucket = "urn:pulumi:prod-us::shop::aws:s3/bucket:Bucket::b";
294
+ const stackUrn = "urn:pulumi:prod-us::shop::pulumi:pulumi:Stack::shop-prod-us";
295
+ const replaceSteps = (urn: string) => [
296
+ { op: "create-replacement", urn, newState: { type: "command:local:Command", inputs: { create: "./rotate.sh" } } },
297
+ { op: "replace", urn, newState: { type: "command:local:Command", inputs: { create: "./rotate.sh" } } },
298
+ { op: "delete-replaced", urn, oldState: { type: "command:local:Command", inputs: { create: "./rotate.sh" } } },
299
+ ];
300
+ const doc = (steps: unknown[]) => JSON.stringify({ steps: [{ op: "same", urn: stackUrn }, ...steps] });
301
+ const previewArgs = ["preview", "--json", "--target", rotate, "--replace", rotate, "--stack", "prod-us"];
302
+ const upArgs = ["up", "--yes", "--target", rotate, "--replace", rotate, "--stack", "prod-us"];
303
+
304
+ test("the first call previews the replacement and answers waiting, with no update", async () => {
305
+ const l = ledger();
306
+ const pulumi = runner(() => doc(replaceSteps(rotate)));
307
+ const first = await gatedStackAction({ by: "alice", rules: { mode: "ledger" }, gated: l.api, env: {}, run: pulumi.run, dir: stackDir(), stack: "prod-us", engine: "pulumi", urn: rotate, name: "rotate" });
308
+ expect(first.status).toBe("waiting");
309
+ expect(first.digest).toMatch(/^sha256:/);
310
+ expect(pulumi.calls).toEqual([previewArgs]);
311
+ expect(pulumi.calls.some((c) => c[0] === "up")).toBe(false);
312
+ expect(first.request).toMatchObject({
313
+ kind: "stack-action",
314
+ subject: "action:prod-us:rotate",
315
+ fields: { stack: "prod-us", urn: rotate, engine: "pulumi", name: "rotate", preview: expect.stringMatching(/sha256:/) },
316
+ });
317
+ expect(l.spent.size).toBe(0);
318
+ });
319
+
320
+ test("a preview that replaces a second resource is refused action-scope, naming it, and asks for nothing", async () => {
321
+ const l = ledger();
322
+ const pulumi = runner(() => doc([...replaceSteps(rotate), ...replaceSteps(bucket)]));
323
+ const call = gatedStackAction({ by: "alice", rules: { mode: "ledger" }, gated: l.api, env: {}, run: pulumi.run, dir: stackDir(), stack: "prod-us", engine: "pulumi", urn: rotate });
324
+ await expect(call).rejects.toThrow(PulumiStackActionRefused);
325
+ await expect(call).rejects.toMatchObject({ code: "action-scope", urns: [bucket] });
326
+ await expect(call).rejects.toThrow(new RegExp(`^action-scope: .*${bucket.replace(/[$.]/g, "\\$&")}`));
327
+ expect(l.pending).toEqual([]);
328
+ expect(pulumi.calls.every((c) => c[0] === "preview")).toBe(true);
329
+ });
330
+
331
+ test("a preview that does not replace the URN, or updates it in place, is refused action-scope", () => {
332
+ expect(stackActionScope(rotate, JSON.parse(doc([])))).toEqual({ others: [], problem: `the preview does not replace ${rotate}` });
333
+ expect(stackActionScope(rotate, JSON.parse(doc([{ op: "update", urn: rotate }])))).toMatchObject({ problem: `the preview shows update of ${rotate}, not its replacement` });
334
+ expect(stackActionScope(rotate, JSON.parse(doc(replaceSteps(rotate))))).toEqual({ others: [] });
335
+ });
336
+
337
+ test("a URN of another type is refused action-type before any engine call", async () => {
338
+ const l = ledger();
339
+ const pulumi = runner(() => doc([]));
340
+ const common = { by: "alice", rules: { mode: "ledger" as const }, gated: l.api, env: {}, run: pulumi.run, dir: stackDir(), stack: "prod-us", engine: "pulumi" as const };
341
+ await expect(gatedStackAction({ ...common, urn: bucket })).rejects.toMatchObject({ code: "action-type" });
342
+ await expect(gatedStackAction({ ...common, urn: bucket })).rejects.toThrow(/aws:s3\/bucket:Bucket/);
343
+ await expect(gatedStackAction({ ...common, urn: "rotate" })).rejects.toMatchObject({ code: "action-urn" });
344
+ await expect(gatedStackAction({ ...common, urn: "urn:pulumi:dev::shop::command:local:Command::rotate" })).rejects.toMatchObject({ code: "action-urn" });
345
+ expect(pulumi.calls).toEqual([]);
346
+ expect(l.pending).toEqual([]);
347
+ });
348
+
349
+ test("after a second identity approves, the update runs once, applied and finished are written, and a repeat is spent", async () => {
350
+ const l = ledger();
351
+ const pulumi = runner((args) => (args[0] === "up" ? "Updating (prod-us)\n ++ command:local:Command rotate created replacement\nResources: 1 replaced" : doc(replaceSteps(rotate))));
352
+ const opts = { by: "alice", rules: { mode: "ledger" as const }, gated: l.api, env: {}, run: pulumi.run, dir: stackDir(), stack: "prod-us", engine: "pulumi" as const, urn: rotate, name: "rotate" };
353
+ const first = await gatedStackAction(opts);
354
+ l.approve(first.digest, "alice");
355
+ expect((await gatedStackAction(opts)).status).toBe("refused");
356
+ expect(pulumi.calls.filter((c) => c[0] === "up")).toEqual([]);
357
+
358
+ l.approve(first.digest, "bob");
359
+ const done = await gatedStackAction(opts);
360
+ expect(done.status).toBe("done");
361
+ expect(done.digest).toBe(first.digest);
362
+ if (done.status !== "done") throw new Error("not done");
363
+ expect(done.result).toMatchObject({ urn: rotate, engine: "pulumi", exit: 0, tail: expect.stringContaining("1 replaced") });
364
+ expect(pulumi.calls.filter((c) => c[0] === "up")).toEqual([upArgs]);
365
+ expect(l.spent.has(first.digest)).toBe(true);
366
+ expect(l.finished).toEqual([{ digest: first.digest, result: "applied", detail: expect.objectContaining({ result: "ok", exit: 0, urn: rotate }) }]);
367
+
368
+ const again = await gatedStackAction(opts);
369
+ expect(again.status).toBe("spent");
370
+ expect(again.digest).toBe(first.digest);
371
+ expect(pulumi.calls.filter((c) => c[0] === "up")).toHaveLength(1);
372
+ // Asked anew, the same action is a new request that waits for its own approval.
373
+ const anew = await gatedStackAction({ ...opts, anew: true, now: "2000-01-01T00:00:00.000Z" });
374
+ expect(anew.status).toBe("waiting");
375
+ expect(pulumi.calls.filter((c) => c[0] === "up")).toHaveLength(1);
376
+ });
377
+
378
+ test("a failed update finishes failed with the exit code and the output's tail", async () => {
379
+ const l = ledger();
380
+ const pulumi = runner((args) => (args[0] === "up" ? Object.assign(new Error("exit 255"), { code: 255, stdout: "error: rotate.sh exited 3" }) : doc(replaceSteps(rotate))));
381
+ const opts = { by: "alice", rules: { mode: "ledger" as const }, gated: l.api, env: {}, run: pulumi.run, dir: stackDir(), stack: "prod-us", engine: "pulumi" as const, urn: rotate };
382
+ l.approve((await gatedStackAction(opts)).digest, "bob");
383
+ const failed = await gatedStackAction(opts);
384
+ expect(failed.status).toBe("failed");
385
+ expect(l.finished[0]).toMatchObject({ result: "failed", detail: { result: "failed", exit: 255, tail: "error: rotate.sh exited 3", urn: rotate } });
386
+ });
387
+
388
+ test("a stack on plunge previews and updates under plunge", async () => {
389
+ const l = ledger();
390
+ const plunge = runner((args) => (args[0] === "up" ? "done" : doc(replaceSteps(rotate))));
391
+ const opts = { by: "alice", rules: { mode: "ledger" as const }, gated: l.api, env: {}, plunge: plunge.run, dir: stackDir(), stack: "prod-us", engine: "plunge" as const, urn: rotate };
392
+ const first = await gatedStackAction(opts);
393
+ expect(first.request.fields.engine).toBe("plunge");
394
+ l.approve(first.digest, "bob");
395
+ expect((await gatedStackAction(opts)).status).toBe("done");
396
+ expect(plunge.calls).toEqual([previewArgs, previewArgs, upArgs]);
397
+ });
398
+ });
package/src/stack-ops.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  * | export | `stack-export` | `<stack>` | `stack`, `engine`, `deliver: requester` |
17
17
  * | cancel | `stack-cancel` | `<stack>` | `stack`, `lockId` |
18
18
  * | move | `stack-move` | `move:<source>:<dest>` | `source`, `dest`, `engine`, `urns` (sorted, comma-joined) |
19
+ * | action | `stack-action` | `action:<stack>:<name>` (the URN without a name) | `stack`, `urn`, `engine`, `preview` (the preview's digest), `name` when given |
19
20
  *
20
21
  * The first call records the pending fact and answers `waiting` with the
21
22
  * `chant approve` command. A later call with the same request reuses the
@@ -47,6 +48,19 @@
47
48
  * again afterwards; a change left on either finishes the request `failed`
48
49
  * with the changes named.
49
50
  *
51
+ * - action (#4654): a day-2 action is a `command:` resource
52
+ * (`command:local:Command`, `command:remote:Command`) whose replacement is
53
+ * the operation. Its type is checked before anything runs (`action-type`).
54
+ * Each call previews `preview --json --target <urn> --replace <urn>`, and
55
+ * refuses (`action-scope`) unless the preview changes that URN alone, by
56
+ * a replacement. The request binds the preview's digest, so a preview
57
+ * that moved after the approval asks again. Once approved, `up --yes
58
+ * --target <urn> --replace <urn> --stack <s>` under the stack's engine,
59
+ * and the finished line keeps chant's `applied | failed` with
60
+ * `detail.result` `ok` or `failed`, the exit code and the output's last
61
+ * lines. A request that applied answers `spent` to the same call, unless
62
+ * the caller asks `anew`.
63
+ *
50
64
  * The writes go through `runGatedPulumi` and `runGatedPlunge`
51
65
  * (`./run.ts`), whose fixed forms are the whole of what may run.
52
66
  */
@@ -63,15 +77,17 @@ import type {
63
77
  GatedRequestRules,
64
78
  RequestGateResult,
65
79
  } from "@intentius/chant/lifecycle/gated-request";
66
- import { pulumiNodeKey } from "@intentius/chant/pulumi-urn";
80
+ import { parsePulumiUrn, pulumiNodeKey } from "@intentius/chant/pulumi-urn";
67
81
  import { pulumiStackBackend } from "./adopt";
68
- import { pulumiExportResources } from "./change-set";
82
+ import { pulumiExportResources, pulumiPreviewDigest, pulumiSteps, PULUMI_UNCHANGED_OPS } from "./change-set";
69
83
  import { pulumiMemberEngine, type PulumiMemberEngine } from "./member-engine";
70
84
  import {
71
85
  defaultPlungeRunner,
72
86
  defaultPulumiRunner,
73
87
  plungeMoveArgs,
74
88
  previewPulumiMemberRead,
89
+ pulumiActionPreviewArgs,
90
+ pulumiActionUpArgs,
75
91
  pulumiCancelArgs,
76
92
  pulumiRunFailure,
77
93
  pulumiStateMoveArgs,
@@ -86,7 +102,7 @@ import { isObject } from "./values";
86
102
  /** The op the requests are recorded under, unless the caller names its own. */
87
103
  export const PULUMI_STACK_OPS_OP = "pulumi-stack-ops";
88
104
 
89
- export type PulumiStackOpKind = "stack-export" | "stack-cancel" | "stack-move";
105
+ export type PulumiStackOpKind = "stack-export" | "stack-cancel" | "stack-move" | "stack-action";
90
106
 
91
107
  /** chant's gated-request API (#4551), injectable for tests. */
92
108
  export interface GatedRequestApi {
@@ -152,12 +168,14 @@ async function askThenAct<T>(
152
168
  description: string,
153
169
  act: () => Promise<{ result: T; detail: Record<string, string | number | boolean>; failed?: string }>,
154
170
  precheck?: () => Promise<void>,
171
+ options: { reuseApplied?: boolean } = {},
155
172
  ): Promise<PulumiStackOpOutcome<T>> {
156
173
  const api = await gatedApi(common.gated);
157
174
  const op = common.op ?? PULUMI_STACK_OPS_OP;
158
175
  const cwd = common.ledgerCwd ?? process.cwd();
176
+ // With reuseApplied (#4654), a request with these fields that applied already is asked again as itself, so it answers `spent`.
159
177
  const standing = (await api.readGatedRequests(op, { cwd }).catch(() => []))
160
- .filter((r) => !r.applied && r.request.kind === kind && r.request.subject === subject && r.request.by === common.by && sameFields(r.request.fields, fields))
178
+ .filter((r) => (options.reuseApplied || !r.applied) && r.request.kind === kind && r.request.subject === subject && r.request.by === common.by && sameFields(r.request.fields, fields))
161
179
  .at(-1);
162
180
  const request: GatedRequest = { kind, subject, by: common.by, at: standing?.request.at ?? common.now ?? new Date().toISOString(), fields };
163
181
  const asked: RequestGateResult = await api.requestGate({
@@ -550,3 +568,170 @@ export async function gatedStackMove(opts: PulumiStackMoveOptions): Promise<Pulu
550
568
  },
551
569
  );
552
570
  }
571
+
572
+ // ── action ───────────────────────────────────────────────────────────────
573
+
574
+ /** Why a stack action was refused before anything was asked (#4654). */
575
+ export type PulumiStackActionRefusal = "action-urn" | "action-type" | "action-scope";
576
+
577
+ /** A stack action refused before its request: `code` says why, `urns` names what the preview changed besides the action, for `action-scope`. */
578
+ export class PulumiStackActionRefused extends Error {
579
+ readonly code: PulumiStackActionRefusal;
580
+ readonly urns: string[];
581
+ constructor(code: PulumiStackActionRefusal, message: string, urns: string[] = []) {
582
+ super(`${code}: ${message}`);
583
+ this.name = "PulumiStackActionRefused";
584
+ this.code = code;
585
+ this.urns = urns;
586
+ }
587
+ }
588
+
589
+ /** The resource types an action may name: the command provider's (`command:local:Command`, `command:remote:Command`). */
590
+ export const PULUMI_ACTION_TYPE_PREFIX = "command:";
591
+
592
+ /** An action's name as a caller's config gives it, for the request's subject. */
593
+ const ACTION_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/;
594
+
595
+ /** The subject an action's request is asked and approved under: `action:<stack>:<name>`, or the URN in place of a name. */
596
+ export const stackActionSubject = (stack: string, nameOrUrn: string): string => `action:${stack}:${nameOrUrn}`;
597
+
598
+ export interface PulumiStackActionOptions extends PulumiStackOpCommon {
599
+ /** The stack's directory, where `Pulumi.yaml` is. */
600
+ dir: string;
601
+ cwd?: string;
602
+ /** The stack, as `--stack` takes it. */
603
+ stack: string;
604
+ engine?: PulumiMemberEngine;
605
+ /** The `command:` resource whose replacement is the action. */
606
+ urn: string;
607
+ /** The action's name in the caller's config, which the subject carries in place of the URN. */
608
+ name?: string;
609
+ /** Ask anew even when the same request applied already. Absent, such a call answers `spent`, so a retried job never runs the action twice. */
610
+ anew?: boolean;
611
+ }
612
+
613
+ export interface PulumiStackActionResult {
614
+ urn: string;
615
+ engine: PulumiMemberEngine;
616
+ /** The preview's digest the request bound. */
617
+ preview: string;
618
+ /** The update's exit code: 0, since a failed update ends `failed`. */
619
+ exit: number;
620
+ /** The update's last lines of output. */
621
+ tail: string;
622
+ }
623
+
624
+ /** The last `n` lines of `text`, trimmed. */
625
+ const lastLines = (text: string | undefined, n: number): string =>
626
+ (text ?? "").trim().split("\n").slice(-n).join("\n");
627
+
628
+ /**
629
+ * Check an action's URN before anything runs: a Pulumi URN on `stack` whose
630
+ * type is a command provider's. Throws {@link PulumiStackActionRefused}.
631
+ */
632
+ export function checkStackActionUrn(stack: string, urn: string): void {
633
+ const parsed = parsePulumiUrn(urn);
634
+ if (!parsed) throw new PulumiStackActionRefused("action-urn", `${JSON.stringify(urn)} is not a resource URN (urn:pulumi:<stack>::<project>::<type>::<name>)`);
635
+ const stackName = stack.split("/").at(-1);
636
+ if (parsed.stack !== stackName) throw new PulumiStackActionRefused("action-urn", `${urn} names stack ${parsed.stack}, and the action runs on ${stack}`);
637
+ if (!parsed.type.startsWith(PULUMI_ACTION_TYPE_PREFIX)) {
638
+ throw new PulumiStackActionRefused(
639
+ "action-type",
640
+ `${urn} is a ${parsed.type}, and an action is a command resource (command:local:Command or command:remote:Command), whose replacement runs it; replacing any other resource is an update`,
641
+ );
642
+ }
643
+ }
644
+
645
+ /**
646
+ * What a preview changes besides the action's replacement (#4654): each URN
647
+ * with a changing step other than `urn`, and `urn` itself when it changes
648
+ * other than by replacement or is not replaced at all. Empty when the
649
+ * preview replaces `urn` and nothing else.
650
+ */
651
+ export function stackActionScope(urn: string, preview: unknown): { others: string[]; problem?: string } {
652
+ const steps = pulumiSteps(preview) ?? [];
653
+ const others = new Set<string>();
654
+ let replaced = false;
655
+ let problem: string | undefined;
656
+ for (const s of steps) {
657
+ const op = s.op ?? "";
658
+ if (PULUMI_UNCHANGED_OPS.has(op)) continue;
659
+ if (s.urn !== urn) {
660
+ others.add(s.urn!);
661
+ continue;
662
+ }
663
+ if (op === "replace" || op === "create-replacement" || op === "delete-replaced") replaced = true;
664
+ else problem = `the preview shows ${op} of ${urn}, not its replacement`;
665
+ }
666
+ if (!problem && !replaced) problem = `the preview does not replace ${urn}`;
667
+ return { others: [...others].sort(), ...(problem ? { problem } : {}) };
668
+ }
669
+
670
+ /**
671
+ * A day-2 action on a stack (#4654): the replacement of one `command:`
672
+ * resource, previewed, asked for under a gated request, and run once a
673
+ * second identity approves. Refused before anything runs for a URN that is
674
+ * not a command resource's (`action-type`), and before anything is asked for
675
+ * a preview that changes more than that replacement (`action-scope`).
676
+ */
677
+ export async function gatedStackAction(opts: PulumiStackActionOptions): Promise<PulumiStackOpOutcome<PulumiStackActionResult>> {
678
+ checkStackActionUrn(opts.stack, opts.urn);
679
+ if (opts.name !== undefined && !ACTION_NAME.test(opts.name)) throw new Error(`an action's name is letters, digits, "_", "." and "-", not ${JSON.stringify(opts.name)}`);
680
+ const env = opts.env ?? process.env;
681
+ const dir = stackDirOf(opts.dir, opts.cwd);
682
+ const engine = engineOf(dir, opts.engine, env);
683
+ const target = { stack: opts.stack, urn: opts.urn };
684
+
685
+ const previewArgs = pulumiActionPreviewArgs(target);
686
+ let stdout: string;
687
+ try {
688
+ ({ stdout } = engine === "plunge" ? await runAllowedPlunge(opts.plunge ?? defaultPlungeRunner, previewArgs, dir) : await runAllowedPulumi(opts.run ?? defaultPulumiRunner, previewArgs, dir));
689
+ } catch (err) {
690
+ throw new Error(`${engine} ${previewArgs.join(" ")} failed: ${why(err, engine)}`);
691
+ }
692
+ let preview: unknown;
693
+ try {
694
+ preview = JSON.parse(stdout);
695
+ } catch {
696
+ throw new Error(`${engine} ${previewArgs.join(" ")} printed no JSON`);
697
+ }
698
+ const scope = stackActionScope(opts.urn, preview);
699
+ if (scope.others.length > 0) {
700
+ throw new PulumiStackActionRefused(
701
+ "action-scope",
702
+ `the preview of ${opts.stack} replacing ${opts.urn} also changes ${scope.others.join(", ")}, and an action replaces its command resource alone; nothing was asked for`,
703
+ scope.others,
704
+ );
705
+ }
706
+ if (scope.problem) throw new PulumiStackActionRefused("action-scope", `${scope.problem}; nothing was asked for`);
707
+
708
+ const digest = pulumiPreviewDigest(preview, { changedOnly: true });
709
+ const subject = stackActionSubject(opts.stack, opts.name ?? opts.urn);
710
+ const fields: Record<string, string> = { stack: opts.stack, urn: opts.urn, engine, preview: digest, ...(opts.name ? { name: opts.name } : {}) };
711
+ return askThenAct(
712
+ opts,
713
+ "stack-action",
714
+ subject,
715
+ fields,
716
+ `action ${opts.name ?? opts.urn} on stack ${opts.stack}: replace ${opts.urn}, requested by ${opts.by}`,
717
+ async () => {
718
+ const args = pulumiActionUpArgs(target);
719
+ try {
720
+ const done = engine === "plunge" ? await runGatedPlunge(opts.plunge ?? defaultPlungeRunner, args, dir) : await runGatedPulumi(opts.run ?? defaultPulumiRunner, args, dir);
721
+ const tail = lastLines(done.stdout, 10);
722
+ return { result: { urn: opts.urn, engine, preview: digest, exit: 0, tail }, detail: { result: "ok", exit: 0, tail: tail.slice(-400), urn: opts.urn } };
723
+ } catch (err) {
724
+ const f = pulumiRunFailure(err);
725
+ const exit = f.exitCode ?? 1;
726
+ const tail = lastLines(`${f.stdout ?? ""}\n${f.stderr ?? ""}`, 10);
727
+ return {
728
+ result: { urn: opts.urn, engine, preview: digest, exit, tail },
729
+ detail: { result: "failed", exit, tail: tail.slice(-400), urn: opts.urn },
730
+ failed: `${engine} ${args.join(" ")} failed: ${why(err, engine)}`,
731
+ };
732
+ }
733
+ },
734
+ undefined,
735
+ { reuseApplied: !opts.anew },
736
+ );
737
+ }