@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.
- package/dist/check-step.d.ts +61 -0
- package/dist/check-step.d.ts.map +1 -0
- package/dist/check-step.js +270 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/member-outputs.d.ts +10 -3
- package/dist/member-outputs.d.ts.map +1 -1
- package/dist/member-outputs.js +11 -3
- package/dist/planned-output-changes.d.ts +60 -0
- package/dist/planned-output-changes.d.ts.map +1 -0
- package/dist/planned-output-changes.js +94 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +13 -0
- package/dist/preview-tips.d.ts +71 -0
- package/dist/preview-tips.d.ts.map +1 -0
- package/dist/preview-tips.js +76 -0
- package/dist/pulumi-up.d.ts +8 -0
- package/dist/pulumi-up.d.ts.map +1 -1
- package/dist/pulumi-up.js +15 -2
- package/dist/run.d.ts +23 -4
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +51 -10
- package/package.json +2 -2
- package/src/check-step.test.ts +191 -0
- package/src/check-step.ts +316 -0
- package/src/index.ts +2 -0
- package/src/member-outputs.test.ts +28 -6
- package/src/member-outputs.ts +22 -4
- package/src/planned-output-changes.test.ts +85 -0
- package/src/planned-output-changes.ts +141 -0
- package/src/plugin.ts +14 -0
- package/src/preview-tips.test.ts +135 -0
- package/src/preview-tips.ts +128 -0
- package/src/pulumi-up.ts +23 -2
- package/src/run-plan-flags.test.ts +53 -0
- package/src/run.test.ts +1 -1
- package/src/run.ts +53 -10
|
@@ -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, {
|
|
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
|
-
|
|
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
|
+
});
|