@intentius/chant-lexicon-pulumi 0.124.55 → 0.124.56

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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Which stack outputs a preview changes (#4635), over the recorded previews
3
+ * in `testdata/pulumi-outputs` (pulumi 3.272.0, the plunge corpus's aws-ts
4
+ * program on floci, #4475) and copies of their steps edited in the test.
5
+ * No engine runs.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { describe, expect, test } from "vitest";
10
+ import { pulumiPreviewPart, type PulumiStep } from "./change-set";
11
+ import { pulumiMemberPlannedOutputs } from "./member-outputs";
12
+ import { pulumiPlannedOutputChanges } from "./planned-output-changes";
13
+
14
+ const dir = join(import.meta.dirname, "testdata", "pulumi-outputs");
15
+ const read = (f: string) => JSON.parse(readFileSync(join(dir, f), "utf8")) as { steps: PulumiStep[] };
16
+ const exported = JSON.parse(readFileSync(join(dir, "stack-export.json"), "utf8")) as unknown;
17
+ const partOf = (preview: { steps: PulumiStep[] }, targeted = false) => pulumiPreviewPart({ member: "aws", preview, prior: exported, ...(targeted ? { targeted } : {}) });
18
+
19
+ describe("the recorded previews", () => {
20
+ test("a replaced VPC moves its id, the security group's, the deleted parameter's name and the deleted queue's URL", () => {
21
+ const r = pulumiPlannedOutputChanges({ part: partOf(read("preview-replaced.json")), exported });
22
+ expect(r.unknown).toEqual(["securityGroupId", "vpcId", "vpcParameterName", "workerQueueUrls"]);
23
+ expect(r.outputs.find((o) => o.output === "vpcId")).toEqual({ output: "vpcId", verdict: "changing", because: "aws:ec2/vpc:Vpc::main.id (replace)" });
24
+ expect(r.outputs.find((o) => o.output === "workerQueueUrls")).toMatchObject({ verdict: "changing", because: "aws:sqs/queue:Queue::worker-2.id (delete)" });
25
+ // The events queue updates in place: its id (its URL) stays.
26
+ expect(r.outputs.find((o) => o.output === "eventsQueueUrl")).toMatchObject({ verdict: "unchanged", because: "aws:sqs/queue:Queue::events.id (update)" });
27
+ expect(r.known.siteBucketName).toBe("rec-ts-site");
28
+ expect(r.outputs.map((o) => o.output)).not.toContain("dbPasswordValue");
29
+ });
30
+
31
+ test("a settled preview changes nothing", () => {
32
+ expect(pulumiPlannedOutputChanges({ part: partOf(read("preview-settled.json")), exported }).unknown).toEqual([]);
33
+ });
34
+
35
+ test("pulumiMemberPlannedOutputs takes the trace when the preview plans no stack outputs", () => {
36
+ const preview = read("preview-replaced.json");
37
+ const planned = pulumiMemberPlannedOutputs("aws", { part: partOf(preview), preview, exported });
38
+ expect(planned.unknown).toEqual(["securityGroupId", "vpcId", "vpcParameterName", "workerQueueUrls"]);
39
+ expect(planned.outputChanges?.length).toBe(12);
40
+ // With no export there is nothing to trace and no output to name.
41
+ expect(pulumiMemberPlannedOutputs("aws", { part: partOf(preview), preview })).toMatchObject({ known: {}, unknown: [] });
42
+ });
43
+ });
44
+
45
+ describe("edited from the recording", () => {
46
+ const settled = () => structuredClone(read("preview-settled.json"));
47
+ const at = (p: { steps: PulumiStep[] }, tail: string) => p.steps.find((s) => s.urn?.endsWith(tail))!;
48
+
49
+ test("an update whose diff names the input an output reads moves it", () => {
50
+ const p = settled();
51
+ const s = at(p, "aws:s3/bucket:Bucket::site-bucket");
52
+ s.op = "update";
53
+ s.newState = { ...s.newState!, inputs: { ...(s.newState!.inputs as object), bucket: "rec-ts-site-2" } };
54
+ s.diffReasons = ["bucket"];
55
+ const r = pulumiPlannedOutputChanges({ part: partOf(p), exported });
56
+ // siteBucketName matches the bucket's id too, and an id stays through an in-place update; bucket is the input the diff names.
57
+ expect(r.outputs.find((o) => o.output === "siteBucketName")).toMatchObject({ verdict: "changing", because: "aws:s3/bucket:Bucket::site-bucket.bucket (update)" });
58
+ });
59
+
60
+ test("an output only a provider-computed property holds may move on an update, so it is changing", () => {
61
+ const exp = structuredClone(exported) as { deployment: { resources: Array<Record<string, unknown>> } };
62
+ const stack = exp.deployment.resources.find((r) => r.type === "pulumi:pulumi:Stack")!;
63
+ const queue = exp.deployment.resources.find((r) => String(r.urn).endsWith("Queue::events"))!;
64
+ (queue.outputs as Record<string, unknown>).computedMark = "mark-0042";
65
+ (stack.outputs as Record<string, unknown>).eventsMark = "mark-0042";
66
+ const r = pulumiPlannedOutputChanges({ part: pulumiPreviewPart({ member: "aws", preview: read("preview-replaced.json"), prior: exp }), exported: exp });
67
+ expect(r.outputs.find((o) => o.output === "eventsMark")).toEqual({ output: "eventsMark", verdict: "changing", because: "aws:sqs/queue:Queue::events.computedMark (update)" });
68
+ });
69
+
70
+ test("an output no property holds is untraced, so unknown", () => {
71
+ const exp = structuredClone(exported) as { deployment: { resources: Array<Record<string, unknown>> } };
72
+ const stack = exp.deployment.resources.find((r) => r.type === "pulumi:pulumi:Stack")!;
73
+ (stack.outputs as Record<string, unknown>).sitePattern = "arn:aws:s3:::rec-ts-site/*";
74
+ const r = pulumiPlannedOutputChanges({ part: pulumiPreviewPart({ member: "aws", preview: read("preview-replaced.json"), prior: exp }), exported: exp });
75
+ expect(r.outputs.find((o) => o.output === "sitePattern")).toEqual({ output: "sitePattern", verdict: "untraced" });
76
+ expect(r.unknown).toContain("sitePattern");
77
+ });
78
+
79
+ test("a resource a targeted preview does not list is left alone; an untargeted one that leaves it out may register it again", () => {
80
+ const p = settled();
81
+ p.steps = p.steps.filter((s) => !s.urn?.endsWith("aws:ec2/vpc:Vpc::main"));
82
+ expect(pulumiPlannedOutputChanges({ part: partOf(p, true), exported, targeted: true }).outputs.find((o) => o.output === "vpcId")?.verdict).toBe("unchanged");
83
+ expect(pulumiPlannedOutputChanges({ part: partOf(p), exported }).outputs.find((o) => o.output === "vpcId")?.verdict).toBe("changing");
84
+ });
85
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Which of a stack's outputs its preview changes (#4635). A real `pulumi
3
+ * preview --json` plans no stack outputs (recorded, #4475), so until this
4
+ * module every output of a stack with any change was unknown, and every
5
+ * stack reading any of them waited for the `up`. Here each output the last
6
+ * deployment holds is traced, by value, to the resource properties in the
7
+ * export that hold the same value, and the preview's entry for each such
8
+ * resource says whether the value can move:
9
+ *
10
+ * | the resource's entry | the property | verdict |
11
+ * |---|---|---|
12
+ * | replace, delete, or a `mayAppear` update | any | changes |
13
+ * | update | an input the diff names, or one that is unknown | changes |
14
+ * | update | an input the diff leaves alone; `id`, `arn` or `urn` | stable |
15
+ * | update | any other output (a provider-computed `version`) | may change |
16
+ * | no-op, read, or no entry in a targeted preview | any | stable |
17
+ *
18
+ * An output is changing when one of its matches changes, or when every
19
+ * match only may change; it is unchanged, with the export's value, when no
20
+ * match changes and one is stable. An output no property matches (one the
21
+ * program computed, such as `` `${bucket.arn}/*` ``), or an object, is
22
+ * untraced, and so unknown while the stack has changes. A list traces each
23
+ * string in it. Strings shorter than four characters match nothing, so a
24
+ * `"1"` or a `"true"` traces no output. Secret outputs are left out, as
25
+ * everywhere: chant reads outputs blind.
26
+ *
27
+ * The trace runs only when the preview plans no stack outputs and has
28
+ * changes; core's `member-outputs` and `pulumi-up`'s plan then take its
29
+ * unchanged outputs as known and the rest as unknown, so a stack reading
30
+ * through `StackReference` (#4547) an output the change moves is held until
31
+ * the producer applied and the reader is previewed again, and one reading
32
+ * only unchanged outputs plans now.
33
+ */
34
+ import type { ChangeSetEntry, ChangeSetPart } from "@intentius/chant/change-set";
35
+ import { isPulumiNodeless } from "@intentius/chant/pulumi-urn";
36
+ import { pulumiExportedStackOutputs } from "./stack-outputs";
37
+ import { isObject, isSecret } from "./values";
38
+
39
+ /** One output's verdict, and the resource property that decided it. */
40
+ export interface PulumiOutputChange {
41
+ output: string;
42
+ verdict: "changing" | "unchanged" | "untraced";
43
+ /** The deciding match: `<urn tail>.<property>` and the resource's action. Absent when untraced. */
44
+ because?: string;
45
+ }
46
+
47
+ /** The trace of every output the export holds that is not secret, by name. */
48
+ export interface PulumiPlannedOutputChanges {
49
+ outputs: PulumiOutputChange[];
50
+ /** Unchanged outputs with the export's value. */
51
+ known: Record<string, unknown>;
52
+ /** Changing and untraced outputs. */
53
+ unknown: string[];
54
+ }
55
+
56
+ /** Properties that stay put through an in-place update. */
57
+ const STABLE_OUTPUTS = new Set(["id", "arn", "urn"]);
58
+
59
+ type Verdict = "changes" | "stable" | "maybe";
60
+
61
+ interface Holder {
62
+ urn: string;
63
+ prop: string;
64
+ input: boolean;
65
+ }
66
+
67
+ const tailOf = (urn: string): string => urn.split("::").slice(2).join("::") || urn;
68
+
69
+ /** Every (resource, property) of the export holding a string worth matching, by value. */
70
+ function holdersByValue(exported: unknown): Map<string, Holder[]> {
71
+ const deployment = isObject(exported) && isObject(exported.deployment) ? exported.deployment : {};
72
+ const resources = Array.isArray(deployment.resources) ? deployment.resources : [];
73
+ const by = new Map<string, Holder[]>();
74
+ const add = (value: unknown, h: Holder) => {
75
+ if (typeof value !== "string" || value.length < 4) return;
76
+ const list = by.get(value) ?? [];
77
+ list.push(h);
78
+ by.set(value, list);
79
+ };
80
+ for (const r of resources) {
81
+ if (!isObject(r) || typeof r.urn !== "string" || typeof r.type !== "string") continue;
82
+ if (r.custom !== true || isPulumiNodeless(r.type)) continue;
83
+ const inputs = isObject(r.inputs) ? r.inputs : {};
84
+ add(r.id, { urn: r.urn, prop: "id", input: false });
85
+ for (const [k, v] of Object.entries(isObject(r.outputs) ? r.outputs : {})) {
86
+ if (isSecret(v)) continue;
87
+ add(v, { urn: r.urn, prop: k, input: k in inputs });
88
+ }
89
+ }
90
+ return by;
91
+ }
92
+
93
+ function verdictOf(h: Holder, entry: ChangeSetEntry | undefined, targeted: boolean): Verdict {
94
+ if (!entry) return targeted ? "stable" : "changes";
95
+ if (entry.mayAppear === true || entry.action === "replace" || entry.action === "delete" || entry.action === "forget") return "changes";
96
+ if (entry.action !== "update") return "stable";
97
+ const attr = entry.attributes.find((a) => a.path === h.prop);
98
+ if (attr) return "changes";
99
+ if (h.input || STABLE_OUTPUTS.has(h.prop)) return "stable";
100
+ return "maybe";
101
+ }
102
+
103
+ /**
104
+ * Trace each of the export's stack outputs through the member's part (the
105
+ * change set its preview became). `targeted` says the preview was narrowed
106
+ * with `--target`, so a resource it did not list is not touched by it.
107
+ */
108
+ export function pulumiPlannedOutputChanges(input: { part: ChangeSetPart; exported: unknown; targeted?: boolean }): PulumiPlannedOutputChanges {
109
+ const byUrn = new Map<string, ChangeSetEntry>();
110
+ for (const e of input.part.entries) if (!byUrn.has(e.address)) byUrn.set(e.address, e);
111
+ const holders = holdersByValue(input.exported);
112
+ const targeted = input.targeted === true;
113
+
114
+ const traceString = (value: string): { verdict: PulumiOutputChange["verdict"]; because?: string } => {
115
+ const list = holders.get(value) ?? [];
116
+ if (list.length === 0) return { verdict: "untraced" };
117
+ const judged = list.map((h) => ({ h, v: verdictOf(h, byUrn.get(h.urn), targeted), action: byUrn.get(h.urn)?.action ?? "unlisted" }));
118
+ const why = (j: (typeof judged)[number]) => `${tailOf(j.h.urn)}.${j.h.prop} (${j.action})`;
119
+ const changes = judged.find((j) => j.v === "changes");
120
+ if (changes) return { verdict: "changing", because: why(changes) };
121
+ const stable = judged.find((j) => j.v === "stable");
122
+ if (stable) return { verdict: "unchanged", because: why(stable) };
123
+ return { verdict: "changing", because: why(judged[0]!) };
124
+ };
125
+
126
+ const outputs: PulumiOutputChange[] = [];
127
+ const known: Record<string, unknown> = {};
128
+ for (const o of pulumiExportedStackOutputs(input.exported)) {
129
+ if (o.secret) continue;
130
+ let traced: { verdict: PulumiOutputChange["verdict"]; because?: string };
131
+ if (typeof o.value === "string") traced = traceString(o.value);
132
+ else if (Array.isArray(o.value) && o.value.length > 0 && o.value.every((v) => typeof v === "string")) {
133
+ const each = (o.value as string[]).map(traceString);
134
+ traced = each.find((t) => t.verdict === "changing") ?? each.find((t) => t.verdict === "untraced") ?? each[0]!;
135
+ } else traced = { verdict: "untraced" };
136
+ outputs.push({ output: o.name, verdict: traced.verdict, ...(traced.because ? { because: traced.because } : {}) });
137
+ if (traced.verdict === "unchanged") known[o.name] = o.value;
138
+ }
139
+ outputs.sort((a, b) => (a.output < b.output ? -1 : a.output > b.output ? 1 : 0));
140
+ return { outputs, known, unknown: outputs.filter((o) => o.verdict !== "unchanged").map((o) => o.output) };
141
+ }
package/src/plugin.ts CHANGED
@@ -37,6 +37,7 @@
37
37
  * takes a `<project>/<stack>` template, and `prEnvTeardown` removes the
38
38
  * environment's `pr-<n>` stacks (`./review-stack.ts`).
39
39
  */
40
+ import { pulumiCheckSteps } from "./check-step";
40
41
  import { pulumiCiSetup } from "./ci-setup";
41
42
  import { basename, isAbsolute, resolve } from "node:path";
42
43
  import type { InitTemplateSet, LexiconPlugin } from "@intentius/chant/lexicon";
@@ -95,6 +96,19 @@ export const pulumiPlugin: LexiconPlugin = {
95
96
  return reviewStackTeardown({ cwd: ctx.cwd });
96
97
  },
97
98
 
99
+ // #4633: pulumi install, each stack's config against the project's schema, and pulumi about --json, per pulumi member in chant's check stage.
100
+ checkSteps() {
101
+ return pulumiCheckSteps();
102
+ },
103
+
104
+ // #4634: preview tips (rename without aliases, a replace forced by a name, what may register at up) as a note section per member.
105
+ async prNoteDetail(input) {
106
+ if (input.stage !== "plan") return undefined;
107
+ const [{ pulumiTipsDetail }, { activeProductWords }] = await Promise.all([import("./preview-tips"), import("@intentius/chant/ci-product-words")]);
108
+ const words = activeProductWords();
109
+ return pulumiTipsDetail({ member: input.member, changeSet: input.changeSet, words: { product: words.product, noun: words.noun } });
110
+ },
111
+
98
112
  // A pulumi workspace member's reader project (#3179): its stack export from
99
113
  // the index store, else the checked-in one, never the program. See ./workspace-member.ts. `chant workspace
100
114
  // graph` writes `dir` absolute; a project that writes the namespace itself
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Preview tips (#4634) over the recorded preview stream
3
+ * (`testdata/pulumi-stream`, converted from the #4475 recording) and over
4
+ * copies of its steps edited in the test to show each pattern. No engine
5
+ * runs.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { describe, expect, test } from "vitest";
10
+ import { withProductWords } from "@intentius/chant/ci-product-words";
11
+ import { pulumiPreviewPart, type PulumiStep } from "./change-set";
12
+ import { pulumiStreamPreview } from "./engine-events";
13
+ import { pulumiPlugin } from "./plugin";
14
+ import { nameStem, pulumiPreviewTips, pulumiTipsDetail } from "./preview-tips";
15
+
16
+ const TESTDATA = join(import.meta.dirname, "testdata");
17
+ const stream = readFileSync(join(TESTDATA, "pulumi-stream", "preview-stream.ndjson"), "utf8");
18
+ const prior = JSON.parse(readFileSync(join(TESTDATA, "pulumi-preview", "stack-export.json"), "utf8")) as unknown;
19
+ const recorded = (): PulumiStep[] => structuredClone(pulumiStreamPreview(stream).steps);
20
+
21
+ const part = (steps: PulumiStep[]) => pulumiPreviewPart({ member: "app", preview: { steps }, prior });
22
+ const doc = (steps: PulumiStep[]) => ({ entries: part(steps).entries });
23
+
24
+ const BUCKET = "urn:pulumi:floci::corpus-aws-ts::aws:s3/bucket:Bucket::site-bucket";
25
+ const ROLE = "urn:pulumi:floci::corpus-aws-ts::aws:iam/role:Role::app-role";
26
+
27
+ /** The recorded steps with site-bucket deleted and site-bucket-v2 created in its place, as a rename with no alias previews. */
28
+ function renamed(): PulumiStep[] {
29
+ const steps = recorded();
30
+ const at = steps.findIndex((s) => s.urn === BUCKET);
31
+ const bucket = steps[at]!;
32
+ const created: PulumiStep = { op: "create", urn: BUCKET.replace(/site-bucket$/, "site-bucket-v2"), newState: { ...bucket.newState!, urn: BUCKET.replace(/site-bucket$/, "site-bucket-v2") } };
33
+ steps[at] = { op: "delete", urn: BUCKET, oldState: bucket.oldState! };
34
+ steps.push(created);
35
+ return steps;
36
+ }
37
+
38
+ describe("the recorded preview", () => {
39
+ test("shows no tip: worker-2's delete has no create of its type and stem, and neither replace is forced by a name", () => {
40
+ const entries = part(recorded()).entries;
41
+ expect(entries.some((e) => e.action === "delete" && e.name === "worker-2")).toBe(true);
42
+ expect(pulumiPreviewTips(entries)).toEqual([]);
43
+ expect(pulumiTipsDetail({ member: "app", changeSet: { entries } })).toBeUndefined();
44
+ });
45
+ });
46
+
47
+ describe("rename-without-aliases", () => {
48
+ test("a delete and a create of one type and stem", () => {
49
+ expect(pulumiPreviewTips(doc(renamed()).entries)).toEqual([
50
+ { id: "rename-without-aliases", member: "app", address: BUCKET, type: "aws:s3/bucket:Bucket", name: "site-bucket", created: { address: BUCKET.replace(/site-bucket$/, "site-bucket-v2"), name: "site-bucket-v2" } },
51
+ ]);
52
+ });
53
+
54
+ test("the same name under a new parent", () => {
55
+ const steps = recorded();
56
+ const at = steps.findIndex((s) => s.urn === BUCKET);
57
+ const bucket = steps[at]!;
58
+ const moved = "urn:pulumi:floci::corpus-aws-ts::corpus:index:Site$aws:s3/bucket:Bucket::site-bucket";
59
+ steps[at] = { op: "delete", urn: BUCKET, oldState: bucket.oldState! };
60
+ steps.push({ op: "create", urn: moved, newState: { ...bucket.newState!, urn: moved } });
61
+ expect(pulumiPreviewTips(doc(steps).entries).map((t) => [t.id, t.created?.address])).toEqual([["rename-without-aliases", moved]]);
62
+ });
63
+
64
+ test("a different type, or a different stem, is no rename", () => {
65
+ const steps = recorded();
66
+ const bucket = steps.find((s) => s.urn === BUCKET)!;
67
+ const other = "urn:pulumi:floci::corpus-aws-ts::aws:s3/bucket:Bucket::logs";
68
+ steps.push({ op: "create", urn: other, newState: { ...bucket.newState!, urn: other } });
69
+ expect(pulumiPreviewTips(doc(steps).entries)).toEqual([]);
70
+ expect(nameStem("Site-Bucket_v2")).toBe("site");
71
+ });
72
+ });
73
+
74
+ describe("replace-on-name", () => {
75
+ test("a create-before-delete replace forced by name", () => {
76
+ const steps = recorded();
77
+ const at = steps.findIndex((s) => s.urn === ROLE);
78
+ const role = steps[at]!;
79
+ steps[at] = {
80
+ ...role,
81
+ op: "replace",
82
+ newState: { ...role.newState!, inputs: { ...(role.newState!.inputs as object), name: "app-role-2" } },
83
+ replaceReasons: ["name"],
84
+ diffReasons: ["name"],
85
+ detailedDiff: { name: { kind: "update-replace", inputDiff: true } },
86
+ };
87
+ const tips = pulumiPreviewTips(doc(steps).entries);
88
+ expect(tips).toEqual([expect.objectContaining({ id: "replace-on-name", name: "app-role", inputs: ["name"] })]);
89
+ });
90
+
91
+ test("a delete-before-replace already deletes first", () => {
92
+ const steps = recorded();
93
+ const at = steps.findIndex((s) => s.urn === ROLE);
94
+ const role = steps[at]!;
95
+ steps.splice(at, 1, { ...role, op: "delete-replaced", replaceReasons: ["name"] }, { ...role, op: "create-replacement", replaceReasons: ["name"] });
96
+ expect(pulumiPreviewTips(doc(steps).entries)).toEqual([]);
97
+ });
98
+ });
99
+
100
+ describe("unknown-in-apply", () => {
101
+ test("a component whose child waits on an unknown may register more at up", () => {
102
+ // The JSON recording: the stream omits a component's custom: false, which signal 1 of the change set reads.
103
+ const steps = structuredClone((JSON.parse(readFileSync(join(TESTDATA, "pulumi-preview", "preview.json"), "utf8")) as { steps: PulumiStep[] }).steps);
104
+ const child = steps.find((s) => s.urn?.endsWith("Site$aws:s3/bucket:Bucket::docs-assets"))!;
105
+ child.op = "update";
106
+ child.newState = { ...child.newState!, inputs: { ...(child.newState!.inputs as object), tags: "04da6b54-80e4-46f7-96ec-b56ff0331ba9" } };
107
+ child.diffReasons = ["tags"];
108
+ const tips = pulumiPreviewTips(doc(steps).entries);
109
+ expect(tips.map((t) => t.id)).toEqual(["unknown-in-apply"]);
110
+ expect(tips[0]!.address).toContain("corpus:index:Site$*::docs");
111
+ });
112
+ });
113
+
114
+ describe("the note", () => {
115
+ test("one row per tip with its id, and the product's tips command", () => {
116
+ const detail = pulumiTipsDetail({ member: "app", changeSet: doc(renamed()), words: { product: "plywood", noun: ["stack", "stacks"] } });
117
+ expect(detail).toEqual({
118
+ title: "Tips for stack `app` (1)",
119
+ lines: [
120
+ "| Tip | Resource | Preview |",
121
+ "|---|---|---|",
122
+ "| `rename-without-aliases` | `aws:s3/bucket:Bucket` `site-bucket` | deleted, and `site-bucket-v2` of the same type created, with no alias between them |",
123
+ "",
124
+ "`plywood tips rename-without-aliases` prints the fix.",
125
+ ],
126
+ });
127
+ });
128
+
129
+ test("the plugin renders it at plan, in the active product's words", async () => {
130
+ const changeSet = doc(renamed()) as never;
131
+ const detail = await withProductWords({ product: "plywood", noun: ["stack", "stacks"] }, async () => pulumiPlugin.prNoteDetail!({ member: "app", changeSet, stage: "plan" }));
132
+ expect(detail?.lines.at(-1)).toBe("`plywood tips rename-without-aliases` prints the fix.");
133
+ expect(await pulumiPlugin.prNoteDetail!({ member: "app", changeSet, stage: "apply" })).toBeUndefined();
134
+ });
135
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Preview tips from a Pulumi member's change set (#4634). A tip names a
3
+ * pattern in a preview that usually wants a fix before the update: the
4
+ * product holds each tip's words and fix (plywood's `src/tips.ts` and
5
+ * `plywood tips <id>`), and this module finds the pattern in the change set
6
+ * the preview became (`./change-set.ts`). The note shows each tip as a row
7
+ * with its id, through the lexicon's `prNoteDetail`.
8
+ *
9
+ * | id | found when |
10
+ * |---|---|
11
+ * | `rename-without-aliases` | the preview deletes a resource and creates one of the same type whose name has the same stem, or the same name under another parent |
12
+ * | `replace-on-name` | a replace that creates before it deletes, forced by a name input (`name`, `<x>Name`, `bucket`) |
13
+ * | `unknown-in-apply` | an entry the preview could not list (`mayAppear`): something registers inside an `apply()` |
14
+ *
15
+ * A rename with `aliases` is not a delete and a create: Pulumi follows the
16
+ * alias and the preview shows the resource under its new URN as `same` or
17
+ * `update`. So a delete and a create of one type and stem in one preview is
18
+ * the rename without them. The stem is the name up to its first `-`, `_`,
19
+ * `.`, `:` or `/`, lowercased, so `assets` and `assets-v2` pair, and so
20
+ * does `worker-2` with a new `worker-3`; each delete pairs with one create
21
+ * at most.
22
+ *
23
+ * Tips plywood lists that a change set cannot show are left out:
24
+ * `secret-in-plain-config` reads stack config, which no preview step holds,
25
+ * and `no-protect-on-data` reads `protect`, which the change set does not
26
+ * carry.
27
+ */
28
+ import type { ChangeSetDocument, ChangeSetEntry } from "@intentius/chant/change-set";
29
+
30
+ /** The tip ids this module finds, as plywood's tips page names them. */
31
+ export const PULUMI_PREVIEW_TIP_IDS = ["rename-without-aliases", "replace-on-name", "unknown-in-apply"] as const;
32
+
33
+ export type PulumiPreviewTipId = (typeof PULUMI_PREVIEW_TIP_IDS)[number];
34
+
35
+ /** One tip found in a member's change set. */
36
+ export interface PulumiPreviewTip {
37
+ id: PulumiPreviewTipId;
38
+ member: string;
39
+ /** The entry's address (a URN, or a may-appear address). */
40
+ address: string;
41
+ type: string;
42
+ name?: string;
43
+ /** For a rename: the created resource's address and name. */
44
+ created?: { address: string; name?: string };
45
+ /** For a replace: the name inputs that force it. */
46
+ inputs?: string[];
47
+ }
48
+
49
+ /** The stem a rename keeps: the name up to its first separator, lowercased. */
50
+ export function nameStem(name: string): string {
51
+ return name.toLowerCase().split(/[-_.:/]/)[0] ?? "";
52
+ }
53
+
54
+ /** A name input: `name`, `<x>Name`, `<x>_name`, or an S3 bucket's `bucket`. */
55
+ const NAME_INPUT = /^(name|bucket|[a-z][A-Za-z0-9]*Name|[a-z0-9_]+_name)$/;
56
+
57
+ /** The tips in `entries`, in order: renames, then replaces, then what may register at up. Only pulumi entries are read. */
58
+ export function pulumiPreviewTips(entries: readonly ChangeSetEntry[]): PulumiPreviewTip[] {
59
+ const mine = entries.filter((e) => e.lexicon === "pulumi");
60
+ const tips: PulumiPreviewTip[] = [];
61
+ const base = (e: ChangeSetEntry) => ({ member: e.member, address: e.address, type: e.type, ...(e.name !== undefined ? { name: e.name } : {}) });
62
+
63
+ // rename-without-aliases
64
+ const creates = mine.filter((e) => e.action === "create" && e.mayAppear !== true && e.name !== undefined);
65
+ const taken = new Set<string>();
66
+ for (const del of mine) {
67
+ if (del.action !== "delete" || del.mayAppear === true || del.name === undefined) continue;
68
+ const stem = nameStem(del.name);
69
+ if (!stem) continue;
70
+ const match = creates.find((c) => !taken.has(c.address) && c.member === del.member && c.type === del.type && c.address !== del.address && nameStem(c.name!) === stem);
71
+ if (!match) continue;
72
+ taken.add(match.address);
73
+ tips.push({ id: "rename-without-aliases", ...base(del), created: { address: match.address, ...(match.name !== undefined ? { name: match.name } : {}) } });
74
+ }
75
+
76
+ // replace-on-name
77
+ for (const e of mine) {
78
+ if (e.action !== "replace" || e.disruption === "destroy") continue;
79
+ const inputs = e.attributes.filter((a) => a.forcesReplacement === true && NAME_INPUT.test(a.path)).map((a) => a.path);
80
+ if (inputs.length > 0) tips.push({ id: "replace-on-name", ...base(e), inputs });
81
+ }
82
+
83
+ // unknown-in-apply
84
+ for (const e of mine) {
85
+ if (e.mayAppear === true) tips.push({ id: "unknown-in-apply", ...base(e) });
86
+ }
87
+ return tips;
88
+ }
89
+
90
+ const code = (s: string): string => "`" + s.replace(/`/g, "'") + "`";
91
+
92
+ /** What the row says the preview showed. */
93
+ function seen(t: PulumiPreviewTip): string {
94
+ switch (t.id) {
95
+ case "rename-without-aliases":
96
+ return `deleted, and ${code(t.created?.name ?? t.created?.address ?? "?")} of the same type created, with no alias between them`;
97
+ case "replace-on-name":
98
+ return `replaced because ${t.inputs!.map(code).join(", ")} changed, creating the new one before deleting the old`;
99
+ case "unknown-in-apply":
100
+ return "may register at up: the preview could not list it";
101
+ }
102
+ }
103
+
104
+ /** The words the detail section reads: the product's name and its noun. */
105
+ export interface PulumiTipWords {
106
+ product: string;
107
+ noun: [string, string];
108
+ }
109
+
110
+ /**
111
+ * The note's detail section for one member's tips (`prNoteDetail`), or
112
+ * undefined when it has none. One row per tip, with its id first. A product
113
+ * other than chant gets a closing line naming `<product> tips <id>`, the
114
+ * command its words give the fix with.
115
+ */
116
+ export function pulumiTipsDetail(input: { member: string; changeSet: Pick<ChangeSetDocument, "entries">; words?: PulumiTipWords }): { title: string; lines: string[] } | undefined {
117
+ const tips = pulumiPreviewTips(input.changeSet.entries.filter((e) => e.member === input.member));
118
+ if (tips.length === 0) return undefined;
119
+ const words = input.words ?? { product: "chant", noun: ["member", "members"] };
120
+ const lines = ["| Tip | Resource | Preview |", "|---|---|---|"];
121
+ for (const t of tips) lines.push(`| ${code(t.id)} | ${code(t.type)} ${code(t.name ?? t.address)} | ${seen(t)} |`);
122
+ if (words.product !== "chant") {
123
+ const ids = [...new Set(tips.map((t) => t.id))];
124
+ lines.push("", `${ids.map((id) => code(`${words.product} tips ${id}`)).join(", ")} ${ids.length === 1 ? "prints the fix" : "print the fixes"}.`);
125
+ }
126
+ const noun = words.noun[0];
127
+ return { title: `Tips for ${noun} ${code(input.member)} (${tips.length})`, lines };
128
+ }
package/src/pulumi-up.ts CHANGED
@@ -167,6 +167,7 @@ import { pulumiExitOf } from "./exit-codes";
167
167
  import { pulumiMemberEngine, type PulumiMemberEngine, type PulumiMemberEngineSource } from "./member-engine";
168
168
  import { ciEngineDir, ciRelease, CI_ENGINE_ROOT } from "./ci-setup";
169
169
  import { pulumiMemberPlannedOutputs } from "./member-outputs";
170
+ import type { PulumiOutputChange } from "./planned-output-changes";
170
171
  import { pulumiPlanShape, pulumiShapeDifferences } from "./plan-shape";
171
172
  import { pulumiLockRows } from "./lock-rows";
172
173
  import { pulumiTraceFacts } from "./trace-facts";
@@ -300,6 +301,13 @@ export interface PulumiUpArtifact {
300
301
  * outside reference stale, current or unknown.
301
302
  */
302
303
  stackReferences?: { read: string[]; outside: { name: string; why: string }[]; lastUpdate?: string };
304
+ /**
305
+ * Each stack output's verdict from the preview (#4635,
306
+ * `./planned-output-changes.ts`): `changing` and `untraced` ones are
307
+ * unknown to the stacks that read them, `unchanged` ones keep the
308
+ * export's value. Absent when the preview planned nothing to apply.
309
+ */
310
+ plannedOutputs?: PulumiOutputChange[];
303
311
  }
304
312
 
305
313
  /** The CrossGuard packs a plan read at base (#4613). */
@@ -619,7 +627,10 @@ async function previewStack(
619
627
  stack: input.stack,
620
628
  ...(files.planFile ? { savePlan: files.planFile } : {}),
621
629
  ...(files.configFile ? { configFile: files.configFile } : {}),
630
+ // #4627: --refresh and --target-dependents pass to the preview as they came.
631
+ ...(ctx.refresh ? { refresh: true } : {}),
622
632
  ...(targets ? { targets } : {}),
633
+ ...(targets && ctx.targetDependents ? { targetDependents: true } : {}),
623
634
  ...(packs.length ? { policyPacks: packs } : {}),
624
635
  });
625
636
  let stdout: string;
@@ -720,7 +731,12 @@ async function planStack(ctx: DeployContext, original: PulumiUpStepInput, opts:
720
731
  }
721
732
  const planDigest = read.part.member.planDigest!;
722
733
  const references = stackReferencesOf(read.prior, input);
723
- const planned = pulumiMemberPlannedOutputs(r.member, { part: read.part, preview: read.preview, ...(read.prior !== undefined ? { exported: read.prior } : {}) });
734
+ const planned = pulumiMemberPlannedOutputs(r.member, {
735
+ part: read.part,
736
+ preview: read.preview,
737
+ ...(read.prior !== undefined ? { exported: read.prior } : {}),
738
+ ...(ctx.targets?.length ? { targeted: true } : {}),
739
+ });
724
740
  const artifact: PulumiUpArtifact = {
725
741
  kind: "pulumi-up",
726
742
  member: r.member,
@@ -736,6 +752,7 @@ async function planStack(ctx: DeployContext, original: PulumiUpStepInput, opts:
736
752
  policy: read.policy,
737
753
  ...(packs.read ? { policyPacks: packs.read } : {}),
738
754
  ...(references ? { stackReferences: references } : {}),
755
+ ...(planned.outputChanges ? { plannedOutputs: planned.outputChanges } : {}),
739
756
  };
740
757
  return {
741
758
  member: r.member,
@@ -892,6 +909,7 @@ async function runStack(ctx: DeployContext, original: PulumiUpStepInput, opts: P
892
909
  const tail = {
893
910
  ...(configFile ? { configFile } : {}),
894
911
  ...(targets ? { targets } : {}),
912
+ ...(targets && ctx.targetDependents ? { targetDependents: true } : {}),
895
913
  ...(policyPacks?.length ? { policyPacks } : {}),
896
914
  };
897
915
 
@@ -914,7 +932,8 @@ async function runStack(ctx: DeployContext, original: PulumiUpStepInput, opts: P
914
932
  if (differences.length > 0) throw new PlanShapeMovedError(r.member, differences);
915
933
  }
916
934
  try {
917
- ({ stdout } = await runApplyPulumi(runnersFor(r, opts).pulumi, pulumiUpArgs({ stack: input.stack, ...tail }), r.dir));
935
+ // #4627: stock up plans again, so it refreshes as its preview did; plunge applies the saved plan, whose preview refreshed.
936
+ ({ stdout } = await runApplyPulumi(runnersFor(r, opts).pulumi, pulumiUpArgs({ stack: input.stack, ...tail, ...(ctx.refresh ? { refresh: true } : {}) }), r.dir));
918
937
  } catch (err) {
919
938
  throw upFailure(r, err, approved.policy);
920
939
  }
@@ -989,6 +1008,8 @@ export function createPulumiUpCapability(opts: PulumiUpOptions = {}): Capability
989
1008
  rollbackPolicy: "needs-opt-out",
990
1009
  // Each target passes as `--target <urn>` to the preview and to `up`.
991
1010
  targets: "accept",
1011
+ // #4627: `--refresh` and `--target-dependents` pass to the preview, and to `pulumi up`; the digest binds them.
1012
+ planFlags: { refresh: "accept", targetDependents: "accept" },
992
1013
  plan: (ctx, input) => planStack(ctx, input, opts),
993
1014
  run: (ctx, input) => runStack(ctx, input, opts),
994
1015
  // The outputs standing now, for a dependent planned before this stack applies.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `--refresh` and `--target-dependents` through the pulumi lexicon's
3
+ * allowlists (#4627): both in the preview and in stock `pulumi up`, in the
4
+ * tail's order, `--target-dependents` only after a `--target`, and no
5
+ * `--refresh` on plunge's `up --plan`, which applies a saved plan whose
6
+ * preview refreshed. Written for the ci-products post-merge pass.
7
+ */
8
+ import { describe, expect, test } from "vitest";
9
+ import { plungeUpArgs, pulumiUpArgs, pulumiUpPreviewArgs, runAllowedPlunge, runAllowedPulumi, runApplyPlunge, runApplyPulumi, type PulumiRunner } from "./run";
10
+ import { createPulumiUpCapability } from "./pulumi-up";
11
+
12
+ function fake(): { run: PulumiRunner; calls: string[][] } {
13
+ const calls: string[][] = [];
14
+ return { run: async (args) => (calls.push(args), { stdout: "" }), calls };
15
+ }
16
+
17
+ describe("--refresh and --target-dependents (#4627)", () => {
18
+ test("the preview carries them in the tail's order", async () => {
19
+ const args = pulumiUpPreviewArgs({ stack: "dev", refresh: true, targets: ["urn:a"], targetDependents: true });
20
+ expect(args).toEqual(["preview", "--json", "--show-sames", "--stack", "dev", "--refresh", "--target", "urn:a", "--target-dependents"]);
21
+ const { run, calls } = fake();
22
+ await runAllowedPulumi(run, args, "/tmp");
23
+ await runAllowedPlunge(run, args, "/tmp");
24
+ expect(calls).toHaveLength(2);
25
+ });
26
+
27
+ test("--target-dependents is dropped without a target", () => {
28
+ expect(pulumiUpPreviewArgs({ stack: "dev", targetDependents: true })).not.toContain("--target-dependents");
29
+ });
30
+
31
+ test("stock up takes both; plunge up --plan takes --target-dependents and no --refresh", async () => {
32
+ const { run, calls } = fake();
33
+ await runApplyPulumi(run, pulumiUpArgs({ stack: "dev", refresh: true, targets: ["urn:a"], targetDependents: true }), "/tmp");
34
+ await runApplyPlunge(run, plungeUpArgs({ plan: "/tmp/p.json", stack: "dev", targets: ["urn:a"], targetDependents: true }), "/tmp");
35
+ expect(calls).toEqual([
36
+ ["up", "--yes", "--json", "--stack", "dev", "--refresh", "--target", "urn:a", "--target-dependents"],
37
+ ["up", "--plan", "/tmp/p.json", "--yes", "--json", "--stack", "dev", "--target", "urn:a", "--target-dependents"],
38
+ ]);
39
+ await expect(runApplyPlunge(run, ["up", "--plan", "/tmp/p.json", "--yes", "--json", "--stack", "dev", "--refresh"], "/tmp")).rejects.toThrow(/applies a plunge stack only as/);
40
+ });
41
+
42
+ test("out of order, or --target-dependents with no target, is refused", async () => {
43
+ const { run, calls } = fake();
44
+ for (const tail of [["--target-dependents"], ["--target", "urn:a", "--refresh"], ["--target-dependents", "--target", "urn:a"], ["--refresh", "--refresh"]]) {
45
+ await expect(runApplyPulumi(run, ["up", "--yes", "--json", "--stack", "dev", ...tail], "/tmp"), tail.join(" ")).rejects.toThrow(/applies a Pulumi stack only as/);
46
+ }
47
+ expect(calls).toEqual([]);
48
+ });
49
+
50
+ test("the capability says it takes both", () => {
51
+ expect(createPulumiUpCapability().planFlags).toEqual({ refresh: "accept", targetDependents: "accept" });
52
+ });
53
+ });