@intentius/chant-lexicon-terraform 0.75.1 → 0.77.0

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 (45) hide show
  1. package/dist/behaviour/index.d.ts +1 -1
  2. package/dist/behaviour/index.d.ts.map +1 -1
  3. package/dist/behaviour/kinds.d.ts +9 -0
  4. package/dist/behaviour/kinds.d.ts.map +1 -1
  5. package/dist/behaviour/predict.d.ts +67 -9
  6. package/dist/behaviour/predict.d.ts.map +1 -1
  7. package/dist/behaviour/request.d.ts +1 -1
  8. package/dist/behaviour/request.d.ts.map +1 -1
  9. package/dist/describe-resources.d.ts +3 -2
  10. package/dist/describe-resources.d.ts.map +1 -1
  11. package/dist/hcl/descend.d.ts +1 -1
  12. package/dist/hcl/parse.d.ts +2 -2
  13. package/dist/hcl/roots.d.ts +1 -1
  14. package/dist/hcl/suppressions.d.ts +1 -1
  15. package/dist/hcl/value.d.ts +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/integrity.json +3 -3
  19. package/dist/lint/post-synth/tf016.d.ts +1 -1
  20. package/dist/manifest.json +1 -1
  21. package/dist/op/activities/live-detect.d.ts +1 -1
  22. package/dist/op/activities/terraform.d.ts +38 -0
  23. package/dist/op/activities/terraform.d.ts.map +1 -1
  24. package/dist/plugin.d.ts.map +1 -1
  25. package/dist/rules/tf016.ts +1 -1
  26. package/package.json +5 -5
  27. package/src/behaviour/index.ts +3 -0
  28. package/src/behaviour/kinds.ts +6 -1
  29. package/src/behaviour/predict.test.ts +171 -3
  30. package/src/behaviour/predict.ts +171 -9
  31. package/src/behaviour/request.test.ts +8 -1
  32. package/src/behaviour/request.ts +2 -1
  33. package/src/describe-resources.ts +4 -3
  34. package/src/hcl/descend.ts +1 -1
  35. package/src/hcl/parse.ts +2 -2
  36. package/src/hcl/roots.ts +1 -1
  37. package/src/hcl/suppressions.ts +1 -1
  38. package/src/hcl/value.ts +1 -1
  39. package/src/index.ts +3 -1
  40. package/src/lint/post-synth/tf016.ts +1 -1
  41. package/src/op/activities/live-detect.ts +1 -1
  42. package/src/op/activities/terraform.test.ts +117 -0
  43. package/src/op/activities/terraform.ts +85 -28
  44. package/src/plugin.test.ts +23 -1
  45. package/src/plugin.ts +13 -0
@@ -23,6 +23,8 @@ import {
23
23
  terraformApply,
24
24
  terraformShow,
25
25
  choudoufuLivePlan,
26
+ choudoufuLivePlanDocument,
27
+ choudoufuLiveLs,
26
28
  terraformInitCommand,
27
29
  terraformPlanCommand,
28
30
  terraformApplyCommand,
@@ -36,6 +38,7 @@ import {
36
38
  DEFAULT_PLAN_FILE,
37
39
  __resetChoudoufuVersionCheckForTests,
38
40
  } from "./terraform";
41
+ import { withLiveReadSession } from "@intentius/chant/live-read-session";
39
42
 
40
43
  // ── The child-process stub ──────────────────────────────────────────────────
41
44
 
@@ -601,3 +604,117 @@ describe("every activity forwards the AbortSignal (#2086)", () => {
601
604
  });
602
605
  }
603
606
  });
607
+
608
+ describe("one command, one read of the account (chant#2498)", () => {
609
+ const LIVE_PLAN_DOCUMENT = JSON.stringify({ estate: "one-read", bound: [], omissions: [], unowned: [] });
610
+ const LIVE_LS = JSON.stringify({ estate: "one-read", resources: [] });
611
+
612
+ function liveProject(): string {
613
+ const dir = project({ binary: "choudoufu", roots: { estate: { dir: "./infra" } } });
614
+ writeFileSync(join(dir, "infra", "estate.chdf.hcl"), 'estate = "one-read"\n');
615
+ return dir;
616
+ }
617
+
618
+ const runs = (fragment: string): number => execCalls.filter((c) => c.cmd.includes(fragment)).length;
619
+
620
+ beforeEach(() => {
621
+ __resetChoudoufuVersionCheckForTests();
622
+ replies.push(
623
+ { match: "choudoufu version", reply: { stdout: "choudoufu v0.15.0 (based on OpenTofu v1.13.0)\non darwin_arm64", stderr: "" } },
624
+ { match: "live-plan -detailed-exitcode -json", reply: { stdout: LIVE_PLAN_DOCUMENT, stderr: "" } },
625
+ { match: "-no-color", reply: { stdout: "No changes.\n\nPlan: 0 to add, 0 to change, 0 to destroy.\n", stderr: "" } },
626
+ { match: "live-ls", reply: { stdout: LIVE_LS, stderr: "" } },
627
+ );
628
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
629
+ });
630
+
631
+ test("the document alone is one run, with no human render", async () => {
632
+ const dir = liveProject();
633
+ const result = await choudoufuLivePlanDocument({ root: "estate", cwd: dir });
634
+ expect(result.estate).toBe("one-read");
635
+ expect(result.drift).toBe(false);
636
+ expect(result.json).toEqual(JSON.parse(LIVE_PLAN_DOCUMENT));
637
+ expect(runs("live-plan -detailed-exitcode -json")).toBe(1);
638
+ expect(runs("-no-color")).toBe(0);
639
+ expect("text" in result).toBe(false);
640
+ });
641
+
642
+ test("the full activity still makes the two runs, the second for the render", async () => {
643
+ const dir = liveProject();
644
+ const result = await choudoufuLivePlan({ root: "estate", cwd: dir });
645
+ expect(result.text).toContain("No changes.");
646
+ expect(runs("live-plan -detailed-exitcode -json")).toBe(1);
647
+ expect(runs("live-plan -detailed-exitcode -no-color")).toBe(1);
648
+ });
649
+
650
+ test("outside a read session, every call reads", async () => {
651
+ const dir = liveProject();
652
+ await choudoufuLivePlanDocument({ root: "estate", cwd: dir });
653
+ await choudoufuLivePlanDocument({ root: "estate", cwd: dir });
654
+ await choudoufuLiveLs({ root: "estate", cwd: dir, consistent: true });
655
+ await choudoufuLiveLs({ root: "estate", cwd: dir, consistent: true });
656
+ expect(runs("live-plan -detailed-exitcode -json")).toBe(2);
657
+ expect(runs("live-ls")).toBe(2);
658
+ });
659
+
660
+ test("inside one session, the observation's read and the prediction's read are one live-plan and one live-ls", async () => {
661
+ const dir = liveProject();
662
+ await withLiveReadSession(async () => {
663
+ // What describeResources() does, then what predictBehaviour() does,
664
+ // then the full activity a finding mode would run on top.
665
+ const observed = await choudoufuLivePlanDocument({ root: "estate", cwd: dir });
666
+ const listed = await choudoufuLiveLs({ root: "estate", cwd: dir, consistent: true });
667
+ const predicted = await choudoufuLivePlanDocument({ root: "estate", cwd: dir });
668
+ const listedAgain = await choudoufuLiveLs({ root: "estate", cwd: dir, consistent: true });
669
+ const rendered = await choudoufuLivePlan({ root: "estate", cwd: dir });
670
+ expect(predicted.json).toEqual(observed.json);
671
+ expect(listedAgain.json).toEqual(listed.json);
672
+ expect(rendered.json).toEqual(observed.json);
673
+ });
674
+ expect(runs("live-plan -detailed-exitcode -json")).toBe(1);
675
+ expect(runs("live-plan -detailed-exitcode -no-color")).toBe(1);
676
+ expect(runs("live-ls")).toBe(1);
677
+ });
678
+
679
+ test("a different flag is a different read", async () => {
680
+ const dir = liveProject();
681
+ await withLiveReadSession(async () => {
682
+ await choudoufuLivePlanDocument({ root: "estate", cwd: dir });
683
+ await choudoufuLivePlanDocument({ root: "estate", cwd: dir, adoptionOnly: true });
684
+ await choudoufuLiveLs({ root: "estate", cwd: dir, consistent: true });
685
+ await choudoufuLiveLs({ root: "estate", cwd: dir });
686
+ });
687
+ expect(runs("live-plan -detailed-exitcode -json")).toBe(2);
688
+ expect(runs("live-ls -estate=one-read -json -consistent")).toBe(1);
689
+ expect(runs("live-ls")).toBe(2);
690
+ const adoption = execCalls.filter((c) => c.opts.env?.TOFU_LIVE_COLLECT_UNCLAIMED === "1");
691
+ expect(adoption.length).toBe(1);
692
+ });
693
+
694
+ test("a read that failed is not shared: the next caller reads again", async () => {
695
+ const dir = liveProject();
696
+ replies.unshift({ match: "live-plan -detailed-exitcode -json", reply: execError(1, "no credentials") });
697
+ await withLiveReadSession(async () => {
698
+ await expect(choudoufuLivePlanDocument({ root: "estate", cwd: dir })).rejects.toThrow("no credentials");
699
+ replies.shift();
700
+ const result = await choudoufuLivePlanDocument({ root: "estate", cwd: dir });
701
+ expect(result.json).toEqual(JSON.parse(LIVE_PLAN_DOCUMENT));
702
+ });
703
+ expect(runs("live-plan -detailed-exitcode -json")).toBe(2);
704
+ });
705
+
706
+ test("drift on the shared read is the same drift for every caller", async () => {
707
+ const dir = liveProject();
708
+ replies.unshift({
709
+ match: "live-plan -detailed-exitcode -json",
710
+ reply: execError(2, "", LIVE_PLAN_DOCUMENT),
711
+ });
712
+ await withLiveReadSession(async () => {
713
+ const first = await choudoufuLivePlanDocument({ root: "estate", cwd: dir });
714
+ const second = await choudoufuLivePlanDocument({ root: "estate", cwd: dir });
715
+ expect(first.drift).toBe(true);
716
+ expect(second.drift).toBe(true);
717
+ });
718
+ expect(runs("live-plan -detailed-exitcode -json")).toBe(1);
719
+ });
720
+ });
@@ -44,6 +44,7 @@ import { writeFileSync } from "node:fs";
44
44
  import { promisify } from "node:util";
45
45
  import { resolve, dirname, join } from "node:path";
46
46
  import { loadChantConfigUpward } from "@intentius/chant/config";
47
+ import { liveReadKey, memoLiveRead } from "@intentius/chant/live-read-session";
47
48
  import { computePlanDigest } from "@intentius/chant/op";
48
49
  import type { TerraformConfig, TerraformRootConfig } from "../../config";
49
50
  import { detectLiveEstate } from "./live-detect";
@@ -1173,10 +1174,46 @@ export function liveDocumentFrom(stdout: string): string {
1173
1174
  return start === -1 ? stdout : lines.slice(start).join("\n");
1174
1175
  }
1175
1176
 
1176
- export async function choudoufuLivePlan(
1177
+ /**
1178
+ * What {@link choudoufuLivePlanDocument} resolves: the document and the facts
1179
+ * around it, and none of the human render. The subset of
1180
+ * {@link ChoudoufuLivePlanResult} a reader of the document needs.
1181
+ */
1182
+ export interface ChoudoufuLivePlanDocumentResult extends LivePlanUnownedCounts {
1183
+ /** `true` when `-detailed-exitcode` reported exit 2: the live plan proposes changes. */
1184
+ drift: boolean;
1185
+ /** GitHub issue #788's JSON document, captured whole. */
1186
+ json: unknown;
1187
+ /** Absolute path of the root module directory. */
1188
+ dir: string;
1189
+ /** Where the JSON document was written, relative to `dir`. */
1190
+ documentPath: string;
1191
+ /** The estate this plan ran against. */
1192
+ estate: string;
1193
+ }
1194
+
1195
+ /**
1196
+ * `live-plan -detailed-exitcode -json` alone: the document, without the
1197
+ * second run that renders the plan for a human (chant #2498).
1198
+ *
1199
+ * Two of {@link choudoufuLivePlan}'s three callers never read `text`:
1200
+ * `describeResources()` indexes the document and `predictBehaviour()` prices
1201
+ * it. Each was paying for the render anyway, and on a drifted estate whose
1202
+ * provider retries a deleted resource the render costs as much as the
1203
+ * document. This is the read they make instead.
1204
+ *
1205
+ * Inside a `withLiveReadSession` scope (core's `live-read-session.ts`) the
1206
+ * run is shared with every identical read in the same command, keyed on
1207
+ * the root, its resolved directory, the binary, the estate, the flags and
1208
+ * the root's environment. The graph command runs in such a scope, so its
1209
+ * observation and its prediction see one `live-plan` between them, and the
1210
+ * graph's statuses and the prediction's figures are statements about the
1211
+ * same read. The document file is still written on every call.
1212
+ */
1213
+ export async function choudoufuLivePlanDocument(
1177
1214
  args: ChoudoufuLivePlanArgs & { documentPath?: string },
1178
1215
  signal?: AbortSignal,
1179
- ): Promise<ChoudoufuLivePlanResult> {
1216
+ ): Promise<ChoudoufuLivePlanDocumentResult> {
1180
1217
  const resolved = await resolveRoot(args, signal);
1181
1218
  const { binary, dir } = resolved;
1182
1219
  const estate = resolveEstate(args, resolved, "choudoufuLivePlan");
@@ -1187,8 +1224,6 @@ export async function choudoufuLivePlan(
1187
1224
  // from the declaration itself. See {@link choudoufuLivePlanCommand}.
1188
1225
  const estateFlag: { estate?: string } = resolved.estate === undefined ? { estate } : {};
1189
1226
 
1190
- let drift: boolean;
1191
- let jsonStdout: string;
1192
1227
  const planCmd = choudoufuLivePlanCommand({ binary, ...estateFlag, json: true });
1193
1228
  // The document's `adoptable` and `swept` sections are populated only on a
1194
1229
  // run that asked the estate-wide sweep the account-bounded question, which
@@ -1198,30 +1233,47 @@ export async function choudoufuLivePlan(
1198
1233
  // run asks for the same thing. Set only under `adoptionOnly`: the sweep is
1199
1234
  // an account-wide list per admitted type, and an observation read has no use
1200
1235
  // for it.
1201
- const planEnv = args.adoptionOnly ? { ...env, TOFU_LIVE_COLLECT_UNCLAIMED: "1" } : env;
1202
- try {
1203
- const { stdout, stderr } = await run(planCmd, dir, planEnv, signal);
1204
- report(stdout, stderr);
1205
- drift = false;
1206
- jsonStdout = stdout;
1207
- } catch (err) {
1208
- const failure = err as ExecFailure;
1209
- if (typeof failure.code !== "number") throw err;
1210
- if (failure.code !== 2) {
1211
- const detail = (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim();
1212
- throw new Error(
1213
- `${binary} live-plan failed in ${dir} (exit ${failure.code})${detail ? `\n${detail}` : ""}`,
1214
- );
1236
+ const adoptionOnly = args.adoptionOnly === true;
1237
+ const planEnv = adoptionOnly ? { ...env, TOFU_LIVE_COLLECT_UNCLAIMED: "1" } : env;
1238
+ const key = liveReadKey("live-plan", { root: args.root, dir, binary, estate, adoptionOnly, env });
1239
+
1240
+ const { drift, jsonStdout } = await memoLiveRead(key, async (): Promise<{ drift: boolean; jsonStdout: string }> => {
1241
+ try {
1242
+ const { stdout, stderr } = await run(planCmd, dir, planEnv, signal);
1243
+ report(stdout, stderr);
1244
+ return { drift: false, jsonStdout: stdout };
1245
+ } catch (err) {
1246
+ const failure = err as ExecFailure;
1247
+ if (typeof failure.code !== "number") throw err;
1248
+ if (failure.code !== 2) {
1249
+ const detail = (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim();
1250
+ throw new Error(
1251
+ `${binary} live-plan failed in ${dir} (exit ${failure.code})${detail ? `\n${detail}` : ""}`,
1252
+ );
1253
+ }
1254
+ report(failure.stdout ?? "", failure.stderr ?? "");
1255
+ return { drift: true, jsonStdout: failure.stdout ?? "" };
1215
1256
  }
1216
- report(failure.stdout ?? "", failure.stderr ?? "");
1217
- drift = true;
1218
- jsonStdout = failure.stdout ?? "";
1219
- }
1257
+ });
1220
1258
 
1221
1259
  // Belt and braces: v0.14.0 keeps `-json` stdout to the document, so this is
1222
1260
  // the identity on a supported binary. See {@link liveDocumentFrom}.
1223
1261
  const document = liveDocumentFrom(jsonStdout);
1224
1262
  const json: unknown = JSON.parse(document);
1263
+ writeFileSync(join(dir, documentPath), document);
1264
+ return { drift, json, dir, documentPath, estate, ...countLivePlanUnowned(json) };
1265
+ }
1266
+
1267
+ export async function choudoufuLivePlan(
1268
+ args: ChoudoufuLivePlanArgs & { documentPath?: string },
1269
+ signal?: AbortSignal,
1270
+ ): Promise<ChoudoufuLivePlanResult> {
1271
+ const { drift, json, dir, documentPath, estate, ...unowned } = await choudoufuLivePlanDocument(args, signal);
1272
+ const resolved = await resolveRoot(args, signal);
1273
+ const { binary } = resolved;
1274
+ const env = terraformEnvironment(resolved.root);
1275
+ const estateFlag: { estate?: string } = resolved.estate === undefined ? { estate } : {};
1276
+
1225
1277
  // #788's document carries no render of the human plan itself
1226
1278
  // (`views.LivePlanDocument` has no such field), so the human text needs a
1227
1279
  // second live read rather than a field on the same response. `-adoption-only`
@@ -1255,8 +1307,6 @@ export async function choudoufuLivePlan(
1255
1307
  text = failure.stdout ?? "";
1256
1308
  }
1257
1309
 
1258
- writeFileSync(join(dir, documentPath), document);
1259
-
1260
1310
  // An `adoptable[]` row carries its own tagging command in the document
1261
1311
  // (choudoufu #962). An `unowned[]` row does not, and the render is the only
1262
1312
  // place one is printed for it, so the parse still runs on an adoption run
@@ -1279,7 +1329,7 @@ export async function choudoufuLivePlan(
1279
1329
  dir,
1280
1330
  documentPath,
1281
1331
  estate,
1282
- ...countLivePlanUnowned(json),
1332
+ ...unowned,
1283
1333
  ...parseChoudoufuPlanSummary(text),
1284
1334
  };
1285
1335
  }
@@ -1301,9 +1351,16 @@ export async function choudoufuLiveLs(
1301
1351
  const estate = resolveEstate(args, resolved, "choudoufuLiveLs");
1302
1352
  const env = terraformEnvironment(resolved.root);
1303
1353
 
1304
- const cmd = choudoufuLiveLsCommand({ binary, estate, ...(args.consistent ? { consistent: true } : {}) });
1305
- const { stdout, stderr } = await run(cmd, dir, env, signal);
1306
- report(stdout, stderr);
1354
+ const consistent = args.consistent === true;
1355
+ const cmd = choudoufuLiveLsCommand({ binary, estate, ...(consistent ? { consistent: true } : {}) });
1356
+ // Shared with every identical listing in the same read session, the way
1357
+ // {@link choudoufuLivePlanDocument} shares the document (chant #2498).
1358
+ const key = liveReadKey("live-ls", { root: args.root, dir, binary, estate, consistent, env });
1359
+ const stdout = await memoLiveRead(key, async () => {
1360
+ const result = await run(cmd, dir, env, signal);
1361
+ report(result.stdout, result.stderr);
1362
+ return result.stdout;
1363
+ });
1307
1364
 
1308
1365
  return { json: JSON.parse(stdout), dir, estate };
1309
1366
  }
@@ -1,6 +1,7 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { describe, expect, it } from "vitest";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { behaviourEngineVariables, isBehaviourRefusalReport } from "@intentius/chant/behaviour";
4
5
  import { isLexiconPlugin } from "@intentius/chant/lexicon";
5
6
  import { validateLexiconConfig } from "@intentius/chant/lexicon-config";
6
7
  import type { ChantConfig } from "@intentius/chant/config";
@@ -79,6 +80,27 @@ describe("terraform plugin", () => {
79
80
  });
80
81
  });
81
82
 
83
+ describe("predictBehaviour on the plugin (#2495)", () => {
84
+ afterEach(() => vi.unstubAllEnvs());
85
+
86
+ it("reaches core's engine front with nothing injected, and refuses by name when no engine is set", async () => {
87
+ for (const name of behaviourEngineVariables("chant")) vi.stubEnv(name, "");
88
+ const result = await terraformPlugin.predictBehaviour!({
89
+ environment: "prod",
90
+ buildOutput: "",
91
+ from: "declared",
92
+ traffic: "100 rps, p50",
93
+ entityNames: ["net/aws_vpc.main"],
94
+ entities: new Map([
95
+ ["net/aws_vpc.main", { entityType: "Terraform::Resource", props: { root: "net", address: "aws_vpc.main", body: {} } }],
96
+ ]),
97
+ edges: [],
98
+ edgeCoverage: { verdict: "unknown" },
99
+ });
100
+ expect(isBehaviourRefusalReport(result) && result.refusal.cause).toBe("no-engine");
101
+ });
102
+ });
103
+
82
104
  describe("terraform config schema", () => {
83
105
  it("accepts a root with every optional field", () => {
84
106
  const parsed = terraformConfigSchema.parse({
package/src/plugin.ts CHANGED
@@ -135,6 +135,19 @@ export const terraformPlugin: LexiconPlugin = {
135
135
  return describeResources(options);
136
136
  },
137
137
 
138
+ /**
139
+ * The prediction, for whichever estate `options.from` names (#2495). The
140
+ * producer is `predictTerraformBehaviour` and this is its second caller, not
141
+ * a second producer: the contract's options are read as its own and handed
142
+ * over, and it runs with the real reads and core's engine front. What that
143
+ * reading has to put back, for a declared graph and for a live one, is on
144
+ * `terraformPredictOptionsFrom`.
145
+ */
146
+ async predictBehaviour(options) {
147
+ const { predictTerraformBehaviour, terraformPredictOptionsFrom } = await import("./behaviour");
148
+ return predictTerraformBehaviour(await terraformPredictOptionsFrom(options));
149
+ },
150
+
138
151
  /**
139
152
  * The kinds a live root can enumerate beyond its declaration (#1278,
140
153
  * #2104). Empty on a project with no live root, because `live-ls` is the