@intentius/chant-lexicon-pulumi 0.124.56 → 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/composites/pulumi-adopt-op.d.ts.map +1 -1
- package/dist/composites/pulumi-adopt-op.js +4 -2
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- 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/plugin.d.ts.map +1 -1
- package/dist/plugin.js +11 -0
- package/dist/pulumi-up.d.ts +16 -1
- package/dist/pulumi-up.d.ts.map +1 -1
- package/dist/pulumi-up.js +49 -14
- 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 +4 -4
- package/dist/run.js +4 -4
- package/package.json +2 -2
- package/src/changed-units.test.ts +87 -0
- package/src/changed-units.ts +96 -0
- package/src/composites/pulumi-adopt-op.test.ts +35 -0
- package/src/composites/pulumi-adopt-op.ts +4 -2
- package/src/op/activities/pulumi-adopt.ts +13 -2
- package/src/op/activity-contracts.ts +2 -2
- package/src/plugin.ts +12 -0
- package/src/pulumi-up.test.ts +100 -0
- package/src/pulumi-up.ts +61 -16
- package/src/release-copy.test.ts +90 -2
- package/src/release-copy.ts +103 -22
- package/src/run.ts +4 -4
- package/src/stack-export-snapshot.test.ts +3 -1
package/dist/release-copy.js
CHANGED
|
@@ -24,10 +24,28 @@
|
|
|
24
24
|
* - `go`, `dotnet`, `java`, `yaml`: never; their tools fetch what the
|
|
25
25
|
* program needs on the run.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
27
|
+
* ## In-place checkouts (plywood#22)
|
|
28
|
+
*
|
|
29
|
+
* A CI job's checkout has the same gap: git cloned the tracked files, and
|
|
30
|
+
* nothing installed the program's dependencies before the step's first
|
|
31
|
+
* preview. {@link prepareProgram}, which `pulumi-up`'s plan and run call,
|
|
32
|
+
* prepares a release copy as above and an in-place directory by the same
|
|
33
|
+
* install and the same lockfile rule, with two differences for a directory
|
|
34
|
+
* that sits in a working tree:
|
|
35
|
+
*
|
|
36
|
+
* - `nodejs`: `node_modules` in the directory or any directory above it (a
|
|
37
|
+
* workspace's hoisted install), or a Yarn Plug'n'Play `.pnp.cjs` or
|
|
38
|
+
* `.pnp.js` there, counts as installed, since Node resolves the SDK
|
|
39
|
+
* from either.
|
|
40
|
+
* - The refusal for a Node program with no lockfile names the checkout, and
|
|
41
|
+
* says to commit a lockfile or install before the run.
|
|
42
|
+
*
|
|
43
|
+
* Preparation runs once per directory per process, whichever engine asks
|
|
44
|
+
* first, so two stacks of one project on different engines never install
|
|
45
|
+
* into the same directory at once.
|
|
28
46
|
*/
|
|
29
47
|
import { existsSync, readFileSync } from "node:fs";
|
|
30
|
-
import { join, sep } from "node:path";
|
|
48
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
31
49
|
import { parseYAML } from "@intentius/chant/yaml";
|
|
32
50
|
import { pulumiRunFailure, runReleaseInstall } from "./run.js";
|
|
33
51
|
import { isObject } from "./values.js";
|
|
@@ -68,17 +86,57 @@ export class PulumiReleaseCopyRefusedError extends Error {
|
|
|
68
86
|
this.name = "PulumiReleaseCopyRefusedError";
|
|
69
87
|
}
|
|
70
88
|
}
|
|
89
|
+
/** An in-place program that cannot be made runnable, with the reason (plywood#22). */
|
|
90
|
+
export class PulumiProgramNotReadyError extends Error {
|
|
91
|
+
constructor(member, dir, reason) {
|
|
92
|
+
super(`pulumi-up: "${member}": the program in ${dir} cannot run: ${reason}`);
|
|
93
|
+
this.member = member;
|
|
94
|
+
this.dir = dir;
|
|
95
|
+
this.name = "PulumiProgramNotReadyError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Whether `dir` or a directory above it holds what Node resolves a package from: `node_modules`, or Yarn Plug'n'Play's loader. */
|
|
99
|
+
function nodeResolvesAbove(dir) {
|
|
100
|
+
let at = resolve(dir);
|
|
101
|
+
for (;;) {
|
|
102
|
+
for (const f of ["node_modules", ".pnp.cjs", ".pnp.js"])
|
|
103
|
+
if (existsSync(join(at, f)))
|
|
104
|
+
return join(at, f);
|
|
105
|
+
const up = dirname(at);
|
|
106
|
+
if (up === at)
|
|
107
|
+
return undefined;
|
|
108
|
+
at = up;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
71
111
|
/** Why the copy needs an install, or why not; throws when it cannot be prepared. */
|
|
72
112
|
export function releaseCopyNeeds(dir, member) {
|
|
113
|
+
return programNeeds(dir, member, "copy");
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Why a program's directory needs an install, or why not: a release copy
|
|
117
|
+
* (`copy`) or an in-place checkout (`checkout`). Throws when it cannot be
|
|
118
|
+
* prepared: a Node program with no lockfile beside its package.json.
|
|
119
|
+
*/
|
|
120
|
+
export function programNeeds(dir, member, place) {
|
|
73
121
|
const { name, options } = pulumiRuntime(dir);
|
|
74
122
|
switch (name) {
|
|
75
123
|
case "nodejs": {
|
|
76
|
-
if (
|
|
77
|
-
|
|
124
|
+
if (place === "copy") {
|
|
125
|
+
if (existsSync(join(dir, "node_modules")))
|
|
126
|
+
return { install: false, reason: "the copy has node_modules" };
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const found = nodeResolvesAbove(dir);
|
|
130
|
+
if (found)
|
|
131
|
+
return { install: false, reason: `Node resolves the program's packages from ${found}` };
|
|
132
|
+
}
|
|
78
133
|
if (!NODE_LOCKFILES.some((f) => existsSync(join(dir, f)))) {
|
|
134
|
+
if (place === "checkout") {
|
|
135
|
+
throw new PulumiProgramNotReadyError(member, dir, `the program is Node, nothing installed its dependencies (no node_modules here or above), and there is no lockfile (${NODE_LOCKFILES.join(", ")}) beside its package.json, so an install would take whatever versions resolve today; commit a lockfile, or install the dependencies before the run`);
|
|
136
|
+
}
|
|
79
137
|
throw new PulumiReleaseCopyRefusedError(member, `the program is Node, and the release recorded no lockfile (${NODE_LOCKFILES.join(", ")}) beside its package.json, so an install would take today's versions, not the release's; commit a lockfile and cut the release again`);
|
|
80
138
|
}
|
|
81
|
-
return { install: true, reason: "a Node program's copy has no node_modules" };
|
|
139
|
+
return { install: true, reason: place === "copy" ? "a Node program's copy has no node_modules" : "a Node program's checkout has no node_modules" };
|
|
82
140
|
}
|
|
83
141
|
case "python": {
|
|
84
142
|
const venv = typeof options.virtualenv === "string" ? options.virtualenv : undefined;
|
|
@@ -86,7 +144,7 @@ export function releaseCopyNeeds(dir, member) {
|
|
|
86
144
|
if (toolchain === "poetry" || toolchain === "uv")
|
|
87
145
|
return { install: true, reason: `a Python program on ${toolchain} installs its environment` };
|
|
88
146
|
if (venv && !existsSync(join(dir, venv)))
|
|
89
|
-
return { install: true, reason: `the virtualenv ${venv} is not in the
|
|
147
|
+
return { install: true, reason: `the virtualenv ${venv} is not in the ${place}` };
|
|
90
148
|
return { install: false, reason: "the Python program names no virtualenv to build" };
|
|
91
149
|
}
|
|
92
150
|
default:
|
|
@@ -95,37 +153,53 @@ export function releaseCopyNeeds(dir, member) {
|
|
|
95
153
|
}
|
|
96
154
|
const prepared = new Map();
|
|
97
155
|
/**
|
|
98
|
-
* Make a
|
|
99
|
-
* in
|
|
100
|
-
*
|
|
156
|
+
* Make a program's directory runnable before its first engine run, once per
|
|
157
|
+
* directory in this process: `install` there when {@link programNeeds} says
|
|
158
|
+
* so. A release copy follows the copy's rules, any other directory the
|
|
159
|
+
* checkout's. Throws for a directory that cannot be prepared, or an install
|
|
160
|
+
* that fails. The first caller's engine runs the install; a second engine
|
|
161
|
+
* asking for the same directory waits on it.
|
|
101
162
|
*/
|
|
102
|
-
export function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
let done = prepared.get(key);
|
|
163
|
+
export function prepareProgram(opts) {
|
|
164
|
+
const dir = resolve(opts.dir);
|
|
165
|
+
const place = isReleaseCopy(dir) ? "copy" : "checkout";
|
|
166
|
+
let done = prepared.get(dir);
|
|
107
167
|
if (!done) {
|
|
108
168
|
done = (async () => {
|
|
109
|
-
const needs =
|
|
169
|
+
const needs = programNeeds(dir, opts.member, place);
|
|
110
170
|
if (!needs.install)
|
|
111
|
-
return { action: "none", reason: needs.reason };
|
|
171
|
+
return { place, action: "none", reason: needs.reason };
|
|
112
172
|
const command = `${opts.engine} install`;
|
|
113
173
|
try {
|
|
114
|
-
await runReleaseInstall(opts.run, ["install"],
|
|
174
|
+
await runReleaseInstall(opts.run, ["install"], dir);
|
|
115
175
|
}
|
|
116
176
|
catch (err) {
|
|
117
177
|
const f = pulumiRunFailure(err);
|
|
118
178
|
const why = f.missingBinary ? `the ${opts.engine} binary was not found on PATH` : (f.stderr?.trim() || f.message).split("\n").slice(-3).join("\n");
|
|
119
|
-
|
|
179
|
+
if (place === "copy")
|
|
180
|
+
throw new PulumiReleaseCopyRefusedError(opts.member, `${command} in the copy failed: ${why}`);
|
|
181
|
+
throw new PulumiProgramNotReadyError(opts.member, dir, `${command} failed: ${why}`);
|
|
120
182
|
}
|
|
121
|
-
return { action: "installed", command };
|
|
183
|
+
return { place, action: "installed", command };
|
|
122
184
|
})();
|
|
123
|
-
prepared.set(
|
|
124
|
-
done.catch(() => prepared.delete(
|
|
185
|
+
prepared.set(dir, done);
|
|
186
|
+
done.catch(() => prepared.delete(dir));
|
|
125
187
|
}
|
|
126
188
|
return done;
|
|
127
189
|
}
|
|
128
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* Make a release copy runnable, once per directory in this process: `install`
|
|
192
|
+
* in the copy when {@link releaseCopyNeeds} says so. Throws for a copy that
|
|
193
|
+
* cannot be prepared, or an install that fails. A directory that is not a
|
|
194
|
+
* copy is left alone; {@link prepareProgram} prepares both.
|
|
195
|
+
*/
|
|
196
|
+
export async function prepareReleaseCopy(opts) {
|
|
197
|
+
if (!isReleaseCopy(opts.dir))
|
|
198
|
+
return { action: "not-a-copy" };
|
|
199
|
+
const done = await prepareProgram(opts);
|
|
200
|
+
return done.action === "none" ? { action: "none", reason: done.reason } : { action: "installed", command: done.command };
|
|
201
|
+
}
|
|
202
|
+
/** Forget every prepared directory. For tests. */
|
|
129
203
|
export function resetPreparedReleaseCopies() {
|
|
130
204
|
prepared.clear();
|
|
131
205
|
}
|
package/dist/run.d.ts
CHANGED
|
@@ -254,11 +254,11 @@ export declare function runReviewStackPlunge(run: PulumiRunner, args: string[],
|
|
|
254
254
|
stdout: string;
|
|
255
255
|
}>;
|
|
256
256
|
/**
|
|
257
|
-
* The install chant runs in a
|
|
257
|
+
* The install chant runs in a program's directory before its first engine
|
|
258
|
+
* run, a release's recorded copy (#4542) or a job's checkout (plywood#22),
|
|
258
259
|
* for `./release-copy.ts` alone: exactly `pulumi install` or `plunge
|
|
259
|
-
* install`, no flag.
|
|
260
|
-
*
|
|
261
|
-
* runtime options and the recorded lockfile.
|
|
260
|
+
* install`, no flag. Neither has `node_modules` or a virtualenv; the CLI's
|
|
261
|
+
* install follows `Pulumi.yaml`'s runtime options and the lockfile.
|
|
262
262
|
*/
|
|
263
263
|
export declare const PULUMI_INSTALL_COMMANDS: readonly string[];
|
|
264
264
|
/** Run `install` with no flag in a release copy, refusing any other argv. The runner picks the binary. */
|
package/dist/run.js
CHANGED
|
@@ -577,11 +577,11 @@ export async function runReviewStackPlunge(run, args, cwd, env) {
|
|
|
577
577
|
return run(args, env ? { cwd, env } : { cwd });
|
|
578
578
|
}
|
|
579
579
|
/**
|
|
580
|
-
* 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),
|
|
581
582
|
* for `./release-copy.ts` alone: exactly `pulumi install` or `plunge
|
|
582
|
-
* install`, no flag.
|
|
583
|
-
*
|
|
584
|
-
* 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.
|
|
585
585
|
*/
|
|
586
586
|
export const PULUMI_INSTALL_COMMANDS = ["pulumi install", "plunge install"];
|
|
587
587
|
/** Run `install` with no flag in a release copy, refusing any other argv. The runner picks the binary. */
|
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
|
+
}
|
|
@@ -79,6 +79,23 @@ describe("PulumiAdoptOp", () => {
|
|
|
79
79
|
expect(preview.args?.file).toMatchObject({ step: "export-1", path: "file" });
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
test("the stack's backend reaches all four steps, Preview and Adopt included", () => {
|
|
83
|
+
const backend = "plunge+s3://state?region=us-east-1";
|
|
84
|
+
const op = props({
|
|
85
|
+
name: "adopt",
|
|
86
|
+
stacks: [
|
|
87
|
+
{ member: "infra", dir: "./infra", stack: "dev", backend },
|
|
88
|
+
{ member: "web", dir: "./web", stack: "dev" },
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
const steps = op.phases.flatMap((p) => p.steps as ActivityStep[]).filter((s) => s.fn?.startsWith("pulumiAdopt"));
|
|
92
|
+
const infra = steps.filter((s) => s.args?.member === "infra");
|
|
93
|
+
expect(infra.map((s) => s.fn)).toEqual(["pulumiAdoptCheck", "pulumiAdoptExport", "pulumiAdoptPreview", "pulumiAdopt"]);
|
|
94
|
+
for (const s of infra) expect(s.args?.backend).toBe(backend);
|
|
95
|
+
// A stack without one leaves each step to resolve it (plywood.yml, PULUMI_BACKEND_URL, Pulumi.yaml).
|
|
96
|
+
for (const s of steps.filter((s) => s.args?.member === "web")) expect(s.args?.backend).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
82
99
|
test("its steps validate against the activities' contracts", () => {
|
|
83
100
|
const op = props({
|
|
84
101
|
name: "adopt",
|
|
@@ -207,6 +224,24 @@ describe("the activities", () => {
|
|
|
207
224
|
await expect(adoptPreview(stack, { plunge: refused })).rejects.toThrow(/pending-operations: the checkpoint has 1 pending operation\. Nothing was written/);
|
|
208
225
|
});
|
|
209
226
|
|
|
227
|
+
test("preview and adopt hand plunge the stack's backend, resolved as the Check step resolves it", async () => {
|
|
228
|
+
const envs: (Record<string, string> | undefined)[] = [];
|
|
229
|
+
const plunge: PulumiRunner = async (_args, opts) => {
|
|
230
|
+
envs.push(opts.env);
|
|
231
|
+
return { stdout: view("would-adopt") };
|
|
232
|
+
};
|
|
233
|
+
const backend = "plunge+s3://state?region=us-east-1";
|
|
234
|
+
await adoptPreview({ ...stack, backend }, { plunge, env: { PULUMI_BACKEND_URL: "file:///tmp/elsewhere" } });
|
|
235
|
+
await adoptStack({ ...stack, backend }, { plunge, env: { PULUMI_BACKEND_URL: "file:///tmp/elsewhere" } });
|
|
236
|
+
expect(envs).toEqual([{ PULUMI_BACKEND_URL: backend }, { PULUMI_BACKEND_URL: backend }]);
|
|
237
|
+
// With no step backend, the environment's is the one named.
|
|
238
|
+
envs.length = 0;
|
|
239
|
+
const dir = mkdtempSync(join(tmpdir(), "adopt-test-"));
|
|
240
|
+
await adoptPreview({ ...stack, dir }, { plunge, env: { PULUMI_BACKEND_URL: "plunge+file:///tmp/s" } });
|
|
241
|
+
await adoptPreview({ ...stack, dir }, { plunge, env: {} });
|
|
242
|
+
expect(envs).toEqual([{ PULUMI_BACKEND_URL: "plunge+file:///tmp/s" }, undefined]);
|
|
243
|
+
});
|
|
244
|
+
|
|
210
245
|
test("adopt runs the --yes form and says whether it matched the approved digest", async () => {
|
|
211
246
|
const seen: string[][] = [];
|
|
212
247
|
const plunge: PulumiRunner = async (args) => {
|
|
@@ -153,9 +153,11 @@ export function PulumiAdoptOp(config: PulumiAdoptOpConfig): PulumiAdoptOpResourc
|
|
|
153
153
|
|
|
154
154
|
const waves = config.stacks.flatMap((s, i) => {
|
|
155
155
|
const file = s.from ? { file: exports[i]!.out.file } : {};
|
|
156
|
-
|
|
156
|
+
// Every step gets the stack's backend, so Preview and Adopt read the store Check and Export read.
|
|
157
|
+
const backend = s.backend ? { backend: s.backend } : {};
|
|
158
|
+
const preview = pulumiAdoptPreview(s.member, { dir: s.dir, stack: s.stack, ...where, ...file, ...backend, id: `preview-${i}` });
|
|
157
159
|
preview.outcomeAttribute = { name: single ? "Adoptable" : `Adoptable-${slug(s.member)}-${slug(s.stack)}`, from: "resources" };
|
|
158
|
-
const adopt = pulumiAdopt(s.member, { dir: s.dir, stack: s.stack, ...where, ...file, planDigest: preview.out.planDigest, id: `adopt-${i}` });
|
|
160
|
+
const adopt = pulumiAdopt(s.member, { dir: s.dir, stack: s.stack, ...where, ...file, ...backend, planDigest: preview.out.planDigest, id: `adopt-${i}` });
|
|
159
161
|
adopt.outcomeAttribute = { name: single ? "Adopted" : `Adopted-${slug(s.member)}-${slug(s.stack)}`, from: "resources" };
|
|
160
162
|
const label = single ? "" : ` ${s.member}/${s.stack}`;
|
|
161
163
|
return [
|
|
@@ -118,6 +118,11 @@ export interface PulumiAdoptExportResult {
|
|
|
118
118
|
export interface PulumiAdoptPreviewArgs extends PulumiAdoptStackArgs {
|
|
119
119
|
/** `--file`: the export to adopt, from the Export step when `adoptFromFile`. */
|
|
120
120
|
file?: string;
|
|
121
|
+
/**
|
|
122
|
+
* The plunge backend URL plunge adopts onto, passed as `PULUMI_BACKEND_URL`. Default, as the Check
|
|
123
|
+
* step resolves it: `plywood.yml`'s, `PULUMI_BACKEND_URL`, `Pulumi.yaml`'s.
|
|
124
|
+
*/
|
|
125
|
+
backend?: string;
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
export interface PulumiAdoptPreviewResult {
|
|
@@ -292,12 +297,18 @@ function summary(a: PulumiAdoptStackArgs, view: PlungeAdoptView): PulumiAdoptPre
|
|
|
292
297
|
};
|
|
293
298
|
}
|
|
294
299
|
|
|
300
|
+
/** The backend plunge adopts onto, resolved as the Check step resolves it, as plunge's environment. */
|
|
301
|
+
function adoptEnv(a: PulumiAdoptPreviewArgs, dir: string, deps: PulumiAdoptDeps): Record<string, string> | undefined {
|
|
302
|
+
const backend = pulumiStackBackend(dir, { ...(a.backend ? { configured: a.backend } : {}), env: deps.env ?? process.env });
|
|
303
|
+
return backend ? { PULUMI_BACKEND_URL: backend.url } : undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
295
306
|
/** `plunge stack adopt --json`: the classification, written nowhere. A refusal fails the step. */
|
|
296
307
|
export async function adoptPreview(a: PulumiAdoptPreviewArgs, deps: PulumiAdoptDeps = {}): Promise<PulumiAdoptPreviewResult> {
|
|
297
308
|
requireStack(a, "pulumiAdoptPreview");
|
|
298
309
|
const dir = stackDir(a);
|
|
299
310
|
const args = plungeAdoptArgs({ stack: a.stack, ...(a.file ? { file: a.file } : {}) });
|
|
300
|
-
const view = await adoptView(() => runAllowedPlunge(deps.plunge ?? defaultPlungeRunner, args, dir), "pulumiAdoptPreview", `plunge ${args.join(" ")}`);
|
|
311
|
+
const view = await adoptView(() => runAllowedPlunge(deps.plunge ?? defaultPlungeRunner, args, dir, adoptEnv(a, dir, deps)), "pulumiAdoptPreview", `plunge ${args.join(" ")}`);
|
|
301
312
|
if (view.status === "refused") {
|
|
302
313
|
throw new Error(`pulumiAdoptPreview: plunge refuses to adopt "${a.member}/${a.stack}": ${refusalText(view.refusals)}. Nothing was written.`);
|
|
303
314
|
}
|
|
@@ -309,7 +320,7 @@ export async function adoptStack(a: PulumiAdoptArgs, deps: PulumiAdoptDeps = {})
|
|
|
309
320
|
requireStack(a, "pulumiAdopt");
|
|
310
321
|
const dir = stackDir(a);
|
|
311
322
|
const args = plungeAdoptArgs({ stack: a.stack, yes: true, ...(a.file ? { file: a.file } : {}) });
|
|
312
|
-
const view = await adoptView(() => runAdoptPlunge(deps.plunge ?? defaultPlungeRunner, args, dir), "pulumiAdopt", `plunge ${args.join(" ")}`);
|
|
323
|
+
const view = await adoptView(() => runAdoptPlunge(deps.plunge ?? defaultPlungeRunner, args, dir, adoptEnv(a, dir, deps)), "pulumiAdopt", `plunge ${args.join(" ")}`);
|
|
313
324
|
if (view.status === "refused") {
|
|
314
325
|
throw new Error(`pulumiAdopt: plunge refused to adopt "${a.member}/${a.stack}": ${refusalText(view.refusals)}. Nothing was written.`);
|
|
315
326
|
}
|
|
@@ -116,14 +116,14 @@ export const pulumiAdoptExportContract = activityContract(
|
|
|
116
116
|
|
|
117
117
|
export const pulumiAdoptPreviewContract = activityContract(
|
|
118
118
|
"pulumiAdoptPreview",
|
|
119
|
-
z.strictObject({ ...adoptStackArgs, file: z.string().optional() }),
|
|
119
|
+
z.strictObject({ ...adoptStackArgs, file: z.string().optional(), backend: z.string().optional() }),
|
|
120
120
|
z.object(adoptPreviewReturns),
|
|
121
121
|
memberEntity,
|
|
122
122
|
);
|
|
123
123
|
|
|
124
124
|
export const pulumiAdoptContract = activityContract(
|
|
125
125
|
"pulumiAdopt",
|
|
126
|
-
z.strictObject({ ...adoptStackArgs, file: z.string().optional(), planDigest: z.string().optional() }),
|
|
126
|
+
z.strictObject({ ...adoptStackArgs, file: z.string().optional(), backend: z.string().optional(), planDigest: z.string().optional() }),
|
|
127
127
|
z.object({ ...adoptPreviewReturns, matchesApproved: z.boolean().optional() }),
|
|
128
128
|
memberEntity,
|
|
129
129
|
);
|
package/src/plugin.ts
CHANGED
|
@@ -136,6 +136,18 @@ export const pulumiPlugin: LexiconPlugin = {
|
|
|
136
136
|
return { entities: read.entities, warnings: read.warnings };
|
|
137
137
|
},
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The stacks a change touched (#3183, INTENTIUS/plywood#22): a changed
|
|
141
|
+
* `Pulumi.<stack>.yaml` is that stack, any other file of a project is every
|
|
142
|
+
* stack with a config beside its `Pulumi.yaml`. Named as a `pulumi-up`
|
|
143
|
+
* step's deploy unit, so `chant components pr-plan` and `fan-out --base`
|
|
144
|
+
* select the components that update them. See ./changed-units.ts.
|
|
145
|
+
*/
|
|
146
|
+
async changedUnits(ctx) {
|
|
147
|
+
const { pulumiChangedUnits } = await import("./changed-units");
|
|
148
|
+
return pulumiChangedUnits(ctx.projectRoot, ctx.changedFiles);
|
|
149
|
+
},
|
|
150
|
+
|
|
139
151
|
graphMeta() {
|
|
140
152
|
const report = lastMemberReport();
|
|
141
153
|
return report ? { pulumiMember: report } : undefined;
|