@intentius/chant-lexicon-terraform 0.124.31 → 0.124.32

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 (141) hide show
  1. package/dist/__fixtures__/terragrunt/affected-cases.js +111 -0
  2. package/dist/__fixtures__/terragrunt/record.js +57 -0
  3. package/dist/behaviour/index.js +6 -0
  4. package/dist/behaviour/kinds.js +194 -0
  5. package/dist/behaviour/predict.js +297 -0
  6. package/dist/behaviour/reference-catalog.js +178 -0
  7. package/dist/behaviour/request.js +482 -0
  8. package/dist/change-set.js +234 -0
  9. package/dist/changed-roots.js +97 -0
  10. package/dist/codegen/docs-cli.js +3 -0
  11. package/dist/codegen/docs.js +44 -0
  12. package/dist/codegen/generate-cli.js +9 -0
  13. package/dist/codegen/generate.js +59 -0
  14. package/dist/codegen/package.js +43 -0
  15. package/dist/commands.js +106 -0
  16. package/dist/components/capability-plugin.d.ts +38 -2
  17. package/dist/components/capability-plugin.d.ts.map +1 -1
  18. package/dist/components/capability-plugin.js +260 -0
  19. package/dist/composites/terraform-adopt-op.js +152 -0
  20. package/dist/composites/terraform-apply-op.js +183 -0
  21. package/dist/composites/terraform-pin-rollout-op.js +45 -0
  22. package/dist/composites/terraform-watch-op.js +219 -0
  23. package/dist/config.js +118 -0
  24. package/dist/describe-resources.js +984 -0
  25. package/dist/generated/index.js +1 -0
  26. package/dist/hcl/descend.js +208 -0
  27. package/dist/hcl/edges.js +287 -0
  28. package/dist/hcl/jsonencode.js +442 -0
  29. package/dist/hcl/marker.js +212 -0
  30. package/dist/hcl/parse.js +372 -0
  31. package/dist/hcl/references.js +156 -0
  32. package/dist/hcl/roots.js +98 -0
  33. package/dist/hcl/suppressions.js +189 -0
  34. package/dist/hcl/value.js +72 -0
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +46 -0
  38. package/dist/integrity.json +2 -2
  39. package/dist/lint/audit-catalog.js +501 -0
  40. package/dist/lint/audit-lineage.js +442 -0
  41. package/dist/lint/post-synth/blocks.js +87 -0
  42. package/dist/lint/post-synth/fixtures/load.js +94 -0
  43. package/dist/lint/post-synth/flat-attributes.js +135 -0
  44. package/dist/lint/post-synth/index.js +90 -0
  45. package/dist/lint/post-synth/module-source.js +153 -0
  46. package/dist/lint/post-synth/scope.js +104 -0
  47. package/dist/lint/post-synth/terragrunt.js +63 -0
  48. package/dist/lint/post-synth/tf001.js +138 -0
  49. package/dist/lint/post-synth/tf002.js +143 -0
  50. package/dist/lint/post-synth/tf003.js +60 -0
  51. package/dist/lint/post-synth/tf004.js +54 -0
  52. package/dist/lint/post-synth/tf005.js +68 -0
  53. package/dist/lint/post-synth/tf006.js +43 -0
  54. package/dist/lint/post-synth/tf007.js +66 -0
  55. package/dist/lint/post-synth/tf008.js +64 -0
  56. package/dist/lint/post-synth/tf009.js +49 -0
  57. package/dist/lint/post-synth/tf010.js +40 -0
  58. package/dist/lint/post-synth/tf011.js +38 -0
  59. package/dist/lint/post-synth/tf012.js +36 -0
  60. package/dist/lint/post-synth/tf013.js +53 -0
  61. package/dist/lint/post-synth/tf014.js +61 -0
  62. package/dist/lint/post-synth/tf015.js +62 -0
  63. package/dist/lint/post-synth/tf016.js +66 -0
  64. package/dist/lint/post-synth/tf017.js +39 -0
  65. package/dist/lint/post-synth/tf018.js +63 -0
  66. package/dist/lint/post-synth/tf019.js +60 -0
  67. package/dist/lint/post-synth/tf020.js +103 -0
  68. package/dist/lint/post-synth/tf021.js +117 -0
  69. package/dist/lint/post-synth/tf022.js +51 -0
  70. package/dist/lint/post-synth/tf024.js +62 -0
  71. package/dist/lint/post-synth/tf025.js +83 -0
  72. package/dist/lint/post-synth/tf026.js +74 -0
  73. package/dist/lint/post-synth/tf027.js +66 -0
  74. package/dist/lint/post-synth/tf028.js +83 -0
  75. package/dist/lint/post-synth/tf029.js +73 -0
  76. package/dist/lint/post-synth/tf030.js +253 -0
  77. package/dist/lint/post-synth/tf031.js +332 -0
  78. package/dist/lint/post-synth/tf032.js +201 -0
  79. package/dist/lint/post-synth/tf033.js +126 -0
  80. package/dist/lint/post-synth/tf034.js +45 -0
  81. package/dist/lint/post-synth/tf035.js +63 -0
  82. package/dist/lint/post-synth/tf036.js +97 -0
  83. package/dist/lint/post-synth/tf037.js +80 -0
  84. package/dist/lint/post-synth/tf038.js +63 -0
  85. package/dist/lint/post-synth/tf039.js +55 -0
  86. package/dist/lint/post-synth/tf040.js +67 -0
  87. package/dist/lint/post-synth/tf041.js +53 -0
  88. package/dist/lint/post-synth/tf042.js +38 -0
  89. package/dist/lint/post-synth/tf043.js +45 -0
  90. package/dist/lint/post-synth/tf044.js +45 -0
  91. package/dist/lint/post-synth/tf045.js +44 -0
  92. package/dist/lint/rules/fixtures/load.js +50 -0
  93. package/dist/lint/rules/index.js +4 -0
  94. package/dist/lint/rules/plan-before-apply.js +136 -0
  95. package/dist/lint/secret-shape.js +149 -0
  96. package/dist/live-ownership.js +57 -0
  97. package/dist/lsp/completions.js +95 -0
  98. package/dist/lsp/context.js +358 -0
  99. package/dist/lsp/hover.js +45 -0
  100. package/dist/lsp/option-keys.js +93 -0
  101. package/dist/manifest.json +1 -1
  102. package/dist/op/activities/index.js +30 -0
  103. package/dist/op/activities/live-detect.js +143 -0
  104. package/dist/op/activities/pin-rollout.js +45 -0
  105. package/dist/op/activities/terraform.js +1270 -0
  106. package/dist/op/activity-contracts.js +162 -0
  107. package/dist/op/adoption.js +272 -0
  108. package/dist/op/builders.js +125 -0
  109. package/dist/op/resolve-root-mode.js +77 -0
  110. package/dist/package-cli.js +16 -0
  111. package/dist/pin/edit-ts.js +76 -0
  112. package/dist/pin/edit.js +118 -0
  113. package/dist/pin/forge.js +65 -0
  114. package/dist/pin/index.js +16 -0
  115. package/dist/pin/rollout.d.ts.map +1 -1
  116. package/dist/pin/rollout.js +382 -0
  117. package/dist/pin/scan.js +287 -0
  118. package/dist/pin/source.js +171 -0
  119. package/dist/pin/waves.js +109 -0
  120. package/dist/plan-digest.js +67 -0
  121. package/dist/plugin.js +323 -0
  122. package/dist/serializer.js +23 -0
  123. package/dist/state-ownership.js +29 -0
  124. package/dist/terragrunt/affected.d.ts.map +1 -1
  125. package/dist/terragrunt/affected.js +290 -0
  126. package/dist/terragrunt/index.js +10 -0
  127. package/dist/terragrunt/mocks.js +270 -0
  128. package/dist/terragrunt/run.js +264 -0
  129. package/dist/terragrunt/units.js +195 -0
  130. package/dist/terragrunt/wave.js +125 -0
  131. package/dist/validate-cli.js +4 -0
  132. package/dist/validate.js +31 -0
  133. package/package.json +10 -10
  134. package/src/codegen/package.ts +2 -2
  135. package/src/components/capability-plugin.test.ts +97 -2
  136. package/src/components/capability-plugin.ts +117 -12
  137. package/src/components/pr-loop.tofu.test.ts +51 -11
  138. package/src/index.ts +1 -0
  139. package/src/pin/rollout.ts +2 -1
  140. package/src/terragrunt/affected.test.ts +11 -0
  141. package/src/terragrunt/affected.ts +2 -1
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The Terragrunt runner (#3414): discovery and one wave, run against a real
3
+ * `terragrunt`. The pure halves are `./units.ts` and `./wave.ts`.
4
+ *
5
+ * Every call takes an injectable `exec`, so tests stub the process and the
6
+ * acceptance test runs the real binary.
7
+ */
8
+ import { execFile } from "node:child_process";
9
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { plannerForBinary } from "../change-set.js";
12
+ import { parseTerragruntFind, terragruntFindArgs, terragruntVersionProblem, } from "./units.js";
13
+ import { describeMockRefusal, failMockedParts, markProvisional, parseRenderedDependencies, parseTerragruntOutputs, PROVISIONAL_MARKER, terragruntMockReads, terragruntMockWarnings, terragruntOutputArgs, terragruntOutputUpstreams, terragruntRenderArgs, } from "./mocks.js";
14
+ import { parseTerragruntReport, terragruntEnv, terragruntUnitResults, terragruntWaveArgs, terragruntWaveParts, } from "./wave.js";
15
+ export const defaultTerragruntExec = (file, args, options) => new Promise((done) => {
16
+ execFile(file, [...args], { cwd: options.cwd, env: { ...process.env, ...options.env }, maxBuffer: 256 * 1024 * 1024 }, (err, stdout, stderr) => {
17
+ const code = err ? (typeof err.code === "number" ? (err.code) : null) : 0;
18
+ done({ code, stdout: String(stdout), stderr: err && code === null ? `${String(stderr)}${err.message}` : String(stderr) });
19
+ });
20
+ });
21
+ /** Thrown when `terragrunt` is missing, too old or fails. */
22
+ export class TerragruntError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = "TerragruntError";
26
+ }
27
+ }
28
+ /** Refuse a Terragrunt older than 1.1. Returns the version line. */
29
+ export async function checkTerragruntVersion(options) {
30
+ const exec = options.exec ?? defaultTerragruntExec;
31
+ const tg = options.terragrunt ?? "terragrunt";
32
+ const r = await exec(tg, ["--version"], { cwd: options.dir, env: {} });
33
+ if (r.code !== 0)
34
+ throw new TerragruntError(`\`${tg} --version\` failed (exit ${r.code}): ${(r.stderr || r.stdout).trim()}`);
35
+ const problem = terragruntVersionProblem(r.stdout);
36
+ if (problem)
37
+ throw new TerragruntError(problem);
38
+ return r.stdout.trim();
39
+ }
40
+ /**
41
+ * The project's units, from `terragrunt find`, with catalog templates and the
42
+ * module cache left out and `.terragrunt-filters` honoured.
43
+ */
44
+ export async function discoverTerragruntUnits(options) {
45
+ const exec = options.exec ?? defaultTerragruntExec;
46
+ const tg = options.terragrunt ?? "terragrunt";
47
+ const args = terragruntFindArgs(options);
48
+ const r = await exec(tg, args, { cwd: options.dir, env: terragruntEnv(options.binary) });
49
+ if (r.code !== 0)
50
+ throw new TerragruntError(`\`${tg} ${args.join(" ")}\` failed (exit ${r.code}): ${r.stderr.trim()}`);
51
+ let units;
52
+ try {
53
+ units = parseTerragruntFind(r.stdout);
54
+ }
55
+ catch (err) {
56
+ throw new TerragruntError(`could not read \`terragrunt find\` output: ${err instanceof Error ? err.message : String(err)}`);
57
+ }
58
+ return { units, warnings: options.binary ? terraformBinaryWarnings(options.dir, units, options.binary) : [] };
59
+ }
60
+ const TERRAFORM_BINARY = /^\s*terraform_binary\s*=\s*(.+?)\s*$/m;
61
+ /**
62
+ * A warning per unit whose `terragrunt.hcl`, or a file it includes, sets
63
+ * `terraform_binary` to something other than `binary`. `TG_TF_PATH` wins over
64
+ * it (observed on 1.1.6), so the unit runs `binary`, not what it names.
65
+ */
66
+ export function terraformBinaryWarnings(dir, units, binary) {
67
+ const out = [];
68
+ for (const u of units) {
69
+ const files = [join(u.path, "terragrunt.hcl"), ...Object.values(u.include ?? {})];
70
+ for (const f of files) {
71
+ let text;
72
+ try {
73
+ text = readFileSync(resolve(dir, f), "utf8");
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ const m = TERRAFORM_BINARY.exec(text);
79
+ if (!m)
80
+ continue;
81
+ const literal = /^"([^"$]*)"$/.exec(m[1]);
82
+ if (literal && literal[1] === binary)
83
+ break;
84
+ out.push(`${u.path}: ${f} sets terraform_binary = ${m[1]}, but the project's binary is ${binary}; ` +
85
+ `TG_TF_PATH=${binary} overrides it, so the unit runs ${binary}`);
86
+ break;
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+ /** The planner a wave's plans come from: the project's binary, else Terragrunt's default of tofu. */
92
+ export function terragruntPlanner(binary) {
93
+ return plannerForBinary(binary ?? "tofu");
94
+ }
95
+ /**
96
+ * Thrown when a wave's plans would read `mock_outputs` (#3416). Nothing in
97
+ * the wave was planned, so there is nothing to gate. `reads` names each unit,
98
+ * dependency and upstream.
99
+ */
100
+ export class TerragruntMockRefusal extends TerragruntError {
101
+ constructor(reads) {
102
+ super(describeMockRefusal(reads));
103
+ this.reads = reads;
104
+ this.name = "TerragruntMockRefusal";
105
+ }
106
+ }
107
+ /** Run `fn` over `items`, at most `limit` at a time, keeping their order. */
108
+ async function pool(items, limit, fn) {
109
+ const out = new Array(items.length);
110
+ let next = 0;
111
+ const worker = async () => {
112
+ while (next < items.length) {
113
+ const i = next++;
114
+ out[i] = await fn(items[i]);
115
+ }
116
+ };
117
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
118
+ return out;
119
+ }
120
+ /** `dir` and its real path, for reading the absolute paths Terragrunt prints. */
121
+ function dirsOf(dir) {
122
+ const abs = resolve(dir);
123
+ try {
124
+ const real = realpathSync(abs);
125
+ return real === abs ? [abs] : [abs, real];
126
+ }
127
+ catch {
128
+ return [abs];
129
+ }
130
+ }
131
+ /**
132
+ * Which dependencies of the wave's units would read `mock_outputs` at plan,
133
+ * asked of Terragrunt: `render --json` in each unit for its evaluated
134
+ * `dependency` blocks, then `output -json` in each upstream they read. Empty
135
+ * when the wave would plan on real outputs only. Throws when Terragrunt
136
+ * cannot render a unit or read an upstream's outputs, since then nobody
137
+ * knows what the plan would read. A unit with no `terragrunt.hcl` in `dir`
138
+ * (removed by the change, so planned as a destroy) is not rendered.
139
+ */
140
+ export async function checkTerragruntWaveMocks(input) {
141
+ const exec = input.exec ?? defaultTerragruntExec;
142
+ const tg = input.terragrunt ?? "terragrunt";
143
+ const env = terragruntEnv(input.binary);
144
+ const limit = input.parallelism ?? 8;
145
+ const dirs = dirsOf(input.dir);
146
+ // A unit this change removed has no terragrunt.hcl here: Terragrunt plans its destroy from the old checkout.
147
+ // It is not rendered; the plan log check still covers it.
148
+ const units = [...new Set(input.units)].sort().filter((u) => existsSync(join(resolve(input.dir), u, "terragrunt.hcl")));
149
+ const rendered = await pool(units, limit, async (unit) => {
150
+ const args = terragruntRenderArgs(unit);
151
+ const r = await exec(tg, args, { cwd: input.dir, env });
152
+ if (r.code !== 0)
153
+ throw new TerragruntError(`\`${tg} ${args.join(" ")}\` failed (exit ${r.code}), so the wave's dependencies are unknown: ${r.stderr.trim()}`);
154
+ try {
155
+ return [unit, parseRenderedDependencies(unit, r.stdout, dirs)];
156
+ }
157
+ catch (err) {
158
+ throw new TerragruntError(`could not read \`terragrunt render --json\` for ${unit}: ${err instanceof Error ? err.message : String(err)}`);
159
+ }
160
+ });
161
+ const dependencies = new Map(rendered);
162
+ const upstreams = terragruntOutputUpstreams(dependencies);
163
+ const outputs = await pool(upstreams, limit, async (upstream) => {
164
+ const args = terragruntOutputArgs(upstream);
165
+ const r = await exec(tg, args, { cwd: input.dir, env });
166
+ const parsed = r.code === 0 ? parseTerragruntOutputs(r.stdout) : undefined;
167
+ if (!parsed) {
168
+ throw new TerragruntError(`could not read the outputs of ${upstream}, which the wave reads (\`${tg} ${args.join(" ")}\` exit ${r.code}): ${(r.stderr || r.stdout).trim().slice(-2000)}`);
169
+ }
170
+ return [upstream, parsed];
171
+ });
172
+ return terragruntMockReads({ dependencies, outputs: new Map(outputs) });
173
+ }
174
+ /**
175
+ * Plan one wave with one `terragrunt run --all`, and read each unit's plan
176
+ * JSON and run-report row.
177
+ *
178
+ * Unless the plan is provisional, the wave is first checked for mock reads
179
+ * ({@link checkTerragruntWaveMocks}) and refused with a
180
+ * {@link TerragruntMockRefusal} when any dependency would read
181
+ * `mock_outputs`; nothing is planned then. A unit the plan log still shows
182
+ * reading a mock (its upstream's state went away in between) becomes a
183
+ * failed member.
184
+ */
185
+ export async function planTerragruntWave(input) {
186
+ const exec = input.exec ?? defaultTerragruntExec;
187
+ const tg = input.terragrunt ?? "terragrunt";
188
+ const work = resolve(input.dir, input.workDir);
189
+ const outDir = join(work, "plans");
190
+ const jsonOutDir = join(work, "json");
191
+ const reportFile = join(work, "plan-report.json");
192
+ const marker = join(work, PROVISIONAL_MARKER);
193
+ // A plan or report left by an earlier attempt must not stand in for a unit that did not plan this time.
194
+ for (const stale of [outDir, jsonOutDir, reportFile, marker])
195
+ rmSync(stale, { recursive: true, force: true });
196
+ if (!input.provisional) {
197
+ const reads = await checkTerragruntWaveMocks({ ...input, exec });
198
+ if (reads.length > 0)
199
+ throw new TerragruntMockRefusal(reads);
200
+ }
201
+ mkdirSync(work, { recursive: true });
202
+ // Written before planning, so a plan cut short still never applies.
203
+ if (input.provisional)
204
+ writeFileSync(marker, "planned before its upstream applied; never applied\n");
205
+ const args = terragruntWaveArgs({
206
+ units: input.units,
207
+ command: "plan",
208
+ outDir,
209
+ jsonOutDir,
210
+ reportFile,
211
+ ...(input.destroy ? { destroy: true } : {}),
212
+ ...(input.parallelism !== undefined ? { parallelism: input.parallelism } : {}),
213
+ });
214
+ const r = await exec(tg, args, { cwd: input.dir, env: terragruntEnv(input.binary) });
215
+ const report = existsSync(reportFile) ? parseTerragruntReport(readFileSync(reportFile, "utf8")) : new Map();
216
+ const planFor = (unit) => {
217
+ const f = join(jsonOutDir, unit, "tfplan.json");
218
+ return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : undefined;
219
+ };
220
+ const parts = terragruntWaveParts({ units: input.units, report, planFor, planner: terragruntPlanner(input.binary) });
221
+ const inWave = new Set(input.units);
222
+ const mocked = terragruntMockWarnings(`${r.stderr}\n${r.stdout}`, dirsOf(input.dir)).filter((m) => inWave.has(m.unit));
223
+ return {
224
+ parts: input.provisional ? markProvisional(parts) : failMockedParts(parts, mocked),
225
+ provisional: input.provisional === true,
226
+ results: terragruntUnitResults(input.units, report),
227
+ code: r.code,
228
+ log: r.stderr,
229
+ };
230
+ }
231
+ /**
232
+ * Apply one planned wave from its saved plans, with the same filters. Refuses
233
+ * before running anything when a unit of the wave has no saved plan, since
234
+ * Terragrunt would plan it afresh and apply a plan nobody saw, and when the
235
+ * plans are provisional.
236
+ */
237
+ export async function applyTerragruntWave(input) {
238
+ const exec = input.exec ?? defaultTerragruntExec;
239
+ const tg = input.terragrunt ?? "terragrunt";
240
+ const work = resolve(input.dir, input.workDir);
241
+ const outDir = join(work, "plans");
242
+ if (input.provisional || existsSync(join(work, PROVISIONAL_MARKER))) {
243
+ throw new TerragruntError(`the plans under ${work} are provisional: planned before their upstream applied, possibly on mock_outputs. ` +
244
+ `They are never applied; plan the wave again once its upstream applied`);
245
+ }
246
+ const missing = [...new Set(input.units)].sort().filter((u) => !existsSync(join(outDir, u, "tfplan.tfplan")));
247
+ if (missing.length > 0) {
248
+ throw new TerragruntError(`no saved plan for ${missing.join(", ")} under ${outDir}; plan the wave before applying it, so apply runs the plan that was reviewed`);
249
+ }
250
+ const reportFile = join(work, "apply-report.json");
251
+ rmSync(reportFile, { force: true });
252
+ mkdirSync(dirname(reportFile), { recursive: true });
253
+ const args = terragruntWaveArgs({
254
+ units: input.units,
255
+ command: "apply",
256
+ outDir,
257
+ reportFile,
258
+ ...(input.destroy ? { destroy: true } : {}),
259
+ ...(input.parallelism !== undefined ? { parallelism: input.parallelism } : {}),
260
+ });
261
+ const r = await exec(tg, args, { cwd: input.dir, env: terragruntEnv(input.binary) });
262
+ const report = existsSync(reportFile) ? parseTerragruntReport(readFileSync(reportFile, "utf8")) : new Map();
263
+ return { results: terragruntUnitResults(input.units, report), code: r.code, log: r.stderr };
264
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Terragrunt units as roots (#3414): what a unit is, how discovery finds the
3
+ * units, and how the unit graph orders them into waves.
4
+ *
5
+ * A Terragrunt unit is a root: its own state, plan file and plan digest. The
6
+ * units and their edges come from `terragrunt find --json --dag
7
+ * --dependencies`, run by Terragrunt itself, so chant never evaluates
8
+ * Terragrunt's HCL to learn the graph. Directories that group units (implicit
9
+ * stacks) are labels: a unit's stack is its parent directory.
10
+ *
11
+ * This module runs nothing and imports only chant's `gated-waves` subpath, so
12
+ * terragucci can bundle it without TypeScript (#3421). `./run.ts` spawns.
13
+ */
14
+ import { layerWaves } from "@intentius/chant/gated-waves";
15
+ /** The oldest Terragrunt this runner drives: `run --all`, `find --dag` and `--filter` as 1.1 ships them. */
16
+ export const TERRAGRUNT_MIN_VERSION = "1.1.0";
17
+ /**
18
+ * Filters every discovery adds: catalog unit templates, which parse only
19
+ * inside a stack, and Terragrunt's module cache. A project's own `exclude`
20
+ * globs are added after these.
21
+ */
22
+ export const TERRAGRUNT_DISCOVERY_EXCLUDES = ["catalog/**", "**/.terragrunt-cache/**"];
23
+ /** Read `terragrunt --version` output (`terragrunt version v1.1.6`). Undefined when no version is in it. */
24
+ export function parseTerragruntVersion(output) {
25
+ const m = /v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(output);
26
+ if (!m)
27
+ return undefined;
28
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), ...(m[4] ? { pre: m[4] } : {}), raw: m[0] };
29
+ }
30
+ /**
31
+ * Why this Terragrunt cannot run units, or undefined when it can. A release
32
+ * candidate of 1.1.0 or later counts as that release line.
33
+ */
34
+ export function terragruntVersionProblem(output) {
35
+ const v = parseTerragruntVersion(output);
36
+ if (!v)
37
+ return `could not read a version from \`terragrunt --version\` (got ${JSON.stringify(output.trim().slice(0, 80))})`;
38
+ const [minMajor, minMinor] = TERRAGRUNT_MIN_VERSION.split(".").map(Number);
39
+ if (v.major > minMajor || (v.major === minMajor && v.minor >= minMinor))
40
+ return undefined;
41
+ return `Terragrunt ${v.raw} is older than ${TERRAGRUNT_MIN_VERSION}, the oldest release chant runs units with (run --all, find --dag and --filter as 1.1 ships them)`;
42
+ }
43
+ /** A path filter that selects exactly one unit. Braced so a path with glob characters stays a path. */
44
+ export function unitPathFilter(path) {
45
+ return `{./${path}}`;
46
+ }
47
+ /** A filter that drops everything under `glob`, relative to where discovery runs. */
48
+ export function excludeFilter(glob) {
49
+ return `!./${glob.replace(/^\.\//, "")}`;
50
+ }
51
+ /**
52
+ * Arguments to `terragrunt` for discovery. `.terragrunt-filters` is read, as
53
+ * Terragrunt reads it by default; the excludes narrow what it selects.
54
+ */
55
+ export function terragruntFindArgs(options = {}) {
56
+ const excludes = [...TERRAGRUNT_DISCOVERY_EXCLUDES, ...(options.exclude ?? [])];
57
+ return [
58
+ "find",
59
+ "--json",
60
+ "--dag",
61
+ "--dependencies",
62
+ "--include",
63
+ "--reading",
64
+ "--no-color",
65
+ ...excludes.flatMap((g) => ["--filter", excludeFilter(g)]),
66
+ ];
67
+ }
68
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
69
+ const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []);
70
+ const clean = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
71
+ /**
72
+ * The units in `find --json` output, in the order Terragrunt printed them
73
+ * (its DAG order under `--dag`). Stacks and anything else that is not a unit
74
+ * are left out. Throws when the output is not a JSON array.
75
+ */
76
+ export function parseTerragruntFind(output) {
77
+ const doc = typeof output === "string" ? JSON.parse(output.trim() === "" ? "[]" : output) : output;
78
+ if (!Array.isArray(doc))
79
+ throw new Error("terragrunt find --json did not print an array");
80
+ const units = [];
81
+ for (const raw of doc) {
82
+ if (!isObject(raw) || raw.type !== "unit" || typeof raw.path !== "string")
83
+ continue;
84
+ const include = isObject(raw.include)
85
+ ? Object.fromEntries(Object.entries(raw.include).filter((e) => typeof e[1] === "string").map(([k, v]) => [k, clean(v)]))
86
+ : undefined;
87
+ const reading = Array.isArray(raw.reading) ? strings(raw.reading).map(clean) : undefined;
88
+ units.push({
89
+ path: clean(raw.path),
90
+ dependencies: [...new Set(strings(raw.dependencies).map(clean))].sort(),
91
+ ...(include && Object.keys(include).length > 0 ? { include } : {}),
92
+ ...(reading ? { reading } : {}),
93
+ });
94
+ }
95
+ return units;
96
+ }
97
+ /** A unit's implicit stack: its parent directory, or `.` for a unit at the top. */
98
+ export function stackOfUnit(path) {
99
+ const i = path.lastIndexOf("/");
100
+ return i <= 0 ? "." : path.slice(0, i);
101
+ }
102
+ /** Whether `path` matches `glob` (`*` within a segment, `**` across segments, `?` one character). */
103
+ export function matchesUnitGlob(path, glob) {
104
+ const g = clean(glob);
105
+ let re = "";
106
+ for (let i = 0; i < g.length; i++) {
107
+ const c = g[i];
108
+ if (c === "*" && g[i + 1] === "*") {
109
+ // `a/**/b` and `a/**` also match zero directories.
110
+ if (g[i + 2] === "/") {
111
+ re += "(?:.*/)?";
112
+ i += 2;
113
+ }
114
+ else {
115
+ re += ".*";
116
+ i += 1;
117
+ }
118
+ }
119
+ else if (c === "*")
120
+ re += "[^/]*";
121
+ else if (c === "?")
122
+ re += "[^/]";
123
+ else
124
+ re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
125
+ }
126
+ return new RegExp(`^${re}$`).test(path);
127
+ }
128
+ /** The units, as the nodes gated waves (#3049) and a fan-out (#2417) layer. */
129
+ export function terragruntWaveNodes(units) {
130
+ return units.map((u) => ({ name: u.path, dependsOn: u.dependencies }));
131
+ }
132
+ /** Thrown when units cannot be cut into waves. The message names the units. */
133
+ export class TerragruntWaveError extends Error {
134
+ constructor(message) {
135
+ super(message);
136
+ this.name = "TerragruntWaveError";
137
+ }
138
+ }
139
+ /**
140
+ * Cut units into waves by Terragrunt's unit graph: each unit after every unit
141
+ * it depends on. A dependency outside `units` holds nothing back; it is not
142
+ * part of this change. Each wave is sorted by path.
143
+ *
144
+ * Canaries go first, layered among themselves, so a canary that reads
145
+ * another canary still waits for it to apply. A canary that depends on a unit
146
+ * of the set that is not a canary is refused: it would apply before what it
147
+ * reads.
148
+ */
149
+ export function terragruntWaves(units, options = {}) {
150
+ const canaries = new Set((options.canary ?? []).flatMap((g) => units.filter((u) => matchesUnitGlob(u.path, g)).map((u) => u.path)));
151
+ const names = new Set(units.map((u) => u.path));
152
+ for (const u of units) {
153
+ if (!canaries.has(u.path))
154
+ continue;
155
+ const outside = u.dependencies.filter((d) => names.has(d) && !canaries.has(d));
156
+ if (outside.length > 0) {
157
+ throw new TerragruntWaveError(`canary ${u.path} depends on ${outside.join(", ")}, which is not a canary, so the canary wave would apply it first`);
158
+ }
159
+ }
160
+ const nodes = terragruntWaveNodes(units);
161
+ const layer = (subset) => {
162
+ try {
163
+ return layerWaves(subset);
164
+ }
165
+ catch (err) {
166
+ throw new TerragruntWaveError(err instanceof Error ? err.message : String(err));
167
+ }
168
+ };
169
+ return [...layer(nodes.filter((n) => canaries.has(n.name))), ...layer(nodes.filter((n) => !canaries.has(n.name)))];
170
+ }
171
+ /**
172
+ * Every unit that depends on one of `changed`, directly or through other
173
+ * units, not counting `changed` itself. Sorted by path.
174
+ *
175
+ * `find` reports `dependency` and ordering-only `dependencies` edges as one
176
+ * list, so this follows both. A caller that follows `dependency` edges only
177
+ * passes `edges` built from the units' own blocks.
178
+ */
179
+ export function terragruntDependents(units, changed, edges = (u) => u.dependencies) {
180
+ const seeds = new Set(changed.map(clean));
181
+ const reached = new Set(seeds);
182
+ let grew = true;
183
+ while (grew) {
184
+ grew = false;
185
+ for (const u of units) {
186
+ if (reached.has(u.path))
187
+ continue;
188
+ if (edges(u).some((d) => reached.has(d))) {
189
+ reached.add(u.path);
190
+ grew = true;
191
+ }
192
+ }
193
+ }
194
+ return [...reached].filter((p) => !seeds.has(p)).sort();
195
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * One wave of Terragrunt units as one `terragrunt run --all` (#3414): the
3
+ * arguments, the environment, the run report and the change-set parts.
4
+ *
5
+ * A wave names its units with explicit path filters, so it plans and applies
6
+ * exactly those units. `.terragrunt-filters` is not read by a wave run:
7
+ * Terragrunt unions its filters with the ones given on the command line
8
+ * (observed on 1.1.6), so a filters file selecting `live/prod/**` would pull
9
+ * every prod unit into every wave. Discovery reads it; the wave's paths came
10
+ * from discovery.
11
+ *
12
+ * Plan writes `<outDir>/<unit>/tfplan.tfplan` and
13
+ * `<jsonOutDir>/<unit>/tfplan.json`; apply reads the saved plans from the
14
+ * same `outDir` with the same filters.
15
+ *
16
+ * Pure: nothing here spawns or reads files, so terragucci can bundle it. It
17
+ * imports `../change-set` and chant's `change-set` types only.
18
+ */
19
+ import { terraformChangeSetPart } from "../change-set.js";
20
+ import { stackOfUnit, unitPathFilter } from "./units.js";
21
+ /** Arguments to `terragrunt` for one wave. Throws on an empty wave: with no filter, `run --all` runs every unit. */
22
+ export function terragruntWaveArgs(input) {
23
+ if (input.units.length === 0)
24
+ throw new Error("a Terragrunt wave needs at least one unit; with no path filter run --all would run every unit");
25
+ if (input.parallelism !== undefined && (!Number.isInteger(input.parallelism) || input.parallelism < 1)) {
26
+ throw new Error(`parallelism must be a whole number of 1 or more, got ${input.parallelism}`);
27
+ }
28
+ const units = [...new Set(input.units)].sort();
29
+ const args = [
30
+ "run",
31
+ "--all",
32
+ "--non-interactive",
33
+ "--no-color",
34
+ "--no-filters-file",
35
+ ...units.flatMap((u) => ["--filter", unitPathFilter(u)]),
36
+ ...(input.destroy ? ["--filter-allow-destroy"] : []),
37
+ "--out-dir",
38
+ input.outDir,
39
+ ...(input.command === "plan" && input.jsonOutDir ? ["--json-out-dir", input.jsonOutDir] : []),
40
+ "--report-file",
41
+ input.reportFile,
42
+ "--report-format",
43
+ "json",
44
+ ...(input.parallelism !== undefined ? ["--parallelism", String(input.parallelism)] : []),
45
+ "--",
46
+ input.command,
47
+ ...(input.command === "plan" && input.destroy ? ["-destroy"] : []),
48
+ ...(input.extra ?? []),
49
+ ];
50
+ return args;
51
+ }
52
+ /**
53
+ * The environment a wave adds: `TG_TF_PATH` when the project names a binary,
54
+ * nothing otherwise, so Terragrunt's own choice (tofu, then terraform, or a
55
+ * unit's `terraform_binary`) stands.
56
+ */
57
+ export function terragruntEnv(binary) {
58
+ return binary ? { TG_TF_PATH: binary } : {};
59
+ }
60
+ /** The rows of a run report, keyed by unit path. Malformed rows are dropped. */
61
+ export function parseTerragruntReport(text) {
62
+ const doc = typeof text === "string" ? JSON.parse(text.trim() === "" ? "[]" : text) : text;
63
+ const rows = Array.isArray(doc) ? doc : [];
64
+ const out = new Map();
65
+ for (const raw of rows) {
66
+ if (raw === null || typeof raw !== "object")
67
+ continue;
68
+ const r = raw;
69
+ if (typeof r.Name !== "string" || typeof r.Result !== "string")
70
+ continue;
71
+ const name = r.Name.replace(/\\/g, "/").replace(/^\.\//, "");
72
+ out.set(name, {
73
+ name,
74
+ result: r.Result,
75
+ ...(typeof r.Reason === "string" && r.Reason ? { reason: r.Reason } : {}),
76
+ ...(typeof r.Cause === "string" && r.Cause ? { cause: r.Cause.trim() } : {}),
77
+ ...(typeof r.Cmd === "string" ? { cmd: r.Cmd } : {}),
78
+ ...(typeof r.Started === "string" ? { started: r.Started } : {}),
79
+ ...(typeof r.Ended === "string" ? { ended: r.Ended } : {}),
80
+ });
81
+ }
82
+ return out;
83
+ }
84
+ /** Each of the wave's units against the run report. A unit the report leaves out failed: nothing says it ran. */
85
+ export function terragruntUnitResults(units, report) {
86
+ return [...new Set(units)].sort().map((unit) => {
87
+ const row = report.get(unit);
88
+ if (!row)
89
+ return { unit, status: "failed", result: "not run", error: "not in Terragrunt's run report, so it did not run" };
90
+ if (row.result === "succeeded")
91
+ return { unit, status: "succeeded", result: row.result };
92
+ const why = [row.reason, row.cause].filter(Boolean).join(": ");
93
+ return { unit, status: "failed", result: row.result, error: `${row.result}${why ? ` (${why})` : ""}` };
94
+ });
95
+ }
96
+ /**
97
+ * The change-set parts of a planned wave: one member per unit, named by the
98
+ * unit's path. A unit the run report marks succeeded, with a plan JSON, goes
99
+ * through {@link terraformChangeSetPart}; any other is a failed member
100
+ * carrying the report's result, so a unit that never planned cannot read as
101
+ * planned with nothing to do.
102
+ */
103
+ export function terragruntWaveParts(input) {
104
+ const scopeFor = input.scopeFor ?? stackOfUnit;
105
+ return terragruntUnitResults(input.units, input.report).map((r) => {
106
+ const scope = scopeFor(r.unit);
107
+ const plan = r.status === "succeeded" ? input.planFor(r.unit) : undefined;
108
+ if (r.status === "succeeded" && plan !== undefined) {
109
+ return terraformChangeSetPart({ member: r.unit, plan, planner: input.planner, ...(scope ? { scope } : {}) });
110
+ }
111
+ return {
112
+ member: {
113
+ member: r.unit,
114
+ lexicon: "terraform",
115
+ planner: input.planner,
116
+ ...(scope ? { scope } : {}),
117
+ status: "failed",
118
+ error: r.error ?? "Terragrunt reported the unit succeeded but wrote no plan JSON for it",
119
+ planDigest: null,
120
+ holes: [],
121
+ },
122
+ entries: [],
123
+ };
124
+ });
125
+ }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env tsx
2
+ import { validate } from "./validate.js";
3
+ // `validate` takes an optional { basePath }; defaults to the lexicon root.
4
+ await validate();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Validate generated lexicon-terraform artifacts.
3
+ *
4
+ * Thin wrapper around the core validation framework
5
+ * with terraform-specific configuration.
6
+ */
7
+ import { dirname } from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { validateLexiconArtifacts } from "@intentius/chant/codegen/validate";
10
+ /**
11
+ * Empty, and correct empty (#2220). `requiredNames` names generated resource
12
+ * classes that must survive a regenerate, and this lexicon generates none:
13
+ * `codegen/generate.ts` writes `lexicon-terraform.json` as `{}` on purpose,
14
+ * because Terraform's resource surface lives in provider registries rather
15
+ * than in one upstream schema, and an estate's `.tf` files are read as they
16
+ * are. There is no name to require, so the list stays empty and the
17
+ * remaining checks (the artifacts exist, the registry parses, the surface
18
+ * snapshot) are what validate actually proves here.
19
+ */
20
+ const REQUIRED_NAMES = [];
21
+ /**
22
+ * Validate the generated lexicon-terraform artifacts.
23
+ */
24
+ export async function validate(opts) {
25
+ const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
26
+ return validateLexiconArtifacts({
27
+ lexiconJsonFilename: "lexicon-terraform.json",
28
+ requiredNames: REQUIRED_NAMES,
29
+ basePath,
30
+ });
31
+ }