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