@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.
Files changed (93) hide show
  1. package/dist/__fixtures__/capabilities/chant.config.d.ts +26 -0
  2. package/dist/__fixtures__/capabilities/chant.config.d.ts.map +1 -0
  3. package/dist/__fixtures__/capabilities/chant.config.js +13 -0
  4. package/dist/__fixtures__/effective-topology/chant.config.d.ts +13 -0
  5. package/dist/__fixtures__/effective-topology/chant.config.d.ts.map +1 -0
  6. package/dist/__fixtures__/effective-topology/chant.config.js +9 -0
  7. package/dist/__fixtures__/kind-type/chant.config.d.ts +13 -0
  8. package/dist/__fixtures__/kind-type/chant.config.d.ts.map +1 -0
  9. package/dist/__fixtures__/kind-type/chant.config.js +9 -0
  10. package/dist/__fixtures__/search-roots/chant.config.d.ts +13 -0
  11. package/dist/__fixtures__/search-roots/chant.config.d.ts.map +1 -0
  12. package/dist/__fixtures__/search-roots/chant.config.js +9 -0
  13. package/dist/__fixtures__/sensitive-default/chant.config.d.ts +13 -0
  14. package/dist/__fixtures__/sensitive-default/chant.config.d.ts.map +1 -0
  15. package/dist/__fixtures__/sensitive-default/chant.config.js +9 -0
  16. package/dist/capabilities/adapter.d.ts +9 -0
  17. package/dist/capabilities/adapter.d.ts.map +1 -0
  18. package/dist/capabilities/adapter.js +25 -0
  19. package/dist/capabilities/mapping.json +166 -0
  20. package/dist/config.d.ts +52 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +23 -0
  23. package/dist/describe-resources.d.ts +101 -8
  24. package/dist/describe-resources.d.ts.map +1 -1
  25. package/dist/describe-resources.js +395 -56
  26. package/dist/hcl/parse.d.ts +10 -0
  27. package/dist/hcl/parse.d.ts.map +1 -1
  28. package/dist/hcl/parse.js +20 -2
  29. package/dist/hcl/redact.d.ts +26 -0
  30. package/dist/hcl/redact.d.ts.map +1 -0
  31. package/dist/hcl/redact.js +228 -0
  32. package/dist/integrity.json +2 -2
  33. package/dist/manifest.json +1 -1
  34. package/dist/op/activities/terraform.d.ts +109 -4
  35. package/dist/op/activities/terraform.d.ts.map +1 -1
  36. package/dist/op/activities/terraform.js +148 -7
  37. package/dist/plan-observation.d.ts +153 -0
  38. package/dist/plan-observation.d.ts.map +1 -0
  39. package/dist/plan-observation.js +354 -0
  40. package/dist/plugin.d.ts.map +1 -1
  41. package/dist/plugin.js +2 -0
  42. package/package.json +2 -2
  43. package/src/__fixtures__/ACCEPTANCE.md +53 -1
  44. package/src/__fixtures__/capabilities/catalog/main.tf +105 -0
  45. package/src/__fixtures__/capabilities/chant.config.ts +15 -0
  46. package/src/__fixtures__/capabilities/findings/main.tf +225 -0
  47. package/src/__fixtures__/capabilities/ledger/main.tf +265 -0
  48. package/src/__fixtures__/capabilities/package.json +1 -0
  49. package/src/__fixtures__/effective-topology/chant.config.ts +11 -0
  50. package/src/__fixtures__/effective-topology/net/main.tf +176 -0
  51. package/src/__fixtures__/effective-topology/package.json +6 -0
  52. package/src/__fixtures__/kind-type/app/main.tf +35 -0
  53. package/src/__fixtures__/kind-type/chant.config.ts +11 -0
  54. package/src/__fixtures__/kind-type/package.json +6 -0
  55. package/src/__fixtures__/live-plan-depth/README.md +59 -0
  56. package/src/__fixtures__/live-plan-depth/live-plan.json +110 -0
  57. package/src/__fixtures__/live-plan-depth/show-plan-sensitive.json +1 -0
  58. package/src/__fixtures__/live-plan-depth/show-plan.json +1 -0
  59. package/src/__fixtures__/plan-read/main.tf +46 -0
  60. package/src/__fixtures__/plan-read/next.tfvars +1 -0
  61. package/src/__fixtures__/plan-read-drift/README.md +33 -0
  62. package/src/__fixtures__/plan-read-drift/main.tf +63 -0
  63. package/src/__fixtures__/plan-read-drift/next.tfvars +1 -0
  64. package/src/__fixtures__/search-roots/chant.config.ts +11 -0
  65. package/src/__fixtures__/search-roots/net/main.tf +73 -0
  66. package/src/__fixtures__/search-roots/package.json +6 -0
  67. package/src/__fixtures__/sensitive-default/chant.config.ts +11 -0
  68. package/src/__fixtures__/sensitive-default/net/main.tf +38 -0
  69. package/src/__fixtures__/sensitive-default/package.json +6 -0
  70. package/src/__fixtures__/show-plan.json +663 -0
  71. package/src/capabilities/adapter.ts +32 -0
  72. package/src/capabilities/mapping.json +166 -0
  73. package/src/capabilities-workspace.e2e.test.ts +251 -0
  74. package/src/capabilities.test.ts +264 -0
  75. package/src/config.ts +26 -0
  76. package/src/describe-resources.live-plan.test.ts +302 -0
  77. package/src/describe-resources.live-root-unchanged.test.ts +126 -0
  78. package/src/describe-resources.plan.test.ts +437 -0
  79. package/src/describe-resources.ts +445 -52
  80. package/src/effective-topology.test.ts +134 -0
  81. package/src/hcl/parse.ts +30 -2
  82. package/src/hcl/redact.ts +181 -0
  83. package/src/live-plan-depth.acceptance.e2e.test.ts +193 -0
  84. package/src/op/activities/terraform.test.ts +118 -1
  85. package/src/op/activities/terraform.ts +213 -9
  86. package/src/plan-observation.ts +428 -0
  87. package/src/plan-read.acceptance.test.ts +184 -0
  88. package/src/plugin.ts +2 -0
  89. package/src/redact-ir.e2e.test.ts +79 -0
  90. package/src/redact.test.ts +111 -0
  91. package/src/search-build-roots.test.ts +125 -0
  92. package/src/search-count.test.ts +144 -0
  93. package/src/search-kind-type.test.ts +136 -0
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { describe, test, expect, beforeEach, afterAll, vi } from "vitest";
17
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
17
+ import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { tmpdir } from "node:os";
19
19
  import { join, resolve } from "node:path";
20
20
  import {
@@ -22,6 +22,13 @@ import {
22
22
  terraformPlan,
23
23
  terraformApply,
24
24
  terraformShow,
25
+ terraformPlanRead,
26
+ terraformPlanReadCommand,
27
+ terraformInitLine,
28
+ classifyPlanReadFailure,
29
+ rootIsInitialised,
30
+ TerraformPlanReadError,
31
+ PLAN_READ_DIR_PREFIX,
25
32
  choudoufuLivePlan,
26
33
  choudoufuLivePlanDocument,
27
34
  choudoufuLiveLs,
@@ -506,6 +513,116 @@ describe("terraformApply — saved plans only (#2086)", () => {
506
513
  });
507
514
  });
508
515
 
516
+ // ── terraformPlanRead (#4358) ───────────────────────────────────────────────
517
+
518
+ /** Every entry under `dir`, recursively, as relative paths. */
519
+ function tree(dir: string): string[] {
520
+ return readdirSync(dir, { recursive: true }).map(String).sort();
521
+ }
522
+
523
+ describe("terraformPlanReadCommand (#4358)", () => {
524
+ test("always carries -lock=false, with -input=false and the plan file outside the root", () => {
525
+ const cmd = terraformPlanReadCommand({ binary: "terraform", planFile: "/tmp/chant-terraform-plan-x/plan.tfplan" });
526
+ expect(cmd.split(" ")).toContain("-lock=false");
527
+ expect(cmd).toBe("terraform plan -input=false -lock=false -out=/tmp/chant-terraform-plan-x/plan.tfplan");
528
+ });
529
+
530
+ test("carries the root's var files, in order, before -out", () => {
531
+ expect(terraformPlanReadCommand({ binary: "tofu", planFile: "/t/p", varFiles: ["a.tfvars", "b.tfvars"] })).toBe(
532
+ "tofu plan -input=false -lock=false -var-file=a.tfvars -var-file=b.tfvars -out=/t/p",
533
+ );
534
+ });
535
+ });
536
+
537
+ describe("terraformInitLine (#4358)", () => {
538
+ test("names the root by -chdir and carries its backend config, so it pastes as printed", () => {
539
+ expect(terraformInitLine({ binary: "terraform", dir: "/p/infra", backendConfig: { bucket: "b" } })).toBe(
540
+ "terraform -chdir=/p/infra init -input=false -backend-config=bucket=b",
541
+ );
542
+ });
543
+ });
544
+
545
+ describe("classifyPlanReadFailure (#4358)", () => {
546
+ test.each([
547
+ ["Error: Backend initialization required, please run \"terraform init\"", "not-initialised"],
548
+ ["Error: Inconsistent dependency lock file", "not-initialised"],
549
+ ["Error: Required plugins are not installed", "not-initialised"],
550
+ ["Error: Failed to get existing workspaces: AccessDenied", "backend"],
551
+ ["Error: error configuring S3 Backend: no valid credential sources for S3 Backend found.", "backend"],
552
+ ["Error: error loading state: storage: bucket doesn't exist", "backend"],
553
+ ["Error: No valid credential sources found", "plan"],
554
+ ["Error: Invalid provider configuration", "plan"],
555
+ ])("%s -> %s", (output, kind) => {
556
+ expect(classifyPlanReadFailure(output)).toBe(kind);
557
+ });
558
+ });
559
+
560
+ describe("terraformPlanRead (#4358)", () => {
561
+ function initialisedProject(): string {
562
+ const dir = project({ roots: { app: { dir: "./infra", varFiles: ["next.tfvars"] } } });
563
+ mkdirSync(join(dir, "infra", ".terraform"));
564
+ return dir;
565
+ }
566
+
567
+ test("plans with -lock=false into a temp dir, shows it, and removes the temp dir", async () => {
568
+ const dir = initialisedProject();
569
+ const before = tree(join(dir, "infra"));
570
+ replies.push({ match: "show -json", reply: { stdout: '{"format_version":"1.2"}', stderr: "" } });
571
+ const result = await terraformPlanRead({ root: "app", cwd: dir });
572
+
573
+ const [plan, show] = execCalls.map((c) => c.cmd);
574
+ expect(plan).toContain(" -lock=false ");
575
+ expect(plan).toBe(`terraform plan -input=false -lock=false -var-file=next.tfvars -out=${join(result.planDir, "plan.tfplan")}`);
576
+ expect(show).toBe(`terraform show -json ${join(result.planDir, "plan.tfplan")}`);
577
+ expect(execCalls[0].opts.cwd).toBe(resolve(dir, "infra"));
578
+ expect(execCalls[0].opts.env?.TF_IN_AUTOMATION).toBe("1");
579
+
580
+ expect(result.planDir.startsWith(join(tmpdir(), PLAN_READ_DIR_PREFIX))).toBe(true);
581
+ expect(existsSync(result.planDir)).toBe(false);
582
+ expect(tree(join(dir, "infra"))).toEqual(before);
583
+ expect(result.json).toEqual({ format_version: "1.2" });
584
+ });
585
+
586
+ test("removes the temp dir when the plan fails, too", async () => {
587
+ const dir = initialisedProject();
588
+ replies.push({ match: "plan -input=false", reply: execError(1, "Error: No valid credential sources found") });
589
+ const err = await terraformPlanRead({ root: "app", cwd: dir }).catch((e: unknown) => e);
590
+ expect(err).toBeInstanceOf(TerraformPlanReadError);
591
+ expect((err as TerraformPlanReadError).kind).toBe("plan");
592
+ expect((err as TerraformPlanReadError).message).toContain("Error: No valid credential sources found");
593
+ const planFile = execCalls[0].cmd.split("-out=")[1]!;
594
+ expect(existsSync(planFile)).toBe(false);
595
+ expect(existsSync(join(planFile, ".."))).toBe(false);
596
+ });
597
+
598
+ test("refuses an uninitialised root with the init line, and runs nothing", async () => {
599
+ const dir = project({ roots: { app: { dir: "./infra", backendConfig: { key: "app.tfstate" } } } });
600
+ const err = (await terraformPlanRead({ root: "app", cwd: dir }).catch((e: unknown) => e)) as TerraformPlanReadError;
601
+ expect(err).toBeInstanceOf(TerraformPlanReadError);
602
+ expect(err.kind).toBe("not-initialised");
603
+ expect(err.initLine).toBe(`terraform -chdir=${resolve(dir, "infra")} init -input=false -backend-config=key=app.tfstate`);
604
+ expect(err.message).toContain(`run \`${err.initLine}\` first`);
605
+ expect(execCalls).toHaveLength(0);
606
+ expect(tree(join(dir, "infra"))).toEqual([]);
607
+ });
608
+
609
+ test("a plan that says the root needs init again is refused the same way", async () => {
610
+ const dir = initialisedProject();
611
+ replies.push({ match: "plan -input=false", reply: execError(1, "Error: Inconsistent dependency lock file") });
612
+ const err = (await terraformPlanRead({ root: "app", cwd: dir }).catch((e: unknown) => e)) as TerraformPlanReadError;
613
+ expect(err.kind).toBe("not-initialised");
614
+ expect(err.message).toContain("init -input=false");
615
+ });
616
+
617
+ test("rootIsInitialised honours TF_DATA_DIR", () => {
618
+ const dir = mkdtempSync(join(tmpdir(), "chant-tf-init-"));
619
+ workspaces.push(dir);
620
+ expect(rootIsInitialised(dir, {})).toBe(false);
621
+ mkdirSync(join(dir, "data"));
622
+ expect(rootIsInitialised(dir, { TF_DATA_DIR: "data" })).toBe(true);
623
+ });
624
+ });
625
+
509
626
  // ── terraformShow ───────────────────────────────────────────────────────────
510
627
 
511
628
  describe("terraformShow (#2086)", () => {
@@ -40,7 +40,8 @@
40
40
  */
41
41
 
42
42
  import { exec } from "node:child_process";
43
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
43
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
44
+ import { tmpdir } from "node:os";
44
45
  import { promisify } from "node:util";
45
46
  import { resolve, dirname, join, relative, sep } from "node:path";
46
47
  import { loadChantConfigUpward } from "@intentius/chant/config";
@@ -72,7 +73,7 @@ export const DEFAULT_PLAN_FILE = "chant.tfplan";
72
73
  /** Binary used when the `terraform` namespace records no preference. */
73
74
  export const DEFAULT_TERRAFORM_BINARY = "terraform";
74
75
 
75
- /** Where {@link choudoufuLivePlan} writes GitHub issue #788's JSON document, relative to the root directory. */
76
+ /** Where the {@link choudoufuLivePlan} Op activity writes GitHub issue #788's JSON document, relative to the root directory. */
76
77
  export const DEFAULT_LIVE_PLAN_DOCUMENT_FILE = "chant.live-plan.json";
77
78
 
78
79
  /**
@@ -1484,6 +1485,190 @@ export async function terraformShow(
1484
1485
  };
1485
1486
  }
1486
1487
 
1488
+ // ── The plan read (#4358) ───────────────────────────────────────────────────
1489
+
1490
+ /** Prefix of the scratch directory a plan read writes its plan file into, under the OS temp directory. */
1491
+ export const PLAN_READ_DIR_PREFIX = "chant-terraform-plan-";
1492
+
1493
+ /** The plan file's name inside that scratch directory. */
1494
+ export const PLAN_READ_FILE = "plan.tfplan";
1495
+
1496
+ /**
1497
+ * `terraform plan` for an observation read (#4358): `-input=false -lock=false
1498
+ * -out=<file>`, plus the root's own `-var-file`s.
1499
+ *
1500
+ * `-lock=false` is the point. A read must never take the backend's state lock,
1501
+ * or a teammate's apply that starts while chant is reading would fail on
1502
+ * "Error acquiring the state lock". `-detailed-exitcode` is not passed: the
1503
+ * read wants the document, not the boolean, so exit 0 is the only success.
1504
+ * The `-out` path is always outside the root (see {@link terraformPlanRead}).
1505
+ */
1506
+ export function terraformPlanReadCommand(opts: { binary: string; planFile: string; varFiles?: string[] }): string {
1507
+ const parts = [opts.binary, "plan", "-input=false", "-lock=false"];
1508
+ for (const varFile of opts.varFiles ?? []) parts.push(`-var-file=${quoteArg(varFile)}`);
1509
+ parts.push(`-out=${quoteArg(opts.planFile)}`);
1510
+ return parts.join(" ");
1511
+ }
1512
+
1513
+ /**
1514
+ * The one line a person runs to initialise a root chant refuses to plan,
1515
+ * with the root's `backendConfig` carried, so it can be pasted as printed.
1516
+ */
1517
+ export function terraformInitLine(opts: { binary: string; dir: string; backendConfig?: Record<string, string> }): string {
1518
+ const init = terraformInitCommand({
1519
+ binary: opts.binary,
1520
+ ...(opts.backendConfig ? { backendConfig: opts.backendConfig } : {}),
1521
+ });
1522
+ return init.replace(`${opts.binary} init`, `${opts.binary} -chdir=${quoteArg(opts.dir)} init`);
1523
+ }
1524
+
1525
+ /**
1526
+ * Why a plan read refused or failed, in the three classes the plan depth
1527
+ * falls back by (#4358):
1528
+ *
1529
+ * - `"not-initialised"`: the root was never initialised, or its providers,
1530
+ * modules or backend changed since. chant never runs `init` on a read,
1531
+ * because `init` writes into the root, so this is refused with the line
1532
+ * to run.
1533
+ * - `"backend"`: the state could not be read at all. A state read needs the
1534
+ * same backend, so there is nothing to fall back to but declared only.
1535
+ * - `"plan"`: anything else, missing provider credentials first among them.
1536
+ * The backend answered, so the state read can still answer.
1537
+ */
1538
+ export type TerraformPlanReadFailureKind = "not-initialised" | "backend" | "plan";
1539
+
1540
+ /** A plan read that did not produce a document. `kind` decides the fallback. */
1541
+ export class TerraformPlanReadError extends Error {
1542
+ readonly kind: TerraformPlanReadFailureKind;
1543
+ readonly dir: string;
1544
+ /** Present on `"not-initialised"`: the line to run. */
1545
+ readonly initLine?: string;
1546
+
1547
+ constructor(kind: TerraformPlanReadFailureKind, message: string, dir: string, initLine?: string) {
1548
+ super(message);
1549
+ this.name = "TerraformPlanReadError";
1550
+ this.kind = kind;
1551
+ this.dir = dir;
1552
+ if (initLine !== undefined) this.initLine = initLine;
1553
+ }
1554
+ }
1555
+
1556
+ /**
1557
+ * Sort a failed plan's own output into a {@link TerraformPlanReadFailureKind}.
1558
+ * Matched on terraform's and OpenTofu's own error summaries. Order matters:
1559
+ * an S3 backend without credentials says both "backend" and "credentials",
1560
+ * and it is the backend that failed, so the backend patterns are read first.
1561
+ */
1562
+ export function classifyPlanReadFailure(output: string): TerraformPlanReadFailureKind {
1563
+ if (
1564
+ /Backend initialization required|Inconsistent dependency lock file|Required plugins are not installed|Module not installed|Missing required provider|please run "(terraform|tofu|choudoufu) init"|Run "(terraform|tofu|choudoufu) init"|Backend configuration changed/i.test(
1565
+ output,
1566
+ )
1567
+ ) {
1568
+ return "not-initialised";
1569
+ }
1570
+ if (
1571
+ /error loading state|Failed to load state|Error refreshing state|Failed to get existing workspaces|error configuring (the )?\S+ backend|Error: Failed to (read|retrieve) state|Error retrieving state|Error acquiring the state lock|state data in S3|NoSuchBucket|failed to (get|read) (remote )?state|Unable to access object|\bbackend\b.*(credential|denied|unauthori[sz]ed|forbidden)/i.test(
1572
+ output,
1573
+ )
1574
+ ) {
1575
+ return "backend";
1576
+ }
1577
+ return "plan";
1578
+ }
1579
+
1580
+ /** What {@link terraformPlanRead} resolved. */
1581
+ export interface TerraformPlanReadResult {
1582
+ /** `terraform show -json <planfile>`, parsed. */
1583
+ json: unknown;
1584
+ /** Absolute path of the root module directory the plan ran in. */
1585
+ dir: string;
1586
+ /**
1587
+ * The scratch directory the plan file was written to, under the OS temp
1588
+ * directory. Already removed when this result is returned; named so a test
1589
+ * can prove both halves of that.
1590
+ */
1591
+ planDir: string;
1592
+ /** The plan command exactly as run, for a test or a note to quote. */
1593
+ command: string;
1594
+ }
1595
+
1596
+ /**
1597
+ * Whether a root has been initialised: `.terraform` (or `TF_DATA_DIR`, when
1598
+ * the environment points elsewhere) exists. A root whose providers or modules
1599
+ * have since changed passes this check and is caught by
1600
+ * {@link classifyPlanReadFailure} on the plan's own error instead.
1601
+ */
1602
+ export function rootIsInitialised(dir: string, env: Record<string, string | undefined> = process.env): boolean {
1603
+ const dataDir = env.TF_DATA_DIR ? resolve(dir, env.TF_DATA_DIR) : join(dir, ".terraform");
1604
+ return existsSync(dataDir);
1605
+ }
1606
+
1607
+ /**
1608
+ * Read a saved plan for one root without writing anything in it (#4358):
1609
+ * `plan -input=false -lock=false -out=<tmp>/plan.tfplan` in the root, then
1610
+ * `show -json <tmp>/plan.tfplan`, then remove `<tmp>`.
1611
+ *
1612
+ * Three rules, each a lesson behold already learned reading choudoufu
1613
+ * members (behold `src/choudoufu-plan.ts`):
1614
+ *
1615
+ * - No lock. `-lock=false` on the plan, always ({@link terraformPlanReadCommand}).
1616
+ * - Nothing in the root. `<tmp>` is a fresh `mkdtemp` under the OS temp
1617
+ * directory and is removed in a `finally`, failure or not. `init` is never
1618
+ * run: an uninitialised root throws a {@link TerraformPlanReadError} of
1619
+ * kind `"not-initialised"` carrying the line to run.
1620
+ * - A failure says which kind it is, so the observation can fall back by
1621
+ * depth ({@link classifyPlanReadFailure}).
1622
+ *
1623
+ * Uses the longInfra profile, like {@link terraformPlan}: a plan refreshes
1624
+ * every resource against its provider.
1625
+ */
1626
+ export async function terraformPlanRead(
1627
+ args: TerraformRootArgs,
1628
+ signal?: AbortSignal,
1629
+ ): Promise<TerraformPlanReadResult> {
1630
+ const { binary, root, dir } = await resolveRoot(args, signal);
1631
+ const env = terraformEnvironment(root);
1632
+ const initLine = terraformInitLine({ binary, dir, ...(root.backendConfig ? { backendConfig: root.backendConfig } : {}) });
1633
+ const notInitialised = (detail: string): TerraformPlanReadError =>
1634
+ new TerraformPlanReadError(
1635
+ "not-initialised",
1636
+ `terraform.roots.${args.root} (${dir}) ${detail}: run \`${initLine}\` first. chant never runs init on a read, since init writes into the root.`,
1637
+ dir,
1638
+ initLine,
1639
+ );
1640
+
1641
+ if (!rootIsInitialised(dir, { ...process.env, ...env })) throw notInitialised("has never been initialised");
1642
+
1643
+ const planDir = mkdtempSync(join(tmpdir(), PLAN_READ_DIR_PREFIX));
1644
+ const planFile = join(planDir, PLAN_READ_FILE);
1645
+ const command = terraformPlanReadCommand({ binary, planFile, ...(root.varFiles ? { varFiles: root.varFiles } : {}) });
1646
+ try {
1647
+ try {
1648
+ const { stderr } = await run(command, dir, env, signal);
1649
+ // The plan's own stdout is the human render, which this read never
1650
+ // uses; stderr carries warnings worth a human's eye.
1651
+ if (stderr) console.error(stderr);
1652
+ } catch (err) {
1653
+ const failure = err as ExecFailure;
1654
+ if (typeof failure.code !== "number") throw err;
1655
+ const output = `${failure.stderr ?? ""}\n${failure.stdout ?? ""}`.replace(/\x1b\[[0-9;]*m/g, "");
1656
+ const kind = classifyPlanReadFailure(output);
1657
+ const line =
1658
+ output
1659
+ .split("\n")
1660
+ .map((l) => l.replace(/^[\s│╷╵]+/, "").trim())
1661
+ .find((l) => /^Error:/.test(l)) ?? `exit ${failure.code}`;
1662
+ if (kind === "not-initialised") throw notInitialised(`needs init (${line})`);
1663
+ throw new TerraformPlanReadError(kind, `${binary} plan failed in ${dir}: ${line}`, dir);
1664
+ }
1665
+ const shown = await run(terraformShowCommand({ binary, json: true, planFile }), dir, env, signal);
1666
+ return { json: JSON.parse(shown.stdout) as unknown, dir, planDir, command };
1667
+ } finally {
1668
+ rmSync(planDir, { recursive: true, force: true });
1669
+ }
1670
+ }
1671
+
1487
1672
  /**
1488
1673
  * The estate a choudoufu-only activity runs against, in precedence order:
1489
1674
  * `args.estate` when the step names one, then the root's own `estate`
@@ -1560,8 +1745,12 @@ export interface ChoudoufuLivePlanDocumentResult extends LivePlanUnownedCounts {
1560
1745
  json: unknown;
1561
1746
  /** Absolute path of the root module directory. */
1562
1747
  dir: string;
1563
- /** Where the JSON document was written, relative to `dir`. */
1564
- documentPath: string;
1748
+ /**
1749
+ * Where the JSON document was written, relative to `dir`. Present only when
1750
+ * the caller asked for a file with `documentPath`; a read writes nothing
1751
+ * into the root (#4379).
1752
+ */
1753
+ documentPath?: string;
1565
1754
  /** The estate this plan ran against. */
1566
1755
  estate: string;
1567
1756
  }
@@ -1582,7 +1771,9 @@ export interface ChoudoufuLivePlanDocumentResult extends LivePlanUnownedCounts {
1582
1771
  * the root's environment. The graph command runs in such a scope, so its
1583
1772
  * observation and its prediction see one `live-plan` between them, and the
1584
1773
  * graph's statuses and the prediction's figures are statements about the
1585
- * same read. The document file is still written on every call.
1774
+ * same read. The document is returned, not written: the root is left
1775
+ * byte-for-byte as it was (#4379). A caller that wants the file passes
1776
+ * `documentPath` (relative to the root) and it is written there on request.
1586
1777
  */
1587
1778
  export async function choudoufuLivePlanDocument(
1588
1779
  args: ChoudoufuLivePlanArgs & { documentPath?: string },
@@ -1592,7 +1783,7 @@ export async function choudoufuLivePlanDocument(
1592
1783
  const { binary, dir } = resolved;
1593
1784
  const estate = resolveEstate(args, resolved, "choudoufuLivePlan");
1594
1785
  const env = terraformEnvironment(resolved.root);
1595
- const documentPath = args.documentPath ?? DEFAULT_LIVE_PLAN_DOCUMENT_FILE;
1786
+ const documentPath = args.documentPath;
1596
1787
  // A root that declares its own estate is run without `-estate`: choudoufu
1597
1788
  // refuses the flag beside a declaration and, since v0.14.0, settles the name
1598
1789
  // from the declaration itself. See {@link choudoufuLivePlanCommand}.
@@ -1634,15 +1825,28 @@ export async function choudoufuLivePlanDocument(
1634
1825
  // the identity on a supported binary. See {@link liveDocumentFrom}.
1635
1826
  const document = liveDocumentFrom(jsonStdout);
1636
1827
  const json: unknown = JSON.parse(document);
1637
- writeFileSync(join(dir, documentPath), document);
1638
- return { drift, json, dir, documentPath, estate, ...countLivePlanUnowned(json) };
1828
+ if (documentPath !== undefined) writeFileSync(join(dir, documentPath), document);
1829
+ return {
1830
+ drift,
1831
+ json,
1832
+ dir,
1833
+ ...(documentPath === undefined ? {} : { documentPath }),
1834
+ estate,
1835
+ ...countLivePlanUnowned(json),
1836
+ };
1639
1837
  }
1640
1838
 
1641
1839
  export async function choudoufuLivePlan(
1642
1840
  args: ChoudoufuLivePlanArgs & { documentPath?: string },
1643
1841
  signal?: AbortSignal,
1644
1842
  ): Promise<ChoudoufuLivePlanResult> {
1645
- const { drift, json, dir, documentPath, estate, ...unowned } = await choudoufuLivePlanDocument(args, signal);
1843
+ // The Op activity is the one caller that wants the file: `documentPath` is a
1844
+ // declared `.out` ref a later step or reviewer opens (#2103), so it asks.
1845
+ const documentPath = args.documentPath ?? DEFAULT_LIVE_PLAN_DOCUMENT_FILE;
1846
+ const { drift, json, dir, estate, ...unowned } = await choudoufuLivePlanDocument(
1847
+ { ...args, documentPath },
1848
+ signal,
1849
+ );
1646
1850
  const resolved = await resolveRoot(args, signal);
1647
1851
  const { binary } = resolved;
1648
1852
  const env = terraformEnvironment(resolved.root);