@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/README.md +4 -1
- package/dist/drift-read.d.ts +9 -2
- package/dist/drift-read.d.ts.map +1 -1
- package/dist/drift-read.js +15 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/integrity.json +3 -3
- package/dist/lint/post-synth/pul015.d.ts +3 -2
- package/dist/lint/post-synth/pul015.d.ts.map +1 -1
- package/dist/lint/post-synth/pul015.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -1
- package/dist/okf/rules/PUL015.md +2 -2
- package/dist/rules/pul015.ts +4 -3
- package/dist/run.d.ts +18 -2
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +37 -3
- package/dist/stack-ops.d.ts +60 -1
- package/dist/stack-ops.d.ts.map +1 -1
- package/dist/stack-ops.js +138 -5
- package/package.json +2 -2
- package/src/drift-read.test.ts +73 -1
- package/src/drift-read.ts +24 -3
- package/src/index.ts +1 -1
- package/src/lint/post-synth/post-synth.test.ts +47 -1
- package/src/lint/post-synth/pul015.ts +4 -3
- package/src/run.test.ts +40 -0
- package/src/run.ts +39 -3
- package/src/stack-ops.test.ts +112 -0
- package/src/stack-ops.ts +189 -4
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.
|
|
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
|
+
"@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",
|
package/src/drift-read.test.ts
CHANGED
|
@@ -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
|
|
17
|
-
* `pulumi.member.stack`, else the
|
|
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
|
-
...(
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
|
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 (
|
|
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 });
|