@intentius/chant-lexicon-terraform 0.124.27 → 0.124.28

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "7c73de933373fd74e5b748e53a5280faa32a3114a8edfce3c1dc9d73af7c4f1c",
4
+ "manifest.json": "f7180992dc1958b02021c16194c6e8708902e711ca1a94b93536c377594db42b",
5
5
  "meta.json": "ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356",
6
6
  "types/index.d.ts": "022927e6a7b351cc4cf52d5f973f60ccd140e5810e17b32fb8868965aa1db161",
7
7
  "rules/plan-before-apply.ts": "7c10245b2c3335253b48ee7d8b075d89f43116bde20313a5d1031b343fef1282",
@@ -56,5 +56,5 @@
56
56
  "rules/tf045.ts": "760b1f4f385fbe7a86897efe3078fdeac11bcc9bfb806285d63db50488174b7b",
57
57
  "skills/chant-terraform.md": "d9ca7b7c28aed0038ce775a3491150298492eed2858fd3651ed9bfca0e577ae8"
58
58
  },
59
- "composite": "9a19035f727895d0754d577dd4864ff0e9dd1a4d8d35d042ae3684b45eaec70a"
59
+ "composite": "cef3ea2d21a047946dd260f00650538395ca7d5448da7f1b6ceefc1ce22a3212"
60
60
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terraform",
3
- "version": "0.124.27",
3
+ "version": "0.124.28",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Terraform"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-terraform",
3
- "version": "0.124.27",
3
+ "version": "0.124.28",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -67,7 +67,7 @@
67
67
  "access": "public"
68
68
  },
69
69
  "peerDependencies": {
70
- "@intentius/chant": "^0.124.27",
70
+ "@intentius/chant": "^0.124.28",
71
71
  "zod": "^4.3.6"
72
72
  },
73
73
  "description": "terraform lexicon for chant, existing Terraform root modules as declarable entities",
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A stored change set's node ids against the spawned `chant graph --format ir`
3
+ * of a real terraform root (#4022).
4
+ *
5
+ * The root is `__fixtures__/change-set/v2.tf.txt` with its local module
6
+ * `v2-module-app.tf.txt` (less the sensitive variable's default), and the plan is `terraform.plan.json`, the real
7
+ * `show -json` of applying v2 over v1 (`../change-set.test.ts`). The graph is
8
+ * read the way `components pr-apply` reads it, by starting chant. The fast
9
+ * pull-request loop test (`pr-loop.tofu.test.ts`) reads the same ids in
10
+ * process instead; the first test here holds the two equal.
11
+ */
12
+
13
+ import { spawnSync } from "node:child_process";
14
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
18
+ import { composeChangeSet } from "@intentius/chant/change-set";
19
+ import { buildGraphIr } from "@intentius/chant/graph-ir";
20
+ import { readGraphIds, storeGateChangeSet } from "@intentius/chant/lifecycle/gate-change-set";
21
+ import { readStoredChangeSet } from "@intentius/chant/lifecycle/change-set-store";
22
+ import { terraformChangeSetPart } from "../change-set";
23
+ import { renderTerraformRoots } from "../hcl/roots";
24
+
25
+ const REPO = join(import.meta.dirname, "..", "..", "..", "..");
26
+ const FIXTURES = join(import.meta.dirname, "..", "__fixtures__", "change-set");
27
+ const TIMEOUT = 240_000;
28
+
29
+ describe("a terraform change set mapped onto the spawned chant graph", () => {
30
+ let dir: string;
31
+ const git = (...a: string[]) => {
32
+ const r = spawnSync("git", a, { cwd: dir, encoding: "utf8" });
33
+ if (r.status !== 0) throw new Error(`git ${a.join(" ")}: ${r.stderr}`);
34
+ return r.stdout.trim();
35
+ };
36
+
37
+ beforeAll(() => {
38
+ dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-tf-change-set-graph-")));
39
+ symlinkSync(join(REPO, "node_modules"), join(dir, "node_modules"));
40
+ mkdirSync(join(dir, "estate", "modules", "app"), { recursive: true });
41
+ // The sensitive variable's committed default is dropped: `chant graph` refuses source that fails
42
+ // `chant lint` (TF006, TF007), and the block, which is all the graph needs, stays.
43
+ writeFileSync(join(dir, "estate", "main.tf"), readFileSync(join(FIXTURES, "v2.tf.txt"), "utf8").replace(/\n\s*default\s*=\s*"s3cr3t-2"/, ""));
44
+ writeFileSync(join(dir, "estate", "modules", "app", "main.tf"), readFileSync(join(FIXTURES, "v2-module-app.tf.txt"), "utf8"));
45
+ writeFileSync(join(dir, "chant.config.json"), JSON.stringify({ lexicons: ["terraform"], terraform: { roots: { estate: { dir: "estate" } } } }));
46
+ writeFileSync(join(dir, ".gitignore"), "node_modules\n");
47
+ git("init", "-q", "-b", "main");
48
+ git("config", "user.email", "ci@example.test");
49
+ git("config", "user.name", "ci");
50
+ git("add", "-A");
51
+ git("commit", "-qm", "estate");
52
+ });
53
+
54
+ afterAll(() => {
55
+ if (dir) rmSync(dir, { recursive: true, force: true });
56
+ });
57
+
58
+ test("the in-process graph has the ids chant graph --format ir prints", async () => {
59
+ const spawned = await readGraphIds(dir);
60
+ if ("error" in spawned) throw new Error(spawned.error);
61
+ const rendered = await renderTerraformRoots({ projectRoot: dir, roots: { estate: { dir: "estate" } } });
62
+ expect(buildGraphIr(rendered.entities).nodes.map((n) => n.id).sort()).toEqual([...spawned.ids].sort());
63
+ }, TIMEOUT);
64
+
65
+ test("each entry is stored on the block the graph names, and a deleted block has no node", async () => {
66
+ const plan = JSON.parse(readFileSync(join(FIXTURES, "terraform.plan.json"), "utf8"));
67
+ const doc = composeChangeSet([terraformChangeSetPart({ member: "estate", plan })]);
68
+ const head = git("rev-parse", "HEAD");
69
+ const stored = await storeGateChangeSet({ doc, cwd: dir, at: head });
70
+ expect(stored.warning).toBeUndefined();
71
+ expect(stored).toMatchObject({ written: true, graph: { member: null, at: head } });
72
+ const read = await readStoredChangeSet(doc.digest, { cwd: dir });
73
+ if (read.status !== "found") throw new Error(read.status);
74
+ expect(read.document.entries.map((e) => [e.address, e.node])).toEqual([
75
+ ["module.app.terraform_data.instance[0]", "estate/module.app/terraform_data.instance"],
76
+ ["module.app.terraform_data.instance[1]", "estate/module.app/terraform_data.instance"],
77
+ ["terraform_data.config", "estate/terraform_data.config"],
78
+ ["terraform_data.keep", "estate/terraform_data.keep"],
79
+ ["terraform_data.old", null],
80
+ ["terraform_data.secret", "estate/terraform_data.secret"],
81
+ ["terraform_data.worker", "estate/terraform_data.worker"],
82
+ ]);
83
+ }, TIMEOUT);
84
+ });
@@ -22,7 +22,9 @@ import { dirname, join } from "node:path";
22
22
  import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
23
23
  import type { ParsedArgs } from "@intentius/chant/cli/registry";
24
24
  import type { PrReport } from "@intentius/chant/pr-loop";
25
+ import { buildGraphIr } from "@intentius/chant/graph-ir";
25
26
  import { terraformPlugin } from "../plugin";
27
+ import { renderTerraformRoots } from "../hcl/roots";
26
28
 
27
29
  const REPO = join(import.meta.dirname, "..", "..", "..", "..");
28
30
 
@@ -44,10 +46,25 @@ const ROOTS: Record<string, string> = {
44
46
  dns: 'resource "terraform_data" "zone" {\n input = "example.test"\n}\n',
45
47
  };
46
48
 
49
+ // The source passes `chant lint`, since `chant graph --format ir`, which maps
50
+ // the stored change set onto nodes (#4022), refuses source that does not.
51
+ /**
52
+ * The estate's graph node ids, read in process with the pair `chant graph
53
+ * --format ir` runs for terraform roots (`renderTerraformRoots`, then
54
+ * `buildGraphIr`), so pr-apply maps its stored change set without starting a
55
+ * chant child process. `change-set-graph.e2e.test.ts` holds these ids equal
56
+ * to the spawned `chant graph --format ir`'s.
57
+ */
58
+ async function estateGraphIds(projectRoot: string): Promise<{ ids: string[] }> {
59
+ const config = JSON.parse(readFileSync(join(projectRoot, "chant.config.json"), "utf8")) as { terraform: { binary?: string; roots: Record<string, { dir: string }> } };
60
+ const rendered = await renderTerraformRoots({ projectRoot, roots: config.terraform.roots, ...(config.terraform.binary ? { binary: config.terraform.binary } : {}) });
61
+ return { ids: buildGraphIr(rendered.entities).nodes.map((n) => n.id) };
62
+ }
63
+
47
64
  const COMPONENTS = `import { phase, stackOutput, type Component } from "@intentius/chant/components";
48
65
 
49
66
  const apply = (root: string, vars?: Record<string, unknown>) =>
50
- [phase("Apply", [{ kind: "terraform-apply", root, ...(vars ? { vars } : {}) }])];
67
+ [phase("Apply", [{ kind: "terraform-apply", root, noRollback: "test roots with local state", vars: vars ?? {} }])];
51
68
 
52
69
  export const net: Component = { name: "net", dependsOn: [], deploy: apply("net") };
53
70
  export const a: Component = { name: "a", dependsOn: ["net"], deploy: apply("a", { cidr: stackOutput("net", "cidr") }) };
@@ -151,6 +168,7 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
151
168
  });
152
169
  vi.stubGlobal("fetch", github.fetch);
153
170
  handlers = await import("@intentius/chant/cli/handlers/pr");
171
+ (await import("@intentius/chant/lifecycle/gate-change-set")).setGraphIdReader(estateGraphIds);
154
172
  ({ recordGateApproval } = await import("@intentius/chant/cli/handlers/operator"));
155
173
 
156
174
  // Every root applied once, so each has state and outputs to read.
@@ -159,7 +177,8 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
159
177
  expect(await runComponentsFanOut(ctx({ fromAffected: "all.json" }) as never)).toBe(0);
160
178
  }, 240_000);
161
179
 
162
- afterAll(() => {
180
+ afterAll(async () => {
181
+ (await import("@intentius/chant/lifecycle/gate-change-set")).setGraphIdReader(undefined);
163
182
  vi.unstubAllGlobals();
164
183
  if (cwd) process.chdir(cwd);
165
184
  for (const key of Object.keys(process.env)) if (!(key in env)) delete process.env[key];
@@ -222,6 +241,25 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
222
241
  expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "success", sha: applied.head });
223
242
  }, 240_000);
224
243
 
244
+ test("the change set the approval bound is stored by its digest, each entry on its graph node (#4022)", async () => {
245
+ const applied = report("apply");
246
+ const { changeSetShowDocument } = await import("@intentius/chant/workspace/change-set-show");
247
+ const shown = await changeSetShowDocument({ cwd: dir, digest: applied.digest });
248
+ if ("error" in shown) throw new Error(shown.error.message);
249
+ expect(shown).toMatchObject({ member: null, digest: applied.digest, reason: null });
250
+ const stored = shown.changeSet!;
251
+ expect(stored.digest).toBe(applied.digest);
252
+ expect(stored.graph).toEqual({ member: null, at: applied.head });
253
+ // The plan's addresses, each on the block the terraform lexicon's graph names `<root>/<address>`.
254
+ expect(stored.entries.map((e) => [e.member, e.address, e.action, e.node])).toEqual([
255
+ ["a", "terraform_data.subnet", "update", "a/terraform_data.subnet"],
256
+ ["app", "terraform_data.app", "no-op", "app/terraform_data.app"],
257
+ ]);
258
+ // And those are ids the estate's graph has.
259
+ const graph = await estateGraphIds(dir);
260
+ for (const e of stored.entries) expect(graph.ids).toContain(e.node);
261
+ }, 240_000);
262
+
225
263
  test("a plan that changed after review is refused, naming both digests, and nothing applies", async () => {
226
264
  const reviewed = await openPullRequest(2, "a-v3");
227
265
  github.reviews[2] = [{ user: { login: "alice" }, state: "APPROVED" }];
@@ -240,5 +278,11 @@ describe.skipIf(!BINARY)(`the pull-request loop over five ${BINARY ?? "terraform
240
278
  expect(state("a")).toContain("a-v2");
241
279
  expect(state("a")).not.toContain("a-v4");
242
280
  expect(github.statuses.at(-1)).toMatchObject({ context: "chant/apply", state: "failure", description: "refused: the plan changed after review" });
281
+
282
+ // The gate now waits on the plan it was refused, and workspace status links it to that change set (#4022).
283
+ const { readMemberGates } = await import("@intentius/chant/workspace/status-gates");
284
+ const { gates } = await readMemberGates("_gates", "flat", git("rev-parse", "chant/lifecycle"), [], dir, new Date().toISOString());
285
+ const gate = gates.find((g) => g.component === "pr-2")!;
286
+ expect(gate).toMatchObject({ state: "pending", planDigest: refused.digest, changeSet: refused.digest });
243
287
  }, 240_000);
244
288
  });