@intentius/chant-lexicon-terraform 0.124.45 → 0.124.48
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/__fixtures__/capabilities/chant.config.d.ts +26 -0
- package/dist/__fixtures__/capabilities/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/capabilities/chant.config.js +13 -0
- package/dist/__fixtures__/effective-topology/chant.config.d.ts +13 -0
- package/dist/__fixtures__/effective-topology/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/effective-topology/chant.config.js +9 -0
- package/dist/__fixtures__/kind-type/chant.config.d.ts +13 -0
- package/dist/__fixtures__/kind-type/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/kind-type/chant.config.js +9 -0
- package/dist/__fixtures__/search-roots/chant.config.d.ts +13 -0
- package/dist/__fixtures__/search-roots/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/search-roots/chant.config.js +9 -0
- package/dist/__fixtures__/sensitive-default/chant.config.d.ts +13 -0
- package/dist/__fixtures__/sensitive-default/chant.config.d.ts.map +1 -0
- package/dist/__fixtures__/sensitive-default/chant.config.js +9 -0
- package/dist/capabilities/adapter.d.ts +9 -0
- package/dist/capabilities/adapter.d.ts.map +1 -0
- package/dist/capabilities/adapter.js +25 -0
- package/dist/capabilities/mapping.json +166 -0
- package/dist/config.d.ts +52 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/describe-resources.d.ts +101 -8
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/describe-resources.js +395 -56
- package/dist/hcl/parse.d.ts +10 -0
- package/dist/hcl/parse.d.ts.map +1 -1
- package/dist/hcl/parse.js +20 -2
- package/dist/hcl/redact.d.ts +26 -0
- package/dist/hcl/redact.d.ts.map +1 -0
- package/dist/hcl/redact.js +228 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/terraform.d.ts +109 -4
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/activities/terraform.js +148 -7
- package/dist/plan-observation.d.ts +153 -0
- package/dist/plan-observation.d.ts.map +1 -0
- package/dist/plan-observation.js +354 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +2 -0
- package/package.json +2 -2
- package/src/__fixtures__/ACCEPTANCE.md +53 -1
- package/src/__fixtures__/capabilities/catalog/main.tf +105 -0
- package/src/__fixtures__/capabilities/chant.config.ts +15 -0
- package/src/__fixtures__/capabilities/findings/main.tf +225 -0
- package/src/__fixtures__/capabilities/ledger/main.tf +265 -0
- package/src/__fixtures__/capabilities/package.json +1 -0
- package/src/__fixtures__/effective-topology/chant.config.ts +11 -0
- package/src/__fixtures__/effective-topology/net/main.tf +176 -0
- package/src/__fixtures__/effective-topology/package.json +6 -0
- package/src/__fixtures__/kind-type/app/main.tf +35 -0
- package/src/__fixtures__/kind-type/chant.config.ts +11 -0
- package/src/__fixtures__/kind-type/package.json +6 -0
- package/src/__fixtures__/live-plan-depth/README.md +59 -0
- package/src/__fixtures__/live-plan-depth/live-plan.json +110 -0
- package/src/__fixtures__/live-plan-depth/show-plan-sensitive.json +1 -0
- package/src/__fixtures__/live-plan-depth/show-plan.json +1 -0
- package/src/__fixtures__/plan-read/main.tf +46 -0
- package/src/__fixtures__/plan-read/next.tfvars +1 -0
- package/src/__fixtures__/plan-read-drift/README.md +33 -0
- package/src/__fixtures__/plan-read-drift/main.tf +63 -0
- package/src/__fixtures__/plan-read-drift/next.tfvars +1 -0
- package/src/__fixtures__/search-roots/chant.config.ts +11 -0
- package/src/__fixtures__/search-roots/net/main.tf +73 -0
- package/src/__fixtures__/search-roots/package.json +6 -0
- package/src/__fixtures__/sensitive-default/chant.config.ts +11 -0
- package/src/__fixtures__/sensitive-default/net/main.tf +38 -0
- package/src/__fixtures__/sensitive-default/package.json +6 -0
- package/src/__fixtures__/show-plan.json +663 -0
- package/src/capabilities/adapter.ts +32 -0
- package/src/capabilities/mapping.json +166 -0
- package/src/capabilities-workspace.e2e.test.ts +251 -0
- package/src/capabilities.test.ts +264 -0
- package/src/config.ts +26 -0
- package/src/describe-resources.live-plan.test.ts +302 -0
- package/src/describe-resources.live-root-unchanged.test.ts +126 -0
- package/src/describe-resources.plan.test.ts +437 -0
- package/src/describe-resources.ts +445 -52
- package/src/effective-topology.test.ts +134 -0
- package/src/hcl/parse.ts +30 -2
- package/src/hcl/redact.ts +181 -0
- package/src/live-plan-depth.acceptance.e2e.test.ts +193 -0
- package/src/op/activities/terraform.test.ts +118 -1
- package/src/op/activities/terraform.ts +213 -9
- package/src/plan-observation.ts +428 -0
- package/src/plan-read.acceptance.test.ts +184 -0
- package/src/plugin.ts +2 -0
- package/src/redact-ir.e2e.test.ts +79 -0
- package/src/redact.test.ts +111 -0
- package/src/search-build-roots.test.ts +125 -0
- package/src/search-count.test.ts +144 -0
- package/src/search-kind-type.test.ts +136 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A live read leaves the root as it found it (#4379).
|
|
3
|
+
*
|
|
4
|
+
* `describeResources` on a choudoufu root runs `live-plan -json` and used to
|
|
5
|
+
* write the document it parsed into the root as `chant.live-plan.json`, at the
|
|
6
|
+
* default depth and at the plan depth alike. A reader that observes a project
|
|
7
|
+
* (behold's only write inside a served project is `.behold/layout.json`) must
|
|
8
|
+
* not find a new file there afterwards.
|
|
9
|
+
*
|
|
10
|
+
* The root is a copy of `__fixtures__/live-estate/`, the real activities run
|
|
11
|
+
* against a stubbed `node:child_process` that answers `live-plan -json` with
|
|
12
|
+
* the recorded document, and the test snapshots every file in the root (path
|
|
13
|
+
* and bytes) before and after.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { dirname, join, relative } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
22
|
+
import { describeResources, type TerraformReadDeps } from "./describe-resources";
|
|
23
|
+
import { renderTerraformRoots } from "./hcl/roots";
|
|
24
|
+
import {
|
|
25
|
+
__resetChoudoufuVersionCheckForTests,
|
|
26
|
+
choudoufuLiveLs,
|
|
27
|
+
choudoufuLivePlan,
|
|
28
|
+
choudoufuLivePlanDocument,
|
|
29
|
+
terraformInit,
|
|
30
|
+
terraformShow,
|
|
31
|
+
} from "./op/activities/terraform";
|
|
32
|
+
|
|
33
|
+
const fixtures = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__");
|
|
34
|
+
const LIVE_PLAN_DOC = readFileSync(join(fixtures, "live-plan.json"), "utf-8");
|
|
35
|
+
const PLAN: unknown = JSON.parse(readFileSync(join(fixtures, "live-plan-depth", "show-plan.json"), "utf-8"));
|
|
36
|
+
|
|
37
|
+
const commands: string[] = [];
|
|
38
|
+
|
|
39
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
40
|
+
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
41
|
+
const custom = Symbol.for("nodejs.util.promisify.custom");
|
|
42
|
+
const exec = ((_cmd: string, _opts: unknown, cb?: (...a: unknown[]) => void) => {
|
|
43
|
+
cb?.(new Error("unmocked exec path"));
|
|
44
|
+
}) as unknown as Record<symbol, unknown>;
|
|
45
|
+
exec[custom] = async (cmd: string) => {
|
|
46
|
+
commands.push(cmd);
|
|
47
|
+
if (cmd === "choudoufu version") return { stdout: "choudoufu v99.0.0\n", stderr: "" };
|
|
48
|
+
if (cmd.includes("live-plan") && cmd.includes("-json")) return { stdout: LIVE_PLAN_DOC, stderr: "" };
|
|
49
|
+
return { stdout: "", stderr: "" };
|
|
50
|
+
};
|
|
51
|
+
return { ...actual, exec };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const workspaces: string[] = [];
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
for (const dir of workspaces) rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
commands.length = 0;
|
|
60
|
+
__resetChoudoufuVersionCheckForTests();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** A project whose one root is a copy of the live-estate fixture. */
|
|
64
|
+
function project(): string {
|
|
65
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-live-root-"));
|
|
66
|
+
workspaces.push(dir);
|
|
67
|
+
cpSync(join(fixtures, "live-estate"), join(dir, "estate"), { recursive: true });
|
|
68
|
+
writeFileSync(
|
|
69
|
+
join(dir, "chant.config.json"),
|
|
70
|
+
JSON.stringify({ lexicons: ["terraform"], terraform: { binary: "choudoufu", roots: { estate: { dir: "./estate" } } } }),
|
|
71
|
+
);
|
|
72
|
+
return dir;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every file under `dir`, path -> bytes, so a new, removed or rewritten file all show. */
|
|
76
|
+
function snapshot(dir: string): Record<string, string> {
|
|
77
|
+
const out: Record<string, string> = {};
|
|
78
|
+
const walk = (d: string): void => {
|
|
79
|
+
for (const name of readdirSync(d).sort()) {
|
|
80
|
+
const path = join(d, name);
|
|
81
|
+
if (statSync(path).isDirectory()) walk(path);
|
|
82
|
+
else out[relative(dir, path)] = readFileSync(path, "base64");
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
walk(dir);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function read(dir: string, depth: "plan" | "state") {
|
|
90
|
+
const { entities } = await renderTerraformRoots({ projectRoot: dir, roots: { estate: { dir: "./estate" } }, binary: "choudoufu" });
|
|
91
|
+
const declared = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
92
|
+
for (const [key, entity] of entities) {
|
|
93
|
+
declared.set(key, { entityType: entity.entityType, props: (entity as Declarable & { props: Record<string, unknown> }).props });
|
|
94
|
+
}
|
|
95
|
+
// The plan read is injected (it writes under the OS temp dir by design, #4358);
|
|
96
|
+
// the live-plan read is the real activity over the stubbed binary.
|
|
97
|
+
const planRead = (async () => ({
|
|
98
|
+
json: PLAN,
|
|
99
|
+
dir: join(dir, "estate"),
|
|
100
|
+
planDir: "/tmp/chant-terraform-plan-x",
|
|
101
|
+
command: "choudoufu plan -input=false -lock=false -out=/tmp/chant-terraform-plan-x/plan.tfplan",
|
|
102
|
+
})) as TerraformReadDeps["planRead"];
|
|
103
|
+
return describeResources(
|
|
104
|
+
{ environment: "prod", buildOutput: "", entityNames: [...declared.keys()].sort(), entities: declared, depth, cwd: dir },
|
|
105
|
+
{ init: terraformInit, show: terraformShow, liveLs: choudoufuLiveLs, livePlan: choudoufuLivePlanDocument, planRead },
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("a live read writes nothing into the root (#4379)", () => {
|
|
110
|
+
for (const depth of ["state", "plan"] as const) {
|
|
111
|
+
it(`${depth === "state" ? "the default depth" : "the plan depth"} leaves the root byte-for-byte unchanged`, async () => {
|
|
112
|
+
const dir = project();
|
|
113
|
+
const before = snapshot(dir);
|
|
114
|
+
await read(dir, depth);
|
|
115
|
+
expect(commands.some((c) => c.includes("live-plan") && c.includes("-json"))).toBe(true);
|
|
116
|
+
expect(snapshot(dir)).toEqual(before);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
it("the Op activity still writes the document, because its documentPath is an output a later step opens", async () => {
|
|
121
|
+
const dir = project();
|
|
122
|
+
const result = await choudoufuLivePlan({ root: "estate", cwd: dir });
|
|
123
|
+
expect(result.documentPath).toBe("chant.live-plan.json");
|
|
124
|
+
expect(JSON.parse(readFileSync(join(dir, "estate", "chant.live-plan.json"), "utf-8"))).toEqual(JSON.parse(LIVE_PLAN_DOC));
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan depth (#4358): a stock root read from a saved plan.
|
|
3
|
+
*
|
|
4
|
+
* The declared side is real: `renderTerraformRoots` parses
|
|
5
|
+
* `src/__fixtures__/plan-read-drift/` exactly as `buildRoots()` does. The
|
|
6
|
+
* plan side is `src/__fixtures__/show-plan.json`, recorded from that root by
|
|
7
|
+
* Terraform v1.15.8 (`plan-read-drift/README.md` is the log): one drifted
|
|
8
|
+
* object, two replaces, three updates, a sensitive value nested in a map.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here runs terraform: the activities are injected. The real binary
|
|
11
|
+
* is `plan-read.acceptance.test.ts`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
18
|
+
import { normalizeObservation } from "@intentius/chant/observation";
|
|
19
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
20
|
+
import { buildLiveGraphIr, sourceOverlayGraphs, type GraphIR } from "@intentius/chant/graph-ir";
|
|
21
|
+
import { carryAnnotations } from "@intentius/chant/lifecycle/observe";
|
|
22
|
+
import { buildChangeSet } from "@intentius/chant/lifecycle/change-set";
|
|
23
|
+
import { __searchInternals } from "@intentius/chant/cli/handlers/search";
|
|
24
|
+
import {
|
|
25
|
+
describeResources,
|
|
26
|
+
planFailureIsCredentials,
|
|
27
|
+
TERRAFORM_OBSERVE_ENV,
|
|
28
|
+
type TerraformReadDeps,
|
|
29
|
+
} from "./describe-resources";
|
|
30
|
+
import { TerraformPlanReadError } from "./op/activities/terraform";
|
|
31
|
+
import { diffChange, indexPlan, KNOWN_AFTER_APPLY, PLAN_ATTRIBUTE_NAMES, plannedAttributesFor, SENSITIVE_VALUE } from "./plan-observation";
|
|
32
|
+
import { renderTerraformRoots } from "./hcl/roots";
|
|
33
|
+
|
|
34
|
+
const fixtures = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__");
|
|
35
|
+
const PLAN: unknown = JSON.parse(readFileSync(join(fixtures, "show-plan.json"), "utf-8"));
|
|
36
|
+
const ROOT_DIR = join(fixtures, "plan-read-drift");
|
|
37
|
+
/** `var.token`'s value in the fixture. It must never appear in an observation. */
|
|
38
|
+
const SECRET = "hunter2";
|
|
39
|
+
|
|
40
|
+
async function declaredEntities(): Promise<Map<string, { entityType: string; props: Record<string, unknown> }>> {
|
|
41
|
+
const { entities } = await renderTerraformRoots({ projectRoot: fixtures, roots: { app: { dir: "./plan-read-drift" } } });
|
|
42
|
+
const out = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
43
|
+
for (const [key, entity] of entities) {
|
|
44
|
+
out.set(key, { entityType: entity.entityType, props: (entity as Declarable & { props: Record<string, unknown> }).props });
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function options(depth: "plan" | "state" | undefined = "plan") {
|
|
50
|
+
const entities = await declaredEntities();
|
|
51
|
+
return { environment: "prod", buildOutput: "", entityNames: [...entities.keys()].sort(), entities, ...(depth ? { depth } : {}) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const never = ((args: { root: string }) => {
|
|
55
|
+
throw new Error(`unexpected activity for root "${args.root}"`);
|
|
56
|
+
}) as never;
|
|
57
|
+
|
|
58
|
+
/** The state a fallback reads: the plan's own prior state, which is what state held. */
|
|
59
|
+
const STATE_FROM_PLAN = { values: (PLAN as { prior_state: { values: unknown } }).prior_state.values };
|
|
60
|
+
|
|
61
|
+
function deps(overrides?: Partial<TerraformReadDeps>): TerraformReadDeps {
|
|
62
|
+
return {
|
|
63
|
+
init: never,
|
|
64
|
+
show: (async () => ({ source: "state" as const, json: STATE_FROM_PLAN, text: "", dir: ROOT_DIR, adds: 0, changes: 0, destroys: 0 })) as TerraformReadDeps["show"],
|
|
65
|
+
livePlan: never,
|
|
66
|
+
liveLs: never,
|
|
67
|
+
planRead: (async () => ({
|
|
68
|
+
json: PLAN,
|
|
69
|
+
dir: ROOT_DIR,
|
|
70
|
+
planDir: "/tmp/chant-terraform-plan-x",
|
|
71
|
+
command: "terraform plan -input=false -lock=false -var-file=next.tfvars -out=/tmp/chant-terraform-plan-x/plan.tfplan",
|
|
72
|
+
})) as TerraformReadDeps["planRead"],
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const planFails = (err: Error): TerraformReadDeps["planRead"] =>
|
|
78
|
+
(async () => {
|
|
79
|
+
throw err;
|
|
80
|
+
}) as TerraformReadDeps["planRead"];
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
delete process.env[TERRAFORM_OBSERVE_ENV];
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("the plan depth puts the plan on the IR (#4358)", () => {
|
|
87
|
+
it("planned actions, replace_paths, action_reason, after_unknown and resource_drift ride each block", async () => {
|
|
88
|
+
const result = normalizeObservation(await describeResources(await options(), deps()));
|
|
89
|
+
const attrs = (name: string) => result.resources[`app/${name}`]!.attributes as Record<string, unknown>;
|
|
90
|
+
|
|
91
|
+
expect(attrs("terraform_data.replaced")).toMatchObject({
|
|
92
|
+
planAction: "replace",
|
|
93
|
+
planActions: { "terraform_data.replaced": ["delete", "create"] },
|
|
94
|
+
planActionReason: "replace_because_cannot_update",
|
|
95
|
+
planReplacePaths: ["triggers_replace"],
|
|
96
|
+
planRelevantAttributes: ["id"],
|
|
97
|
+
});
|
|
98
|
+
expect(attrs("terraform_data.downstream")).toMatchObject({ planAction: "update", planAfterUnknown: ["input", "output"] });
|
|
99
|
+
expect(attrs("terraform_data.reader").planAfterUnknown).toEqual(["input", "output"]);
|
|
100
|
+
|
|
101
|
+
const conf = attrs("shell_script.conf");
|
|
102
|
+
expect(conf).toMatchObject({
|
|
103
|
+
planAction: "replace",
|
|
104
|
+
planReplacePaths: ["dirty"],
|
|
105
|
+
planActionReason: "replace_because_cannot_update",
|
|
106
|
+
planDriftAction: "update",
|
|
107
|
+
planRelevantAttributes: ["output.port"],
|
|
108
|
+
});
|
|
109
|
+
expect(conf.planDrift).toEqual(
|
|
110
|
+
expect.arrayContaining([
|
|
111
|
+
{ path: "dirty", before: false, after: true },
|
|
112
|
+
{ path: "read_error", before: null, after: "" },
|
|
113
|
+
]),
|
|
114
|
+
);
|
|
115
|
+
// The drift touched `dirty` and `read_error`; the plan reads `output.port`,
|
|
116
|
+
// which did not move, so no drift entry is marked relevant.
|
|
117
|
+
expect((conf.planDrift as Array<{ relevant?: boolean }>).some((c) => c.relevant)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("an update carries its attribute-level changes, the unknown side spelled as terraform spells it", async () => {
|
|
121
|
+
const result = normalizeObservation(await describeResources(await options(), deps()));
|
|
122
|
+
const updated = result.resources["app/terraform_data.updated"]!.attributes as Record<string, unknown>;
|
|
123
|
+
expect(updated.planChanges).toEqual(
|
|
124
|
+
expect.arrayContaining([
|
|
125
|
+
{ path: "input.name", before: "app-1", after: "app-2" },
|
|
126
|
+
{ path: "output.name", before: "app-1", after: KNOWN_AFTER_APPLY },
|
|
127
|
+
]),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("presence and ownership come from prior_state, by the state read's own rule", async () => {
|
|
132
|
+
const result = normalizeObservation(await describeResources(await options(), deps()));
|
|
133
|
+
const replaced = result.resources["app/terraform_data.replaced"]!;
|
|
134
|
+
expect(replaced.ownership).toBe("owned");
|
|
135
|
+
expect(replaced.physicalId).toMatch(/^[0-9a-f-]{36}$/);
|
|
136
|
+
// Not a row in state, and not a block the plan touches.
|
|
137
|
+
expect(result.unobserved["app/var.generation"]?.reason).toBe("unsupported-kind");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("every attribute name it sets is one PLAN_ATTRIBUTE_NAMES lists", async () => {
|
|
141
|
+
const result = normalizeObservation(await describeResources(await options(), deps()));
|
|
142
|
+
const listed = new Set<string>(PLAN_ATTRIBUTE_NAMES);
|
|
143
|
+
for (const meta of Object.values(result.resources)) {
|
|
144
|
+
for (const key of Object.keys(meta.attributes ?? {})) {
|
|
145
|
+
if (key.startsWith("plan")) expect(listed.has(key), key).toBe(true);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("chant search matches the plan attributes", async () => {
|
|
151
|
+
const result = normalizeObservation(await describeResources(await options(), deps()));
|
|
152
|
+
const ir = buildLiveGraphIr([{ lexicon: "terraform", resources: result.resources }]);
|
|
153
|
+
const { parseQuery, matchTerm } = __searchInternals;
|
|
154
|
+
const search = (query: string) =>
|
|
155
|
+
ir.nodes
|
|
156
|
+
.filter((n) => parseQuery(query).every((t) => matchTerm(n, t, ir)))
|
|
157
|
+
.map((n) => n.id)
|
|
158
|
+
.sort();
|
|
159
|
+
|
|
160
|
+
expect(search("attr:planAction=replace")).toEqual(["app/shell_script.conf", "app/terraform_data.replaced"]);
|
|
161
|
+
expect(search("attr:planReplacePaths=triggers_replace")).toEqual(["app/terraform_data.replaced"]);
|
|
162
|
+
expect(search("attr:planActionReason=replace_because_cannot_update")).toHaveLength(2);
|
|
163
|
+
expect(search("attr:planAfterUnknown=input")).toEqual(["app/terraform_data.downstream", "app/terraform_data.reader"]);
|
|
164
|
+
expect(search("attr:planDrift")).toEqual(["app/shell_script.conf"]);
|
|
165
|
+
expect(search(SECRET)).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The recorded plan with `terraform_data.downstream` turned into a planned
|
|
171
|
+
* create (#4372): no row in `prior_state`, `actions: ["create"]`, no before,
|
|
172
|
+
* and `id`, `input` and `output` unknown until apply, which is what a real
|
|
173
|
+
* plan says about a new `terraform_data` reading another block's new id.
|
|
174
|
+
*/
|
|
175
|
+
function planWithCreate(): unknown {
|
|
176
|
+
const plan = structuredClone(PLAN) as {
|
|
177
|
+
prior_state: { values: { root_module: { resources: Array<{ address: string }> } } };
|
|
178
|
+
resource_changes: Array<{ address: string; change: Record<string, unknown> }>;
|
|
179
|
+
};
|
|
180
|
+
const module = plan.prior_state.values.root_module;
|
|
181
|
+
module.resources = module.resources.filter((r) => r.address !== "terraform_data.downstream");
|
|
182
|
+
const entry = plan.resource_changes.find((r) => r.address === "terraform_data.downstream")!;
|
|
183
|
+
entry.change = {
|
|
184
|
+
actions: ["create"],
|
|
185
|
+
before: null,
|
|
186
|
+
after: { triggers_replace: null },
|
|
187
|
+
after_unknown: { id: true, input: true, output: true },
|
|
188
|
+
before_sensitive: false,
|
|
189
|
+
after_sensitive: {},
|
|
190
|
+
};
|
|
191
|
+
return plan;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const withCreate = (): TerraformReadDeps =>
|
|
195
|
+
deps({
|
|
196
|
+
planRead: (async () => ({
|
|
197
|
+
json: planWithCreate(),
|
|
198
|
+
dir: ROOT_DIR,
|
|
199
|
+
planDir: "/tmp/chant-terraform-plan-x",
|
|
200
|
+
command: "terraform plan -input=false -lock=false -out=/tmp/chant-terraform-plan-x/plan.tfplan",
|
|
201
|
+
})) as TerraformReadDeps["planRead"],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("a planned create carries its plan attributes and stays absent (#4372)", () => {
|
|
205
|
+
const CREATED = "app/terraform_data.downstream";
|
|
206
|
+
|
|
207
|
+
it("is in neither map, and its plan attributes are annotations", async () => {
|
|
208
|
+
const raw = await describeResources(await options(), withCreate());
|
|
209
|
+
const result = normalizeObservation(raw);
|
|
210
|
+
expect(result.resources[CREATED]).toBeUndefined();
|
|
211
|
+
expect(result.unobserved[CREATED]).toBeUndefined();
|
|
212
|
+
expect(result.sources[CREATED]).toBe("plan");
|
|
213
|
+
expect(result.annotations?.[CREATED]).toEqual({
|
|
214
|
+
planAction: "create",
|
|
215
|
+
planActions: { "terraform_data.downstream": ["create"] },
|
|
216
|
+
planAfterUnknown: ["id", "input", "output"],
|
|
217
|
+
});
|
|
218
|
+
// Only the planned create is annotated: a present block keeps its
|
|
219
|
+
// attributes on its row, and nothing else is absent.
|
|
220
|
+
expect(Object.keys(result.annotations ?? {})).toEqual([CREATED]);
|
|
221
|
+
expect(result.resources["app/terraform_data.replaced"]!.attributes).toMatchObject({ planAction: "replace" });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("every annotation name is one PLAN_ATTRIBUTE_NAMES lists", async () => {
|
|
225
|
+
const result = normalizeObservation(await describeResources(await options(), withCreate()));
|
|
226
|
+
const listed = new Set<string>(PLAN_ATTRIBUTE_NAMES);
|
|
227
|
+
for (const key of Object.keys(result.annotations?.[CREATED] ?? {})) expect(listed.has(key), key).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("a block the plan does not mention has no annotations", () => {
|
|
231
|
+
const index = indexPlan(planWithCreate());
|
|
232
|
+
expect(plannedAttributesFor("terraform_data.nowhere", index)).toBeUndefined();
|
|
233
|
+
expect(plannedAttributesFor("terraform_data.downstream", index)).toMatchObject({ planAction: "create" });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("the state read never annotates", async () => {
|
|
237
|
+
const result = normalizeObservation(await describeResources(await options("state"), withCreate()));
|
|
238
|
+
expect(result.annotations).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("lifecycle plan still proposes the create", async () => {
|
|
242
|
+
const opts = await options();
|
|
243
|
+
const result = normalizeObservation(await describeResources(opts, withCreate()));
|
|
244
|
+
const set = buildChangeSet("prod", {
|
|
245
|
+
declared: new Set(opts.entityNames),
|
|
246
|
+
observedNow: result.resources,
|
|
247
|
+
observedThen: undefined,
|
|
248
|
+
unobserved: result.unobserved,
|
|
249
|
+
});
|
|
250
|
+
expect(set.entries.find((e) => e.name === CREATED)?.action).toBe("create");
|
|
251
|
+
expect(set.entries.find((e) => e.name === "app/terraform_data.replaced")?.action).not.toBe("create");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("chant search matches it on the overlay, which still paints it pending", async () => {
|
|
255
|
+
const opts = await options();
|
|
256
|
+
const result = normalizeObservation(await describeResources(opts, withCreate()));
|
|
257
|
+
const live = buildLiveGraphIr([{ lexicon: "terraform", resources: result.resources }]);
|
|
258
|
+
const declared: GraphIR = {
|
|
259
|
+
...live,
|
|
260
|
+
nodes: opts.entityNames.map((id) => ({ id, kind: opts.entities.get(id)!.entityType, lexicon: "terraform", attrs: {} })),
|
|
261
|
+
edges: [],
|
|
262
|
+
};
|
|
263
|
+
const overlaid = sourceOverlayGraphs(declared, live, {});
|
|
264
|
+
const ir = { ...overlaid, nodes: carryAnnotations(overlaid.nodes, result.annotations) };
|
|
265
|
+
const node = ir.nodes.find((n) => n.id === CREATED)!;
|
|
266
|
+
expect(node.attrs._status).toBe("accent");
|
|
267
|
+
expect(node.physicalId).toBeUndefined();
|
|
268
|
+
|
|
269
|
+
const { parseQuery, matchTerm } = __searchInternals;
|
|
270
|
+
const search = (query: string) =>
|
|
271
|
+
ir.nodes
|
|
272
|
+
.filter((n) => parseQuery(query).every((t) => matchTerm(n, t, ir)))
|
|
273
|
+
.map((n) => n.id)
|
|
274
|
+
.sort();
|
|
275
|
+
expect(search("attr:planAction=create")).toEqual([CREATED]);
|
|
276
|
+
// A replace's new id is unknown too, so the create is one of three.
|
|
277
|
+
expect(search("attr:planAfterUnknown=id")).toEqual(["app/shell_script.conf", CREATED, "app/terraform_data.replaced"]);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("which read answered is on the wire: \"plan\" (#2267, #4358)", () => {
|
|
282
|
+
it("every entity of a plan-depth root reads \"plan\", whatever its verdict", async () => {
|
|
283
|
+
const opts = await options();
|
|
284
|
+
const result = normalizeObservation(await describeResources(opts, deps()));
|
|
285
|
+
expect(Object.keys(result.sources).sort()).toEqual([...opts.entityNames].sort());
|
|
286
|
+
expect(new Set(Object.values(result.sources))).toEqual(new Set(["plan"]));
|
|
287
|
+
expect(result.notes.join("\n")).toMatch(/app: the plan read answered \(`terraform plan -input=false -lock=false .*-out=<tmp>\/plan\.tfplan`\)/);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("the default depth is still the state read, and never calls the plan read", async () => {
|
|
291
|
+
const result = normalizeObservation(
|
|
292
|
+
await describeResources(await options(undefined), deps({ init: (async () => ({ dir: ROOT_DIR })) as TerraformReadDeps["init"], planRead: never })),
|
|
293
|
+
);
|
|
294
|
+
expect(new Set(Object.values(result.sources))).toEqual(new Set(["state"]));
|
|
295
|
+
const replaced = result.resources["app/terraform_data.replaced"]!.attributes as Record<string, unknown>;
|
|
296
|
+
expect(replaced.planAction).toBeUndefined();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it(`${TERRAFORM_OBSERVE_ENV}=plan selects the plan depth for one run`, async () => {
|
|
300
|
+
process.env[TERRAFORM_OBSERVE_ENV] = "plan";
|
|
301
|
+
const result = normalizeObservation(await describeResources(await options(undefined), deps()));
|
|
302
|
+
expect(new Set(Object.values(result.sources))).toEqual(new Set(["plan"]));
|
|
303
|
+
expect(result.rootReads).toEqual({ "terraform.roots.app": { depth: "plan" } });
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("the plan depth falls back by depth, and says which read answered (#4358)", () => {
|
|
308
|
+
it("an uninitialised root is refused with the init line, and nothing else is read", async () => {
|
|
309
|
+
const initLine = `terraform -chdir=${ROOT_DIR} init -input=false`;
|
|
310
|
+
const err = new TerraformPlanReadError(
|
|
311
|
+
"not-initialised",
|
|
312
|
+
`terraform.roots.app (${ROOT_DIR}) has never been initialised: run \`${initLine}\` first. chant never runs init on a read, since init writes into the root.`,
|
|
313
|
+
ROOT_DIR,
|
|
314
|
+
initLine,
|
|
315
|
+
);
|
|
316
|
+
const result = normalizeObservation(await describeResources(await options(), deps({ planRead: planFails(err), show: never, init: never })));
|
|
317
|
+
expect(Object.keys(result.resources)).toEqual([]);
|
|
318
|
+
const replaced = result.unobserved["app/terraform_data.replaced"]!;
|
|
319
|
+
expect(replaced.reason).toBe("no-binding");
|
|
320
|
+
expect(replaced.detail).toContain(initLine);
|
|
321
|
+
expect(replaced.detail).toContain("declared only");
|
|
322
|
+
expect(result.sources["app/terraform_data.replaced"]).toBe("plan");
|
|
323
|
+
expect(result.notes.join("\n")).toMatch(/no read answered, declared only \(the root needs init/);
|
|
324
|
+
expect(result.rootReads).toEqual({ "terraform.roots.app": { depth: "plan", fellBackTo: "declared", reason: "needs-init" } });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("missing provider credentials fall back to the state read, which answers without plan attributes", async () => {
|
|
328
|
+
const err = new TerraformPlanReadError(
|
|
329
|
+
"plan",
|
|
330
|
+
"terraform plan failed in /x: Error: No valid credential sources found",
|
|
331
|
+
ROOT_DIR,
|
|
332
|
+
);
|
|
333
|
+
const result = normalizeObservation(await describeResources(await options(), deps({ planRead: planFails(err) })));
|
|
334
|
+
const replaced = result.resources["app/terraform_data.replaced"]!;
|
|
335
|
+
expect(replaced.ownership).toBe("owned");
|
|
336
|
+
expect((replaced.attributes as Record<string, unknown>).planAction).toBeUndefined();
|
|
337
|
+
expect(new Set(Object.values(result.sources))).toEqual(new Set(["state"]));
|
|
338
|
+
expect(result.notes.join("\n")).toMatch(/the plan read failed \(no provider credentials: .*\), so the state read answered/);
|
|
339
|
+
expect(result.rootReads).toEqual({ "terraform.roots.app": { depth: "plan", fellBackTo: "state", reason: "no-credentials" } });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("missing backend access falls back to declared only, and the state read is never tried", async () => {
|
|
343
|
+
const err = new TerraformPlanReadError(
|
|
344
|
+
"backend",
|
|
345
|
+
"terraform plan failed in /x: Error: Failed to get existing workspaces: AccessDenied",
|
|
346
|
+
ROOT_DIR,
|
|
347
|
+
);
|
|
348
|
+
const result = normalizeObservation(await describeResources(await options(), deps({ planRead: planFails(err), show: never })));
|
|
349
|
+
expect(Object.keys(result.resources)).toEqual([]);
|
|
350
|
+
const replaced = result.unobserved["app/terraform_data.replaced"]!;
|
|
351
|
+
expect(replaced.detail).toMatch(/declared only\. .*Failed to get existing workspaces.*The state read needs the same backend/);
|
|
352
|
+
expect(result.notes.join("\n")).toMatch(/no read answered, declared only \(the plan read could not reach the backend\)/);
|
|
353
|
+
expect(result.rootReads).toEqual({ "terraform.roots.app": { depth: "plan", fellBackTo: "declared", reason: "backend-unreachable" } });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("a state read that fails after the plan read is declared only too, never absent", async () => {
|
|
357
|
+
const err = new TerraformPlanReadError("plan", "terraform plan failed in /x: Error: Invalid provider configuration", ROOT_DIR);
|
|
358
|
+
const show = (async () => {
|
|
359
|
+
throw new Error("Error: error loading state");
|
|
360
|
+
}) as TerraformReadDeps["show"];
|
|
361
|
+
const result = normalizeObservation(await describeResources(await options(), deps({ planRead: planFails(err), show })));
|
|
362
|
+
expect(Object.keys(result.resources)).toEqual([]);
|
|
363
|
+
expect(result.unobserved["app/terraform_data.replaced"]!.reason).toBe("read-failed");
|
|
364
|
+
expect(result.unobserved["app/terraform_data.replaced"]!.detail).toMatch(/The plan read failed .* and the state read after it failed too/);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("names a credentials failure as one", () => {
|
|
368
|
+
expect(planFailureIsCredentials("Error: No valid credential sources found")).toBe(true);
|
|
369
|
+
expect(planFailureIsCredentials("Error: Invalid reference")).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe("sensitive values never reach the IR (#4358)", () => {
|
|
374
|
+
it("not anywhere in the recorded plan's observation", async () => {
|
|
375
|
+
const result = await describeResources(await options(), deps());
|
|
376
|
+
expect(readFileSync(join(fixtures, "show-plan.json"), "utf-8")).toContain(SECRET);
|
|
377
|
+
expect(JSON.stringify(result)).not.toContain(SECRET);
|
|
378
|
+
const updated = normalizeObservation(result).resources["app/terraform_data.updated"]!.attributes as Record<string, unknown>;
|
|
379
|
+
// `input.token` is flagged; `output.token` carries the same value
|
|
380
|
+
// unflagged, and is redacted because it equals a flagged one.
|
|
381
|
+
expect(JSON.stringify(updated.planChanges)).not.toContain(SECRET);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("a sensitive value nested in a map is redacted on both sides, and its siblings are not", () => {
|
|
385
|
+
const changes = diffChange(
|
|
386
|
+
{
|
|
387
|
+
before: { tags: { team: "core", password: "pw-old" }, name: "a" },
|
|
388
|
+
after: { tags: { team: "edge", password: "pw-new" }, name: "a" },
|
|
389
|
+
before_sensitive: { tags: { password: true } },
|
|
390
|
+
after_sensitive: { tags: { password: true } },
|
|
391
|
+
after_unknown: {},
|
|
392
|
+
},
|
|
393
|
+
new Set(),
|
|
394
|
+
);
|
|
395
|
+
expect(changes).toEqual([
|
|
396
|
+
{ path: "tags.password", before: SENSITIVE_VALUE, after: SENSITIVE_VALUE },
|
|
397
|
+
{ path: "tags.team", before: "core", after: "edge" },
|
|
398
|
+
]);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("a whole map flagged sensitive is one redacted entry, never walked", () => {
|
|
402
|
+
const changes = diffChange(
|
|
403
|
+
{ before: { env: { A: "1" } }, after: { env: { A: "2", B: "3" } }, before_sensitive: { env: true }, after_sensitive: { env: true } },
|
|
404
|
+
new Set(),
|
|
405
|
+
);
|
|
406
|
+
expect(changes).toEqual([{ path: "env", before: SENSITIVE_VALUE, after: SENSITIVE_VALUE }]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("a sensitive leaf under a map that becomes unknown stays redacted", () => {
|
|
410
|
+
const changes = diffChange(
|
|
411
|
+
{ before: { out: { user: "u", pass: "p4ss" } }, after: {}, before_sensitive: { out: { pass: true } }, after_unknown: { out: true } },
|
|
412
|
+
new Set(),
|
|
413
|
+
);
|
|
414
|
+
expect(changes).toEqual([
|
|
415
|
+
{ path: "out.pass", before: SENSITIVE_VALUE, after: KNOWN_AFTER_APPLY },
|
|
416
|
+
{ path: "out.user", before: "u", after: KNOWN_AFTER_APPLY },
|
|
417
|
+
]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("a drift entry follows the same rule", () => {
|
|
421
|
+
const index = indexPlan({
|
|
422
|
+
resource_drift: [
|
|
423
|
+
{
|
|
424
|
+
address: "x.y",
|
|
425
|
+
change: {
|
|
426
|
+
actions: ["update"],
|
|
427
|
+
before: { labels: { secret: "s-1", app: "web" } },
|
|
428
|
+
after: { labels: { secret: "s-2", app: "api" } },
|
|
429
|
+
before_sensitive: { labels: { secret: true } },
|
|
430
|
+
after_sensitive: { labels: { secret: true } },
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
});
|
|
435
|
+
expect(JSON.stringify([...index.drift.values()])).not.toMatch(/s-1|s-2/);
|
|
436
|
+
});
|
|
437
|
+
});
|