@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.
Files changed (65) hide show
  1. package/README.md +1 -1
  2. package/dist/changed-units.d.ts +5 -0
  3. package/dist/changed-units.d.ts.map +1 -0
  4. package/dist/changed-units.js +101 -0
  5. package/dist/check-step.d.ts +61 -0
  6. package/dist/check-step.d.ts.map +1 -0
  7. package/dist/check-step.js +270 -0
  8. package/dist/composites/pulumi-adopt-op.d.ts.map +1 -1
  9. package/dist/composites/pulumi-adopt-op.js +4 -2
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -0
  13. package/dist/integrity.json +2 -2
  14. package/dist/manifest.json +1 -1
  15. package/dist/member-outputs.d.ts +10 -3
  16. package/dist/member-outputs.d.ts.map +1 -1
  17. package/dist/member-outputs.js +11 -3
  18. package/dist/op/activities/pulumi-adopt.d.ts +5 -0
  19. package/dist/op/activities/pulumi-adopt.d.ts.map +1 -1
  20. package/dist/op/activities/pulumi-adopt.js +7 -2
  21. package/dist/op/activity-contracts.d.ts +2 -0
  22. package/dist/op/activity-contracts.d.ts.map +1 -1
  23. package/dist/op/activity-contracts.js +2 -2
  24. package/dist/planned-output-changes.d.ts +60 -0
  25. package/dist/planned-output-changes.d.ts.map +1 -0
  26. package/dist/planned-output-changes.js +94 -0
  27. package/dist/plugin.d.ts.map +1 -1
  28. package/dist/plugin.js +24 -0
  29. package/dist/preview-tips.d.ts +71 -0
  30. package/dist/preview-tips.d.ts.map +1 -0
  31. package/dist/preview-tips.js +76 -0
  32. package/dist/pulumi-up.d.ts +24 -1
  33. package/dist/pulumi-up.d.ts.map +1 -1
  34. package/dist/pulumi-up.js +64 -16
  35. package/dist/release-copy.d.ts +42 -2
  36. package/dist/release-copy.d.ts.map +1 -1
  37. package/dist/release-copy.js +96 -22
  38. package/dist/run.d.ts +27 -8
  39. package/dist/run.d.ts.map +1 -1
  40. package/dist/run.js +55 -14
  41. package/package.json +2 -2
  42. package/src/changed-units.test.ts +87 -0
  43. package/src/changed-units.ts +96 -0
  44. package/src/check-step.test.ts +191 -0
  45. package/src/check-step.ts +316 -0
  46. package/src/composites/pulumi-adopt-op.test.ts +35 -0
  47. package/src/composites/pulumi-adopt-op.ts +4 -2
  48. package/src/index.ts +2 -0
  49. package/src/member-outputs.test.ts +28 -6
  50. package/src/member-outputs.ts +22 -4
  51. package/src/op/activities/pulumi-adopt.ts +13 -2
  52. package/src/op/activity-contracts.ts +2 -2
  53. package/src/planned-output-changes.test.ts +85 -0
  54. package/src/planned-output-changes.ts +141 -0
  55. package/src/plugin.ts +26 -0
  56. package/src/preview-tips.test.ts +135 -0
  57. package/src/preview-tips.ts +128 -0
  58. package/src/pulumi-up.test.ts +100 -0
  59. package/src/pulumi-up.ts +84 -18
  60. package/src/release-copy.test.ts +90 -2
  61. package/src/release-copy.ts +103 -22
  62. package/src/run-plan-flags.test.ts +53 -0
  63. package/src/run.test.ts +1 -1
  64. package/src/run.ts +57 -14
  65. package/src/stack-export-snapshot.test.ts +3 -1
@@ -0,0 +1,316 @@
1
+ /**
2
+ * The pulumi lexicon's steps in chant's check stage (#4633). `chant ci
3
+ * check` runs them after the `ci.hooks.check` hooks, for each workspace
4
+ * member of kind `pulumi` (`LexiconPlugin.checkSteps`,
5
+ * `@intentius/chant/components/check-steps`), so a product's check
6
+ * (`plywood check`) gets them through chant with nothing of its own:
7
+ *
8
+ * - `pulumi-install`: `pulumi install` (`plunge install` for a member on
9
+ * plunge, `./member-engine.ts`) in the member's directory, which fetches
10
+ * the program's dependencies and plugins the way `Pulumi.yaml` says;
11
+ * - `stack-config`: each stack's `Pulumi.<stack>.yaml` checked against the
12
+ * project's config schema in `Pulumi.yaml`, with no command run (a key
13
+ * with a type and no default must be set, a value must read as its type,
14
+ * and a `secret: true` key must be encrypted). The stacks are the ones the
15
+ * project lists by their files, plus the member's declared stack;
16
+ * - `pulumi-about`: `pulumi about --json`, which reports the engine, its
17
+ * plugins and the backend for the report.
18
+ *
19
+ * The argv go through `runCheckPulumi` (`./run.ts`), which refuses any
20
+ * other. The environment is the check job's ({@link CheckStepsContext.env}):
21
+ * no forge token, no OIDC request variables, no cloud credential, so neither
22
+ * command can write a stack. A failure names the member and stack, and the
23
+ * check exits with chant's `failed` code.
24
+ *
25
+ * The runner is injectable, so the tests run no binary.
26
+ */
27
+ import { execFile } from "node:child_process";
28
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
29
+ import { join } from "node:path";
30
+ import { promisify } from "node:util";
31
+ import type { CheckStageMember, CheckStepOutcome, CheckStepsAdapter, CheckStepsContext } from "@intentius/chant/components/check-steps";
32
+ import { parseYAML } from "@intentius/chant/yaml";
33
+ import { pulumiMemberEngine, type PulumiMemberEngine } from "./member-engine";
34
+ import { engineEnv, pulumiRunFailure, runCheckPulumi, type PulumiRunner } from "./run";
35
+
36
+ const execFileAsync = promisify(execFile);
37
+
38
+ /** The lexicon name the report carries. */
39
+ const LEXICON = "pulumi";
40
+
41
+ /** A runner for `bin` with exactly `env` (the check job's), never the process's whole environment. */
42
+ export function checkRunner(bin: PulumiMemberEngine, env: Record<string, string>): PulumiRunner {
43
+ return (args, opts) => execFileAsync(bin, args, { cwd: opts.cwd, maxBuffer: 64 * 1024 * 1024, env: engineEnv({ ...env, ...opts.env }) });
44
+ }
45
+
46
+ // ── Stack config against the project's schema ───────────────────────
47
+
48
+ /** One key the project declares in `Pulumi.yaml`'s `config:`. */
49
+ export interface PulumiConfigKey {
50
+ /** The full key, `<project>:<name>` for a project key. */
51
+ key: string;
52
+ type?: string;
53
+ items?: { type?: string };
54
+ secret: boolean;
55
+ /** Whether it has a `default` or a project-level `value`. */
56
+ hasDefault: boolean;
57
+ }
58
+
59
+ /** The project's config schema: its name, the keys it declares, and where its stack files are. */
60
+ export interface PulumiProjectSchema {
61
+ project: string;
62
+ keys: PulumiConfigKey[];
63
+ /** The directory the stack files are in, relative to the project (`stackConfigDir`), or "." */
64
+ stackConfigDir: string;
65
+ }
66
+
67
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
68
+
69
+ /** The key in its full form: a bare name belongs to the project. */
70
+ function fullKey(project: string, key: string): string {
71
+ return key.includes(":") ? key : `${project}:${key}`;
72
+ }
73
+
74
+ /** Read `Pulumi.yaml`'s schema from its parsed document. A short-form entry (`key: value`) is a value with no type. */
75
+ export function pulumiProjectSchema(doc: unknown): PulumiProjectSchema {
76
+ const d = isRecord(doc) ? doc : {};
77
+ const project = typeof d.name === "string" ? d.name : "";
78
+ const config = isRecord(d.config) ? d.config : {};
79
+ const keys: PulumiConfigKey[] = [];
80
+ for (const [name, entry] of Object.entries(config)) {
81
+ const key = fullKey(project, name);
82
+ const schema = isRecord(entry) && ("type" in entry || "default" in entry || "value" in entry || "secret" in entry || "description" in entry || "items" in entry);
83
+ if (!schema) {
84
+ keys.push({ key, secret: false, hasDefault: true });
85
+ continue;
86
+ }
87
+ const e = entry as Record<string, unknown>;
88
+ keys.push({
89
+ key,
90
+ ...(typeof e.type === "string" ? { type: e.type } : {}),
91
+ ...(isRecord(e.items) && typeof e.items.type === "string" ? { items: { type: e.items.type } } : {}),
92
+ secret: e.secret === true,
93
+ hasDefault: "default" in e || "value" in e,
94
+ });
95
+ }
96
+ return { project, keys, stackConfigDir: typeof d.stackConfigDir === "string" && d.stackConfigDir.length > 0 ? d.stackConfigDir : "." };
97
+ }
98
+
99
+ const isSecure = (v: unknown): boolean => isRecord(v) && typeof v.secure === "string";
100
+
101
+ /** Whether a scalar stack value reads as `type`. Values are strings once Pulumi reads them, so `"3"` is an integer. */
102
+ function readsAs(type: string, value: unknown): boolean {
103
+ switch (type) {
104
+ case "string":
105
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
106
+ case "integer":
107
+ return (typeof value === "number" && Number.isInteger(value)) || (typeof value === "string" && /^-?[0-9]+$/.test(value.trim()));
108
+ case "boolean":
109
+ return typeof value === "boolean" || value === "true" || value === "false";
110
+ case "array":
111
+ return Array.isArray(value);
112
+ default:
113
+ return true;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * What is wrong with one stack's config (`config:` of its
119
+ * `Pulumi.<stack>.yaml`, parsed) against the project's schema, as lines
120
+ * naming the stack and key. Empty when it passes. Keys of other namespaces
121
+ * (a provider's `aws:region`) are not checked: the project declares no type
122
+ * for them.
123
+ */
124
+ export function pulumiStackConfigProblems(schema: PulumiProjectSchema, stack: string, stackDoc: unknown): string[] {
125
+ const raw = isRecord(stackDoc) && isRecord(stackDoc.config) ? stackDoc.config : {};
126
+ const values = new Map<string, unknown>();
127
+ for (const [k, v] of Object.entries(raw)) values.set(fullKey(schema.project, k), v);
128
+ const problems: string[] = [];
129
+ for (const k of schema.keys) {
130
+ if (!k.type && !k.secret) continue;
131
+ if (!values.has(k.key)) {
132
+ if (k.type && !k.hasDefault) problems.push(`stack ${stack} is missing the configuration value ${k.key} (type ${k.type}, no default)`);
133
+ continue;
134
+ }
135
+ const value = values.get(k.key);
136
+ if (k.secret && !isSecure(value)) {
137
+ problems.push(`stack ${stack} sets ${k.key} in plain text, and the project declares it secret; set it with pulumi config set --secret`);
138
+ continue;
139
+ }
140
+ if (isSecure(value) || !k.type) continue;
141
+ if (!readsAs(k.type, value)) {
142
+ problems.push(`stack ${stack} sets ${k.key} to ${JSON.stringify(value)}, which is not of type ${k.type}`);
143
+ continue;
144
+ }
145
+ if (k.type === "array" && k.items?.type && Array.isArray(value)) {
146
+ const bad = value.findIndex((item) => !isSecure(item) && !readsAs(k.items!.type!, item));
147
+ if (bad >= 0) problems.push(`stack ${stack} sets ${k.key}[${bad}] to ${JSON.stringify(value[bad])}, which is not of type ${k.items.type}`);
148
+ }
149
+ }
150
+ return problems;
151
+ }
152
+
153
+ /** The project's file in `dir`, `Pulumi.yaml` or `Pulumi.yml`. */
154
+ function projectFile(dir: string): string | undefined {
155
+ return ["Pulumi.yaml", "Pulumi.yml"].map((f) => join(dir, f)).find((f) => existsSync(f));
156
+ }
157
+
158
+ /** The stacks the project lists by their `Pulumi.<stack>.yaml` files in `dir`, sorted. */
159
+ export function listedStacks(dir: string): string[] {
160
+ if (!existsSync(dir)) return [];
161
+ return readdirSync(dir)
162
+ .map((f) => /^Pulumi\.(.+)\.ya?ml$/.exec(f)?.[1])
163
+ .filter((s): s is string => s !== undefined)
164
+ .sort();
165
+ }
166
+
167
+ /** The stack file for `stack` in `dir`, if there is one. */
168
+ function stackFile(dir: string, stack: string): string | undefined {
169
+ return [`Pulumi.${stack}.yaml`, `Pulumi.${stack}.yml`].map((f) => join(dir, f)).find((f) => existsSync(f));
170
+ }
171
+
172
+ // ── pulumi about ────────────────────────────────────────────────────
173
+
174
+ /** What `pulumi about --json` reported, for the check report. */
175
+ export interface PulumiAboutDetail {
176
+ engine: PulumiMemberEngine;
177
+ version?: string;
178
+ plugins: Array<{ name: string; version?: string }>;
179
+ backend?: { name?: string; url?: string; user?: string };
180
+ }
181
+
182
+ /** Read `pulumi about --json`'s document. Undefined when it is not JSON. */
183
+ export function pulumiAboutDetail(engine: PulumiMemberEngine, stdout: string | undefined): PulumiAboutDetail | undefined {
184
+ if (!stdout) return undefined;
185
+ let doc: unknown;
186
+ try {
187
+ doc = JSON.parse(stdout);
188
+ } catch {
189
+ return undefined;
190
+ }
191
+ if (!isRecord(doc)) return undefined;
192
+ const cli = isRecord(doc.cli) ? doc.cli : {};
193
+ const plugins = Array.isArray(doc.plugins)
194
+ ? doc.plugins.filter(isRecord).map((p) => ({ name: String(p.name ?? ""), ...(typeof p.version === "string" ? { version: p.version } : {}) })).filter((p) => p.name)
195
+ : [];
196
+ const b = isRecord(doc.backend) ? doc.backend : undefined;
197
+ const backend = b
198
+ ? { ...(typeof b.name === "string" ? { name: b.name } : {}), ...(typeof b.url === "string" ? { url: b.url } : {}), ...(typeof b.user === "string" ? { user: b.user } : {}) }
199
+ : undefined;
200
+ return { engine, ...(typeof cli.version === "string" ? { version: cli.version } : {}), plugins, ...(backend ? { backend } : {}) };
201
+ }
202
+
203
+ // ── The steps ───────────────────────────────────────────────────────
204
+
205
+ /** The last lines of what a failed run printed, for its reason. */
206
+ function tail(text: string | undefined): string {
207
+ const lines = (text ?? "").trim().split("\n").filter(Boolean);
208
+ return lines.slice(-3).join(" | ");
209
+ }
210
+
211
+ export interface PulumiCheckStepsOptions {
212
+ /** The runner for an engine, given the check job's environment. Default {@link checkRunner}. */
213
+ runner?: (engine: PulumiMemberEngine, env: Record<string, string>) => PulumiRunner;
214
+ }
215
+
216
+ /** The steps for one member, in order: install, each stack's config, about. */
217
+ export async function pulumiMemberCheck(member: CheckStageMember, ctx: Pick<CheckStepsContext, "env" | "log">, opts: PulumiCheckStepsOptions = {}): Promise<CheckStepOutcome[]> {
218
+ const fields = member.fields ?? {};
219
+ const declaredEngine = fields.engine === "pulumi" || fields.engine === "plunge" ? fields.engine : undefined;
220
+ const declaredStack = typeof fields.stack === "string" && fields.stack.length > 0 ? fields.stack : undefined;
221
+ const { engine } = pulumiMemberEngine(member.dir, declaredEngine, { env: ctx.env });
222
+ const run = (opts.runner ?? checkRunner)(engine, ctx.env);
223
+ const out: CheckStepOutcome[] = [];
224
+ const base = { lexicon: LEXICON, member: member.name };
225
+
226
+ const file = projectFile(member.dir);
227
+ if (!file) {
228
+ out.push({ ...base, step: "pulumi-install", exit: 1, outcome: "failed", reason: `member ${member.name} has no Pulumi.yaml in ${member.dir}` });
229
+ return out;
230
+ }
231
+
232
+ // pulumi install
233
+ let installed = true;
234
+ try {
235
+ await runCheckPulumi(run, ["install"], member.dir);
236
+ out.push({ ...base, step: "pulumi-install", exit: 0, outcome: "passed" });
237
+ } catch (err) {
238
+ installed = false;
239
+ const f = pulumiRunFailure(err);
240
+ const why = f.missingBinary ? `${engine} is not installed in the check job` : tail(f.stderr) || tail(f.stdout) || f.message;
241
+ out.push({ ...base, step: "pulumi-install", exit: f.exitCode ?? 1, outcome: "failed", reason: `${engine} install failed for member ${member.name}: ${why}` });
242
+ }
243
+
244
+ // Stack config, run by nothing but this module.
245
+ let schema: PulumiProjectSchema | undefined;
246
+ try {
247
+ schema = pulumiProjectSchema(parseYAML(readFileSync(file, "utf-8")));
248
+ } catch (err) {
249
+ out.push({ ...base, step: "stack-config", exit: 1, outcome: "failed", reason: `member ${member.name}: ${file} does not read as YAML (${err instanceof Error ? err.message : String(err)})` });
250
+ }
251
+ if (schema) {
252
+ const stackDir = join(member.dir, schema.stackConfigDir);
253
+ const stacks = [...new Set([...listedStacks(stackDir), ...(declaredStack ? [declaredStack] : [])])].sort();
254
+ if (stacks.length === 0) out.push({ ...base, step: "stack-config", exit: 0, outcome: "skipped", reason: `member ${member.name} lists no stack (no Pulumi.<stack>.yaml in ${stackDir})` });
255
+ for (const stack of stacks) {
256
+ const at = stackFile(stackDir, stack);
257
+ let doc: unknown = {};
258
+ if (at) {
259
+ try {
260
+ doc = parseYAML(readFileSync(at, "utf-8"));
261
+ } catch (err) {
262
+ out.push({ ...base, step: "stack-config", stack, exit: 1, outcome: "failed", reason: `member ${member.name}, stack ${stack}: ${at} does not read as YAML (${err instanceof Error ? err.message : String(err)})` });
263
+ continue;
264
+ }
265
+ }
266
+ const problems = pulumiStackConfigProblems(schema, stack, doc);
267
+ out.push(
268
+ problems.length === 0
269
+ ? { ...base, step: "stack-config", stack, exit: 0, outcome: "passed" }
270
+ : { ...base, step: "stack-config", stack, exit: 1, outcome: "failed", reason: `member ${member.name}: ${problems.join("; ")}`, detail: { problems } },
271
+ );
272
+ }
273
+ }
274
+
275
+ // pulumi about --json
276
+ if (!installed) {
277
+ out.push({ ...base, step: "pulumi-about", exit: 0, outcome: "skipped", reason: `member ${member.name}: ${engine} install failed, so about did not run` });
278
+ return out;
279
+ }
280
+ const aboutArgs = declaredStack ? ["about", "--json", "--stack", declaredStack] : ["about", "--json"];
281
+ const stackPart = declaredStack ? { stack: declaredStack } : {};
282
+ try {
283
+ const { stdout } = await runCheckPulumi(run, aboutArgs, member.dir);
284
+ const detail = pulumiAboutDetail(engine, stdout);
285
+ out.push(
286
+ detail
287
+ ? { ...base, step: "pulumi-about", ...stackPart, exit: 0, outcome: "passed", detail }
288
+ : { ...base, step: "pulumi-about", ...stackPart, exit: 1, outcome: "failed", reason: `${engine} about --json for member ${member.name} printed no JSON` },
289
+ );
290
+ } catch (err) {
291
+ const f = pulumiRunFailure(err);
292
+ // about exits non-zero when one of its reads fails (no backend login in a check job), and still prints what it read.
293
+ const detail = pulumiAboutDetail(engine, f.stdout);
294
+ if (detail) {
295
+ ctx.log(`pulumi check: ${engine} about for ${member.name} exited ${f.exitCode ?? 1} and reported: ${tail(f.stderr) || "no reason"}`);
296
+ out.push({ ...base, step: "pulumi-about", ...stackPart, exit: f.exitCode ?? 1, outcome: "passed", detail });
297
+ } else {
298
+ const why = f.missingBinary ? `${engine} is not installed in the check job` : tail(f.stderr) || f.message;
299
+ out.push({ ...base, step: "pulumi-about", ...stackPart, exit: f.exitCode ?? 1, outcome: "failed", reason: `${engine} about failed for member ${member.name}${declaredStack ? `, stack ${declaredStack}` : ""}: ${why}` });
300
+ }
301
+ }
302
+ return out;
303
+ }
304
+
305
+ /** The pulumi lexicon's check steps, for `LexiconPlugin.checkSteps`. */
306
+ export function pulumiCheckSteps(opts: PulumiCheckStepsOptions = {}): CheckStepsAdapter {
307
+ return {
308
+ lexicon: LEXICON,
309
+ kinds: ["pulumi"],
310
+ async run(ctx) {
311
+ const out: CheckStepOutcome[] = [];
312
+ for (const member of ctx.members) out.push(...(await pulumiMemberCheck(member, ctx, opts)));
313
+ return out;
314
+ },
315
+ };
316
+ }
@@ -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
- const preview = pulumiAdoptPreview(s.member, { dir: s.dir, stack: s.stack, ...where, ...file, id: `preview-${i}` });
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 [
package/src/index.ts CHANGED
@@ -132,3 +132,5 @@ export { parsePlungeAdoptView, pulumiAdoptDigest, adoptBucketTarget, type Plunge
132
132
  export { pulumiPreview as pulumiPreviewStep, pulumiDrift as pulumiDriftStep } from "./op/builders";
133
133
  // Pulumi and plunge exit statuses on chant's exit-code table (#4553).
134
134
  export { pulumiExitOf, pulumiPlanMoved, PULUMI_EXIT, PLUNGE_PLAN_MOVED_EXIT } from "./exit-codes";
135
+ // Which stack outputs a preview changes, traced through the export (#4635).
136
+ export { pulumiPlannedOutputChanges, type PulumiOutputChange, type PulumiPlannedOutputChanges } from "./planned-output-changes";
@@ -11,8 +11,10 @@
11
11
  * program on floci (see their READMEs): `stack output --json
12
12
  * --show-secrets=false` prints a secret output as the string `"[secret]"`,
13
13
  * and `preview --json` plans no stack outputs, so a settled preview gives
14
- * the export's and a preview with changes leaves every output unknown. No
15
- * binary runs here.
14
+ * the export's. A preview with changes traces each output to the export's
15
+ * resources (#4635): the replaced VPC's and security group's ids, the
16
+ * deleted parameter's name and the deleted queue's URL are unknown, and the
17
+ * rest keep the export's value. No binary runs here.
16
18
  */
17
19
  import { readFileSync } from "node:fs";
18
20
  import { join } from "node:path";
@@ -48,6 +50,9 @@ const withoutSecret = (file: string): Record<string, unknown> =>
48
50
  Object.fromEntries(Object.entries(JSON.parse(text(file, "stack-output.json")) as Record<string, unknown>).filter(([, v]) => v !== "[secret]"));
49
51
  const APPLIED = withoutSecret("pulumi-outputs");
50
52
  const PLUNGE_APPLIED = withoutSecret("plunge-outputs");
53
+ /** The outputs preview-replaced.json moves (#4635): the replaced VPC's and security group's ids, the deleted parameter's name, the deleted queue's URL. */
54
+ const MOVED = ["securityGroupId", "vpcId", "vpcParameterName", "workerQueueUrls"];
55
+ const KEPT = Object.fromEntries(Object.entries(APPLIED).filter(([k]) => !MOVED.includes(k)));
51
56
 
52
57
  function fake(answer: (args: string[]) => string): { run: PulumiRunner; calls: string[][] } {
53
58
  const calls: string[][] = [];
@@ -126,11 +131,12 @@ describe("pulumiOutputSource", () => {
126
131
  expect(cli.calls).toEqual([["preview", "--json", "--show-sames", "--stack", "floci"]]);
127
132
  });
128
133
 
129
- test("planned values: a preview with changes (a replaced VPC) leaves every output unknown, the secret neither known nor unknown", async () => {
134
+ test("planned values: a preview with changes (a replaced VPC) leaves the outputs it moves unknown and keeps the rest, the secret neither known nor unknown", async () => {
130
135
  const read = await previewPulumiMemberRead({ member: "aws", dir: AWS_DIR, run: awsCli({ preview: "preview-replaced.json" }).run });
131
136
  const planned = pulumiMemberPlannedOutputs("aws", { ...read, exported: JSON.parse(text("pulumi-outputs", "stack-export.json")) });
132
- expect(planned).toMatchObject({ known: {}, unknown: Object.keys(APPLIED).sort(), changes: true, planDigest: read.part.member.planDigest });
137
+ expect(planned).toMatchObject({ known: KEPT, unknown: MOVED, changes: true, planDigest: read.part.member.planDigest });
133
138
  expect(planned.unknown).not.toContain("dbPasswordValue");
139
+ expect(planned.known).not.toHaveProperty("dbPasswordValue");
134
140
  });
135
141
 
136
142
  test("a member on plunge runs the same commands under plunge, never pulumi", async () => {
@@ -279,9 +285,9 @@ describe("dns against a Pulumi output (#4455)", () => {
279
285
  const records = set.members.find((m) => m.member === "records")!;
280
286
  expect(records.unknownInputs).toEqual(["aws.vpcId"]);
281
287
  expect(records.reads).toEqual([{ upstream: "aws", outputs: ["vpcId"], unknown: ["vpcId"] }]);
282
- // Every output the stack publishes is unknown while its preview has changes: one hole each.
288
+ // Each output the preview moves is unknown while it has changes: one hole each (#4635).
283
289
  const holes = set.doc.members.find((m) => m.member === memberOutputsMemberName("aws"))!.holes;
284
- expect(holes).toHaveLength(Object.keys(APPLIED).length);
290
+ expect(holes.map((h) => h.address)).toEqual(MOVED.map((o) => `aws.${o}`));
285
291
  expect(holes).toContainEqual({ address: "aws.vpcId", type: "output", reason: "aws.vpcId is known only after aws applies; what reads it is planned again then" });
286
292
 
287
293
  // Merged before aws's own pipeline applied: the read refuses, so records is blocked behind it.
@@ -307,6 +313,22 @@ describe("dns against a Pulumi output (#4455)", () => {
307
313
  expect(w.state.recordsRan).toEqual([]);
308
314
  });
309
315
 
316
+ test("a reader of an output the preview leaves alone plans now with the export's value (#4635)", async () => {
317
+ const w = world({ preview: "preview-replaced.json" });
318
+ const estate: DriverComponent[] = [
319
+ ESTATE[0]!,
320
+ { ...ESTATE[1]!, deploy: [{ phase: "Apply", steps: [{ kind: "fake-records", inputs: { bucket: { stackOutput: { stack: "aws", name: "siteBucketName" } } } }] }] } as DriverComponent,
321
+ ];
322
+ const plan = planFanOut({ components: estate, changed: ["aws"] });
323
+ const set = await planPrSet({ components: estate, plan, registry: w.registry, env: "local" });
324
+ expect(set.failed).toBe(false);
325
+ expect(w.state.recordsPlanned).toEqual([{ inputs: { bucket: "rec-ts-site" } }]);
326
+ const records = set.members.find((m) => m.member === "records")!;
327
+ expect(records.unknownInputs).toBeUndefined();
328
+ expect(records.reads).toEqual([{ upstream: "aws", outputs: ["siteBucketName"] }]);
329
+ expect(set.plannedOutputs).toEqual({ aws: Object.keys(KEPT).sort() });
330
+ });
331
+
310
332
  test("run refuses while the stack's preview has changes left", async () => {
311
333
  const w = world({ preview: "preview-replaced.json" });
312
334
  const capability = w.registry.resolve(MEMBER_OUTPUTS_KIND);
@@ -19,6 +19,13 @@
19
19
  * The plan digest is the member part's (`computePlanDigest("pulumi-preview",
20
20
  * ...)`), so a preview that moves after review moves the consumer's digest.
21
21
  *
22
+ * A preview with changes that plans no stack outputs (every real one,
23
+ * #4475) traces each output to the export's resources
24
+ * (`./planned-output-changes.ts`, #4635): an output whose resources the
25
+ * preview leaves alone keeps the export's value, and the others are
26
+ * unknown. A stack that reads only unchanged outputs plans now; one that
27
+ * reads a changing one waits for the producer's `up`.
28
+ *
22
29
  * A member on plunge (`pulumiMemberEngine`: the member config, then a
23
30
  * `plunge` backend URL in `Pulumi.yaml`) runs the same two commands under
24
31
  * `plunge`.
@@ -39,6 +46,7 @@
39
46
  import type { DeployContext } from "@intentius/chant/components/capability";
40
47
  import type { EngineMemberRef, EngineOutputSource, EnginePlannedOutputs } from "@intentius/chant/components/verbs/member-outputs";
41
48
  import type { ChangeSetPart } from "@intentius/chant/change-set";
49
+ import { pulumiPlannedOutputChanges, type PulumiOutputChange } from "./planned-output-changes";
42
50
  import { pulumiPlannedStackOutputs, pulumiExportedStackOutputs, type PulumiStackOutput } from "./stack-outputs";
43
51
  import { memberExportOf, previewPulumiMemberRead, pulumiMemberStackOutputs, type PulumiMemberEngine, type PulumiRunner } from "./run";
44
52
  import { isSecret } from "./values";
@@ -88,10 +96,16 @@ export function pulumiPartHasChanges(part: ChangeSetPart): boolean {
88
96
  * the preview's error. A preview that plans no stack outputs (no stack step,
89
97
  * or one without outputs, which is what a real `pulumi preview --json`
90
98
  * prints, #4475) gives the export's outputs when it plans nothing to apply,
91
- * since no apply will move them, and names every output the export holds
92
- * unknown otherwise, so nothing reads a value the preview did not plan.
99
+ * since no apply will move them. With changes, each output is traced to the
100
+ * export's resources (#4635, {@link pulumiPlannedOutputChanges}): the ones
101
+ * the preview leaves alone keep their value, and the rest are unknown, so
102
+ * nothing reads a value the preview may move. `outputChanges` lists each
103
+ * output's verdict and why.
93
104
  */
94
- export function pulumiMemberPlannedOutputs(member: string, read: { part: ChangeSetPart; preview: unknown; exported?: unknown }): EnginePlannedOutputs {
105
+ export function pulumiMemberPlannedOutputs(
106
+ member: string,
107
+ read: { part: ChangeSetPart; preview: unknown; exported?: unknown; targeted?: boolean },
108
+ ): EnginePlannedOutputs & { outputChanges?: PulumiOutputChange[] } {
95
109
  const { part } = read;
96
110
  if (part.member.status === "failed" || part.member.planDigest === null) {
97
111
  throw new Error(`member-outputs: the preview of Pulumi member "${member}" failed: ${part.member.error ?? "no plan"}`);
@@ -101,7 +115,11 @@ export function pulumiMemberPlannedOutputs(member: string, read: { part: ChangeS
101
115
  if (planned === undefined) {
102
116
  const exported = read.exported === undefined ? [] : pulumiExportedStackOutputs(read.exported);
103
117
  if (!changes) return { known: knownValues(exported), unknown: [], planDigest: part.member.planDigest, changes };
104
- return { known: {}, unknown: exported.filter((o) => !o.secret).map((o) => o.name), planDigest: part.member.planDigest, changes };
118
+ if (part.member.holes.length > 0 || read.exported === undefined) {
119
+ return { known: {}, unknown: exported.filter((o) => !o.secret).map((o) => o.name), planDigest: part.member.planDigest, changes };
120
+ }
121
+ const traced = pulumiPlannedOutputChanges({ part, exported: read.exported, ...(read.targeted ? { targeted: true } : {}) });
122
+ return { known: traced.known, unknown: traced.unknown, planDigest: part.member.planDigest, changes, outputChanges: traced.outputs };
105
123
  }
106
124
  const unknown = planned.filter((o) => o.unknown).map((o) => o.name);
107
125
  return { known: knownValues(planned), unknown, planDigest: part.member.planDigest, changes: changes || unknown.length > 0 };
@@ -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
  );