@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.
package/dist/stack-ops.js 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,17 +48,30 @@
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
  */
53
67
  import { createHash } from "node:crypto";
54
68
  import { existsSync, readdirSync, writeFileSync } from "node:fs";
55
69
  import { dirname, isAbsolute, join, resolve } from "node:path";
56
- import { pulumiNodeKey } from "@intentius/chant/pulumi-urn";
70
+ import { parsePulumiUrn, pulumiNodeKey } from "@intentius/chant/pulumi-urn";
57
71
  import { pulumiStackBackend } from "./adopt.js";
58
- import { pulumiExportResources } from "./change-set.js";
72
+ import { pulumiExportResources, pulumiPreviewDigest, pulumiSteps, PULUMI_UNCHANGED_OPS } from "./change-set.js";
59
73
  import { pulumiMemberEngine } from "./member-engine.js";
60
- import { defaultPlungeRunner, defaultPulumiRunner, plungeMoveArgs, previewPulumiMemberRead, pulumiCancelArgs, pulumiRunFailure, pulumiStateMoveArgs, runAllowedPlunge, runAllowedPulumi, runGatedPlunge, runGatedPulumi, } from "./run.js";
74
+ import { defaultPlungeRunner, defaultPulumiRunner, plungeMoveArgs, previewPulumiMemberRead, pulumiActionPreviewArgs, pulumiActionUpArgs, pulumiCancelArgs, pulumiRunFailure, pulumiStateMoveArgs, runAllowedPlunge, runAllowedPulumi, runGatedPlunge, runGatedPulumi, } from "./run.js";
61
75
  import { isObject } from "./values.js";
62
76
  /** The op the requests are recorded under, unless the caller names its own. */
63
77
  export const PULUMI_STACK_OPS_OP = "pulumi-stack-ops";
@@ -80,12 +94,13 @@ const sameFields = (a, b) => Object.keys(a).length === Object.keys(b).length &&
80
94
  * every operation. `precheck` runs after the approval and before the request
81
95
  * is spent, so a refusal there leaves the approval unspent.
82
96
  */
83
- async function askThenAct(common, kind, subject, fields, description, act, precheck) {
97
+ async function askThenAct(common, kind, subject, fields, description, act, precheck, options = {}) {
84
98
  const api = await gatedApi(common.gated);
85
99
  const op = common.op ?? PULUMI_STACK_OPS_OP;
86
100
  const cwd = common.ledgerCwd ?? process.cwd();
101
+ // With reuseApplied (#4654), a request with these fields that applied already is asked again as itself, so it answers `spent`.
87
102
  const standing = (await api.readGatedRequests(op, { cwd }).catch(() => []))
88
- .filter((r) => !r.applied && r.request.kind === kind && r.request.subject === subject && r.request.by === common.by && sameFields(r.request.fields, fields))
103
+ .filter((r) => (options.reuseApplied || !r.applied) && r.request.kind === kind && r.request.subject === subject && r.request.by === common.by && sameFields(r.request.fields, fields))
89
104
  .at(-1);
90
105
  const request = { kind, subject, by: common.by, at: standing?.request.at ?? common.now ?? new Date().toISOString(), fields };
91
106
  const asked = await api.requestGate({
@@ -398,3 +413,121 @@ export async function gatedStackMove(opts) {
398
413
  }
399
414
  });
400
415
  }
416
+ /** A stack action refused before its request: `code` says why, `urns` names what the preview changed besides the action, for `action-scope`. */
417
+ export class PulumiStackActionRefused extends Error {
418
+ constructor(code, message, urns = []) {
419
+ super(`${code}: ${message}`);
420
+ this.name = "PulumiStackActionRefused";
421
+ this.code = code;
422
+ this.urns = urns;
423
+ }
424
+ }
425
+ /** The resource types an action may name: the command provider's (`command:local:Command`, `command:remote:Command`). */
426
+ export const PULUMI_ACTION_TYPE_PREFIX = "command:";
427
+ /** An action's name as a caller's config gives it, for the request's subject. */
428
+ const ACTION_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/;
429
+ /** The subject an action's request is asked and approved under: `action:<stack>:<name>`, or the URN in place of a name. */
430
+ export const stackActionSubject = (stack, nameOrUrn) => `action:${stack}:${nameOrUrn}`;
431
+ /** The last `n` lines of `text`, trimmed. */
432
+ const lastLines = (text, n) => (text ?? "").trim().split("\n").slice(-n).join("\n");
433
+ /**
434
+ * Check an action's URN before anything runs: a Pulumi URN on `stack` whose
435
+ * type is a command provider's. Throws {@link PulumiStackActionRefused}.
436
+ */
437
+ export function checkStackActionUrn(stack, urn) {
438
+ const parsed = parsePulumiUrn(urn);
439
+ if (!parsed)
440
+ throw new PulumiStackActionRefused("action-urn", `${JSON.stringify(urn)} is not a resource URN (urn:pulumi:<stack>::<project>::<type>::<name>)`);
441
+ const stackName = stack.split("/").at(-1);
442
+ if (parsed.stack !== stackName)
443
+ throw new PulumiStackActionRefused("action-urn", `${urn} names stack ${parsed.stack}, and the action runs on ${stack}`);
444
+ if (!parsed.type.startsWith(PULUMI_ACTION_TYPE_PREFIX)) {
445
+ throw new PulumiStackActionRefused("action-type", `${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`);
446
+ }
447
+ }
448
+ /**
449
+ * What a preview changes besides the action's replacement (#4654): each URN
450
+ * with a changing step other than `urn`, and `urn` itself when it changes
451
+ * other than by replacement or is not replaced at all. Empty when the
452
+ * preview replaces `urn` and nothing else.
453
+ */
454
+ export function stackActionScope(urn, preview) {
455
+ const steps = pulumiSteps(preview) ?? [];
456
+ const others = new Set();
457
+ let replaced = false;
458
+ let problem;
459
+ for (const s of steps) {
460
+ const op = s.op ?? "";
461
+ if (PULUMI_UNCHANGED_OPS.has(op))
462
+ continue;
463
+ if (s.urn !== urn) {
464
+ others.add(s.urn);
465
+ continue;
466
+ }
467
+ if (op === "replace" || op === "create-replacement" || op === "delete-replaced")
468
+ replaced = true;
469
+ else
470
+ problem = `the preview shows ${op} of ${urn}, not its replacement`;
471
+ }
472
+ if (!problem && !replaced)
473
+ problem = `the preview does not replace ${urn}`;
474
+ return { others: [...others].sort(), ...(problem ? { problem } : {}) };
475
+ }
476
+ /**
477
+ * A day-2 action on a stack (#4654): the replacement of one `command:`
478
+ * resource, previewed, asked for under a gated request, and run once a
479
+ * second identity approves. Refused before anything runs for a URN that is
480
+ * not a command resource's (`action-type`), and before anything is asked for
481
+ * a preview that changes more than that replacement (`action-scope`).
482
+ */
483
+ export async function gatedStackAction(opts) {
484
+ checkStackActionUrn(opts.stack, opts.urn);
485
+ if (opts.name !== undefined && !ACTION_NAME.test(opts.name))
486
+ throw new Error(`an action's name is letters, digits, "_", "." and "-", not ${JSON.stringify(opts.name)}`);
487
+ const env = opts.env ?? process.env;
488
+ const dir = stackDirOf(opts.dir, opts.cwd);
489
+ const engine = engineOf(dir, opts.engine, env);
490
+ const target = { stack: opts.stack, urn: opts.urn };
491
+ const previewArgs = pulumiActionPreviewArgs(target);
492
+ let stdout;
493
+ try {
494
+ ({ stdout } = engine === "plunge" ? await runAllowedPlunge(opts.plunge ?? defaultPlungeRunner, previewArgs, dir) : await runAllowedPulumi(opts.run ?? defaultPulumiRunner, previewArgs, dir));
495
+ }
496
+ catch (err) {
497
+ throw new Error(`${engine} ${previewArgs.join(" ")} failed: ${why(err, engine)}`);
498
+ }
499
+ let preview;
500
+ try {
501
+ preview = JSON.parse(stdout);
502
+ }
503
+ catch {
504
+ throw new Error(`${engine} ${previewArgs.join(" ")} printed no JSON`);
505
+ }
506
+ const scope = stackActionScope(opts.urn, preview);
507
+ if (scope.others.length > 0) {
508
+ throw new PulumiStackActionRefused("action-scope", `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`, scope.others);
509
+ }
510
+ if (scope.problem)
511
+ throw new PulumiStackActionRefused("action-scope", `${scope.problem}; nothing was asked for`);
512
+ const digest = pulumiPreviewDigest(preview, { changedOnly: true });
513
+ const subject = stackActionSubject(opts.stack, opts.name ?? opts.urn);
514
+ const fields = { stack: opts.stack, urn: opts.urn, engine, preview: digest, ...(opts.name ? { name: opts.name } : {}) };
515
+ return askThenAct(opts, "stack-action", subject, fields, `action ${opts.name ?? opts.urn} on stack ${opts.stack}: replace ${opts.urn}, requested by ${opts.by}`, async () => {
516
+ const args = pulumiActionUpArgs(target);
517
+ try {
518
+ const done = engine === "plunge" ? await runGatedPlunge(opts.plunge ?? defaultPlungeRunner, args, dir) : await runGatedPulumi(opts.run ?? defaultPulumiRunner, args, dir);
519
+ const tail = lastLines(done.stdout, 10);
520
+ return { result: { urn: opts.urn, engine, preview: digest, exit: 0, tail }, detail: { result: "ok", exit: 0, tail: tail.slice(-400), urn: opts.urn } };
521
+ }
522
+ catch (err) {
523
+ const f = pulumiRunFailure(err);
524
+ const exit = f.exitCode ?? 1;
525
+ const tail = lastLines(`${f.stdout ?? ""}\n${f.stderr ?? ""}`, 10);
526
+ return {
527
+ result: { urn: opts.urn, engine, preview: digest, exit, tail },
528
+ detail: { result: "failed", exit, tail: tail.slice(-400), urn: opts.urn },
529
+ failed: `${engine} ${args.join(" ")} failed: ${why(err, engine)}`,
530
+ };
531
+ }
532
+ }, undefined, { reuseApplied: !opts.anew });
533
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-pulumi",
3
- "version": "0.124.57",
3
+ "version": "0.124.59",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -54,7 +54,7 @@
54
54
  "access": "public"
55
55
  },
56
56
  "peerDependencies": {
57
- "@intentius/chant": "^0.124.57",
57
+ "@intentius/chant": "^0.124.59",
58
58
  "zod": "^4.3.6"
59
59
  },
60
60
  "description": "pulumi lexicon for chant: a Pulumi program as a workspace member, its preview in the change set and its refresh as drift records",
@@ -9,13 +9,16 @@
9
9
  * `plunge-member`'s export is a cut of the recorded plunge stack's. No binary
10
10
  * runs here.
11
11
  */
12
- import { readFileSync } from "node:fs";
12
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
13
14
  import { join } from "node:path";
14
15
  import { describe, expect, test } from "vitest";
16
+ import { createSnapshotStore } from "@intentius/chant/lifecycle/snapshot-store";
15
17
  import { foldDriftLines, type ReadDriftOptions } from "@intentius/chant/drift-record";
16
18
  import { readPulumiDrift } from "./drift-read";
17
19
  import { pulumiPlugin } from "./plugin";
18
20
  import type { PulumiRunner } from "./run";
21
+ import { writePulumiExportSnapshot } from "./stack-export-snapshot";
19
22
 
20
23
  const testdata = join(import.meta.dirname, "testdata");
21
24
  const text = (...path: string[]) => readFileSync(join(testdata, ...path), "utf-8");
@@ -104,3 +107,72 @@ describe("readPulumiDrift", () => {
104
107
  expect(never.calls).toEqual([]);
105
108
  });
106
109
  });
110
+
111
+ describe("readPulumiDrift with --stack", () => {
112
+ const exportText = text("pulumi-preview", "stack-export.json");
113
+
114
+ /** A member directory with Pulumi.yaml and no checked-in export, and a store holding the stacks named. */
115
+ async function memberWithStore(stacks: string[]) {
116
+ const dir = mkdtempSync(join(tmpdir(), "pulumi-drift-stack-"));
117
+ cpSync(join(testdata, "pulumi-preview", "Pulumi.yaml"), join(dir, "Pulumi.yaml"));
118
+ mkdirSync(join(dir, "node_modules"));
119
+ const store = createSnapshotStore({ store: "local", dir: mkdtempSync(join(tmpdir(), "pulumi-drift-store-")) });
120
+ for (const stack of stacks) {
121
+ const wrote = await writePulumiExportSnapshot({
122
+ dir,
123
+ stack,
124
+ environment: "prod",
125
+ engine: "pulumi",
126
+ run: async () => ({ stdout: exportText }),
127
+ store,
128
+ });
129
+ expect(wrote.written, JSON.stringify(wrote)).toBe(true);
130
+ }
131
+ return { dir, store };
132
+ }
133
+
134
+ test("reads the named stack's stored export and passes that stack to every refresh", async () => {
135
+ const { dir, store } = await memberWithStore(["staging", "prod-eu"]);
136
+ const pulumi = fake(() => text("pulumi-refresh", "bucket-edited.json"));
137
+ const answer = await readPulumiDrift(options(dir, { stack: "prod-eu" }), { run: pulumi.run, plunge: never.run, exportStore: store });
138
+ if (!("lines" in answer)) throw new Error(JSON.stringify(answer));
139
+ expect(pulumi.calls.length).toBeGreaterThan(0);
140
+ for (const call of pulumi.calls) expect(call.slice(call.indexOf("--stack"), call.indexOf("--stack") + 2)).toEqual(["--stack", "prod-eu"]);
141
+ });
142
+
143
+ test("beats the member's declared stack", async () => {
144
+ const { dir, store } = await memberWithStore(["staging", "prod-eu"]);
145
+ const pulumi = fake(() => text("pulumi-refresh", "bucket-edited.json"));
146
+ const config = { pulumi: { member: { dir, label: "site", stack: "staging" } } };
147
+ const answer = await readPulumiDrift({ ...options(dir, { stack: "prod-eu", resource: bucket }), config }, { run: pulumi.run, plunge: never.run, exportStore: store });
148
+ expect("lines" in answer).toBe(true);
149
+ expect(pulumi.calls).toEqual([["refresh", "--preview-only", "--json", "--stack", "prod-eu", "--target", bucket]]);
150
+ });
151
+
152
+ test("a store with no export for that stack is an error naming the stack, and nothing runs", async () => {
153
+ const { dir, store } = await memberWithStore(["staging"]);
154
+ const spy = fake(() => text("pulumi-refresh", "bucket-edited.json"));
155
+ const answer = await readPulumiDrift(options(dir, { stack: "prod-eu" }), { run: spy.run, plunge: never.run, exportStore: store });
156
+ expect(answer).toMatchObject({ refused: { code: "no-drift-read" } });
157
+ expect("refused" in answer && answer.refused.message).toMatch(/holds no export for stack prod-eu of site/);
158
+ expect(spy.calls).toEqual([]);
159
+ });
160
+
161
+ test("an empty store is refused too, though a checked-in export sits beside the member", async () => {
162
+ const dir = join(testdata, "pulumi-preview");
163
+ const store = createSnapshotStore({ store: "local", dir: mkdtempSync(join(tmpdir(), "pulumi-drift-store-")) });
164
+ const spy = fake(() => text("pulumi-refresh", "bucket-edited.json"));
165
+ const answer = await readPulumiDrift(options(dir, { stack: "floci" }), { run: spy.run, plunge: never.run, exportStore: store });
166
+ expect(answer).toMatchObject({ refused: { code: "no-drift-read" } });
167
+ expect(spy.calls).toEqual([]);
168
+ });
169
+
170
+ test("without --stack the read is as before: the checked-in export when the store has none", async () => {
171
+ const dir = join(testdata, "pulumi-preview");
172
+ const store = createSnapshotStore({ store: "local", dir: mkdtempSync(join(tmpdir(), "pulumi-drift-store-")) });
173
+ const pulumi = fake(() => text("pulumi-refresh", "bucket-edited.json"));
174
+ const answer = await readPulumiDrift(options(dir, { resource: bucket }), { run: pulumi.run, plunge: never.run, exportStore: store });
175
+ expect("lines" in answer).toBe(true);
176
+ expect(pulumi.calls).toEqual([["refresh", "--preview-only", "--json", "--stack", "floci", "--target", bucket]]);
177
+ });
178
+ });
package/src/drift-read.ts CHANGED
@@ -13,8 +13,12 @@
13
13
  * | plunge | unset | `plunge drift --resource <urn> --stream`, once per resource in the export |
14
14
  * | plunge | `<urn>` | the same, for that URN alone |
15
15
  *
16
- * Each read passes `--stack` (#4496): the member's declared
17
- * `pulumi.member.stack`, else the stack the export's URNs name.
16
+ * Each read passes `--stack` (#4496): the stack asked for (`chant workspace
17
+ * drift --stack`), else the member's declared `pulumi.member.stack`, else the
18
+ * stack the export's URNs name. A stack asked for is read from that stack's
19
+ * export in the index store alone; with none there the read is refused
20
+ * (`no-drift-read`), where without `--stack` the member reads the store's
21
+ * export, then the checked-in file.
18
22
  *
19
23
  * The one read is `refresh`. The member's engine is `pulumiMemberEngine`'s:
20
24
  * the member config's `engine`, then a plunge backend URL in `Pulumi.yaml`.
@@ -27,11 +31,15 @@ import type { DriftLine, DriftRecord, ReadDriftOptions, ReadDriftResult } from "
27
31
  import { driftRecordLine, driftSettle, driftStart } from "@intentius/chant/drift-record";
28
32
  import type { PulumiConfig } from "./config";
29
33
  import { pulumiMemberEngine, refreshPulumiMember, type PulumiRunner } from "./run";
34
+ import { readStoredPulumiExport } from "./stack-export-snapshot";
35
+ import type { SnapshotStore } from "@intentius/chant/lifecycle/snapshot-store";
30
36
 
31
37
  /** The runners, injectable so the tests run no binary. */
32
38
  export interface PulumiDriftRunners {
33
39
  run?: PulumiRunner;
34
40
  plunge?: PulumiRunner;
41
+ /** The index store the exports are read from. Default: the project's. */
42
+ exportStore?: SnapshotStore | null;
35
43
  }
36
44
 
37
45
  /** `LexiconPlugin.readDrift` for the pulumi lexicon. */
@@ -42,6 +50,18 @@ export async function readPulumiDrift(options: ReadDriftOptions, runners: Pulumi
42
50
  return { refused: { code: "read-unsupported", message: `a Pulumi member answers refresh, not ${options.read}` } };
43
51
  }
44
52
  const { engine } = pulumiMemberEngine(member.dir, member.engine);
53
+ const stack = options.stack ?? member.stack;
54
+ if (options.stack !== undefined) {
55
+ const stored = await readStoredPulumiExport({ dir: member.dir, stack: options.stack, ...(runners.exportStore !== undefined ? { store: runners.exportStore } : {}) });
56
+ if (!stored) {
57
+ return {
58
+ refused: {
59
+ code: "no-drift-read",
60
+ message: `the index store holds no export for stack ${options.stack} of ${options.member}; the pulumi-up capability writes one after each apply through chant, so apply that stack once, or read without --stack`,
61
+ },
62
+ };
63
+ }
64
+ }
45
65
  let records: DriftRecord[];
46
66
  try {
47
67
  records = await refreshPulumiMember({
@@ -49,7 +69,8 @@ export async function readPulumiDrift(options: ReadDriftOptions, runners: Pulumi
49
69
  dir: member.dir,
50
70
  observedAt: options.observedAt,
51
71
  ...(member.engine ? { engine: member.engine } : {}),
52
- ...(member.stack ? { stack: member.stack } : {}),
72
+ ...(stack ? { stack } : {}),
73
+ ...(runners.exportStore !== undefined ? { exportStore: runners.exportStore } : {}),
53
74
  ...(options.resource !== undefined ? { urns: [options.resource] } : {}),
54
75
  ...(runners.run ? { run: runners.run } : {}),
55
76
  ...(runners.plunge ? { plunge: runners.plunge } : {}),
package/src/index.ts CHANGED
@@ -108,7 +108,7 @@ export { readPulumiDrift, type PulumiDriftRunners } from "./drift-read";
108
108
  export { pulumiAuditCatalog } from "./lint/audit-catalog";
109
109
  // The Preview/Drift watch composite and its step builders (#4494).
110
110
  export { PulumiWatchOp, type PulumiWatchOpConfig, type PulumiWatchOpResources } from "./composites/pulumi-watch-op";
111
- export { gatedStackExport, gatedStackCancel, gatedStackMove, moveLeftovers, diyStackLockIds, pulumiStackLockIds, type PulumiStackLocks, PULUMI_STACK_OPS_OP, type PulumiStackOpOutcome, type PulumiStackOpCommon, type PulumiStackOpKind } from "./stack-ops";
111
+ export { gatedStackExport, gatedStackCancel, gatedStackMove, gatedStackAction, checkStackActionUrn, stackActionScope, stackActionSubject, PulumiStackActionRefused, PULUMI_ACTION_TYPE_PREFIX, type PulumiStackActionOptions, type PulumiStackActionResult, type PulumiStackActionRefusal, moveLeftovers, diyStackLockIds, pulumiStackLockIds, type PulumiStackLocks, PULUMI_STACK_OPS_OP, type PulumiStackOpOutcome, type PulumiStackOpCommon, type PulumiStackOpKind } from "./stack-ops";
112
112
  // Review stacks (INTENTIUS/plywood#15): pr-<n> stacks only.
113
113
  export {
114
114
  initReviewStack,
@@ -1,5 +1,7 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
1
3
  import { join } from "node:path";
2
- import { afterEach, describe, expect, test } from "vitest";
4
+ import { afterEach, describe, expect, test, vi } from "vitest";
3
5
  import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
4
6
  import { PULUMI_SECRET_SIG, PULUMI_SIG_KEY } from "../../values";
5
7
  import { setLastMemberRead, setLastMemberReport } from "../../workspace-member";
@@ -23,6 +25,7 @@ import { pul015 } from "./pul015";
23
25
  const ctx = { outputs: new Map(), entities: new Map() } as unknown as PostSynthContext;
24
26
 
25
27
  afterEach(() => {
28
+ vi.unstubAllEnvs();
26
29
  setLastMemberReport(undefined);
27
30
  setLastMemberRead(undefined);
28
31
  });
@@ -196,4 +199,47 @@ describe("pulumi post-synth checks", () => {
196
199
  readWith([], { dir: stockDir, engine: "plunge" });
197
200
  expect(pul015.check(ctx)).toEqual([]);
198
201
  });
202
+
203
+ test("PUL015 names the source of the resolved plunge engine", () => {
204
+ // Pulumi.yaml
205
+ readWith([], { dir: plungeDir, engine: "pulumi" });
206
+ expect(pul015.check(ctx)[0].message).toContain("Pulumi.yaml names the plunge backend");
207
+
208
+ // PULUMI_BACKEND_URL, on a program whose Pulumi.yaml names no backend
209
+ vi.stubEnv("PULUMI_BACKEND_URL", "plunge+s3://bucket/state");
210
+ readWith([], { dir: stockDir, engine: "pulumi" });
211
+ const fromEnv = pul015.check(ctx);
212
+ expect(fromEnv).toHaveLength(1);
213
+ expect(fromEnv[0].message).toContain("PULUMI_BACKEND_URL names the plunge backend plunge+s3://bucket/state");
214
+ readWith([], { dir: stockDir, engine: "plunge" });
215
+ expect(pul015.check(ctx)).toEqual([]);
216
+ vi.stubEnv("PULUMI_BACKEND_URL", "s3://bucket/state");
217
+ readWith([], { dir: stockDir, engine: "pulumi" });
218
+ expect(pul015.check(ctx)).toEqual([]);
219
+ vi.unstubAllEnvs();
220
+
221
+ // plywood.yml, engine and backend
222
+ const root = mkdtempSync(join(tmpdir(), "pul015-"));
223
+ mkdirSync(join(root, ".git"));
224
+ const member = join(root, "infra");
225
+ mkdirSync(member);
226
+ writeFileSync(join(member, "Pulumi.yaml"), "name: app\nruntime: nodejs\n");
227
+ writeFileSync(join(root, "plywood.yml"), "engine: plunge\n");
228
+ readWith([], { dir: member, engine: "pulumi" });
229
+ const fromEngine = pul015.check(ctx);
230
+ expect(fromEngine).toHaveLength(1);
231
+ expect(fromEngine[0].message).toContain("plywood.yml says engine: plunge");
232
+ writeFileSync(join(root, "plywood.yml"), "backend: plunge+file:///state\n");
233
+ const fromBackend = pul015.check(ctx);
234
+ expect(fromBackend).toHaveLength(1);
235
+ expect(fromBackend[0].message).toContain("plywood.yml names the plunge backend plunge+file:///state");
236
+ writeFileSync(join(root, "plywood.yml"), "engine: pulumi\n");
237
+ expect(pul015.check(ctx)).toEqual([]);
238
+ });
239
+
240
+ test("PUL015 does not report the other direction: engine plunge on a stock backend", () => {
241
+ vi.stubEnv("PULUMI_BACKEND_URL", "s3://bucket/state");
242
+ readWith([], { dir: stockDir, engine: "plunge" });
243
+ expect(pul015.check(ctx)).toEqual([]);
244
+ });
199
245
  });
@@ -9,8 +9,9 @@
9
9
  * `plunge+file://...` or `plunge://...` runs stock `pulumi` against a
10
10
  * backend scheme only plunge opens, and every read fails.
11
11
  *
12
- * The other direction, `engine: "plunge"` with a stock backend, is not
13
- * reported: plunge is a Pulumi fork and the config may mean it.
12
+ * One direction only, by maintainer ruling (#4559): `engine: "plunge"` with a
13
+ * stock backend is not reported, because plunge opens stock backends and the
14
+ * config may mean it.
14
15
  */
15
16
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
16
17
  import { pulumiMemberEngine } from "../../run";
@@ -19,7 +20,7 @@ import { finding } from "./member-read-helpers";
19
20
 
20
21
  export const pul015: PostSynthCheck = {
21
22
  id: "PUL015",
22
- description: "pulumi.member.engine says pulumi, but Pulumi.yaml names a plunge backend",
23
+ description: "pulumi.member.engine says pulumi, but the resolved engine is plunge",
23
24
 
24
25
  check(_ctx: PostSynthContext): PostSynthDiagnostic[] {
25
26
  const read = lastMemberRead();
package/src/run.test.ts CHANGED
@@ -14,6 +14,8 @@ import {
14
14
  plungeAdoptArgs,
15
15
  plungeGraphArgs,
16
16
  plungeMoveArgs,
17
+ pulumiActionPreviewArgs,
18
+ pulumiActionUpArgs,
17
19
  pulumiCancelArgs,
18
20
  pulumiStateMoveArgs,
19
21
  runGatedPlunge,
@@ -93,6 +95,44 @@ describe("gated stack operations (#4578)", () => {
93
95
  });
94
96
  });
95
97
 
98
+ describe("a stack action's forms (#4654)", () => {
99
+ const u = "urn:pulumi:prod-us::shop::command:local:Command::rotate";
100
+ const other = "urn:pulumi:prod-us::shop::command:local:Command::flush";
101
+ test("the preview is a read; the update passes the gated entry points only", async () => {
102
+ const { run, calls } = fake(() => "{}");
103
+ await runAllowedPulumi(run, pulumiActionPreviewArgs({ stack: "prod-us", urn: u }), "/tmp");
104
+ await runAllowedPlunge(run, pulumiActionPreviewArgs({ stack: "prod-us", urn: u }), "/tmp");
105
+ await runGatedPulumi(run, pulumiActionUpArgs({ stack: "prod-us", urn: u }), "/tmp");
106
+ await runGatedPlunge(run, pulumiActionUpArgs({ stack: "prod-us", urn: u }), "/tmp");
107
+ const up = ["up", "--yes", "--target", u, "--replace", u, "--stack", "prod-us"];
108
+ expect(calls.map((c) => c.args)).toEqual([
109
+ ["preview", "--json", "--target", u, "--replace", u, "--stack", "prod-us"],
110
+ ["preview", "--json", "--target", u, "--replace", u, "--stack", "prod-us"],
111
+ up,
112
+ up,
113
+ ]);
114
+ });
115
+
116
+ test("no other update form gets through", async () => {
117
+ const { run, calls } = fake(() => "{}");
118
+ const up = pulumiActionUpArgs({ stack: "prod-us", urn: u });
119
+ await expect(runAllowedPulumi(run, up, "/tmp")).rejects.toThrow(/not pulumi up/);
120
+ await expect(runApplyPulumi(run, up, "/tmp")).rejects.toThrow();
121
+ for (const args of [
122
+ ["up", "--yes", "--target", u, "--replace", other, "--stack", "prod-us"],
123
+ ["up", "--yes", "--replace", u, "--stack", "prod-us"],
124
+ ["up", "--yes", "--target", u, "--replace", u, "--stack", "prod-us", "--skip-preview"],
125
+ ["up", "--target", u, "--replace", u, "--stack", "prod-us"],
126
+ ["up", "--yes", "--target", "rotate", "--replace", "rotate", "--stack", "prod-us"],
127
+ ["up", "--yes", "--target", u, "--replace", u, "--stack", "--json"],
128
+ ]) {
129
+ await expect(runGatedPulumi(run, args, "/tmp")).rejects.toThrow(/gated Pulumi stack operation only as/);
130
+ await expect(runGatedPlunge(run, args, "/tmp")).rejects.toThrow(/plunge stack action only as/);
131
+ }
132
+ expect(calls).toEqual([]);
133
+ });
134
+ });
135
+
96
136
  describe("plunge stack adopt (#4562)", () => {
97
137
  test("the preview form passes runAllowedPlunge, and only runAdoptPlunge runs --yes", async () => {
98
138
  const { run, calls } = fake(() => "{}");
package/src/run.ts CHANGED
@@ -492,13 +492,45 @@ export async function runAdoptPlunge(run: PulumiRunner, args: string[], cwd: str
492
492
  *
493
493
  * - `pulumi cancel --yes --stack <stack>`: releases a stuck update's lock;
494
494
  * - `pulumi state move --source <stack> --dest <stack> --yes <urn>...`: moves
495
- * resources between stock stacks, never `--include-parents`.
495
+ * resources between stock stacks, never `--include-parents`;
496
+ * - `pulumi up --yes --target <urn> --replace <urn> --stack <stack>`: a
497
+ * day-2 action (#4654), the replacement of one `command:` resource, the
498
+ * same URN in both places. Its preview, `preview --json --target <urn>
499
+ * --replace <urn> --stack <stack>`, is a read and passes
500
+ * {@link runAllowedPulumi}. `up` takes `--replace` here only:
501
+ * {@link PULUMI_APPLY_COMMANDS} still refuses it.
496
502
  */
497
503
  export const PULUMI_GATED_COMMANDS: readonly string[] = [
498
504
  "pulumi cancel --yes --stack <stack>",
499
505
  "pulumi state move --source <stack> --dest <stack> --yes <urn>...",
506
+ "pulumi up --yes --target <urn> --replace <urn> --stack <stack>",
500
507
  ];
501
508
 
509
+ /** A stack action's preview (#4654): `preview --json --target <urn> --replace <urn> --stack <stack>`, a read. */
510
+ export function pulumiActionPreviewArgs(opts: { stack: string; urn: string }): string[] {
511
+ return ["preview", "--json", "--target", opts.urn, "--replace", opts.urn, "--stack", opts.stack];
512
+ }
513
+
514
+ /** A stack action's update (#4654): `up --yes --target <urn> --replace <urn> --stack <stack>`, under `pulumi` or `plunge`. */
515
+ export function pulumiActionUpArgs(opts: { stack: string; urn: string }): string[] {
516
+ return ["up", "--yes", "--target", opts.urn, "--replace", opts.urn, "--stack", opts.stack];
517
+ }
518
+
519
+ /** Exactly {@link pulumiActionUpArgs}' form: one URN, targeted and replaced, on one stack. */
520
+ function allowedActionUp(args: readonly string[]): boolean {
521
+ return (
522
+ args.length === 8 &&
523
+ args[0] === "up" &&
524
+ args[1] === "--yes" &&
525
+ args[2] === "--target" &&
526
+ isUrn(args[3]) &&
527
+ args[4] === "--replace" &&
528
+ args[5] === args[3] &&
529
+ args[6] === "--stack" &&
530
+ isValue(args[7])
531
+ );
532
+ }
533
+
502
534
  /**
503
535
  * plunge's resource move (INTENTIUS/plunge#56) in its two forms. The first
504
536
  * prints the plan and writes nothing, so it passes {@link runAllowedPlunge};
@@ -507,6 +539,7 @@ export const PULUMI_GATED_COMMANDS: readonly string[] = [
507
539
  export const PLUNGE_GATED_COMMANDS: readonly string[] = [
508
540
  "plunge stack move --stack <stack> --dest <stack> <urn>... --json",
509
541
  "plunge stack move --stack <stack> --dest <stack> <urn>... --json --yes",
542
+ "plunge up --yes --target <urn> --replace <urn> --stack <stack>",
510
543
  ];
511
544
 
512
545
  const isUrn = (v: string | undefined): v is string => typeof v === "string" && v.startsWith("urn:pulumi:");
@@ -539,6 +572,7 @@ function allowedGatedPulumi(args: string[]): boolean {
539
572
  const urns = args.slice(7);
540
573
  return args[2] === "--source" && isValue(args[3]) && args[4] === "--dest" && isValue(args[5]) && args[6] === "--yes" && urns.length > 0 && urns.every(isUrn);
541
574
  }
575
+ if (args[0] === "up") return allowedActionUp(args);
542
576
  return false;
543
577
  }
544
578
 
@@ -550,9 +584,11 @@ export async function runGatedPulumi(run: PulumiRunner, args: string[], cwd: str
550
584
  return run(args, env ? { cwd, env } : { cwd });
551
585
  }
552
586
 
553
- /** Run the moving form of {@link PLUNGE_GATED_COMMANDS}, refusing any other argv. Only `./stack-ops.ts` calls it, after the request is spent. */
587
+ /** Run a writing form of {@link PLUNGE_GATED_COMMANDS}, the move or a stack action's update, refusing any other argv. Only `./stack-ops.ts` calls it, after the request is spent. */
554
588
  export async function runGatedPlunge(run: PulumiRunner, args: string[], cwd: string, env?: Record<string, string>): Promise<{ stdout: string }> {
555
- if (!allowedPlungeMove(args, true)) {
589
+ if (args[0] === "up") {
590
+ if (!allowedActionUp(args)) throw new Error(`chant runs a plunge stack action only as ${PLUNGE_GATED_COMMANDS[2]}, not plunge ${args.join(" ")}`);
591
+ } else if (!allowedPlungeMove(args, true)) {
556
592
  throw new Error(`chant moves plunge resources only as ${PLUNGE_GATED_COMMANDS[1]}, not plunge ${args.join(" ")}`);
557
593
  }
558
594
  return run(args, env ? { cwd, env } : { cwd });