@intentius/chant-lexicon-terraform 0.124.50 → 0.124.52

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-terraform",
3
- "version": "0.124.50",
3
+ "version": "0.124.52",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -68,7 +68,7 @@
68
68
  "access": "public"
69
69
  },
70
70
  "peerDependencies": {
71
- "@intentius/chant": "^0.124.50",
71
+ "@intentius/chant": "^0.124.52",
72
72
  "zod": "^4.3.6"
73
73
  },
74
74
  "description": "terraform lexicon for chant, existing Terraform root modules as declarable entities",
package/src/commands.ts CHANGED
@@ -105,8 +105,87 @@ async function runPinRolloutVerb(ctx: CommandGroupContext): Promise<number> {
105
105
  }
106
106
  }
107
107
 
108
+ const REPORT_USAGE =
109
+ "usage: chant terraform report --stage tf-plan|tf-apply --plan <show.json> [--applied <show-after-apply.json>] (--out <dir> | --to <url>) " +
110
+ "[--project <host/path>] [--commit <sha>] [--pull-request <n>] [--binary <name>] [--binary-version <v>] [--root-dir <dir>] [--self-root <dir>] [--repo <dir>] " +
111
+ "[--started <iso>] [--finished <iso>] [--json]";
112
+
113
+ const REPORT_VALUE_FLAGS = new Set(["--stage", "--plan", "--applied", "--out", "--to", "--project", "--commit", "--pull-request", "--binary", "--binary-version", "--root-dir", "--self-root", "--repo", "--started", "--finished"]);
114
+
115
+ /** Parse `chant terraform report`'s flags. Throws on an unknown flag, a missing value or a missing required flag. */
116
+ export function parseReportArgs(rawArgs: string[]): { values: Map<string, string>; json: boolean } {
117
+ const tokens = splitJoinedFlags(rawArgs, new Set(["--json"]));
118
+ const values = new Map<string, string>();
119
+ let json = false;
120
+ for (let i = 0; i < tokens.length; i++) {
121
+ const t = tokens[i]!;
122
+ if (t === "--json") {
123
+ json = true;
124
+ continue;
125
+ }
126
+ if (!REPORT_VALUE_FLAGS.has(t)) throw t.startsWith("-") ? unknownFlagError(t, REPORT_USAGE) : new Error(`unexpected argument "${t}"\n${REPORT_USAGE}`);
127
+ const v = tokens[++i];
128
+ if (v === undefined || v.startsWith("--")) throw new Error(`${t} needs a value\n${REPORT_USAGE}`);
129
+ values.set(t, v);
130
+ }
131
+ const stage = values.get("--stage");
132
+ if (stage !== "tf-plan" && stage !== "tf-apply") throw new Error(`--stage is tf-plan or tf-apply\n${REPORT_USAGE}`);
133
+ if (!values.has("--plan")) throw new Error(`--plan is required\n${REPORT_USAGE}`);
134
+ if (stage === "tf-apply" && !values.has("--applied")) throw new Error(`--stage tf-apply needs --applied, the show -json of a plan made after the apply\n${REPORT_USAGE}`);
135
+ if (values.has("--out") === values.has("--to")) throw new Error(`give one of --out <dir> and --to <url>\n${REPORT_USAGE}`);
136
+ return { values, json };
137
+ }
138
+
139
+ async function runReportVerb(ctx: CommandGroupContext): Promise<number> {
140
+ try {
141
+ const { values, json } = parseReportArgs(ctx.rawArgs);
142
+ const { readFileSync } = await import("node:fs");
143
+ const { runFactsFromEnv, terraformReport, writeReport, httpStore, dirStore, moduleEstates, atmosStackReads, topModule } = await import("./report");
144
+ const read = (flag: string): unknown => {
145
+ const file = values.get(flag)!;
146
+ try {
147
+ return JSON.parse(readFileSync(file, "utf8"));
148
+ } catch (err) {
149
+ throw new Error(`${flag} ${file}: ${err instanceof Error ? err.message : String(err)}`);
150
+ }
151
+ };
152
+ const run = runFactsFromEnv(process.env, { pullRequest: values.get("--pull-request"), project: values.get("--project"), commit: values.get("--commit") });
153
+ const plan = read("--plan");
154
+ // The plan holds no `live` block: each module's estate is read from its directory, <repo>/<root-dir>/<module>.
155
+ const repo = values.get("--repo") ?? process.env.GITHUB_WORKSPACE ?? process.cwd();
156
+ const modules = [...new Set(((plan as { resource_changes?: { address?: string; module_address?: string }[] }).resource_changes ?? []).map((r) => topModule(String(r.module_address ?? r.address ?? ""))).filter((m): m is string => m !== undefined))];
157
+ const estates = moduleEstates(repo, values.get("--root-dir") ?? "", modules);
158
+ // An Atmos repo wires its components with !terraform.output in the stack manifests, which the plan does not keep.
159
+ const componentReads = atmosStackReads(repo);
160
+ const result = terraformReport({
161
+ stage: values.get("--stage") as "tf-plan" | "tf-apply",
162
+ plan,
163
+ ...(Object.keys(estates).length > 0 ? { estates } : {}),
164
+ ...(Object.keys(componentReads).length > 0 ? { componentReads } : {}),
165
+ ...(values.has("--applied") ? { applied: read("--applied") } : {}),
166
+ run,
167
+ ...(values.has("--binary") ? { binary: values.get("--binary")! } : {}),
168
+ ...(values.has("--binary-version") ? { binaryVersion: values.get("--binary-version")! } : {}),
169
+ ...(values.has("--root-dir") ? { rootDir: values.get("--root-dir")! } : {}),
170
+ ...(values.has("--self-root") ? { selfRoot: values.get("--self-root")! } : {}),
171
+ ...(values.has("--started") ? { started: values.get("--started")! } : {}),
172
+ finished: values.get("--finished") ?? new Date().toISOString().replace(/\.\d+Z$/, "Z"),
173
+ });
174
+ const store = values.has("--out") ? dirStore(values.get("--out")!) : httpStore(values.get("--to")!);
175
+ const keys = await writeReport(store, result);
176
+ console.log(json ? JSON.stringify({ path: result.path, keys, row: result.row }, null, 2) : `wrote ${keys.join(", ")}`);
177
+ return 0;
178
+ } catch (err) {
179
+ console.error(err instanceof Error ? err.message : String(err));
180
+ return 1;
181
+ }
182
+ }
183
+
108
184
  export const terraformCommands: CommandGroup = {
109
185
  name: "terraform",
110
- description: "Terraform and OpenTofu roots: roll a module version out as one pin-bump PR per wave",
111
- commands: [{ name: "pin-rollout", description: "Read a pin-bump rollout's state and open the next wave's PR when it is due", handler: runPinRolloutVerb }],
186
+ description: "Terraform and OpenTofu roots: roll a module version out as one pin-bump PR per wave; write a run report from a saved plan",
187
+ commands: [
188
+ { name: "pin-rollout", description: "Read a pin-bump rollout's state and open the next wave's PR when it is due", handler: runPinRolloutVerb },
189
+ { name: "report", description: "Write a terragucci.report/v1 run report and its index row from show -json of a composed plan", handler: runReportVerb },
190
+ ],
112
191
  };
@@ -29,9 +29,9 @@ describe("TerraformPinRolloutOp (#3189)", () => {
29
29
  });
30
30
 
31
31
  describe("chant terraform pin-rollout", () => {
32
- test("is mounted as the terraform group's pin-rollout verb", () => {
32
+ test("is mounted as the terraform group's pin-rollout and report verbs", () => {
33
33
  expect(terraformCommands.name).toBe("terraform");
34
- expect(terraformCommands.commands.map((c) => c.name)).toEqual(["pin-rollout"]);
34
+ expect(terraformCommands.commands.map((c) => c.name)).toEqual(["pin-rollout", "report"]);
35
35
  });
36
36
 
37
37
  test("parses roots, dependencies, a generated root and canaries into the activity's args", () => {
@@ -0,0 +1,342 @@
1
+ /**
2
+ * `chant terraform report`, against a hand-built composed plan in the shape of
3
+ * choudoufu's `show -json`: three module calls (network, app, edge), a subnet
4
+ * whose id the plan cannot know, a security group placed in it, an in-place
5
+ * update, and a sensitive value. The post-apply plan carries the state.
6
+ */
7
+
8
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { describe, expect, test } from "vitest";
12
+ import { addToIndex, atmosOutputReads, atmosStackReads, dirStore, estateOfDir, httpStore, moduleEstates, runFactsFromEnv, slicePlan, terraformReport, topModule, writeReport } from "./report";
13
+ import { parseReportArgs } from "./commands";
14
+
15
+ const rc = (address: string, module: string, type: string, name: string, actions: string[], change: Record<string, unknown>) => ({
16
+ address, module_address: module, mode: "managed", type, name, provider_name: "registry.terraform.io/hashicorp/aws", change: { actions, ...change },
17
+ });
18
+
19
+ const PLAN = {
20
+ format_version: "1.2",
21
+ resource_changes: [
22
+ rc("module.network.aws_subnet.new", "module.network", "aws_subnet", "new", ["create"], {
23
+ before: null, after: { cidr_block: "10.0.9.0/24", tags: { Name: "new" } }, after_unknown: { id: true, arn: true }, after_sensitive: {},
24
+ }),
25
+ rc("module.network.aws_vpc.main", "module.network", "aws_vpc", "main", ["update"], {
26
+ before: { cidr_block: "10.0.0.0/16", enable_dns_support: true }, after: { cidr_block: "10.0.0.0/16", enable_dns_support: false }, after_unknown: {}, before_sensitive: {}, after_sensitive: {},
27
+ }),
28
+ rc("module.app.aws_security_group.sg", "module.app", "aws_security_group", "sg", ["create"], {
29
+ before: null, after: { name: "tgc-sg" }, after_unknown: { id: true, vpc_id: true }, after_sensitive: { password: true },
30
+ }),
31
+ rc("module.edge.aws_route_table.rt", "module.edge", "aws_route_table", "rt", ["delete", "create"], {
32
+ before: { vpc_id: "vpc-1" }, after: { vpc_id: "vpc-2" }, after_unknown: {}, replace_paths: [["vpc_id"]], before_sensitive: {}, after_sensitive: {},
33
+ }),
34
+ rc("module.edge.aws_route.keep", "module.edge", "aws_route", "keep", ["no-op"], { before: { x: 1 }, after: { x: 1 }, after_unknown: {} }),
35
+ ],
36
+ };
37
+
38
+ const AFTER = {
39
+ format_version: "1.2",
40
+ resource_changes: [],
41
+ prior_state: {
42
+ values: {
43
+ root_module: {
44
+ child_modules: [
45
+ { address: "module.network", resources: [{ address: "module.network.aws_subnet.new", values: { id: "subnet-123", arn: "arn:aws:ec2:::subnet-123" }, sensitive_values: {} }] },
46
+ { address: "module.app", resources: [{ address: "module.app.aws_security_group.sg", values: { id: "sg-9", vpc_id: "vpc-2" }, sensitive_values: { vpc_id: true } }] },
47
+ ],
48
+ },
49
+ },
50
+ },
51
+ };
52
+
53
+ const RUN = { project: "forgejo:3000/admin/demo-terragrunt-choudoufu", commit: "abc123", job_url: "http://forgejo:3000/admin/x/actions/runs/7", pull_request: "4" };
54
+ const FINISHED = "2026-10-24T10:00:00Z";
55
+ const base = { plan: PLAN, run: RUN, binary: "choudoufu", rootDir: "estates", finished: FINISHED };
56
+
57
+ const changeOf = (report: any, root: string, address: string) => report.roots.find((r: any) => r.path === root).changes.find((c: any) => c.address === address);
58
+
59
+ describe("slicing the composed plan", () => {
60
+ test("a module is taken off the addresses", () => {
61
+ expect(topModule("module.network.aws_subnet.new")).toBe("network");
62
+ expect(topModule("module.net[0].module.inner")).toBe("net");
63
+ expect(topModule("aws_vpc.main")).toBeUndefined();
64
+ const s = slicePlan(PLAN, "network") as any;
65
+ expect(s.resource_changes.map((r: any) => r.address)).toEqual(["aws_subnet.new", "aws_vpc.main"]);
66
+ expect(s.resource_changes[0].module_address).toBeUndefined();
67
+ });
68
+ });
69
+
70
+ describe("terraformReport, tf-plan", () => {
71
+ const { report, path, row } = terraformReport({ ...base, stage: "tf-plan" });
72
+ const r = report as any;
73
+
74
+ test("one root per module, in the roots' directory, as one composed wave", () => {
75
+ expect(r.schema).toBe("terragucci.report/v1");
76
+ expect(r.roots.map((x: any) => x.path)).toEqual(["estates/app", "estates/edge", "estates/network"]);
77
+ expect(r.roots.every((x: any) => x.status === "planned" && x.composed.wave === 1)).toBe(true);
78
+ expect(r.roots.find((x: any) => x.path === "estates/network").composed.module).toBe("module.network");
79
+ expect(r.waves).toHaveLength(1);
80
+ expect(r.waves[0].roots).toEqual(["estates/app", "estates/edge", "estates/network"]);
81
+ expect(r.waves[0].state).toBe("planned");
82
+ expect(r.waves[0].composed.members.map((m: any) => m.module)).toEqual(["module.app", "module.edge", "module.network"]);
83
+ expect(r.compose).toEqual({ roots: ["estates/app", "estates/edge", "estates/network"], max: 3, wave: 1, estate: true });
84
+ });
85
+
86
+ test("a value the plan cannot know is unknown, with no after", () => {
87
+ const c = changeOf(r, "estates/network", "aws_subnet.new");
88
+ expect(c.action).toBe("create");
89
+ const id = c.attributes.find((a: any) => a.path === "id");
90
+ expect(id.unknown).toBe(true);
91
+ expect(id.after).toBeUndefined();
92
+ expect(c.attributes.find((a: any) => a.path === "cidr_block").after).toBe("10.0.9.0/24");
93
+ });
94
+
95
+ test("an update carries before and after; a no-op is counted but not listed", () => {
96
+ const c = changeOf(r, "estates/network", "aws_vpc.main");
97
+ expect(c.attributes).toEqual([{ path: "enable_dns_support", before: true, after: false }]);
98
+ expect(r.roots.find((x: any) => x.path === "estates/edge").counts).toEqual({ replace: 1, "no-op": 1 });
99
+ expect(changeOf(r, "estates/edge", "aws_route.keep")).toBeUndefined();
100
+ });
101
+
102
+ test("totals, named replacements and the index row agree", () => {
103
+ expect(r.totals).toMatchObject({ create: 2, update: 1, replace: 1, delete: 0 });
104
+ expect(r.totals_roots).toBe(3);
105
+ expect(r.named).toEqual([expect.objectContaining({ root: "estates/edge", address: "aws_route_table.rt", action: "replace" })]);
106
+ expect(path).toBe("2026/10/abc123/tf-plan");
107
+ expect(row).toMatchObject({ project: RUN.project, stage: "tf-plan", path, roots: 3, changed: 3, totals: { create: 2, update: 1, replace: 1, delete: 0 }, pull_request: "4", destroys: ["estates/edge: aws_route_table.rt"] });
108
+ expect(row.applied).toBeUndefined();
109
+ expect(r.change_set).toMatch(/^jcs1-sha256:[0-9a-f]{64}$/);
110
+ });
111
+
112
+ test("the same plan gives the same report", () => {
113
+ expect(terraformReport({ ...base, stage: "tf-plan" }).report).toEqual(report);
114
+ });
115
+ });
116
+
117
+ describe("terraformReport, tf-apply", () => {
118
+ const { report, row } = terraformReport({ ...base, stage: "tf-apply", applied: AFTER, finished: "2026-10-24T10:05:00Z" });
119
+ const r = report as any;
120
+
121
+ test("the values the plan left unknown are read from the state", () => {
122
+ const id = changeOf(r, "estates/network", "aws_subnet.new").attributes.find((a: any) => a.path === "id");
123
+ expect(id).toEqual({ path: "id", after: "subnet-123" });
124
+ const arn = changeOf(r, "estates/network", "aws_subnet.new").attributes.find((a: any) => a.path === "arn");
125
+ expect(arn.after).toBe("arn:aws:ec2:::subnet-123");
126
+ });
127
+
128
+ test("a sensitive value stays unwritten, one the state lacks stays unknown", () => {
129
+ const sg = changeOf(r, "estates/app", "aws_security_group.sg").attributes;
130
+ expect(sg.find((a: any) => a.path === "vpc_id")).toEqual({ path: "vpc_id", sensitive: true });
131
+ expect(sg.find((a: any) => a.path === "id").after).toBe("sg-9");
132
+ });
133
+
134
+ test("the wave is applied, with each member's changes done, and the row says when", () => {
135
+ expect(r.waves[0].state).toBe("applied");
136
+ expect(r.waves[0].composed.members.find((m: any) => m.root === "estates/network")).toMatchObject({ apply: "applied", changes: { done: 2, planned: 2 }, state: "written" });
137
+ expect(row.applied).toBe("2026-10-24T10:05:00Z");
138
+ expect(row.path).toBe("2026/10/abc123/tf-apply");
139
+ });
140
+
141
+ test("an apply report without the post-apply plan is refused", () => {
142
+ expect(() => terraformReport({ ...base, stage: "tf-apply" })).toThrow(/post-apply plan/);
143
+ });
144
+ });
145
+
146
+ describe("reads between the composed modules", () => {
147
+ const data = (name: string, estate: string, names: string[]) => ({ mode: "data", type: "terraform_estate_outputs", name, expressions: { estate: { constant_value: estate }, names: { constant_value: names } } });
148
+ const CONFIG = {
149
+ root_module: {
150
+ module_calls: {
151
+ network: { module: { outputs: { subnet_id: { expression: { references: ["aws_subnet.new.id", "aws_subnet.new"] } }, cidr: { expression: { references: ["aws_vpc.main.cidr_block", "aws_vpc.main"] } } } } },
152
+ app: {
153
+ module: {
154
+ resources: [data("net", "tgc-network", ["subnet_id", "cidr"]), { mode: "managed", type: "aws_security_group", name: "sg", expressions: { vpc_id: { references: ["data.terraform_estate_outputs.net.values.subnet_id", "data.terraform_estate_outputs.net.values", "data.terraform_estate_outputs.net"] } } }],
155
+ outputs: { sg: { expression: { references: ["aws_security_group.sg.id", "aws_security_group.sg"] } } },
156
+ },
157
+ },
158
+ edge: { module: { resources: [data("app", "tgc-app", ["sg"]), data("elsewhere", "tgc-other", ["x"])] } },
159
+ },
160
+ },
161
+ };
162
+ const ESTATES = { network: "tgc-network", app: "tgc-app", edge: "tgc-edge" };
163
+ const withReads = { ...base, plan: { ...PLAN, configuration: CONFIG }, estates: ESTATES, stage: "tf-plan" as const };
164
+ const r = terraformReport(withReads).report as any;
165
+ const rootReads = (root: string) => r.roots.find((x: any) => x.path === root).reads;
166
+
167
+ test("a read of another module's estate is an edge to that module's root, planned and composed", () => {
168
+ expect(rootReads("estates/app")).toEqual([{ upstream: "estates/network", data: "net", outputs: "planned", unknown: ["subnet_id"], composed: true }]);
169
+ });
170
+
171
+ test("only the outputs the reader uses count as unknown, and a read of an estate nobody owns is left out", () => {
172
+ expect(rootReads("estates/edge")).toEqual([{ upstream: "estates/app", data: "app", outputs: "planned", unknown: ["sg"], composed: true }]);
173
+ expect(rootReads("estates/network")).toBeUndefined();
174
+ });
175
+
176
+ test("an output known at plan time is not listed as unknown", () => {
177
+ const known = terraformReport({ ...withReads, plan: { ...withReads.plan, output_changes: {} } }).report as any;
178
+ expect(known.roots.find((x: any) => x.path === "estates/app").reads[0].unknown).toEqual(["subnet_id"]);
179
+ const cfg = JSON.parse(JSON.stringify(CONFIG));
180
+ cfg.root_module.module_calls.app.module.resources[1].expressions.vpc_id.references = ["data.terraform_estate_outputs.net.values.cidr", "data.terraform_estate_outputs.net.values"];
181
+ const only = terraformReport({ ...withReads, plan: { ...PLAN, configuration: cfg } }).report as any;
182
+ expect(only.roots.find((x: any) => x.path === "estates/app").reads[0].unknown).toBeUndefined();
183
+ });
184
+
185
+ test("the composed root's planned outputs name unknowns too", () => {
186
+ const plan = { ...PLAN, configuration: { root_module: { module_calls: { app: CONFIG.root_module.module_calls.app } } }, output_changes: { network__vpc_id: { actions: ["create"], after_unknown: true } } };
187
+ const cfg = JSON.parse(JSON.stringify(plan.configuration));
188
+ cfg.root_module.module_calls.app.module.resources[1].expressions.vpc_id.references = ["data.terraform_estate_outputs.net.values.vpc_id", "data.terraform_estate_outputs.net.values"];
189
+ const out = terraformReport({ ...withReads, plan: { ...plan, configuration: cfg } }).report as any;
190
+ expect(out.roots.find((x: any) => x.path === "estates/app").reads[0].unknown).toEqual(["vpc_id"]);
191
+ });
192
+
193
+ test("with no estates, or a plan without configuration, no root has reads", () => {
194
+ const none = terraformReport({ ...base, stage: "tf-plan" }).report as any;
195
+ expect(none.roots.some((x: any) => "reads" in x)).toBe(false);
196
+ const bare = terraformReport({ ...base, stage: "tf-plan", estates: ESTATES }).report as any;
197
+ expect(bare.roots.some((x: any) => "reads" in x)).toBe(false);
198
+ });
199
+
200
+ test("the estates come from each module's live block", () => {
201
+ const repo = mkdtempSync(join(tmpdir(), "tf-estates-"));
202
+ for (const [m, body] of Object.entries({
203
+ network: 'terraform {\n live {\n estate = "tgc-network"\n record_store "s3" { bucket = "b" }\n }\n}\n',
204
+ app: '# live { estate = "wrong" }\nterraform {\n live {\n estate = "tgc-app"\n }\n}\n',
205
+ edge: "resource \"x\" \"y\" {}\n",
206
+ })) {
207
+ mkdirSync(join(repo, "estates", m), { recursive: true });
208
+ writeFileSync(join(repo, "estates", m, "main.tf"), body);
209
+ }
210
+ expect(moduleEstates(repo, "estates", ["network", "app", "edge", "gone"])).toEqual({ network: "tgc-network", app: "tgc-app" });
211
+ expect(estateOfDir(join(repo, "nowhere"))).toBeUndefined();
212
+ });
213
+ });
214
+
215
+ describe("reads from an Atmos stack's !terraform.output", () => {
216
+ const STACK = [
217
+ "# One stack, three components.",
218
+ "vars:",
219
+ " stage: dev",
220
+ "components:",
221
+ " terraform:",
222
+ " network:",
223
+ " vars:",
224
+ " subnet_a_cidr: 10.0.1.0/24",
225
+ " app:",
226
+ " settings:",
227
+ " depends_on:",
228
+ " 1:",
229
+ " component: network",
230
+ " vars:",
231
+ " vpc_id: !terraform.output network vpc_id",
232
+ " subnet_id: !terraform.output network dev '.subnet_id' # with a stack",
233
+ " edge:",
234
+ " vars:",
235
+ " listener_port: !terraform.output app listener_port",
236
+ " cidr: !terraform.output network subnet_a_cidr",
237
+ " ports:",
238
+ " - !terraform.output app admin_port",
239
+ " edge-blue:",
240
+ " metadata:",
241
+ " component: edge",
242
+ " vars:",
243
+ " extra: !terraform.output network vpc_id",
244
+ "",
245
+ ].join("\n");
246
+
247
+ test("each component's reads, by the component directory, outputs sorted", () => {
248
+ expect(atmosOutputReads(STACK)).toEqual({
249
+ app: { network: ["subnet_id", "vpc_id"] },
250
+ edge: { app: ["admin_port", "listener_port"], network: ["subnet_a_cidr", "vpc_id"] },
251
+ });
252
+ expect(atmosOutputReads("components:\n terraform:\n a:\n vars:\n x: 1\n")).toEqual({});
253
+ });
254
+
255
+ test("the stack manifests under atmos.yaml's stacks.base_path, and none without atmos.yaml", () => {
256
+ const repo = mkdtempSync(join(tmpdir(), "tf-atmos-"));
257
+ expect(atmosStackReads(repo)).toEqual({});
258
+ writeFileSync(join(repo, "atmos.yaml"), "base_path: .\ncomponents:\n terraform:\n base_path: components/terraform\nstacks:\n base_path: my-stacks\n name_template: '{{ .vars.stage }}'\n");
259
+ mkdirSync(join(repo, "my-stacks", "deploy"), { recursive: true });
260
+ writeFileSync(join(repo, "my-stacks", "deploy", "dev.yaml"), STACK);
261
+ expect(atmosStackReads(repo).app).toEqual({ network: ["subnet_id", "vpc_id"] });
262
+ });
263
+
264
+ test("a read is an edge to the upstream's root, planned and composed, with the unknown outputs it reads", () => {
265
+ const plan = {
266
+ ...PLAN,
267
+ output_changes: { network__vpc_id: { actions: ["no-op"], after: "vpc-1", after_unknown: false }, network__subnet_id: { actions: ["create"], after_unknown: true } },
268
+ };
269
+ const r = terraformReport({ ...base, rootDir: "components/terraform", plan, stage: "tf-plan", componentReads: atmosOutputReads(STACK) }).report as any;
270
+ const reads = (root: string) => r.roots.find((x: any) => x.path === root).reads;
271
+ expect(reads("components/terraform/app")).toEqual([
272
+ { upstream: "components/terraform/network", data: "network", outputs: "planned", unknown: ["subnet_id"], why: "!terraform.output network: subnet_id, vpc_id", composed: true },
273
+ ]);
274
+ expect(reads("components/terraform/edge").map((x: any) => x.upstream)).toEqual(["components/terraform/app", "components/terraform/network"]);
275
+ expect(reads("components/terraform/network")).toBeUndefined();
276
+ });
277
+
278
+ test("an upstream that is no module of the run has no edge", () => {
279
+ const r = terraformReport({ ...base, stage: "tf-plan", componentReads: { app: { vault: ["x"] } } }).report as any;
280
+ expect(r.roots.some((x: any) => "reads" in x)).toBe(false);
281
+ });
282
+ });
283
+
284
+ describe("writing", () => {
285
+ test("report.json, the project's index and the prefix's, merged with an earlier row", async () => {
286
+ const dir = mkdtempSync(join(tmpdir(), "tf-report-"));
287
+ const store = dirStore(dir);
288
+ const plan = terraformReport({ ...base, stage: "tf-plan" });
289
+ const apply = terraformReport({ ...base, stage: "tf-apply", applied: AFTER, finished: "2026-10-24T10:05:00Z" });
290
+ expect(await writeReport(store, plan)).toEqual([`${RUN.project}/2026/10/abc123/tf-plan/report.json`, `${RUN.project}/index.json`, "index.json"]);
291
+ await writeReport(store, apply);
292
+ await writeReport(store, apply);
293
+ const project = JSON.parse(readFileSync(join(dir, RUN.project, "index.json"), "utf8"));
294
+ expect(project.schema).toBe("terragucci.report-index/v1");
295
+ expect(project.reports.map((x: any) => [x.stage, x.path])).toEqual([["tf-apply", "2026/10/abc123/tf-apply"], ["tf-plan", "2026/10/abc123/tf-plan"]]);
296
+ const top = JSON.parse(readFileSync(join(dir, "index.json"), "utf8"));
297
+ expect(top.reports[1].path).toBe(`${RUN.project}/2026/10/abc123/tf-plan`);
298
+ const stored = JSON.parse(readFileSync(join(dir, RUN.project, "2026/10/abc123/tf-apply/report.json"), "utf8"));
299
+ expect(stored.run.stage).toBe("tf-apply");
300
+ });
301
+
302
+ test("an unreadable index starts again from the row", () => {
303
+ expect(addToIndex("{nope", terraformReport({ ...base, stage: "tf-plan" }).row).reports).toHaveLength(1);
304
+ });
305
+
306
+ test("the http store reads a 404 as no object and puts unsigned", async () => {
307
+ const seen: string[] = [];
308
+ const objects = new Map<string, string>();
309
+ const fake = (async (url: string, init?: RequestInit) => {
310
+ seen.push(`${init?.method ?? "GET"} ${url}`);
311
+ if (init?.method === "PUT") {
312
+ objects.set(url, String(init.body));
313
+ return new Response("", { status: 200 });
314
+ }
315
+ return objects.has(url) ? new Response(objects.get(url)!) : new Response("", { status: 404 });
316
+ }) as unknown as typeof fetch;
317
+ const store = httpStore("http://floci:4566/demo-reports/demo/", fake);
318
+ await writeReport(store, terraformReport({ ...base, stage: "tf-plan" }));
319
+ expect(seen[0]).toBe("PUT http://floci:4566/demo-reports/demo/forgejo%3A3000/admin/demo-terragrunt-choudoufu/2026/10/abc123/tf-plan/report.json");
320
+ expect(seen).toContain("GET http://floci:4566/demo-reports/demo/index.json");
321
+ expect(JSON.parse(objects.get("http://floci:4566/demo-reports/demo/index.json")!).reports).toHaveLength(1);
322
+ });
323
+ });
324
+
325
+ describe("the run's facts and the verb's flags", () => {
326
+ test("a Forgejo environment names the project as terragucci does", () => {
327
+ const f = runFactsFromEnv({ GITHUB_REPOSITORY: "admin/demo", GITHUB_SERVER_URL: "http://forgejo:3000", GITHUB_SHA: "deadbeef", GITHUB_RUN_ID: "9", FORGEJO_ACTIONS: "true" }, { pullRequest: "12" });
328
+ expect(f).toEqual({
329
+ project: "forgejo:3000/admin/demo", commit: "deadbeef", job_url: "http://forgejo:3000/admin/demo/actions/runs/9",
330
+ commit_url: "http://forgejo:3000/admin/demo/commit/deadbeef", pull_request: "12", pull_request_url: "http://forgejo:3000/admin/demo/pulls/12",
331
+ });
332
+ expect(() => runFactsFromEnv({})).toThrow(/--project and --commit/);
333
+ });
334
+
335
+ test("flags", () => {
336
+ expect(parseReportArgs(["--stage", "tf-plan", "--plan", "p.json", "--out", "d"]).values.get("--out")).toBe("d");
337
+ expect(() => parseReportArgs(["--stage", "tf-apply", "--plan", "p.json", "--out", "d"])).toThrow(/--applied/);
338
+ expect(() => parseReportArgs(["--stage", "tf-plan", "--plan", "p.json"])).toThrow(/--out/);
339
+ expect(() => parseReportArgs(["--stage", "tf-plan", "--plan", "p.json", "--out", "d", "--to", "u"])).toThrow(/--out/);
340
+ expect(() => parseReportArgs(["--bogus"])).toThrow();
341
+ });
342
+ });