@intentius/chant-lexicon-pulumi 0.124.55 → 0.124.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/changed-units.d.ts +5 -0
- package/dist/changed-units.d.ts.map +1 -0
- package/dist/changed-units.js +101 -0
- package/dist/check-step.d.ts +61 -0
- package/dist/check-step.d.ts.map +1 -0
- package/dist/check-step.js +270 -0
- package/dist/composites/pulumi-adopt-op.d.ts.map +1 -1
- package/dist/composites/pulumi-adopt-op.js +4 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/member-outputs.d.ts +10 -3
- package/dist/member-outputs.d.ts.map +1 -1
- package/dist/member-outputs.js +11 -3
- package/dist/op/activities/pulumi-adopt.d.ts +5 -0
- package/dist/op/activities/pulumi-adopt.d.ts.map +1 -1
- package/dist/op/activities/pulumi-adopt.js +7 -2
- package/dist/op/activity-contracts.d.ts +2 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/op/activity-contracts.js +2 -2
- package/dist/planned-output-changes.d.ts +60 -0
- package/dist/planned-output-changes.d.ts.map +1 -0
- package/dist/planned-output-changes.js +94 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +24 -0
- package/dist/preview-tips.d.ts +71 -0
- package/dist/preview-tips.d.ts.map +1 -0
- package/dist/preview-tips.js +76 -0
- package/dist/pulumi-up.d.ts +24 -1
- package/dist/pulumi-up.d.ts.map +1 -1
- package/dist/pulumi-up.js +64 -16
- package/dist/release-copy.d.ts +42 -2
- package/dist/release-copy.d.ts.map +1 -1
- package/dist/release-copy.js +96 -22
- package/dist/run.d.ts +27 -8
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +55 -14
- package/package.json +2 -2
- package/src/changed-units.test.ts +87 -0
- package/src/changed-units.ts +96 -0
- package/src/check-step.test.ts +191 -0
- package/src/check-step.ts +316 -0
- package/src/composites/pulumi-adopt-op.test.ts +35 -0
- package/src/composites/pulumi-adopt-op.ts +4 -2
- package/src/index.ts +2 -0
- package/src/member-outputs.test.ts +28 -6
- package/src/member-outputs.ts +22 -4
- package/src/op/activities/pulumi-adopt.ts +13 -2
- package/src/op/activity-contracts.ts +2 -2
- package/src/planned-output-changes.test.ts +85 -0
- package/src/planned-output-changes.ts +141 -0
- package/src/plugin.ts +26 -0
- package/src/preview-tips.test.ts +135 -0
- package/src/preview-tips.ts +128 -0
- package/src/pulumi-up.test.ts +100 -0
- package/src/pulumi-up.ts +84 -18
- package/src/release-copy.test.ts +90 -2
- package/src/release-copy.ts +103 -22
- package/src/run-plan-flags.test.ts +53 -0
- package/src/run.test.ts +1 -1
- package/src/run.ts +57 -14
- package/src/stack-export-snapshot.test.ts +3 -1
|
@@ -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
|
|
@@ -122,6 +136,18 @@ export const pulumiPlugin: LexiconPlugin = {
|
|
|
122
136
|
return { entities: read.entities, warnings: read.warnings };
|
|
123
137
|
},
|
|
124
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The stacks a change touched (#3183, INTENTIUS/plywood#22): a changed
|
|
141
|
+
* `Pulumi.<stack>.yaml` is that stack, any other file of a project is every
|
|
142
|
+
* stack with a config beside its `Pulumi.yaml`. Named as a `pulumi-up`
|
|
143
|
+
* step's deploy unit, so `chant components pr-plan` and `fan-out --base`
|
|
144
|
+
* select the components that update them. See ./changed-units.ts.
|
|
145
|
+
*/
|
|
146
|
+
async changedUnits(ctx) {
|
|
147
|
+
const { pulumiChangedUnits } = await import("./changed-units");
|
|
148
|
+
return pulumiChangedUnits(ctx.projectRoot, ctx.changedFiles);
|
|
149
|
+
},
|
|
150
|
+
|
|
125
151
|
graphMeta() {
|
|
126
152
|
const report = lastMemberReport();
|
|
127
153
|
return report ? { pulumiMember: report } : undefined;
|
|
@@ -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.test.ts
CHANGED
|
@@ -299,6 +299,63 @@ describe("releaseInputs (#4542)", () => {
|
|
|
299
299
|
});
|
|
300
300
|
});
|
|
301
301
|
|
|
302
|
+
describe("the step's backend (plywood#22)", () => {
|
|
303
|
+
const store = "plunge+s3://plywood-tutorial-plunge?region=us-east-1&endpoint=http://floci:4566&s3ForcePathStyle=true";
|
|
304
|
+
|
|
305
|
+
test("plunge's preview, up and output read each get it as PULUMI_BACKEND_URL, over the job's", async () => {
|
|
306
|
+
const plunge = engine();
|
|
307
|
+
const cap = createPulumiUpCapability({ plunge: plunge.run, env: { PULUMI_BACKEND_URL: "file:///tmp/plywood-tutorial-backend" }, tmp: tmp(), snapshots: null });
|
|
308
|
+
const planned = await cap.plan!(ctx, step({ engine: "plunge", backend: store }));
|
|
309
|
+
await cap.run({ ...ctx, plans: { [member]: planned.artifact as PulumiUpArtifact } }, step({ engine: "plunge", backend: store }));
|
|
310
|
+
expect(plunge.calls.map((c) => c.args[0])).toEqual(["preview", "up", "stack"]);
|
|
311
|
+
for (const c of plunge.calls) expect(c.env?.PULUMI_BACKEND_URL).toBe(store);
|
|
312
|
+
// The preview keeps its streaming flag beside the backend.
|
|
313
|
+
expect(plunge.calls[0].env).toEqual({ [PULUMI_STREAMING_PREVIEW_ENV]: "true", PULUMI_BACKEND_URL: store });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("stock Pulumi's preview, up and output read get it too, and outputs() reads through it", async () => {
|
|
317
|
+
const pulumi = engine();
|
|
318
|
+
const s3 = "s3://plywood-tutorial-state?region=us-east-1&endpoint=http://floci:4566&use_path_style=true";
|
|
319
|
+
const cap = createPulumiUpCapability({ run: pulumi.run, env: {}, tmp: tmp(), snapshots: null });
|
|
320
|
+
const planned = await cap.plan!(ctx, step({ engine: "pulumi", backend: s3 }));
|
|
321
|
+
await cap.run({ ...ctx, plans: { [member]: planned.artifact as PulumiUpArtifact } }, step({ engine: "pulumi", backend: s3 }));
|
|
322
|
+
await cap.outputs!(ctx, step({ engine: "pulumi", backend: s3 }));
|
|
323
|
+
expect(pulumi.calls.length).toBeGreaterThanOrEqual(4);
|
|
324
|
+
for (const c of pulumi.calls) expect(c.env?.PULUMI_BACKEND_URL).toBe(s3);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("a plunge+ backend picks plunge where the job's PULUMI_BACKEND_URL would have picked stock Pulumi", async () => {
|
|
328
|
+
const plunge = engine();
|
|
329
|
+
const pulumi = engine();
|
|
330
|
+
const cap = createPulumiUpCapability({ run: pulumi.run, plunge: plunge.run, env: { PULUMI_BACKEND_URL: "file:///tmp/b" }, tmp: tmp(), snapshots: null });
|
|
331
|
+
const planned = await cap.plan!(ctx, step({ backend: store }));
|
|
332
|
+
expect((planned.artifact as PulumiUpArtifact).engine).toBe("plunge");
|
|
333
|
+
expect(pulumi.calls).toHaveLength(0);
|
|
334
|
+
expect(cap.defaultLockGrain!(step({ backend: store }))).toBe("resource");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("the ledger lock (ruling for ci-products 2026-10-28): a stack's target row is its stack row, from Pulumi.yaml's name", () => {
|
|
338
|
+
const cap = createPulumiUpCapability({ env: {}, tmp: tmp(), snapshots: null });
|
|
339
|
+
expect(cap.targetLockRow!(step())).toEqual({ key: "stack corpus-aws-ts/floci", target: member, address: "corpus-aws-ts/floci" });
|
|
340
|
+
// A plunge stack on the resource grain still leases URN rows; the stack row it may also take meets this one.
|
|
341
|
+
expect(cap.targetLockRow!(step({ stack: "prod-us" }))).toMatchObject({ key: "stack corpus-aws-ts/prod-us" });
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("without it nothing changes: no PULUMI_BACKEND_URL is added", async () => {
|
|
345
|
+
const plunge = engine();
|
|
346
|
+
const cap = createPulumiUpCapability({ plunge: plunge.run, env: {}, tmp: tmp(), snapshots: null });
|
|
347
|
+
await cap.plan!(ctx, step({ engine: "plunge" }));
|
|
348
|
+
expect(plunge.calls[0].env).toEqual({ [PULUMI_STREAMING_PREVIEW_ENV]: "true" });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("an empty backend is refused before anything runs", async () => {
|
|
352
|
+
const plunge = engine();
|
|
353
|
+
const cap = createPulumiUpCapability({ plunge: plunge.run, env: {}, tmp: tmp(), snapshots: null });
|
|
354
|
+
await expect(cap.plan!(ctx, step({ engine: "plunge", backend: " " }))).rejects.toThrow(/"backend" is the stack's backend URL/);
|
|
355
|
+
expect(plunge.calls).toHaveLength(0);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
302
359
|
describe("a step pinned to its engine release (#4592)", () => {
|
|
303
360
|
test("engineVersion runs the versioned binary, through the same argv checks", async () => {
|
|
304
361
|
const pinned = engine();
|
|
@@ -423,6 +480,8 @@ describe("CrossGuard packs read at base (#4613)", () => {
|
|
|
423
480
|
const stack = join(top, "stack");
|
|
424
481
|
mkdirSync(join(stack, "guard"), { recursive: true });
|
|
425
482
|
for (const f of ["Pulumi.yaml", "stack-export.json"]) copyFileSync(join(dir, f), join(stack, f));
|
|
483
|
+
// An installed checkout, so the step runs no install before the preview (plywood#22).
|
|
484
|
+
mkdirSync(join(stack, "node_modules"));
|
|
426
485
|
writeFileSync(join(stack, "guard", "index.ts"), "// no-public-bucket: mandatory\n");
|
|
427
486
|
git(top, "add", ".");
|
|
428
487
|
git(top, "commit", "-q", "-m", "base");
|
|
@@ -468,3 +527,44 @@ describe("CrossGuard packs read at base (#4613)", () => {
|
|
|
468
527
|
expect(pulumi.calls).toHaveLength(0);
|
|
469
528
|
});
|
|
470
529
|
});
|
|
530
|
+
|
|
531
|
+
describe("an in-place checkout (plywood#22)", () => {
|
|
532
|
+
/** A checkout of the corpus program: Pulumi.yaml, package.json and, unless left out, a lockfile; no node_modules. */
|
|
533
|
+
function checkout(lockfile = true): string {
|
|
534
|
+
const stack = tmp();
|
|
535
|
+
for (const f of ["Pulumi.yaml", "stack-export.json"]) copyFileSync(join(dir, f), join(stack, f));
|
|
536
|
+
writeFileSync(join(stack, "package.json"), "{}\n");
|
|
537
|
+
if (lockfile) writeFileSync(join(stack, "package-lock.json"), "{}\n");
|
|
538
|
+
return stack;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
test("a Node checkout with a lockfile and no node_modules gets one install, with the step's backend, before the first preview", async () => {
|
|
542
|
+
const stack = checkout();
|
|
543
|
+
const pulumi = engine();
|
|
544
|
+
const cap = createPulumiUpCapability({ run: pulumi.run, env: {}, tmp: tmp(), snapshots: null });
|
|
545
|
+
const planned = await cap.plan!(ctx, step({ dir: stack, engine: "pulumi", backend: "s3://shop-state" }));
|
|
546
|
+
expect(pulumi.calls[0]).toMatchObject({ args: ["install"], cwd: stack, env: { PULUMI_BACKEND_URL: "s3://shop-state" } });
|
|
547
|
+
expect(pulumi.calls[1]!.args[0]).toBe("preview");
|
|
548
|
+
|
|
549
|
+
await cap.run({ ...ctx, plans: { [planned.member]: planned.artifact as PulumiUpArtifact } }, step({ dir: stack, engine: "pulumi", backend: "s3://shop-state" }));
|
|
550
|
+
expect(pulumi.calls.filter((c) => c.args[0] === "install")).toHaveLength(1);
|
|
551
|
+
expect(pulumi.calls.some((c) => c.args[0] === "up")).toBe(true);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("a Node checkout with no lockfile is refused by name before the engine runs", async () => {
|
|
555
|
+
const stack = checkout(false);
|
|
556
|
+
const pulumi = engine();
|
|
557
|
+
const cap = createPulumiUpCapability({ run: pulumi.run, env: {}, tmp: tmp(), snapshots: null });
|
|
558
|
+
await expect(cap.plan!(ctx, step({ dir: stack, engine: "pulumi" }))).rejects.toThrow(/no lockfile .* beside its package.json/);
|
|
559
|
+
expect(pulumi.calls).toHaveLength(0);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("an installed checkout runs no install", async () => {
|
|
563
|
+
const stack = checkout();
|
|
564
|
+
mkdirSync(join(stack, "node_modules"));
|
|
565
|
+
const pulumi = engine();
|
|
566
|
+
const cap = createPulumiUpCapability({ run: pulumi.run, env: {}, tmp: tmp(), snapshots: null });
|
|
567
|
+
await cap.plan!(ctx, step({ dir: stack, engine: "pulumi" }));
|
|
568
|
+
expect(pulumi.calls.map((c) => c.args[0])).not.toContain("install");
|
|
569
|
+
});
|
|
570
|
+
});
|