@intentius/chant-lexicon-pulumi 0.124.55 → 0.124.57
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 +1 -1
- package/dist/changed-units.d.ts +5 -0
- package/dist/changed-units.d.ts.map +1 -0
- package/dist/changed-units.js +101 -0
- package/dist/check-step.d.ts +61 -0
- package/dist/check-step.d.ts.map +1 -0
- package/dist/check-step.js +270 -0
- package/dist/composites/pulumi-adopt-op.d.ts.map +1 -1
- package/dist/composites/pulumi-adopt-op.js +4 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/member-outputs.d.ts +10 -3
- package/dist/member-outputs.d.ts.map +1 -1
- package/dist/member-outputs.js +11 -3
- package/dist/op/activities/pulumi-adopt.d.ts +5 -0
- package/dist/op/activities/pulumi-adopt.d.ts.map +1 -1
- package/dist/op/activities/pulumi-adopt.js +7 -2
- package/dist/op/activity-contracts.d.ts +2 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/op/activity-contracts.js +2 -2
- package/dist/planned-output-changes.d.ts +60 -0
- package/dist/planned-output-changes.d.ts.map +1 -0
- package/dist/planned-output-changes.js +94 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +24 -0
- package/dist/preview-tips.d.ts +71 -0
- package/dist/preview-tips.d.ts.map +1 -0
- package/dist/preview-tips.js +76 -0
- package/dist/pulumi-up.d.ts +24 -1
- package/dist/pulumi-up.d.ts.map +1 -1
- package/dist/pulumi-up.js +64 -16
- package/dist/release-copy.d.ts +42 -2
- package/dist/release-copy.d.ts.map +1 -1
- package/dist/release-copy.js +96 -22
- package/dist/run.d.ts +27 -8
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +55 -14
- package/package.json +2 -2
- package/src/changed-units.test.ts +87 -0
- package/src/changed-units.ts +96 -0
- package/src/check-step.test.ts +191 -0
- package/src/check-step.ts +316 -0
- package/src/composites/pulumi-adopt-op.test.ts +35 -0
- package/src/composites/pulumi-adopt-op.ts +4 -2
- package/src/index.ts +2 -0
- package/src/member-outputs.test.ts +28 -6
- package/src/member-outputs.ts +22 -4
- package/src/op/activities/pulumi-adopt.ts +13 -2
- package/src/op/activity-contracts.ts +2 -2
- package/src/planned-output-changes.test.ts +85 -0
- package/src/planned-output-changes.ts +141 -0
- package/src/plugin.ts +26 -0
- package/src/preview-tips.test.ts +135 -0
- package/src/preview-tips.ts +128 -0
- package/src/pulumi-up.test.ts +100 -0
- package/src/pulumi-up.ts +84 -18
- package/src/release-copy.test.ts +90 -2
- package/src/release-copy.ts +103 -22
- package/src/run-plan-flags.test.ts +53 -0
- package/src/run.test.ts +1 -1
- package/src/run.ts +57 -14
- package/src/stack-export-snapshot.test.ts +3 -1
package/dist/run.js
CHANGED
|
@@ -28,6 +28,12 @@
|
|
|
28
28
|
* - for a review stack alone (`pr-<n>`, INTENTIUS/plywood#15), `stack
|
|
29
29
|
* init`, `destroy --yes` and `stack rm --yes`, through
|
|
30
30
|
* {@link runReviewStackPulumi} and {@link runReviewStackPlunge} only;
|
|
31
|
+
* - for the check stage (#4633, `./check-step.ts`), `pulumi install` and
|
|
32
|
+
* `pulumi about --json [--stack <stack>]`, or the same under `plunge`,
|
|
33
|
+
* through {@link runCheckPulumi} alone. The install fetches the program's
|
|
34
|
+
* dependencies and plugins; `about` reads the engine, plugins and backend.
|
|
35
|
+
* Neither writes a stack, and both run with no forge token or cloud
|
|
36
|
+
* credential in their environment;
|
|
31
37
|
* - for a member on plunge, its engine graph (#4529, ruled on #4520), as
|
|
32
38
|
* exactly one of `plunge graph --json`, `plunge graph --json --stack
|
|
33
39
|
* <stack>`, `plunge graph --json --versions` and `plunge graph --json
|
|
@@ -267,19 +273,25 @@ export function engineRunnerAt(bin) {
|
|
|
267
273
|
* that is not empty and does not start with `-`; `[…]` is optional; `…`
|
|
268
274
|
* repeats.
|
|
269
275
|
*
|
|
270
|
-
* `up` is refused with `--skip-preview`, `--
|
|
271
|
-
* `--replace`, `--
|
|
272
|
-
* `--
|
|
273
|
-
*
|
|
276
|
+
* `up` is refused with `--skip-preview`, `--show-secrets`, `--replace`,
|
|
277
|
+
* `--target-replace`, `--exclude`, `--remote`, `--continue-on-error`,
|
|
278
|
+
* `--config` and any flag not here, since none of them is in the list.
|
|
279
|
+
* `--refresh` and `--target-dependents` (#4627) come in the tail's order,
|
|
280
|
+
* `--target-dependents` only after a `--target`. plunge's `up --plan`
|
|
281
|
+
* applies a saved plan whose preview did the refresh, so it takes no
|
|
282
|
+
* `--refresh`.
|
|
274
283
|
*/
|
|
275
284
|
export const PULUMI_APPLY_COMMANDS = [
|
|
276
|
-
"plunge up --plan <file> --yes --json --stack <stack> [--config-file <file>] [--target <urn
|
|
277
|
-
"pulumi up --yes --json --stack <stack> [--config-file <file>] [--target <urn
|
|
285
|
+
"plunge up --plan <file> --yes --json --stack <stack> [--config-file <file>] [--target <urn>... [--target-dependents]] [--policy-pack <dir> [--policy-pack-config <file>]]...",
|
|
286
|
+
"pulumi up --yes --json --stack <stack> [--config-file <file>] [--refresh] [--target <urn>... [--target-dependents]] [--policy-pack <dir> [--policy-pack-config <file>]]...",
|
|
278
287
|
];
|
|
279
288
|
function tailArgs(t) {
|
|
289
|
+
const targets = t.targets ?? [];
|
|
280
290
|
return [
|
|
281
291
|
...(t.configFile ? ["--config-file", t.configFile] : []),
|
|
282
|
-
...(t.
|
|
292
|
+
...(t.refresh ? ["--refresh"] : []),
|
|
293
|
+
...targets.flatMap((u) => ["--target", u]),
|
|
294
|
+
...(t.targetDependents && targets.length > 0 ? ["--target-dependents"] : []),
|
|
283
295
|
...(t.policyPacks ?? []).flatMap((p) => ["--policy-pack", p.dir, ...(p.config ? ["--policy-pack-config", p.config] : [])]),
|
|
284
296
|
];
|
|
285
297
|
}
|
|
@@ -301,19 +313,25 @@ export function pulumiUpArgs(opts) {
|
|
|
301
313
|
return ["up", "--yes", "--json", "--stack", opts.stack, ...tailArgs(opts)];
|
|
302
314
|
}
|
|
303
315
|
const isValue = (v) => typeof v === "string" && v.length > 0 && !v.startsWith("-");
|
|
304
|
-
/** Whether `rest` is exactly an apply tail, in its order. */
|
|
305
|
-
function wellFormedTail(rest) {
|
|
316
|
+
/** Whether `rest` is exactly an apply tail, in its order; `refresh` says whether `--refresh` may stand in it (#4627). */
|
|
317
|
+
function wellFormedTail(rest, refresh = true) {
|
|
306
318
|
let i = 0;
|
|
307
319
|
if (rest[i] === "--config-file") {
|
|
308
320
|
if (!isValue(rest[i + 1]))
|
|
309
321
|
return false;
|
|
310
322
|
i += 2;
|
|
311
323
|
}
|
|
324
|
+
if (refresh && rest[i] === "--refresh")
|
|
325
|
+
i += 1;
|
|
326
|
+
let targets = 0;
|
|
312
327
|
while (rest[i] === "--target") {
|
|
313
328
|
if (!isValue(rest[i + 1]))
|
|
314
329
|
return false;
|
|
315
330
|
i += 2;
|
|
331
|
+
targets++;
|
|
316
332
|
}
|
|
333
|
+
if (targets > 0 && rest[i] === "--target-dependents")
|
|
334
|
+
i += 1;
|
|
317
335
|
while (rest[i] === "--policy-pack") {
|
|
318
336
|
if (!isValue(rest[i + 1]))
|
|
319
337
|
return false;
|
|
@@ -335,7 +353,7 @@ function allowedPlungeUp(args) {
|
|
|
335
353
|
args[4] === "--json" &&
|
|
336
354
|
args[5] === "--stack" &&
|
|
337
355
|
isValue(args[6]) &&
|
|
338
|
-
wellFormedTail(args.slice(7)));
|
|
356
|
+
wellFormedTail(args.slice(7), false));
|
|
339
357
|
}
|
|
340
358
|
/** Exactly `pulumi up --yes --json --stack <stack>` and a tail. */
|
|
341
359
|
function allowedPulumiUp(args) {
|
|
@@ -559,11 +577,11 @@ export async function runReviewStackPlunge(run, args, cwd, env) {
|
|
|
559
577
|
return run(args, env ? { cwd, env } : { cwd });
|
|
560
578
|
}
|
|
561
579
|
/**
|
|
562
|
-
* The install chant runs in a
|
|
580
|
+
* The install chant runs in a program's directory before its first engine
|
|
581
|
+
* run, a release's recorded copy (#4542) or a job's checkout (plywood#22),
|
|
563
582
|
* for `./release-copy.ts` alone: exactly `pulumi install` or `plunge
|
|
564
|
-
* install`, no flag.
|
|
565
|
-
*
|
|
566
|
-
* runtime options and the recorded lockfile.
|
|
583
|
+
* install`, no flag. Neither has `node_modules` or a virtualenv; the CLI's
|
|
584
|
+
* install follows `Pulumi.yaml`'s runtime options and the lockfile.
|
|
567
585
|
*/
|
|
568
586
|
export const PULUMI_INSTALL_COMMANDS = ["pulumi install", "plunge install"];
|
|
569
587
|
/** Run `install` with no flag in a release copy, refusing any other argv. The runner picks the binary. */
|
|
@@ -573,6 +591,29 @@ export async function runReleaseInstall(run, args, cwd, env) {
|
|
|
573
591
|
}
|
|
574
592
|
return run(args, env ? { cwd, env } : { cwd });
|
|
575
593
|
}
|
|
594
|
+
/**
|
|
595
|
+
* The two argv the check stage runs for a member (#4633, `./check-step.ts`):
|
|
596
|
+
* `install` with no flag, and `about --json`, optionally with `--stack
|
|
597
|
+
* <stack>`. Under `pulumi` or `plunge`; the runner picks the binary.
|
|
598
|
+
*/
|
|
599
|
+
export const PULUMI_CHECK_COMMANDS = ["pulumi install", "pulumi about --json [--stack <stack>]", "plunge install", "plunge about --json [--stack <stack>]"];
|
|
600
|
+
/** Whether `args` is one of {@link PULUMI_CHECK_COMMANDS}' argv. */
|
|
601
|
+
export function allowedCheck(args) {
|
|
602
|
+
if (args.length === 1)
|
|
603
|
+
return args[0] === "install";
|
|
604
|
+
if (args[0] !== "about" || args[1] !== "--json")
|
|
605
|
+
return false;
|
|
606
|
+
if (args.length === 2)
|
|
607
|
+
return true;
|
|
608
|
+
return args.length === 4 && args[2] === "--stack" && isValue(args[3]);
|
|
609
|
+
}
|
|
610
|
+
/** Run a check-stage argv, refusing any other. The runner picks the binary. */
|
|
611
|
+
export async function runCheckPulumi(run, args, cwd, env) {
|
|
612
|
+
if (!allowedCheck(args)) {
|
|
613
|
+
throw new Error(`the check stage runs only ${PULUMI_CHECK_COMMANDS.join(", ")} for a Pulumi member, not ${args.join(" ")}`);
|
|
614
|
+
}
|
|
615
|
+
return run(args, env ? { cwd, env } : { cwd });
|
|
616
|
+
}
|
|
576
617
|
export { pulumiMemberEngine, readPlywoodEngine } from "./member-engine.js";
|
|
577
618
|
/** The argv chant runs for a member's applied outputs, after `pulumi` (#4455). Secrets stay blinded. */
|
|
578
619
|
export function pulumiStackOutputArgs(opts = {}) {
|
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.57",
|
|
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.57",
|
|
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",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stacks a change touched (INTENTIUS/plywood#22): plywood's bench edits
|
|
3
|
+
* `Pulumi.dev.yaml` in a pull request, and `chant components pr-plan`
|
|
4
|
+
* selected nothing because no lexicon claimed the file. Fixture repositories
|
|
5
|
+
* on disk; nothing here runs Pulumi or git.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { describe, expect, test } from "vitest";
|
|
11
|
+
import { pulumiChangedUnits, pulumiStackUnit } from "./changed-units";
|
|
12
|
+
import { pulumiPlugin } from "./plugin";
|
|
13
|
+
|
|
14
|
+
function repo(files: Record<string, string>): string {
|
|
15
|
+
const root = mkdtempSync(join(tmpdir(), "pulumi-changed-units-"));
|
|
16
|
+
for (const [f, text] of Object.entries(files)) {
|
|
17
|
+
mkdirSync(dirname(join(root, f)), { recursive: true });
|
|
18
|
+
writeFileSync(join(root, f), text);
|
|
19
|
+
}
|
|
20
|
+
return root;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** plywood's bench shop: one project at the root with four stacks, and a nested project. */
|
|
24
|
+
const SHOP = {
|
|
25
|
+
"Pulumi.yaml": "name: shop\nruntime: nodejs\n",
|
|
26
|
+
"Pulumi.dev.yaml": "config: {}\n",
|
|
27
|
+
"Pulumi.staging.yaml": "config: {}\n",
|
|
28
|
+
"Pulumi.prod-us.yaml": "config: {}\n",
|
|
29
|
+
"Pulumi.prod-eu.yaml": "config: {}\n",
|
|
30
|
+
"index.ts": "export {};\n",
|
|
31
|
+
"package.json": "{}\n",
|
|
32
|
+
"plywood.yml": "stacks: {}\n",
|
|
33
|
+
".forgejo/workflows/plywood.yml": "name: plywood\n",
|
|
34
|
+
"net/Pulumi.yaml": "name: net\nruntime: nodejs\n",
|
|
35
|
+
"net/Pulumi.qa.yaml": "config: {}\n",
|
|
36
|
+
"net/vpc.ts": "export {};\n",
|
|
37
|
+
"docs/notes.md": "x\n",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("pulumiChangedUnits", () => {
|
|
41
|
+
test("a changed stack config touches that stack alone: the PR-2 case from the bench", () => {
|
|
42
|
+
const root = repo(SHOP);
|
|
43
|
+
expect(pulumiChangedUnits(root, ["Pulumi.dev.yaml"])).toEqual(["dev"]);
|
|
44
|
+
expect(pulumiChangedUnits(root, ["Pulumi.prod-us.yaml", "Pulumi.prod-eu.yaml"])).toEqual(["prod-eu", "prod-us"]);
|
|
45
|
+
expect(pulumiChangedUnits(root, ["net/Pulumi.qa.yaml"])).toEqual(["net/qa"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a program file touches every stack of its project, and only that project", () => {
|
|
49
|
+
const root = repo(SHOP);
|
|
50
|
+
expect(pulumiChangedUnits(root, ["index.ts"])).toEqual(["dev", "prod-eu", "prod-us", "staging"]);
|
|
51
|
+
expect(pulumiChangedUnits(root, ["Pulumi.yaml"])).toEqual(["dev", "prod-eu", "prod-us", "staging"]);
|
|
52
|
+
// The nested project owns its files; the root project does not reach into it.
|
|
53
|
+
expect(pulumiChangedUnits(root, ["net/vpc.ts"])).toEqual(["net/qa"]);
|
|
54
|
+
// A file under the root project's tree but in no nested project is the root project's program.
|
|
55
|
+
expect(pulumiChangedUnits(root, ["docs/notes.md"])).toEqual(["dev", "prod-eu", "prod-us", "staging"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("dot directories, node_modules and files outside the project root are not the program", () => {
|
|
59
|
+
const root = repo(SHOP);
|
|
60
|
+
expect(pulumiChangedUnits(root, [".forgejo/workflows/plywood.yml", ".chant/op-waves/pulumi-up.spec.json", "node_modules/x/index.js", "../shared/lib.ts"])).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a deleted stack config still names its stack, and a program change reaches it", () => {
|
|
64
|
+
const root = repo(SHOP);
|
|
65
|
+
expect(pulumiChangedUnits(root, ["Pulumi.old.yaml"])).toEqual(["old"]);
|
|
66
|
+
expect(pulumiChangedUnits(root, ["index.ts", "Pulumi.old.yaml"])).toEqual(["dev", "old", "prod-eu", "prod-us", "staging"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a deleted Pulumi.yaml still names its project", () => {
|
|
70
|
+
const root = repo({ "app/Pulumi.dev.yaml": "config: {}\n", "app/index.ts": "export {};\n" });
|
|
71
|
+
expect(pulumiChangedUnits(root, ["app/Pulumi.yaml", "app/index.ts"])).toEqual(["app/dev"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a file in no Pulumi project touches nothing", () => {
|
|
75
|
+
const root = repo({ "README.md": "x\n", "tf/main.tf": "\n" });
|
|
76
|
+
expect(pulumiChangedUnits(root, ["README.md", "tf/main.tf"])).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("units are named as core names a pulumi-up step's deploy unit", () => {
|
|
80
|
+
expect([pulumiStackUnit(".", "dev"), pulumiStackUnit("./infra/", "dev"), pulumiStackUnit("apps\\web", "qa")]).toEqual(["dev", "infra/dev", "apps/web/qa"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the plugin answers changedUnits from the changed files", async () => {
|
|
84
|
+
const root = repo(SHOP);
|
|
85
|
+
expect(await pulumiPlugin.changedUnits!({ projectRoot: root, config: {}, changedFiles: ["Pulumi.dev.yaml"] })).toEqual(["dev"]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Pulumi stacks a change touched, judged from the changed files alone
|
|
3
|
+
* (`LexiconPlugin.changedUnits`, #3183; INTENTIUS/plywood#22). A Pulumi
|
|
4
|
+
* program and its stack configs are not chant source, so `chant lifecycle
|
|
5
|
+
* affected`'s artifact diff never shows them, and without this a pull request
|
|
6
|
+
* that edits `Pulumi.dev.yaml` selects no component at all.
|
|
7
|
+
*
|
|
8
|
+
* A file belongs to the nearest directory at or above it holding a
|
|
9
|
+
* `Pulumi.yaml` (or `Pulumi.yml`), the project, never above the project root:
|
|
10
|
+
*
|
|
11
|
+
* - `Pulumi.<stack>.yaml` in that directory is the stack's config, so it
|
|
12
|
+
* touches that stack alone;
|
|
13
|
+
* - any other file there or below (the program, `Pulumi.yaml`, the
|
|
14
|
+
* package files) touches every stack of the project, which is each
|
|
15
|
+
* `Pulumi.<stack>.yaml` beside `Pulumi.yaml` at head and any the change
|
|
16
|
+
* deleted. A stack with no config file has no file to find it by, so a
|
|
17
|
+
* program change does not reach it.
|
|
18
|
+
*
|
|
19
|
+
* Files under a directory whose name starts with a dot (`.forgejo`, `.chant`,
|
|
20
|
+
* `.git`) or under `node_modules` are not the program. A file outside every
|
|
21
|
+
* project, such as a module the program imports from `../shared`, is not
|
|
22
|
+
* claimed.
|
|
23
|
+
*
|
|
24
|
+
* Each unit is named as core's `DEPLOY_UNIT_RULES` names a `pulumi-up` step:
|
|
25
|
+
* `<dir>/<stack>`, or `<stack>` for a project at the project root.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
28
|
+
import { join, posix } from "node:path";
|
|
29
|
+
|
|
30
|
+
const PROJECT_FILES = ["Pulumi.yaml", "Pulumi.yml"];
|
|
31
|
+
const STACK_CONFIG = /^Pulumi\.(.+)\.ya?ml$/;
|
|
32
|
+
|
|
33
|
+
/** The unit core names for a `pulumi-up` step on `stack` in `dir` (deploy-units.ts `pulumiStackUnit`). */
|
|
34
|
+
export function pulumiStackUnit(dir: string, stack: string): string {
|
|
35
|
+
const d = dir.replace(/\\/g, "/").replace(/^(\.\/)+/, "").replace(/\/+$/, "");
|
|
36
|
+
return d === "" || d === "." ? stack : `${d}/${stack}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The project directory (relative, `""` for the root) holding `file`, or undefined when none does. */
|
|
40
|
+
function projectOf(projectRoot: string, file: string, changed: ReadonlySet<string>): string | undefined {
|
|
41
|
+
let dir = posix.dirname(file);
|
|
42
|
+
for (;;) {
|
|
43
|
+
const rel = dir === "." ? "" : dir;
|
|
44
|
+
for (const name of PROJECT_FILES) {
|
|
45
|
+
const p = rel === "" ? name : `${rel}/${name}`;
|
|
46
|
+
// A deleted Pulumi.yaml still names its project for the change that deleted it.
|
|
47
|
+
if (existsSync(join(projectRoot, p)) || changed.has(p)) return rel;
|
|
48
|
+
}
|
|
49
|
+
if (rel === "") return undefined;
|
|
50
|
+
dir = posix.dirname(dir);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The stacks of the project in `dir`: the stack configs at head, and the ones the change names. */
|
|
55
|
+
function stacksOf(projectRoot: string, dir: string, changed: readonly string[]): string[] {
|
|
56
|
+
const out = new Set<string>();
|
|
57
|
+
let names: string[] = [];
|
|
58
|
+
try {
|
|
59
|
+
names = readdirSync(join(projectRoot, dir));
|
|
60
|
+
} catch {
|
|
61
|
+
// The project directory is gone at head; the change's own files still name stacks.
|
|
62
|
+
}
|
|
63
|
+
for (const n of names) {
|
|
64
|
+
const m = STACK_CONFIG.exec(n);
|
|
65
|
+
if (m) out.add(m[1]!);
|
|
66
|
+
}
|
|
67
|
+
for (const f of changed) {
|
|
68
|
+
if (posix.dirname(f) !== (dir === "" ? "." : dir)) continue;
|
|
69
|
+
const m = STACK_CONFIG.exec(posix.basename(f));
|
|
70
|
+
if (m) out.add(m[1]!);
|
|
71
|
+
}
|
|
72
|
+
return [...out];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ignored(file: string): boolean {
|
|
76
|
+
const parts = file.split("/");
|
|
77
|
+
return parts.slice(0, -1).some((p) => p.startsWith(".") || p === "node_modules");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The stack units `changedFiles` (relative to `projectRoot`, `/` separators) touch, sorted. */
|
|
81
|
+
export function pulumiChangedUnits(projectRoot: string, changedFiles: readonly string[]): string[] {
|
|
82
|
+
const files = changedFiles.map((f) => f.replace(/\\/g, "/").replace(/^\.\//, "")).filter((f) => f !== "" && !f.startsWith("../") && !ignored(f));
|
|
83
|
+
const changed = new Set(files);
|
|
84
|
+
const units = new Set<string>();
|
|
85
|
+
const whole = new Set<string>();
|
|
86
|
+
for (const f of files) {
|
|
87
|
+
const dir = projectOf(projectRoot, f, changed);
|
|
88
|
+
if (dir === undefined) continue;
|
|
89
|
+
const here = posix.dirname(f) === (dir === "" ? "." : dir);
|
|
90
|
+
const m = STACK_CONFIG.exec(posix.basename(f));
|
|
91
|
+
if (here && m && !PROJECT_FILES.includes(posix.basename(f))) units.add(pulumiStackUnit(dir, m[1]!));
|
|
92
|
+
else whole.add(dir);
|
|
93
|
+
}
|
|
94
|
+
for (const dir of whole) for (const stack of stacksOf(projectRoot, dir, files)) units.add(pulumiStackUnit(dir, stack));
|
|
95
|
+
return [...units].sort();
|
|
96
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pulumi check steps (#4633): install, stack config against the
|
|
3
|
+
* project's schema, and about, with a fake runner, so no binary runs and no
|
|
4
|
+
* backend is read or written.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { runCheckStage } from "@intentius/chant/components/check-steps";
|
|
11
|
+
import { listedStacks, pulumiAboutDetail, pulumiCheckSteps, pulumiMemberCheck, pulumiProjectSchema, pulumiStackConfigProblems } from "./check-step";
|
|
12
|
+
import { pulumiPlugin } from "./plugin";
|
|
13
|
+
import { allowedCheck, runCheckPulumi, type PulumiRunner } from "./run";
|
|
14
|
+
|
|
15
|
+
const PROJECT = `name: shop
|
|
16
|
+
runtime: nodejs
|
|
17
|
+
config:
|
|
18
|
+
aws:region: us-east-1
|
|
19
|
+
replicas:
|
|
20
|
+
type: integer
|
|
21
|
+
size:
|
|
22
|
+
type: string
|
|
23
|
+
default: small
|
|
24
|
+
dbPassword:
|
|
25
|
+
type: string
|
|
26
|
+
secret: true
|
|
27
|
+
zones:
|
|
28
|
+
type: array
|
|
29
|
+
items:
|
|
30
|
+
type: string
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const ABOUT = JSON.stringify({
|
|
34
|
+
cli: { version: "3.140.0" },
|
|
35
|
+
plugins: [{ name: "aws", version: "6.60.0" }, { name: "nodejs", version: "unknown" }],
|
|
36
|
+
backend: { name: "laptop", url: "file:///tmp/state", user: "ci" },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function member(files: Record<string, string>, fields: Record<string, unknown> | null = null) {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pulumi-check-"));
|
|
41
|
+
for (const [name, text] of Object.entries(files)) {
|
|
42
|
+
mkdirSync(join(dir, name, ".."), { recursive: true });
|
|
43
|
+
writeFileSync(join(dir, name), text);
|
|
44
|
+
}
|
|
45
|
+
return { name: "shop", dir, kind: "pulumi", fields };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fake(answers: Record<string, { stdout?: string; fail?: { code?: number | string; stderr?: string; stdout?: string } }>) {
|
|
49
|
+
const calls: Array<{ engine: string; args: string[]; env?: Record<string, string> }> = [];
|
|
50
|
+
const runner = (engine: string, env: Record<string, string>): PulumiRunner => async (args) => {
|
|
51
|
+
calls.push({ engine, args, env });
|
|
52
|
+
const a = answers[args.join(" ")] ?? { stdout: "" };
|
|
53
|
+
if (a.fail) throw Object.assign(new Error(`${engine} ${args.join(" ")} failed`), a.fail);
|
|
54
|
+
return { stdout: a.stdout ?? "" };
|
|
55
|
+
};
|
|
56
|
+
return { calls, runner };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ctx = { env: { PATH: "/bin" }, log: () => {} };
|
|
60
|
+
|
|
61
|
+
describe("the check argv (#4633)", () => {
|
|
62
|
+
test("install and about --json, optionally with a stack, and nothing else", () => {
|
|
63
|
+
expect(allowedCheck(["install"])).toBe(true);
|
|
64
|
+
expect(allowedCheck(["about", "--json"])).toBe(true);
|
|
65
|
+
expect(allowedCheck(["about", "--json", "--stack", "dev"])).toBe(true);
|
|
66
|
+
expect(allowedCheck(["install", "--reinstall"])).toBe(false);
|
|
67
|
+
expect(allowedCheck(["about"])).toBe(false);
|
|
68
|
+
expect(allowedCheck(["about", "--json", "--stack", "--yes"])).toBe(false);
|
|
69
|
+
expect(allowedCheck(["up", "--yes"])).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("runCheckPulumi refuses an argv off the list before the runner sees it", async () => {
|
|
73
|
+
let ran = false;
|
|
74
|
+
const run: PulumiRunner = async () => {
|
|
75
|
+
ran = true;
|
|
76
|
+
return { stdout: "" };
|
|
77
|
+
};
|
|
78
|
+
await expect(runCheckPulumi(run, ["up", "--yes"], "/tmp")).rejects.toThrow(/check stage runs only/);
|
|
79
|
+
expect(ran).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("stack config against the project's schema", () => {
|
|
84
|
+
const schema = pulumiProjectSchema({ name: "shop", config: { "aws:region": "us-east-1", replicas: { type: "integer" }, size: { type: "string", default: "small" }, dbPassword: { type: "string", secret: true }, zones: { type: "array", items: { type: "string" } } } });
|
|
85
|
+
|
|
86
|
+
test("reads the keys in full form", () => {
|
|
87
|
+
expect(schema.keys.map((k) => k.key)).toEqual(["aws:region", "shop:replicas", "shop:size", "shop:dbPassword", "shop:zones"]);
|
|
88
|
+
expect(schema.stackConfigDir).toBe(".");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a stack that sets every required key with the right types passes", () => {
|
|
92
|
+
expect(pulumiStackConfigProblems(schema, "dev", { config: { "shop:replicas": "3", "shop:dbPassword": { secure: "AAAB" }, "shop:zones": ["a", "b"] } })).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a missing key with no default, a wrong type and a plain-text secret are each named", () => {
|
|
96
|
+
const problems = pulumiStackConfigProblems(schema, "prod", { config: { "shop:size": 3, "shop:dbPassword": "hunter2", "shop:zones": ["a", ["b"]] } });
|
|
97
|
+
expect(problems).toEqual([
|
|
98
|
+
"stack prod is missing the configuration value shop:replicas (type integer, no default)",
|
|
99
|
+
"stack prod sets shop:dbPassword in plain text, and the project declares it secret; set it with pulumi config set --secret",
|
|
100
|
+
'stack prod sets shop:zones[1] to ["b"], which is not of type string',
|
|
101
|
+
]);
|
|
102
|
+
expect(pulumiStackConfigProblems(schema, "qa", { config: { replicas: "three" } })[0]).toBe('stack qa sets shop:replicas to "three", which is not of type integer');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("the listed stacks are the stack files", () => {
|
|
106
|
+
const m = member({ "Pulumi.yaml": PROJECT, "Pulumi.dev.yaml": "config: {}\n", "Pulumi.prod.yml": "config: {}\n" });
|
|
107
|
+
expect(listedStacks(m.dir)).toEqual(["dev", "prod"]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("pulumi about --json", () => {
|
|
112
|
+
test("reads the engine, plugins and backend", () => {
|
|
113
|
+
expect(pulumiAboutDetail("pulumi", ABOUT)).toEqual({
|
|
114
|
+
engine: "pulumi",
|
|
115
|
+
version: "3.140.0",
|
|
116
|
+
plugins: [{ name: "aws", version: "6.60.0" }, { name: "nodejs", version: "unknown" }],
|
|
117
|
+
backend: { name: "laptop", url: "file:///tmp/state", user: "ci" },
|
|
118
|
+
});
|
|
119
|
+
expect(pulumiAboutDetail("pulumi", "not json")).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("a member's steps", () => {
|
|
124
|
+
test("install, each stack's config and about pass in order, with the check job's environment", async () => {
|
|
125
|
+
const m = member({ "Pulumi.yaml": PROJECT, "Pulumi.dev.yaml": "config:\n shop:replicas: \"2\"\n shop:dbPassword:\n secure: AAAB\n shop:zones: [a, b]\n" });
|
|
126
|
+
const { calls, runner } = fake({ "about --json": { stdout: ABOUT } });
|
|
127
|
+
const out = await pulumiMemberCheck(m, ctx, { runner });
|
|
128
|
+
expect(out.map((o) => [o.step, o.stack ?? "", o.outcome])).toEqual([
|
|
129
|
+
["pulumi-install", "", "passed"],
|
|
130
|
+
["stack-config", "dev", "passed"],
|
|
131
|
+
["pulumi-about", "", "passed"],
|
|
132
|
+
]);
|
|
133
|
+
expect(calls.map((c) => c.args.join(" "))).toEqual(["install", "about --json"]);
|
|
134
|
+
expect(calls.every((c) => c.engine === "pulumi" && c.env?.PATH === "/bin")).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a member on plunge runs plunge, and its declared stack goes to about", async () => {
|
|
138
|
+
const m = member({ "Pulumi.yaml": PROJECT }, { engine: "plunge", stack: "dev" });
|
|
139
|
+
const { calls, runner } = fake({ "about --json --stack dev": { stdout: ABOUT } });
|
|
140
|
+
const out = await pulumiMemberCheck(m, ctx, { runner });
|
|
141
|
+
expect(calls.map((c) => `${c.engine} ${c.args.join(" ")}`)).toEqual(["plunge install", "plunge about --json --stack dev"]);
|
|
142
|
+
// dev has no stack file, and replicas has no default.
|
|
143
|
+
expect(out.find((o) => o.step === "stack-config")).toMatchObject({ stack: "dev", outcome: "failed", reason: expect.stringContaining("member shop: stack dev is missing the configuration value shop:replicas") });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a failed install names the member, and about does not run", async () => {
|
|
147
|
+
const m = member({ "Pulumi.yaml": PROJECT, "Pulumi.dev.yaml": "config:\n shop:replicas: 1\n shop:dbPassword:\n secure: x\n" });
|
|
148
|
+
const { calls, runner } = fake({ install: { fail: { code: 255, stderr: "error: npm install failed\n" } } });
|
|
149
|
+
const out = await pulumiMemberCheck(m, ctx, { runner });
|
|
150
|
+
expect(out[0]).toMatchObject({ step: "pulumi-install", outcome: "failed", exit: 255, reason: "pulumi install failed for member shop: error: npm install failed" });
|
|
151
|
+
expect(out.at(-1)).toMatchObject({ step: "pulumi-about", outcome: "skipped" });
|
|
152
|
+
expect(calls.map((c) => c.args.join(" "))).toEqual(["install"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("about that exits non-zero but prints its JSON still reports", async () => {
|
|
156
|
+
const m = member({ "Pulumi.yaml": "name: shop\nruntime: nodejs\n" });
|
|
157
|
+
const { runner } = fake({ "about --json": { fail: { code: 1, stdout: ABOUT, stderr: "error: not logged in" } } });
|
|
158
|
+
const out = await pulumiMemberCheck(m, ctx, { runner });
|
|
159
|
+
expect(out.find((o) => o.step === "pulumi-about")).toMatchObject({ outcome: "passed", exit: 1, detail: { version: "3.140.0" } });
|
|
160
|
+
expect(out.find((o) => o.step === "stack-config")).toMatchObject({ outcome: "skipped" });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a member with no Pulumi.yaml fails, naming it", async () => {
|
|
164
|
+
const m = member({ "README.md": "x" });
|
|
165
|
+
const out = await pulumiMemberCheck(m, ctx, { runner: fake({}).runner });
|
|
166
|
+
expect(out).toEqual([expect.objectContaining({ step: "pulumi-install", outcome: "failed", reason: expect.stringContaining("has no Pulumi.yaml") })]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("in the check stage", () => {
|
|
171
|
+
test("the plugin gives the steps for the pulumi kind", () => {
|
|
172
|
+
expect(pulumiPlugin.checkSteps?.()).toMatchObject({ lexicon: "pulumi", kinds: ["pulumi"] });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a failed step fails the stage after the hooks, naming member and stack", async () => {
|
|
176
|
+
const m = member({ "Pulumi.yaml": PROJECT, "Pulumi.prod.yaml": "config: {}\n" });
|
|
177
|
+
const cwd = mkdtempSync(join(tmpdir(), "chant-pulumi-stage-"));
|
|
178
|
+
const report = await runCheckStage({
|
|
179
|
+
base: "abc123",
|
|
180
|
+
cwd,
|
|
181
|
+
read: { base: "abc123", hooks: [] },
|
|
182
|
+
members: [m],
|
|
183
|
+
adapters: [pulumiCheckSteps({ runner: fake({ "about --json": { stdout: ABOUT } }).runner })],
|
|
184
|
+
env: {},
|
|
185
|
+
log: () => {},
|
|
186
|
+
});
|
|
187
|
+
expect(report.status).toBe("failed");
|
|
188
|
+
expect(report.failed).toContain("member shop: stack prod is missing the configuration value shop:replicas");
|
|
189
|
+
expect(report.steps?.map((s) => s.step)).toEqual(["pulumi-install", "stack-config", "pulumi-about"]);
|
|
190
|
+
});
|
|
191
|
+
});
|