@intentius/chant-lexicon-terraform 0.124.31 → 0.124.33

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 (149) 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.d.ts +2 -0
  20. package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
  21. package/dist/composites/terraform-adopt-op.js +153 -0
  22. package/dist/composites/terraform-apply-op.d.ts +2 -0
  23. package/dist/composites/terraform-apply-op.d.ts.map +1 -1
  24. package/dist/composites/terraform-apply-op.js +184 -0
  25. package/dist/composites/terraform-pin-rollout-op.js +45 -0
  26. package/dist/composites/terraform-watch-op.js +219 -0
  27. package/dist/config.js +118 -0
  28. package/dist/describe-resources.js +984 -0
  29. package/dist/generated/index.js +1 -0
  30. package/dist/hcl/descend.js +208 -0
  31. package/dist/hcl/edges.js +287 -0
  32. package/dist/hcl/jsonencode.js +442 -0
  33. package/dist/hcl/marker.js +212 -0
  34. package/dist/hcl/parse.js +372 -0
  35. package/dist/hcl/references.js +156 -0
  36. package/dist/hcl/roots.js +98 -0
  37. package/dist/hcl/suppressions.js +189 -0
  38. package/dist/hcl/value.js +72 -0
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +46 -0
  42. package/dist/integrity.json +2 -2
  43. package/dist/lint/audit-catalog.js +501 -0
  44. package/dist/lint/audit-lineage.js +442 -0
  45. package/dist/lint/post-synth/blocks.js +87 -0
  46. package/dist/lint/post-synth/fixtures/load.js +94 -0
  47. package/dist/lint/post-synth/flat-attributes.js +135 -0
  48. package/dist/lint/post-synth/index.js +90 -0
  49. package/dist/lint/post-synth/module-source.js +153 -0
  50. package/dist/lint/post-synth/scope.js +104 -0
  51. package/dist/lint/post-synth/terragrunt.js +63 -0
  52. package/dist/lint/post-synth/tf001.js +138 -0
  53. package/dist/lint/post-synth/tf002.js +143 -0
  54. package/dist/lint/post-synth/tf003.js +60 -0
  55. package/dist/lint/post-synth/tf004.js +54 -0
  56. package/dist/lint/post-synth/tf005.js +68 -0
  57. package/dist/lint/post-synth/tf006.js +43 -0
  58. package/dist/lint/post-synth/tf007.js +66 -0
  59. package/dist/lint/post-synth/tf008.js +64 -0
  60. package/dist/lint/post-synth/tf009.js +49 -0
  61. package/dist/lint/post-synth/tf010.js +40 -0
  62. package/dist/lint/post-synth/tf011.js +38 -0
  63. package/dist/lint/post-synth/tf012.js +36 -0
  64. package/dist/lint/post-synth/tf013.js +53 -0
  65. package/dist/lint/post-synth/tf014.js +61 -0
  66. package/dist/lint/post-synth/tf015.js +62 -0
  67. package/dist/lint/post-synth/tf016.js +66 -0
  68. package/dist/lint/post-synth/tf017.js +39 -0
  69. package/dist/lint/post-synth/tf018.js +63 -0
  70. package/dist/lint/post-synth/tf019.js +60 -0
  71. package/dist/lint/post-synth/tf020.js +103 -0
  72. package/dist/lint/post-synth/tf021.js +117 -0
  73. package/dist/lint/post-synth/tf022.js +51 -0
  74. package/dist/lint/post-synth/tf024.js +62 -0
  75. package/dist/lint/post-synth/tf025.js +83 -0
  76. package/dist/lint/post-synth/tf026.js +74 -0
  77. package/dist/lint/post-synth/tf027.js +66 -0
  78. package/dist/lint/post-synth/tf028.js +83 -0
  79. package/dist/lint/post-synth/tf029.js +73 -0
  80. package/dist/lint/post-synth/tf030.js +253 -0
  81. package/dist/lint/post-synth/tf031.js +332 -0
  82. package/dist/lint/post-synth/tf032.js +201 -0
  83. package/dist/lint/post-synth/tf033.js +126 -0
  84. package/dist/lint/post-synth/tf034.js +45 -0
  85. package/dist/lint/post-synth/tf035.js +63 -0
  86. package/dist/lint/post-synth/tf036.js +97 -0
  87. package/dist/lint/post-synth/tf037.js +80 -0
  88. package/dist/lint/post-synth/tf038.js +63 -0
  89. package/dist/lint/post-synth/tf039.js +55 -0
  90. package/dist/lint/post-synth/tf040.js +67 -0
  91. package/dist/lint/post-synth/tf041.js +53 -0
  92. package/dist/lint/post-synth/tf042.js +38 -0
  93. package/dist/lint/post-synth/tf043.js +45 -0
  94. package/dist/lint/post-synth/tf044.js +45 -0
  95. package/dist/lint/post-synth/tf045.js +44 -0
  96. package/dist/lint/rules/fixtures/load.js +50 -0
  97. package/dist/lint/rules/index.js +4 -0
  98. package/dist/lint/rules/plan-before-apply.js +136 -0
  99. package/dist/lint/secret-shape.js +149 -0
  100. package/dist/live-ownership.js +57 -0
  101. package/dist/lsp/completions.js +95 -0
  102. package/dist/lsp/context.js +358 -0
  103. package/dist/lsp/hover.js +45 -0
  104. package/dist/lsp/option-keys.js +93 -0
  105. package/dist/manifest.json +1 -1
  106. package/dist/op/activities/index.js +30 -0
  107. package/dist/op/activities/live-detect.js +143 -0
  108. package/dist/op/activities/pin-rollout.js +45 -0
  109. package/dist/op/activities/terraform.js +1270 -0
  110. package/dist/op/activity-contracts.js +162 -0
  111. package/dist/op/adoption.js +272 -0
  112. package/dist/op/builders.js +125 -0
  113. package/dist/op/resolve-root-mode.js +77 -0
  114. package/dist/package-cli.js +16 -0
  115. package/dist/pin/edit-ts.js +76 -0
  116. package/dist/pin/edit.js +118 -0
  117. package/dist/pin/forge.js +65 -0
  118. package/dist/pin/index.js +16 -0
  119. package/dist/pin/rollout.d.ts.map +1 -1
  120. package/dist/pin/rollout.js +382 -0
  121. package/dist/pin/scan.js +287 -0
  122. package/dist/pin/source.js +171 -0
  123. package/dist/pin/waves.js +109 -0
  124. package/dist/plan-digest.js +67 -0
  125. package/dist/plugin.js +323 -0
  126. package/dist/serializer.js +23 -0
  127. package/dist/state-ownership.js +29 -0
  128. package/dist/terragrunt/affected.d.ts.map +1 -1
  129. package/dist/terragrunt/affected.js +290 -0
  130. package/dist/terragrunt/index.js +10 -0
  131. package/dist/terragrunt/mocks.js +270 -0
  132. package/dist/terragrunt/run.js +264 -0
  133. package/dist/terragrunt/units.js +195 -0
  134. package/dist/terragrunt/wave.js +125 -0
  135. package/dist/validate-cli.js +4 -0
  136. package/dist/validate.js +31 -0
  137. package/package.json +10 -10
  138. package/src/codegen/package.ts +2 -2
  139. package/src/components/capability-plugin.test.ts +97 -2
  140. package/src/components/capability-plugin.ts +117 -12
  141. package/src/components/pr-loop.tofu.test.ts +51 -11
  142. package/src/composites/terraform-adopt-op.test.ts +6 -0
  143. package/src/composites/terraform-adopt-op.ts +7 -2
  144. package/src/composites/terraform-apply-op.test.ts +6 -0
  145. package/src/composites/terraform-apply-op.ts +7 -2
  146. package/src/index.ts +1 -0
  147. package/src/pin/rollout.ts +2 -1
  148. package/src/terragrunt/affected.test.ts +11 -0
  149. package/src/terragrunt/affected.ts +2 -1
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Change-set adapters for the terraform family (#3181): a `terraform show
3
+ * -json` plan from terraform, tofu or choudoufu, and a choudoufu set plan
4
+ * document (`live-plan-set -json`, choudoufu#1752), each into
5
+ * `@intentius/chant/change-set` parts.
6
+ *
7
+ * A member's plan digest is {@link terraformPlanDigest}, the value a
8
+ * `TerraformApplyOp` gate on that root binds (#2300), so a change set over
9
+ * one root and a gate over the same root agree on what changed.
10
+ *
11
+ * This module imports `./plan-digest` and chant's `change-set` and
12
+ * `lifecycle/plan-digest` subpaths, never the lexicon's entry point, so it
13
+ * bundles without TypeScript or the lint rules (#3421).
14
+ */
15
+ import { terraformPlanDigest } from "./plan-digest.js";
16
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
17
+ /** Whether a sensitivity or unknown map marks anything under it `true`. */
18
+ function marks(v) {
19
+ if (v === true)
20
+ return true;
21
+ if (Array.isArray(v))
22
+ return v.some(marks);
23
+ if (isObject(v))
24
+ return Object.values(v).some(marks);
25
+ return false;
26
+ }
27
+ function sameJson(a, b) {
28
+ if (a === b)
29
+ return true;
30
+ if (Array.isArray(a) && Array.isArray(b))
31
+ return a.length === b.length && a.every((x, i) => sameJson(x, b[i]));
32
+ if (isObject(a) && isObject(b)) {
33
+ const ka = Object.keys(a);
34
+ return ka.length === Object.keys(b).length && ka.every((k) => k in b && sameJson(a[k], b[k]));
35
+ }
36
+ return false;
37
+ }
38
+ /**
39
+ * The planner a root's binary names: `choudoufu` and `tofu` by name, and
40
+ * `terraform` for anything else, a path to terraform included.
41
+ */
42
+ export function plannerForBinary(binary) {
43
+ const base = (binary ?? "terraform").split(/[\\/]/).pop() ?? "";
44
+ if (base.startsWith("choudoufu"))
45
+ return "choudoufu";
46
+ if (base.startsWith("tofu"))
47
+ return "tofu";
48
+ return "terraform";
49
+ }
50
+ /**
51
+ * The action a `resource_changes` entry's `actions` array means. Delete then
52
+ * create and create then delete are both a replace; the order is the
53
+ * disruption ({@link disruptionFor}).
54
+ */
55
+ export function actionFor(actions) {
56
+ const a = Array.isArray(actions) ? actions.map(String) : [];
57
+ if (a.length === 2 && a.includes("delete") && a.includes("create"))
58
+ return "replace";
59
+ if (a.length === 1) {
60
+ switch (a[0]) {
61
+ case "create":
62
+ case "update":
63
+ case "delete":
64
+ case "read":
65
+ case "no-op":
66
+ case "forget":
67
+ return a[0];
68
+ }
69
+ }
70
+ throw new Error(`a plan entry has actions ${JSON.stringify(actions)}, which the change set has no action for`);
71
+ }
72
+ function disruptionFor(actions, action) {
73
+ switch (action) {
74
+ case "update": return "in-place";
75
+ case "delete": return "destroy";
76
+ case "replace": return actions[0] === "create" ? "replace" : "destroy";
77
+ default: return undefined;
78
+ }
79
+ }
80
+ /** A resource address's module path, name and index, read off the address when the entry does not carry them. */
81
+ export function parseTerraformAddress(address) {
82
+ // Module segments, then [data.]type.name[index]. Index brackets may hold a quoted key with dots in it.
83
+ const re = /^((?:module\.[^.[\]]+(?:\[(?:"(?:[^"\\]|\\.)*"|\d+)\])?\.)*)(?:data\.)?([^.[\]]+)\.([^.[\]]+)(?:\[("(?:[^"\\]|\\.)*"|\d+)\])?$/;
84
+ const m = re.exec(address);
85
+ if (!m)
86
+ return { type: "unknown", name: address };
87
+ const module = m[1] ? m[1].slice(0, -1) : undefined;
88
+ const index = m[4] === undefined ? undefined : m[4].startsWith('"') ? JSON.parse(m[4]) : Number(m[4]);
89
+ return { ...(module ? { module } : {}), type: m[2], name: m[3], ...(index !== undefined ? { index } : {}) };
90
+ }
91
+ /** The attributes one change writes, top-level, with sensitive values left out. */
92
+ function attributesFor(change, action) {
93
+ if (action === "delete" || action === "no-op" || action === "forget" || action === "read")
94
+ return [];
95
+ const before = isObject(change.before) ? change.before : {};
96
+ const after = isObject(change.after) ? change.after : {};
97
+ const afterUnknown = isObject(change.after_unknown) ? change.after_unknown : {};
98
+ const beforeSensitive = isObject(change.before_sensitive) ? change.before_sensitive : {};
99
+ const afterSensitive = isObject(change.after_sensitive) ? change.after_sensitive : {};
100
+ const forcing = new Set((Array.isArray(change.replace_paths) ? change.replace_paths : [])
101
+ .map((p) => (Array.isArray(p) ? p[0] : undefined))
102
+ .filter((k) => typeof k === "string"));
103
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after), ...Object.keys(afterUnknown)])].sort();
104
+ const out = [];
105
+ for (const k of keys) {
106
+ const unknown = marks(afterUnknown[k]);
107
+ const hasBefore = action !== "create" && before[k] !== undefined && before[k] !== null;
108
+ const hasAfter = after[k] !== undefined && after[k] !== null;
109
+ if (action === "create" ? !hasAfter && !unknown : !unknown && sameJson(before[k] ?? null, after[k] ?? null))
110
+ continue;
111
+ const sensitive = marks(beforeSensitive[k]) || marks(afterSensitive[k]);
112
+ out.push({
113
+ path: k,
114
+ ...(sensitive ? { sensitive: true } : {
115
+ ...(hasBefore ? { before: before[k] } : {}),
116
+ ...(hasAfter && !unknown ? { after: after[k] } : {}),
117
+ }),
118
+ ...(unknown ? { unknown: true } : {}),
119
+ ...(forcing.has(k) ? { forcesReplacement: true } : {}),
120
+ });
121
+ }
122
+ return out;
123
+ }
124
+ function regionOf(change) {
125
+ for (const side of [change.after, change.before]) {
126
+ if (isObject(side) && typeof side.region === "string" && side.region !== "")
127
+ return side.region;
128
+ }
129
+ return undefined;
130
+ }
131
+ /**
132
+ * One terraform-family root's part, from its `show -json` plan.
133
+ *
134
+ * Every `resource_changes` entry becomes an entry, no-op and read included.
135
+ * A plan stock marked `errored` is a failed member: its entries are kept,
136
+ * but nothing should apply it.
137
+ */
138
+ export function terraformChangeSetPart(input) {
139
+ const plan = isObject(input.plan) ? input.plan : {};
140
+ const planner = input.planner ?? "terraform";
141
+ const raw = Array.isArray(plan.resource_changes) ? plan.resource_changes : [];
142
+ const entries = raw.map((r) => {
143
+ const rc = isObject(r) ? r : {};
144
+ const change = isObject(rc.change) ? rc.change : {};
145
+ const address = String(rc.address ?? "");
146
+ const parsed = parseTerraformAddress(address);
147
+ const action = actionFor(change.actions);
148
+ const disruption = disruptionFor(change.actions, action);
149
+ const region = regionOf(change);
150
+ const index = rc.index !== undefined && rc.index !== null ? rc.index : parsed.index;
151
+ const module = typeof rc.module_address === "string" ? rc.module_address : parsed.module;
152
+ return {
153
+ member: input.member,
154
+ lexicon: "terraform",
155
+ planner,
156
+ address,
157
+ type: typeof rc.type === "string" ? rc.type : parsed.type,
158
+ name: typeof rc.name === "string" ? rc.name : parsed.name,
159
+ ...(index !== undefined ? { index } : {}),
160
+ ...(module ? { module } : {}),
161
+ ...(typeof rc.deposed === "string" ? { deposed: rc.deposed } : {}),
162
+ action,
163
+ ...(isObject(change.importing) ? { importing: true } : {}),
164
+ ...(disruption ? { disruption } : {}),
165
+ ...(region ? { region } : {}),
166
+ ...(input.scope ? { scope: input.scope } : {}),
167
+ attributes: attributesFor(change, action),
168
+ };
169
+ });
170
+ const sideEffects = (Array.isArray(plan.action_invocations) ? plan.action_invocations : []).map(sideEffectFor);
171
+ const errored = plan.errored === true;
172
+ return {
173
+ member: {
174
+ member: input.member,
175
+ lexicon: "terraform",
176
+ planner,
177
+ ...(input.scope ? { scope: input.scope } : {}),
178
+ status: errored ? "failed" : "planned",
179
+ ...(errored ? { error: `${planner} marked the plan errored` } : {}),
180
+ planDigest: terraformPlanDigest(plan),
181
+ ...(input.nativeDigest ? { nativeDigest: input.nativeDigest } : {}),
182
+ holes: [],
183
+ },
184
+ entries,
185
+ ...(sideEffects.length > 0 ? { sideEffects } : {}),
186
+ };
187
+ }
188
+ /**
189
+ * One `action_invocations` entry of a plan (Terraform 1.14): the action's
190
+ * address and type, and, for a lifecycle trigger, the resource and event.
191
+ */
192
+ function sideEffectFor(raw) {
193
+ const inv = isObject(raw) ? raw : {};
194
+ const type = typeof inv.type === "string" ? inv.type : "unknown";
195
+ const lifecycle = isObject(inv.lifecycle_action_trigger) ? inv.lifecycle_action_trigger : {};
196
+ return {
197
+ address: typeof inv.address === "string" ? inv.address : type,
198
+ type,
199
+ ...(typeof lifecycle.triggering_resource_address === "string" ? { trigger: lifecycle.triggering_resource_address } : {}),
200
+ ...(typeof lifecycle.action_trigger_event === "string" ? { event: lifecycle.action_trigger_event } : {}),
201
+ };
202
+ }
203
+ /**
204
+ * One part per root of a choudoufu set plan (choudoufu#1752). A planned
205
+ * root is {@link terraformChangeSetPart} over its embedded plan, with the
206
+ * estate as scope and choudoufu's own root digest as `nativeDigest`. A
207
+ * failed root is a failed member with its error and no plan digest.
208
+ */
209
+ export function choudoufuSetPlanParts(input) {
210
+ const doc = isObject(input.document) ? input.document : {};
211
+ if (!Array.isArray(doc.roots))
212
+ throw new Error('not a choudoufu set plan document: no top-level "roots"');
213
+ return doc.roots.map((r) => {
214
+ const member = input.memberFor ? input.memberFor(r.root, r.estate || undefined) : r.root;
215
+ const scope = r.estate || undefined;
216
+ if (r.status === "planned" && isObject(r.plan)) {
217
+ return terraformChangeSetPart({ member, plan: r.plan, planner: "choudoufu", ...(scope ? { scope } : {}), ...(r.digest ? { nativeDigest: r.digest } : {}) });
218
+ }
219
+ return {
220
+ member: {
221
+ member,
222
+ lexicon: "terraform",
223
+ planner: "choudoufu",
224
+ ...(scope ? { scope } : {}),
225
+ status: "failed",
226
+ error: r.error || `the root did not plan (${r.status ?? "no status"}${r.stage ? `, at ${r.stage}` : ""})`,
227
+ planDigest: null,
228
+ ...(r.digest ? { nativeDigest: r.digest } : {}),
229
+ holes: [],
230
+ },
231
+ entries: [],
232
+ };
233
+ });
234
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Which `terraform.roots` a change touched (#3183), from the changed files
3
+ * alone.
4
+ *
5
+ * A root is hand-written HCL, not chant source, so `chant lifecycle affected`
6
+ * never sees it move: its artifact diff covers what chant builds. A pull
7
+ * request's plan needs the roots a change reaches, and this answers that
8
+ * from paths: a root is touched when a changed file sits in its directory,
9
+ * in a local module it calls (followed through modules that call modules),
10
+ * or is one of its var files.
11
+ *
12
+ * The module calls are read with a regex over the root's `.tf` and `.tofu`
13
+ * files, the way `./op/activities/live-detect.ts` reads a live block, so
14
+ * this stays out of the HCL parser's dependency graph. Only a `source` that
15
+ * starts with `./` or `../` is followed; a registry or git source is a
16
+ * version pin in the root's own files, which a change to it already touches.
17
+ */
18
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
19
+ import { join, posix, relative, resolve, sep } from "node:path";
20
+ /** `source = "./..."` or `"../..."` inside a module block. Other sources are not on disk here. */
21
+ const LOCAL_SOURCE = /\bsource\s*=\s*"(\.{1,2}\/[^"]*)"/g;
22
+ const toPosix = (p) => p.split(sep).join("/");
23
+ /** `dir` relative to `projectRoot`, `/`-separated, without a trailing slash; `.` for the root itself. */
24
+ function projectRelative(projectRoot, dir) {
25
+ const rel = toPosix(relative(projectRoot, dir));
26
+ return rel === "" ? "." : posix.normalize(rel).replace(/\/+$/, "");
27
+ }
28
+ /** Local module directories `dir` calls, read from its HCL files. */
29
+ function localModuleDirs(dir) {
30
+ let names;
31
+ try {
32
+ names = readdirSync(dir);
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ const out = [];
38
+ for (const name of names) {
39
+ if (!name.endsWith(".tf") && !name.endsWith(".tofu"))
40
+ continue;
41
+ let text;
42
+ try {
43
+ text = readFileSync(join(dir, name), "utf8");
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ for (const m of text.matchAll(LOCAL_SOURCE)) {
49
+ // A `//subdir` suffix names a directory inside the source.
50
+ const target = resolve(dir, m[1].replace("//", "/"));
51
+ if (existsSync(target) && statSync(target).isDirectory())
52
+ out.push(target);
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+ /**
58
+ * Every path, relative to `projectRoot`, whose change touches `root`: its
59
+ * directory, each local module it reaches, and its var files.
60
+ */
61
+ export function rootWatchPaths(projectRoot, root) {
62
+ const rootDir = resolve(projectRoot, root.dir);
63
+ const seen = new Set();
64
+ const queue = [rootDir];
65
+ while (queue.length > 0) {
66
+ const dir = queue.shift();
67
+ if (seen.has(dir))
68
+ continue;
69
+ seen.add(dir);
70
+ queue.push(...localModuleDirs(dir));
71
+ }
72
+ const paths = [...seen].map((d) => projectRelative(projectRoot, d));
73
+ for (const file of root.varFiles ?? [])
74
+ paths.push(projectRelative(projectRoot, resolve(rootDir, file)));
75
+ return [...new Set(paths)].sort();
76
+ }
77
+ /** Whether `file` is `path` or sits under it. Both relative to the project, `/`-separated. */
78
+ function covers(path, file) {
79
+ if (path === ".")
80
+ return !file.startsWith("../");
81
+ return file === path || file.startsWith(`${path}/`);
82
+ }
83
+ /**
84
+ * The names of the roots in `roots` that a change to `changedFiles` touches,
85
+ * sorted. `changedFiles` are relative to `projectRoot` with `/` separators,
86
+ * as `git diff --name-only --relative` prints them.
87
+ */
88
+ export function changedRoots(projectRoot, roots, changedFiles) {
89
+ const files = changedFiles.map((f) => posix.normalize(f));
90
+ const touched = [];
91
+ for (const [name, root] of Object.entries(roots)) {
92
+ const watch = rootWatchPaths(projectRoot, root);
93
+ if (files.some((f) => watch.some((p) => covers(p, f))))
94
+ touched.push(name);
95
+ }
96
+ return touched.sort();
97
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env tsx
2
+ import { generateDocs } from "./docs.js";
3
+ await generateDocs({ verbose: true });
@@ -0,0 +1,44 @@
1
+ import { dirname, join } from "path";
2
+ import { fileURLToPath } from "url";
3
+ import { docsPipeline, writeDocsSite } from "@intentius/chant/codegen/docs";
4
+ const overview = `
5
+ The Terraform lexicon reads an estate's existing \`.tf\` files. There is no
6
+ generated resource surface here and nothing is emitted back over the HCL:
7
+ Terraform's types live in provider registries, one schema per provider and
8
+ version, and an estate that already has a root module does not need a second,
9
+ generated copy of it.
10
+
11
+ What you declare is the directory. Each entry in \`terraform.roots\` names a
12
+ root module; \`buildRoots()\` parses it at build time and every HCL block joins
13
+ the build as an entity keyed \`<root>/<address>\`, carrying the block body
14
+ verbatim. Those entities are what the post-synth checks and \`chant audit\`
15
+ read.
16
+
17
+ See [Getting Started](getting-started/) for the config shape, the block-to-entity
18
+ mapping, and what TF001 reports.
19
+ `;
20
+ /**
21
+ * Generate the docs site for the terraform lexicon.
22
+ *
23
+ * `basePath` is what the unified site at /chant needs so assets and internal
24
+ * links resolve once this site is copied under it (scripts/build-docs.test.ts
25
+ * asserts it). Authored pages come from `docs/pages/` by default.
26
+ */
27
+ export async function generateDocs(options) {
28
+ const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
29
+ const config = {
30
+ name: "terraform",
31
+ displayName: "Terraform",
32
+ description: "Existing Terraform root modules as declarable chant entities",
33
+ distDir: join(pkgDir, "dist"),
34
+ outDir: join(pkgDir, "docs"),
35
+ basePath: process.env.DOCS_BASE_PATH ?? "/chant/lexicons/terraform/",
36
+ overview,
37
+ serviceFromType: (type) => type.split("::")[1] ?? type,
38
+ };
39
+ const result = docsPipeline(config);
40
+ writeDocsSite(config, result);
41
+ if (options?.verbose) {
42
+ console.error(`Generated ${result.pages.size} documentation pages`);
43
+ }
44
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env tsx
2
+ import { generate, writeGeneratedFiles } from "./generate.js";
3
+ import { dirname } from "path";
4
+ import { fileURLToPath } from "url";
5
+ // src/codegen/generate-cli.ts -> src/codegen -> src -> package root.
6
+ // Two dirnames land in src/ and the first generate writes src/src/generated/ (#1614).
7
+ const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
8
+ const result = await generate({ verbose: true });
9
+ writeGeneratedFiles(result, pkgDir);
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Generation for the terraform lexicon: there is nothing to generate.
3
+ *
4
+ * Every other lexicon fetches one upstream schema and emits typed resource
5
+ * classes from it. Terraform has no such thing. Its resource surface lives in
6
+ * provider registries, one schema per provider and per version, and this
7
+ * lexicon does not model it: an estate's `.tf` files are read as they are
8
+ * (see `../hcl/parse.ts`) and the blocks become entities with their bodies
9
+ * carried verbatim. So `core`'s `generatePipeline` is not called at all, and
10
+ * this returns the empty result the packaging pipeline needs to assemble a
11
+ * bundle.
12
+ *
13
+ * The registry is still written, empty, because `validate` reads it and
14
+ * `writeBundleSpec` derives `dist/meta.json` from it. An empty registry is the
15
+ * honest statement that this lexicon exports no generated resource classes.
16
+ */
17
+ import { writeGeneratedArtifacts } from "@intentius/chant/codegen/generate";
18
+ import { dirname } from "path";
19
+ import { fileURLToPath } from "url";
20
+ /** Filename of the generated registry, read back by `../validate.ts`. */
21
+ export const LEXICON_JSON_FILENAME = "lexicon-terraform.json";
22
+ const EMPTY_INDEX_TS = `// Code generated by chant generate. DO NOT EDIT.
23
+ // The terraform lexicon models no upstream schema, so there are no resource
24
+ // classes to export. See src/codegen/generate.ts.
25
+ export {};
26
+ `;
27
+ const EMPTY_TYPES_DTS = `// Code generated by chant generate. DO NOT EDIT.
28
+ export {};
29
+ `;
30
+ export async function generate(options) {
31
+ const result = {
32
+ lexiconJSON: `${JSON.stringify({}, null, 2)}\n`,
33
+ typesDTS: EMPTY_TYPES_DTS,
34
+ indexTS: EMPTY_INDEX_TS,
35
+ resources: 0,
36
+ properties: 0,
37
+ enums: 0,
38
+ warnings: [],
39
+ };
40
+ if (options?.verbose) {
41
+ console.error("terraform generates no types: the .tf files an estate already has are the input");
42
+ }
43
+ return result;
44
+ }
45
+ /**
46
+ * Write generated files to the package directory.
47
+ */
48
+ export function writeGeneratedFiles(result, pkgDir) {
49
+ // This file lives in src/codegen/, so the package root is three levels up.
50
+ const dir = pkgDir ?? dirname(dirname(dirname(fileURLToPath(import.meta.url))));
51
+ writeGeneratedArtifacts({
52
+ baseDir: dir,
53
+ files: {
54
+ [LEXICON_JSON_FILENAME]: result.lexiconJSON,
55
+ "index.d.ts": result.typesDTS,
56
+ "index.ts": result.indexTS,
57
+ },
58
+ });
59
+ }
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
+ import { moduleSourceDir } from "@intentius/chant/module-source";
4
+ import { packagePipeline, collectSkills } from "@intentius/chant/codegen/package";
5
+ import { generate } from "./generate.js";
6
+ import { terraformPlugin } from "../plugin.js";
7
+ /**
8
+ * This package's `src/` directory. `packagePipeline` scans it for the rule
9
+ * files that go into `dist/rules/`, so it must be `src/` and not `src/codegen/`
10
+ * (which is where `dirname(import.meta.url)` alone lands, and where
11
+ * `lint/rules` does not exist). `lexicons/aws/src/codegen/package.ts` resolves
12
+ * it the same way.
13
+ */
14
+ const srcDir = dirname(moduleSourceDir(import.meta.url));
15
+ /** The published version, so the manifest cannot drift from package.json. */
16
+ function packageVersion() {
17
+ try {
18
+ const pkg = JSON.parse(readFileSync(join(srcDir, "..", "package.json"), "utf-8"));
19
+ return pkg.version ?? "0.0.0";
20
+ }
21
+ catch {
22
+ return "0.0.0";
23
+ }
24
+ }
25
+ /**
26
+ * Package the terraform lexicon for distribution.
27
+ */
28
+ export async function packageLexicon(options) {
29
+ const version = packageVersion();
30
+ const { spec, stats } = await packagePipeline({
31
+ generate: (opts) => generate({ verbose: opts?.verbose, force: opts?.force }),
32
+ buildManifest: () => ({
33
+ name: "terraform",
34
+ version,
35
+ chantVersion: ">=0.1.0",
36
+ namespace: "Terraform",
37
+ }),
38
+ srcDir,
39
+ collectSkills: () => collectSkills(terraformPlugin.skills?.() ?? []),
40
+ version,
41
+ }, options);
42
+ return { spec, stats };
43
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `chant terraform pin-rollout`: one run of a pin-bump rollout (#3189), from
3
+ * the command line. The `TerraformPinRolloutOp` composite runs the same code
4
+ * as an Op step.
5
+ *
6
+ * chant terraform pin-rollout --module <source> --from <pin> --to <pin>
7
+ * [--root <dir>]... [--depends-on <dir>=<dir>]... [--ts-source <dir>=<file>]...
8
+ * [--canary <dir>]... [--waves-from <live-waves.json> [--waves-prefix <dir>]]
9
+ * [--pull-request] [--base <branch>] [--remote <name>] [--applied-check <name>] [--json]
10
+ *
11
+ * Without `--pull-request` it reports the next wave and opens nothing. Exit
12
+ * 0 when the rollout is complete or a wave's PR was opened (or would be), 3
13
+ * when it is waiting on a merge or an apply (a gate is a fact, #2119), and 1
14
+ * when it stopped on a failed root or a closed PR.
15
+ */
16
+ import { splitJoinedFlags, unknownFlagError } from "@intentius/chant/cli/command-group";
17
+ const USAGE = "usage: chant terraform pin-rollout --module <source> --from <pin> --to <pin> [--root <dir>]... [--depends-on <dir>=<dir>]... " +
18
+ "[--ts-source <dir>=<file>]... [--canary <dir>]... [--waves-from <file> [--waves-prefix <dir>]] [--pull-request] " +
19
+ "[--base <branch>] [--remote <name>] [--applied-check <name>] [--json]";
20
+ const VALUE_FLAGS = new Set(["--module", "--from", "--to", "--root", "--depends-on", "--ts-source", "--canary", "--waves-from", "--waves-prefix", "--base", "--remote", "--applied-check"]);
21
+ const BOOLEAN_FLAGS = new Set(["--pull-request", "--json"]);
22
+ /** Parse the verb's flags into the activity's args. Throws on an unknown flag or a missing value. */
23
+ export function parsePinRolloutArgs(rawArgs) {
24
+ const tokens = splitJoinedFlags(rawArgs, BOOLEAN_FLAGS);
25
+ const values = new Map();
26
+ const flags = new Set();
27
+ for (let i = 0; i < tokens.length; i++) {
28
+ const t = tokens[i];
29
+ if (BOOLEAN_FLAGS.has(t)) {
30
+ flags.add(t);
31
+ continue;
32
+ }
33
+ if (!VALUE_FLAGS.has(t))
34
+ throw t.startsWith("-") ? unknownFlagError(t, USAGE) : new Error(`unexpected argument "${t}"\n${USAGE}`);
35
+ const v = tokens[++i];
36
+ if (v === undefined || v.startsWith("--"))
37
+ throw new Error(`${t} needs a value\n${USAGE}`);
38
+ values.set(t, [...(values.get(t) ?? []), v]);
39
+ }
40
+ const one = (flag) => values.get(flag)?.at(-1);
41
+ const pairs = (flag) => (values.get(flag) ?? []).map((v) => {
42
+ const eq = v.indexOf("=");
43
+ if (eq <= 0 || eq === v.length - 1)
44
+ throw new Error(`${flag} takes <dir>=<value>, got "${v}"`);
45
+ return [v.slice(0, eq), v.slice(eq + 1)];
46
+ });
47
+ for (const required of ["--module", "--from", "--to"])
48
+ if (!one(required))
49
+ throw new Error(`${required} is required\n${USAGE}`);
50
+ const dependsOn = pairs("--depends-on");
51
+ const tsSource = new Map(pairs("--ts-source"));
52
+ const named = [...new Set([...(values.get("--root") ?? []), ...dependsOn.map(([r]) => r), ...tsSource.keys()])];
53
+ const roots = named.length > 0
54
+ ? named.map((root) => {
55
+ const deps = dependsOn.filter(([r]) => r === root).map(([, d]) => d);
56
+ return { root, ...(deps.length > 0 ? { dependsOn: deps } : {}), ...(tsSource.has(root) ? { tsSource: tsSource.get(root) } : {}) };
57
+ })
58
+ : undefined;
59
+ const args = {
60
+ module: one("--module"),
61
+ from: one("--from"),
62
+ to: one("--to"),
63
+ mode: flags.has("--pull-request") ? "pull-request" : "report",
64
+ ...(roots ? { roots } : {}),
65
+ ...(values.has("--canary") ? { canaries: values.get("--canary") } : {}),
66
+ ...(one("--waves-from") ? { wavesFrom: one("--waves-from") } : {}),
67
+ ...(one("--waves-prefix") ? { wavesPrefix: one("--waves-prefix") } : {}),
68
+ ...(one("--base") ? { base: one("--base") } : {}),
69
+ ...(one("--remote") ? { remote: one("--remote") } : {}),
70
+ ...(one("--applied-check") ? { appliedCheck: one("--applied-check") } : {}),
71
+ };
72
+ return { args, json: flags.has("--json") };
73
+ }
74
+ /** Exit status for a rollout's status. */
75
+ export function pinRolloutExitCode(status) {
76
+ if (status === "waiting")
77
+ return 3;
78
+ if (status === "stopped")
79
+ return 1;
80
+ return 0;
81
+ }
82
+ async function runPinRolloutVerb(ctx) {
83
+ let parsed;
84
+ try {
85
+ parsed = parsePinRolloutArgs(ctx.rawArgs);
86
+ }
87
+ catch (err) {
88
+ console.error(err instanceof Error ? err.message : String(err));
89
+ return 1;
90
+ }
91
+ const { readPinRollout } = await import("./op/activities/pin-rollout.js");
92
+ try {
93
+ const result = await readPinRollout(parsed.args);
94
+ console.log(parsed.json ? JSON.stringify(result, null, 2) : result.summary);
95
+ return pinRolloutExitCode(result.status);
96
+ }
97
+ catch (err) {
98
+ console.error(err instanceof Error ? err.message : String(err));
99
+ return 1;
100
+ }
101
+ }
102
+ export const terraformCommands = {
103
+ name: "terraform",
104
+ description: "Terraform and OpenTofu roots: roll a module version out as one pin-bump PR per wave",
105
+ 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 }],
106
+ };
@@ -20,12 +20,28 @@
20
20
  * that file and nothing else. terraform and tofu refuse a saved plan whose
21
21
  * state moved since it was written; choudoufu re-plans against the live
22
22
  * system and refuses with exit 3 when its fresh plan differs (choudoufu
23
- * #878). Either refusal fails the step.
23
+ * #878). Either refusal fails the step. choudoufu's refusal of a plan the
24
+ * wave handed `run` throws {@link TerraformPlanMovedError}, so the command
25
+ * that applied it exits 3 (#3997). terraform's and tofu's stale-plan error
26
+ * is not classified and exits 1.
24
27
  *
25
28
  * Run without a wave plan (`chant run --components`, or a fan-out with no
26
29
  * wave gate), `run` plans and applies in one go, which is what `terraform
27
30
  * apply` with no plan file does too.
28
31
  *
32
+ * ## What the plan says about the root's outputs (#3999)
33
+ *
34
+ * `plan` answers the outputs the root will have once that plan applies
35
+ * (`terraformPlannedOutputs`): `show -json`'s `planned_values.outputs`, less
36
+ * each output whose `output_changes` entry is unknown after apply. A pull
37
+ * request's plan reads a dependent's `stackOutput()` references from there
38
+ * when both roots are in the change.
39
+ *
40
+ * A root whose `vars` read an output not known yet (`DeployContext.unresolvedInputs`)
41
+ * is not planned: terraform takes no unknown variable. Its member gets a hole
42
+ * naming the roots it waits on and no artifact, and the run that applies it
43
+ * plans it once their outputs are known.
44
+ *
29
45
  * ## choudoufu roots in a gated wave (#3459)
30
46
  *
31
47
  * A live choudoufu root that `live-plan-set` can plan as chant would (no
@@ -48,7 +64,7 @@
48
64
  * chant's waves stay the order. Each root is its own one-root set, so
49
65
  * choudoufu's wave over it is always wave 1.
50
66
  */
51
- import type { Capability } from "@intentius/chant/components/capability";
67
+ import type { Capability, PlannedOutputs } from "@intentius/chant/components/capability";
52
68
  import { type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
53
69
  /** The `terraform-apply` step's input. */
54
70
  export interface TerraformApplyStepInput {
@@ -68,6 +84,26 @@ export interface TerraformApplyStepOutput {
68
84
  /** `terraform output -json`, as `{ name: value }`. */
69
85
  outputs: Record<string, unknown>;
70
86
  }
87
+ /**
88
+ * The outputs a root will have once the plan `json` (`show -json`) applies
89
+ * (#3999). An output is known when `planned_values.outputs` gives its value
90
+ * and its `output_changes` entry marks no part of it unknown after apply.
91
+ * Every other output the plan names is unknown, the whole output even when
92
+ * only a part of it is.
93
+ */
94
+ export declare function terraformPlannedOutputs(json: unknown): PlannedOutputs;
95
+ /**
96
+ * choudoufu refused the plan `run` was handed by its wave (its exit 3):
97
+ * the live system moved since the plan, or the plan names another estate.
98
+ * Nothing was applied. `planMoved` is core's mark for a moved plan
99
+ * (`isPlanMovedError`), so the command that applied it exits 3, as for the
100
+ * other capabilities that refuse a moved plan (#3997).
101
+ */
102
+ export declare class TerraformPlanMovedError extends Error {
103
+ readonly root: string;
104
+ readonly planMoved = true;
105
+ constructor(root: string, message: string);
106
+ }
71
107
  export declare const terraformApplyCapability: Capability<TerraformApplyStepInput, TerraformApplyStepOutput>;
72
108
  export declare const TERRAFORM_VERB_FAMILIES: {
73
109
  readonly apply: readonly ["terraform-apply"];