@intentius/chant-lexicon-terraform 0.57.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 (131) hide show
  1. package/README.md +53 -0
  2. package/dist/codegen/docs-cli.d.ts +3 -0
  3. package/dist/codegen/docs-cli.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts +11 -0
  5. package/dist/codegen/docs.d.ts.map +1 -0
  6. package/dist/codegen/generate-cli.d.ts +3 -0
  7. package/dist/codegen/generate-cli.d.ts.map +1 -0
  8. package/dist/codegen/generate.d.ts +28 -0
  9. package/dist/codegen/generate.d.ts.map +1 -0
  10. package/dist/codegen/package.d.ts +17 -0
  11. package/dist/codegen/package.d.ts.map +1 -0
  12. package/dist/composites/terraform-apply-op.d.ts +88 -0
  13. package/dist/composites/terraform-apply-op.d.ts.map +1 -0
  14. package/dist/composites/terraform-watch-op.d.ts +124 -0
  15. package/dist/composites/terraform-watch-op.d.ts.map +1 -0
  16. package/dist/config.d.ts +83 -0
  17. package/dist/config.d.ts.map +1 -0
  18. package/dist/describe-resources.d.ts +127 -0
  19. package/dist/describe-resources.d.ts.map +1 -0
  20. package/dist/generated/index.d.ts +2 -0
  21. package/dist/generated/index.d.ts.map +1 -0
  22. package/dist/hcl/parse.d.ts +87 -0
  23. package/dist/hcl/parse.d.ts.map +1 -0
  24. package/dist/hcl/roots.d.ts +37 -0
  25. package/dist/hcl/roots.d.ts.map +1 -0
  26. package/dist/index.d.ts +12 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/integrity.json +12 -0
  29. package/dist/lint/audit-catalog.d.ts +12 -0
  30. package/dist/lint/audit-catalog.d.ts.map +1 -0
  31. package/dist/lint/audit-lineage.d.ts +21 -0
  32. package/dist/lint/audit-lineage.d.ts.map +1 -0
  33. package/dist/lint/post-synth/index.d.ts +3 -0
  34. package/dist/lint/post-synth/index.d.ts.map +1 -0
  35. package/dist/lint/post-synth/tf001.d.ts +17 -0
  36. package/dist/lint/post-synth/tf001.d.ts.map +1 -0
  37. package/dist/lint/rules/index.d.ts +5 -0
  38. package/dist/lint/rules/index.d.ts.map +1 -0
  39. package/dist/lint/rules/plan-before-apply.d.ts +30 -0
  40. package/dist/lint/rules/plan-before-apply.d.ts.map +1 -0
  41. package/dist/lsp/completions.d.ts +26 -0
  42. package/dist/lsp/completions.d.ts.map +1 -0
  43. package/dist/lsp/context.d.ts +70 -0
  44. package/dist/lsp/context.d.ts.map +1 -0
  45. package/dist/lsp/hover.d.ts +12 -0
  46. package/dist/lsp/hover.d.ts.map +1 -0
  47. package/dist/lsp/option-keys.d.ts +34 -0
  48. package/dist/lsp/option-keys.d.ts.map +1 -0
  49. package/dist/manifest.json +6 -0
  50. package/dist/meta.json +1 -0
  51. package/dist/okf/index.md +8 -0
  52. package/dist/okf/rules/TF001.md +11 -0
  53. package/dist/okf/rules/TF101.md +11 -0
  54. package/dist/op/activities/index.d.ts +22 -0
  55. package/dist/op/activities/index.d.ts.map +1 -0
  56. package/dist/op/activities/terraform.d.ts +213 -0
  57. package/dist/op/activities/terraform.d.ts.map +1 -0
  58. package/dist/op/builders.d.ts +56 -0
  59. package/dist/op/builders.d.ts.map +1 -0
  60. package/dist/package-cli.d.ts +3 -0
  61. package/dist/package-cli.d.ts.map +1 -0
  62. package/dist/plugin.d.ts +11 -0
  63. package/dist/plugin.d.ts.map +1 -0
  64. package/dist/rules/plan-before-apply.ts +143 -0
  65. package/dist/rules/tf001.ts +66 -0
  66. package/dist/serializer.d.ts +19 -0
  67. package/dist/serializer.d.ts.map +1 -0
  68. package/dist/skills/chant-terraform.md +92 -0
  69. package/dist/state-ownership.d.ts +27 -0
  70. package/dist/state-ownership.d.ts.map +1 -0
  71. package/dist/types/index.d.ts +2 -0
  72. package/dist/validate-cli.d.ts +3 -0
  73. package/dist/validate-cli.d.ts.map +1 -0
  74. package/dist/validate.d.ts +15 -0
  75. package/dist/validate.d.ts.map +1 -0
  76. package/package.json +75 -0
  77. package/src/__fixtures__/no-backend/main.tf +24 -0
  78. package/src/__fixtures__/show-state.json +68 -0
  79. package/src/__fixtures__/with-backend/main.tf +28 -0
  80. package/src/__fixtures__/with-module/main.tf +49 -0
  81. package/src/__fixtures__/with-module/modules/inner/main.tf +5 -0
  82. package/src/codegen/docs-cli.ts +4 -0
  83. package/src/codegen/docs.ts +50 -0
  84. package/src/codegen/generate-cli.ts +10 -0
  85. package/src/codegen/generate.ts +68 -0
  86. package/src/codegen/package.ts +50 -0
  87. package/src/composites/terraform-apply-op.acceptance.test.ts +138 -0
  88. package/src/composites/terraform-apply-op.test.ts +193 -0
  89. package/src/composites/terraform-apply-op.ts +166 -0
  90. package/src/composites/terraform-watch-op.test.ts +184 -0
  91. package/src/composites/terraform-watch-op.ts +204 -0
  92. package/src/config.ts +76 -0
  93. package/src/describe-resources.test.ts +342 -0
  94. package/src/describe-resources.ts +357 -0
  95. package/src/generated/index.d.ts +2 -0
  96. package/src/generated/index.ts +4 -0
  97. package/src/generated/lexicon-terraform.json +1 -0
  98. package/src/hcl/parse.ts +235 -0
  99. package/src/hcl/roots.ts +67 -0
  100. package/src/index.ts +65 -0
  101. package/src/lint/audit-catalog.ts +30 -0
  102. package/src/lint/audit-lineage.ts +21 -0
  103. package/src/lint/audit.test.ts +45 -0
  104. package/src/lint/post-synth/index.ts +7 -0
  105. package/src/lint/post-synth/post-synth.test.ts +95 -0
  106. package/src/lint/post-synth/tf001.ts +66 -0
  107. package/src/lint/rules/index.ts +7 -0
  108. package/src/lint/rules/plan-before-apply.test.ts +111 -0
  109. package/src/lint/rules/plan-before-apply.ts +143 -0
  110. package/src/lsp/completions.test.ts +120 -0
  111. package/src/lsp/completions.ts +101 -0
  112. package/src/lsp/context.test.ts +152 -0
  113. package/src/lsp/context.ts +349 -0
  114. package/src/lsp/hover.test.ts +82 -0
  115. package/src/lsp/hover.ts +44 -0
  116. package/src/lsp/option-keys.ts +106 -0
  117. package/src/op/activities/index.ts +48 -0
  118. package/src/op/activities/registry.test.ts +29 -0
  119. package/src/op/activities/terraform.test.ts +445 -0
  120. package/src/op/activities/terraform.ts +469 -0
  121. package/src/op/builders.test.ts +90 -0
  122. package/src/op/builders.ts +96 -0
  123. package/src/package-cli.ts +21 -0
  124. package/src/plugin.test.ts +271 -0
  125. package/src/plugin.ts +157 -0
  126. package/src/serializer.test.ts +26 -0
  127. package/src/serializer.ts +26 -0
  128. package/src/skills/chant-terraform.md +92 -0
  129. package/src/state-ownership.ts +32 -0
  130. package/src/validate-cli.ts +5 -0
  131. package/src/validate.ts +28 -0
@@ -0,0 +1,469 @@
1
+ /**
2
+ * terraform Op activities (#2086) — init, plan, apply and show against a root
3
+ * module named in the project's `terraform.roots` config namespace (#2083).
4
+ *
5
+ * Shaped after `lexicons/k3s/src/op/activities/k3s.ts`: `promisify(exec)` with
6
+ * the caller's `AbortSignal` forwarded so a local timeout or Ctrl-C kills the
7
+ * child, `safeHeartbeat` on an interval around the long calls, and every
8
+ * command and environment string produced by a pure exported function so a
9
+ * test can assert on it without running terraform.
10
+ *
11
+ * Two invariants hold for every call:
12
+ *
13
+ * - `TF_IN_AUTOMATION=1` is in the environment, which is what tells terraform
14
+ * it is not talking to a person.
15
+ * - `-input=false` is on every command that accepts it, so a missing variable
16
+ * fails the step instead of blocking on a prompt nobody will answer.
17
+ * `terraform show` is the one command that does not accept the flag
18
+ * (terraform answers `flag provided but not defined: -input`), so it
19
+ * carries the environment variable alone.
20
+ *
21
+ * `terraformApply` takes a saved plan file and nothing else. A bare apply
22
+ * re-plans at apply time, which is exactly the gap an approval gate exists to
23
+ * close, so it is refused rather than offered.
24
+ */
25
+
26
+ import { exec } from "node:child_process";
27
+ import { promisify } from "node:util";
28
+ import { resolve, dirname } from "node:path";
29
+ import { safeHeartbeat } from "@intentius/chant/op";
30
+ import { loadChantConfigUpward } from "@intentius/chant/config";
31
+ import type { TerraformConfig, TerraformRootConfig } from "../../config";
32
+
33
+ const execAsync = promisify(exec);
34
+
35
+ /**
36
+ * `terraform show -json` on a large estate runs to megabytes, well past
37
+ * `exec`'s 1 MiB default, and the failure mode there is a truncated buffer
38
+ * rather than a clear error.
39
+ */
40
+ const MAX_BUFFER = 64 * 1024 * 1024;
41
+
42
+ /** Heartbeat cadence for the long calls, matching k3s's installer loop. */
43
+ const HEARTBEAT_MS = 15_000;
44
+
45
+ /** Plan file written into the root directory when a step names none. */
46
+ export const DEFAULT_PLAN_FILE = "chant.tfplan";
47
+
48
+ /** Binary used when the `terraform` namespace records no preference. */
49
+ export const DEFAULT_TERRAFORM_BINARY = "terraform";
50
+
51
+ // ── Args and results ────────────────────────────────────────────────────────
52
+
53
+ /** Fields every activity here takes: which root, and where the project is. */
54
+ export interface TerraformRootArgs {
55
+ /**
56
+ * Key into `terraform.roots`. Not a directory — the directory, workspace,
57
+ * var files and backend config all come from the named entry, so an Op step
58
+ * cannot drift from what the project declared.
59
+ */
60
+ root: string;
61
+ /**
62
+ * Directory to start the `chant.config.*` search from. Default:
63
+ * `process.cwd()`, which is the project root under `chant run`.
64
+ */
65
+ cwd?: string;
66
+ }
67
+
68
+ export interface TerraformInitArgs extends TerraformRootArgs {
69
+ /** `-upgrade`: re-resolve provider and module versions within constraints. */
70
+ upgrade?: boolean;
71
+ /** `-reconfigure`: ignore any existing backend state and configure afresh. */
72
+ reconfigure?: boolean;
73
+ }
74
+
75
+ export interface TerraformPlanArgs extends TerraformRootArgs {
76
+ /** Plan file to write, relative to the root directory. Default: {@link DEFAULT_PLAN_FILE}. */
77
+ planFile?: string;
78
+ /** `-destroy`: plan the removal of everything the root manages. */
79
+ destroy?: boolean;
80
+ }
81
+
82
+ export interface TerraformApplyArgs extends TerraformRootArgs {
83
+ /**
84
+ * The saved plan file to apply, relative to the root directory — normally
85
+ * `plan.out.planFile`, the Plan step's own output. Required: this activity
86
+ * has no bare-apply mode.
87
+ */
88
+ planFile: string;
89
+ }
90
+
91
+ export interface TerraformShowArgs extends TerraformRootArgs {
92
+ /** Show this saved plan file. Omitted, the activity shows current state. */
93
+ planFile?: string;
94
+ }
95
+
96
+ /** What {@link terraformInit} resolved. */
97
+ export interface TerraformInitResult {
98
+ /** Absolute path of the root module directory that was initialized. */
99
+ dir: string;
100
+ /** Workspace the run selected via `TF_WORKSPACE`, when the root names one. */
101
+ workspace?: string;
102
+ }
103
+
104
+ /** Counts projected out of a plan's `resource_changes`. */
105
+ export interface PlanChangeCounts {
106
+ /** Resources to create. A replace counts here and in `destroys`, as terraform's own summary does. */
107
+ adds: number;
108
+ /** Resources to update in place. */
109
+ changes: number;
110
+ /** Resources to destroy. This is the number an approval gate exists for. */
111
+ destroys: number;
112
+ }
113
+
114
+ /** What {@link terraformPlan} resolved. Carries the plan itself, so no later step re-plans. */
115
+ export interface TerraformPlanResult extends PlanChangeCounts {
116
+ /** `true` when terraform reported exit 2 under `-detailed-exitcode`: the plan proposes changes. */
117
+ changed: boolean;
118
+ /** The written plan file, relative to `dir` — hand this straight to {@link terraformApply}. */
119
+ planFile: string;
120
+ /** Absolute path of the root module directory. */
121
+ dir: string;
122
+ /** `terraform show -json <planFile>`, parsed. */
123
+ json: unknown;
124
+ /** `terraform show -no-color <planFile>` — the human-readable plan. */
125
+ text: string;
126
+ }
127
+
128
+ /** What {@link terraformApply} resolved. */
129
+ export interface TerraformApplyResult {
130
+ /** The plan file that was applied. */
131
+ planFile: string;
132
+ /** Absolute path of the root module directory. */
133
+ dir: string;
134
+ /** Always `true` on success; the activity throws otherwise. */
135
+ applied: boolean;
136
+ }
137
+
138
+ /** What {@link terraformShow} resolved. */
139
+ export interface TerraformShowResult extends PlanChangeCounts {
140
+ /** Whether the output describes a saved plan or current state. */
141
+ source: "plan" | "state";
142
+ /** The `-json` output, parsed. */
143
+ json: unknown;
144
+ /** The `-no-color` output. */
145
+ text: string;
146
+ /** Absolute path of the root module directory. */
147
+ dir: string;
148
+ /** Present when `source` is `"plan"`. */
149
+ planFile?: string;
150
+ }
151
+
152
+ // ── Pure command and environment builders ───────────────────────────────────
153
+
154
+ /**
155
+ * Quote a command-line argument for the shell `exec` runs it through, leaving
156
+ * ordinary paths and `key=value` pairs untouched so command strings stay
157
+ * readable in logs and in tests.
158
+ */
159
+ export function quoteArg(value: string): string {
160
+ return /^[A-Za-z0-9._/:=@,+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
161
+ }
162
+
163
+ /** Which CLI drives the roots: `terraform.binary`, defaulting to `terraform`. */
164
+ export function terraformBinary(config?: TerraformConfig): string {
165
+ return config?.binary ?? DEFAULT_TERRAFORM_BINARY;
166
+ }
167
+
168
+ /**
169
+ * Environment for every invocation. `TF_IN_AUTOMATION` suppresses the
170
+ * "run terraform apply next" hand-holding and is terraform's own signal that
171
+ * no human is watching; `TF_WORKSPACE` is how a workspace gets selected
172
+ * without a separate `terraform workspace select` round-trip.
173
+ */
174
+ export function terraformEnvironment(root?: Pick<TerraformRootConfig, "workspace">): Record<string, string> {
175
+ return {
176
+ TF_IN_AUTOMATION: "1",
177
+ ...(root?.workspace ? { TF_WORKSPACE: root.workspace } : {}),
178
+ };
179
+ }
180
+
181
+ /** `terraform init`, with the root's `backendConfig` as `-backend-config=k=v` flags. */
182
+ export function terraformInitCommand(opts: {
183
+ binary: string;
184
+ backendConfig?: Record<string, string>;
185
+ upgrade?: boolean;
186
+ reconfigure?: boolean;
187
+ }): string {
188
+ const parts = [opts.binary, "init", "-input=false"];
189
+ if (opts.upgrade) parts.push("-upgrade");
190
+ if (opts.reconfigure) parts.push("-reconfigure");
191
+ for (const [key, value] of Object.entries(opts.backendConfig ?? {})) {
192
+ parts.push(`-backend-config=${quoteArg(`${key}=${value}`)}`);
193
+ }
194
+ return parts.join(" ");
195
+ }
196
+
197
+ /**
198
+ * `terraform plan`. `-detailed-exitcode` is what makes the run answerable:
199
+ * 0 means no changes, 2 means changes, and anything else is a failure. See
200
+ * {@link terraformPlan}.
201
+ */
202
+ export function terraformPlanCommand(opts: {
203
+ binary: string;
204
+ planFile: string;
205
+ varFiles?: string[];
206
+ destroy?: boolean;
207
+ }): string {
208
+ const parts = [opts.binary, "plan", "-input=false", "-detailed-exitcode"];
209
+ if (opts.destroy) parts.push("-destroy");
210
+ for (const varFile of opts.varFiles ?? []) parts.push(`-var-file=${quoteArg(varFile)}`);
211
+ parts.push(`-out=${quoteArg(opts.planFile)}`);
212
+ return parts.join(" ");
213
+ }
214
+
215
+ /** `terraform apply <planFile>` — a saved plan, never a bare apply. */
216
+ export function terraformApplyCommand(opts: { binary: string; planFile: string }): string {
217
+ return `${opts.binary} apply -input=false ${quoteArg(opts.planFile)}`;
218
+ }
219
+
220
+ /**
221
+ * `terraform show`. The one command here that takes no `-input` flag
222
+ * (terraform answers `flag provided but not defined: -input`), so automation
223
+ * rests on `TF_IN_AUTOMATION` alone. Show reads an artifact and prompts for
224
+ * nothing in the first place.
225
+ */
226
+ export function terraformShowCommand(opts: { binary: string; json: boolean; planFile?: string }): string {
227
+ const parts = [opts.binary, "show", opts.json ? "-json" : "-no-color"];
228
+ if (opts.planFile) parts.push(quoteArg(opts.planFile));
229
+ return parts.join(" ");
230
+ }
231
+
232
+ /**
233
+ * Project a plan's `resource_changes` into add/change/destroy counts, the same
234
+ * three terraform prints at the end of a plan. A replace (`["delete","create"]`)
235
+ * counts as one add and one destroy, exactly as terraform reports it.
236
+ */
237
+ export function countPlanChanges(planJson: unknown): PlanChangeCounts {
238
+ const counts: PlanChangeCounts = { adds: 0, changes: 0, destroys: 0 };
239
+ const changes = (planJson as { resource_changes?: unknown } | null | undefined)?.resource_changes;
240
+ if (!Array.isArray(changes)) return counts;
241
+ for (const entry of changes) {
242
+ const actions = (entry as { change?: { actions?: unknown } } | null | undefined)?.change?.actions;
243
+ if (!Array.isArray(actions)) continue;
244
+ if (actions.includes("create")) counts.adds++;
245
+ if (actions.includes("update")) counts.changes++;
246
+ if (actions.includes("delete")) counts.destroys++;
247
+ }
248
+ return counts;
249
+ }
250
+
251
+ // ── Root resolution ─────────────────────────────────────────────────────────
252
+
253
+ /** A root entry resolved against the project config, ready to run in. */
254
+ interface ResolvedRoot {
255
+ binary: string;
256
+ root: TerraformRootConfig;
257
+ /** Absolute path of `root.dir`, resolved against the project root. */
258
+ dir: string;
259
+ }
260
+
261
+ /**
262
+ * Read `terraform.roots` out of the project config and resolve one entry.
263
+ * The walk is {@link loadChantConfigUpward}, so a step invoked from a
264
+ * subdirectory still finds the project's `chant.config.*`, and `dir` resolves
265
+ * against the directory that config lives in rather than against the cwd.
266
+ */
267
+ async function resolveRoot(args: TerraformRootArgs): Promise<ResolvedRoot> {
268
+ const start = resolve(args.cwd ?? process.cwd());
269
+ const { config, configPath } = await loadChantConfigUpward(start);
270
+ const projectRoot = configPath ? dirname(configPath) : start;
271
+ const namespace = (config as { terraform?: TerraformConfig }).terraform;
272
+ const roots = namespace?.roots ?? {};
273
+ const root = roots[args.root];
274
+ if (!root) {
275
+ const known = Object.keys(roots).sort();
276
+ throw new Error(
277
+ `terraform: no root named "${args.root}" in terraform.roots` +
278
+ `${configPath ? ` (${configPath})` : ""} — ` +
279
+ (known.length > 0 ? `known roots: ${known.join(", ")}` : "the namespace declares no roots"),
280
+ );
281
+ }
282
+ return { binary: terraformBinary(namespace), root, dir: resolve(projectRoot, root.dir) };
283
+ }
284
+
285
+ /** The shape `promisify(exec)` rejects with: an Error carrying the child's exit code and output. */
286
+ interface ExecFailure {
287
+ code?: unknown;
288
+ stdout?: string;
289
+ stderr?: string;
290
+ }
291
+
292
+ /** Run `cmd` in `dir`, forwarding the signal so an abort kills the child. */
293
+ async function run(
294
+ cmd: string,
295
+ dir: string,
296
+ env: Record<string, string>,
297
+ signal?: AbortSignal,
298
+ ): Promise<{ stdout: string; stderr: string }> {
299
+ return execAsync(cmd, { cwd: dir, env: { ...process.env, ...env }, signal, maxBuffer: MAX_BUFFER });
300
+ }
301
+
302
+ /** Run `body` with a heartbeat ticking, so a long terraform call is not read as a hung one. */
303
+ async function withHeartbeat<T>(details: Record<string, unknown>, body: () => Promise<T>): Promise<T> {
304
+ const timer = setInterval(() => safeHeartbeat(details), HEARTBEAT_MS);
305
+ try {
306
+ return await body();
307
+ } finally {
308
+ clearInterval(timer);
309
+ }
310
+ }
311
+
312
+ function report(stdout: string, stderr: string): void {
313
+ if (stdout) console.log(stdout);
314
+ if (stderr) console.error(stderr);
315
+ }
316
+
317
+ // ── Activities ──────────────────────────────────────────────────────────────
318
+
319
+ /**
320
+ * `terraform init` in the named root, with the root's `backendConfig` supplied
321
+ * as `-backend-config` flags. Uses the longInfra profile: init downloads
322
+ * providers and modules, a network call of unpredictable size.
323
+ */
324
+ export async function terraformInit(
325
+ args: TerraformInitArgs,
326
+ signal?: AbortSignal,
327
+ ): Promise<TerraformInitResult> {
328
+ const { binary, root, dir } = await resolveRoot(args);
329
+ const cmd = terraformInitCommand({
330
+ binary,
331
+ ...(root.backendConfig ? { backendConfig: root.backendConfig } : {}),
332
+ ...(args.upgrade ? { upgrade: true } : {}),
333
+ ...(args.reconfigure ? { reconfigure: true } : {}),
334
+ });
335
+
336
+ const { stdout, stderr } = await withHeartbeat({ step: "terraform init", root: args.root, dir }, () =>
337
+ run(cmd, dir, terraformEnvironment(root), signal),
338
+ );
339
+ report(stdout, stderr);
340
+
341
+ return { dir, ...(root.workspace ? { workspace: root.workspace } : {}) };
342
+ }
343
+
344
+ /**
345
+ * `terraform plan -detailed-exitcode -out=<planFile>` in the named root, then
346
+ * `terraform show` over the written plan in both `-json` and `-no-color` form.
347
+ *
348
+ * The exit code is the answer, not an error condition: 0 is a plan with no
349
+ * changes, 2 is a plan with changes, and anything else, 1 included, is a
350
+ * failure thrown with terraform's own stderr attached. Both `show` renders
351
+ * come back with the result, so a gate, a report or an apply downstream reads
352
+ * the plan that ran instead of planning again against a moved world.
353
+ *
354
+ * Uses the longInfra profile: plan refreshes every resource against its
355
+ * provider.
356
+ */
357
+ export async function terraformPlan(
358
+ args: TerraformPlanArgs,
359
+ signal?: AbortSignal,
360
+ ): Promise<TerraformPlanResult> {
361
+ const { binary, root, dir } = await resolveRoot(args);
362
+ const planFile = args.planFile ?? DEFAULT_PLAN_FILE;
363
+ const env = terraformEnvironment(root);
364
+ const cmd = terraformPlanCommand({
365
+ binary,
366
+ planFile,
367
+ ...(root.varFiles ? { varFiles: root.varFiles } : {}),
368
+ ...(args.destroy ? { destroy: true } : {}),
369
+ });
370
+
371
+ const changed = await withHeartbeat({ step: "terraform plan", root: args.root, dir }, async () => {
372
+ try {
373
+ const { stdout, stderr } = await run(cmd, dir, env, signal);
374
+ report(stdout, stderr);
375
+ return false;
376
+ } catch (err) {
377
+ // An abort or a spawn failure carries no numeric exit code. That is not
378
+ // terraform answering, so it propagates untouched.
379
+ const failure = err as ExecFailure;
380
+ if (typeof failure.code !== "number") throw err;
381
+ if (failure.code !== 2) {
382
+ const detail = (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim();
383
+ throw new Error(
384
+ `${binary} plan failed in ${dir} (exit ${failure.code})${detail ? `\n${detail}` : ""}`,
385
+ );
386
+ }
387
+ report(failure.stdout ?? "", failure.stderr ?? "");
388
+ return true;
389
+ }
390
+ });
391
+
392
+ const jsonRun = await run(terraformShowCommand({ binary, json: true, planFile }), dir, env, signal);
393
+ const textRun = await run(terraformShowCommand({ binary, json: false, planFile }), dir, env, signal);
394
+ const json: unknown = JSON.parse(jsonRun.stdout);
395
+
396
+ return { changed, planFile, dir, json, text: textRun.stdout, ...countPlanChanges(json) };
397
+ }
398
+
399
+ /**
400
+ * `terraform apply <planFile>` in the named root. A saved plan only: without
401
+ * one, apply re-plans at apply time and acts on something no gate ever saw,
402
+ * so a missing `planFile` is refused here rather than quietly widened into a
403
+ * bare apply.
404
+ *
405
+ * Uses the longInfra profile.
406
+ */
407
+ export async function terraformApply(
408
+ args: TerraformApplyArgs,
409
+ signal?: AbortSignal,
410
+ ): Promise<TerraformApplyResult> {
411
+ if (typeof args.planFile !== "string" || args.planFile.trim() === "") {
412
+ throw new Error(
413
+ "terraformApply: planFile is required — this activity applies a saved plan and has no bare-apply mode. " +
414
+ "Pass the Plan step's own output (`plan.out.planFile`).",
415
+ );
416
+ }
417
+
418
+ const { binary, root, dir } = await resolveRoot(args);
419
+ const cmd = terraformApplyCommand({ binary, planFile: args.planFile });
420
+
421
+ const { stdout, stderr } = await withHeartbeat(
422
+ { step: "terraform apply", root: args.root, dir, planFile: args.planFile },
423
+ () => run(cmd, dir, terraformEnvironment(root), signal),
424
+ );
425
+ report(stdout, stderr);
426
+
427
+ return { planFile: args.planFile, dir, applied: true };
428
+ }
429
+
430
+ /**
431
+ * `terraform show` over current state, or over a saved plan when `planFile` is
432
+ * given. Returns the parsed `-json` output and the `-no-color` render, plus
433
+ * the add/change/destroy counts when the subject is a plan. State has no
434
+ * change set, so for state the three counts are zero.
435
+ *
436
+ * Uses the fastIdempotent profile: show reads an artifact and calls no
437
+ * provider.
438
+ */
439
+ export async function terraformShow(
440
+ args: TerraformShowArgs,
441
+ signal?: AbortSignal,
442
+ ): Promise<TerraformShowResult> {
443
+ const { binary, root, dir } = await resolveRoot(args);
444
+ const env = terraformEnvironment(root);
445
+ const planFile = args.planFile;
446
+
447
+ const jsonRun = await run(
448
+ terraformShowCommand({ binary, json: true, ...(planFile ? { planFile } : {}) }),
449
+ dir,
450
+ env,
451
+ signal,
452
+ );
453
+ const textRun = await run(
454
+ terraformShowCommand({ binary, json: false, ...(planFile ? { planFile } : {}) }),
455
+ dir,
456
+ env,
457
+ signal,
458
+ );
459
+ const json: unknown = JSON.parse(jsonRun.stdout);
460
+
461
+ return {
462
+ source: planFile ? "plan" : "state",
463
+ json,
464
+ text: textRun.stdout,
465
+ dir,
466
+ ...(planFile ? { planFile } : {}),
467
+ ...(planFile ? countPlanChanges(json) : { adds: 0, changes: 0, destroys: 0 }),
468
+ };
469
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Typed step-builder tests (#2086), following
3
+ * `lexicons/k3s/src/op/builders.test.ts`: the produced `ActivityStep` shape,
4
+ * profile defaults, `id` routing to the step rather than into `args`, and the
5
+ * `StepOutputRef` an Apply step takes from a Plan step's `.out`.
6
+ */
7
+
8
+ import { describe, test, expect } from "vitest";
9
+ import { isStepOutputRef, type StepOutputRef } from "@intentius/chant/op";
10
+ import { terraformInit, terraformPlan, terraformApply, terraformShow } from "./builders";
11
+
12
+ describe("terraform typed step builders (#2086)", () => {
13
+ test("terraformInit: root is positional, longInfra by default", () => {
14
+ expect(terraformInit("app")).toMatchObject({
15
+ kind: "activity",
16
+ fn: "terraformInit",
17
+ args: { root: "app" },
18
+ profile: "longInfra",
19
+ });
20
+ });
21
+
22
+ test("terraformInit: opts land in args, profile can be overridden", () => {
23
+ expect(terraformInit("app", { upgrade: true, profile: "fastIdempotent" })).toMatchObject({
24
+ fn: "terraformInit",
25
+ args: { root: "app", upgrade: true },
26
+ profile: "fastIdempotent",
27
+ });
28
+ });
29
+
30
+ test("terraformPlan: longInfra by default", () => {
31
+ expect(terraformPlan("app", { planFile: "chant.tfplan" })).toMatchObject({
32
+ fn: "terraformPlan",
33
+ args: { root: "app", planFile: "chant.tfplan" },
34
+ profile: "longInfra",
35
+ });
36
+ });
37
+
38
+ test("terraformApply: longInfra by default", () => {
39
+ expect(terraformApply("app", { planFile: "chant.tfplan" })).toMatchObject({
40
+ fn: "terraformApply",
41
+ args: { root: "app", planFile: "chant.tfplan" },
42
+ profile: "longInfra",
43
+ });
44
+ });
45
+
46
+ test("terraformShow: fastIdempotent by default, since show calls no provider", () => {
47
+ expect(terraformShow("app")).toMatchObject({ fn: "terraformShow", profile: "fastIdempotent" });
48
+ });
49
+
50
+ test("id routes to the step's own id field, never into args", () => {
51
+ const step = terraformPlan("app", { id: "plan" });
52
+ expect(step.id).toBe("plan");
53
+ expect(step.args).toEqual({ root: "app" });
54
+ });
55
+
56
+ test("an Apply step referencing plan.out.planFile serializes as a StepOutputRef", () => {
57
+ const plan = terraformPlan("app", { planFile: "chant.tfplan", id: "plan" });
58
+ const apply = terraformApply("app", { planFile: plan.out.planFile });
59
+
60
+ const ref = apply.args?.planFile as StepOutputRef;
61
+ expect(isStepOutputRef(ref)).toBe(true);
62
+ expect(ref.step).toBe("plan");
63
+ expect(ref.path).toBe("planFile");
64
+ // The reference is inert data on the step, so it survives a JSON round
65
+ // trip into the generated workflow rather than collapsing to a string.
66
+ expect(JSON.parse(JSON.stringify(apply)).args.planFile).toEqual({
67
+ kind: "step-output-ref",
68
+ step: "plan",
69
+ path: "planFile",
70
+ });
71
+ });
72
+
73
+ test(".out throws when the producing step has no id", () => {
74
+ expect(() => terraformPlan("app").out.planFile).toThrow(/has no id/);
75
+ });
76
+ });
77
+
78
+ // ── Compile-time-only: authoring-time type errors (never executed) ──────────
79
+ function _typeChecksOnly(): void {
80
+ // @ts-expect-error — planFile is required on an apply: this activity has no
81
+ // bare-apply mode, so omitting it is a compile error, not a runtime one.
82
+ terraformApply("app");
83
+
84
+ // @ts-expect-error — "planfile" (wrong case) is not a key of TerraformApplyArgs.
85
+ terraformApply("app", { planfile: "chant.tfplan" });
86
+
87
+ // @ts-expect-error — `root` is positional; it is not a member of opts.
88
+ terraformInit("app", { root: "other" });
89
+ }
90
+ void _typeChecksOnly;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Typed step-builder wrappers for this lexicon's activities, copied from
3
+ * `lexicons/k3s/src/op/builders.ts` (chant #1288 Stage 2). `opts`'s type in
4
+ * each wrapper below IS the activity's own `*Args` interface, via
5
+ * `Omit`/`WithStepRefs`, never restated: rename a field on the activity and
6
+ * the builder's callers stop compiling.
7
+ *
8
+ * The `root` key is positional in every wrapper, because naming the root is
9
+ * the one thing a terraform step cannot be authored without.
10
+ *
11
+ * `id` routes to the step's `id` field rather than into `args`. That matters
12
+ * here more than anywhere else in the lexicon: `.out` throws without an id
13
+ * (`packages/core/src/op/builders.ts`), and `.out` is how an Apply step names
14
+ * the Plan step's `planFile` as a `StepOutputRef` instead of guessing the
15
+ * path a second time.
16
+ */
17
+
18
+ import {
19
+ activity,
20
+ takeProfileAndId,
21
+ type ActivityStep,
22
+ type NamedActivityStep,
23
+ type WithStepRefs,
24
+ } from "@intentius/chant/op";
25
+ import type {
26
+ TerraformInitArgs,
27
+ TerraformPlanArgs,
28
+ TerraformApplyArgs,
29
+ TerraformShowArgs,
30
+ } from "./activities/terraform";
31
+
32
+ /** Extra opts every wrapper below accepts alongside its activity's own fields. */
33
+ type StepOpts = { profile?: ActivityStep["profile"]; id?: string };
34
+
35
+ /**
36
+ * `terraform init` in the named root — the fully typed twin of the
37
+ * `terraformInit` activity. `opts` is {@link TerraformInitArgs} itself, minus
38
+ * the positional `root`. Defaults to the `longInfra` profile: init downloads
39
+ * providers.
40
+ */
41
+ export const terraformInit = (
42
+ root: string,
43
+ opts?: WithStepRefs<Omit<TerraformInitArgs, "root">> & StepOpts,
44
+ ): NamedActivityStep => {
45
+ const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
46
+ return activity("terraformInit", { root, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
47
+ };
48
+
49
+ /**
50
+ * `terraform plan` in the named root, writing a saved plan. `opts` is
51
+ * {@link TerraformPlanArgs} itself, minus the positional `root`. Defaults to
52
+ * the `longInfra` profile: plan refreshes every resource against its provider.
53
+ *
54
+ * Give this step an `id` when a later step applies its plan — `plan.out.planFile`
55
+ * is the reference an Apply step takes.
56
+ */
57
+ export const terraformPlan = (
58
+ root: string,
59
+ opts?: WithStepRefs<Omit<TerraformPlanArgs, "root">> & StepOpts,
60
+ ): NamedActivityStep => {
61
+ const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
62
+ return activity("terraformPlan", { root, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
63
+ };
64
+
65
+ /**
66
+ * `terraform apply <planFile>` in the named root — the fully typed twin of the
67
+ * `terraformApply` activity. `opts` is {@link TerraformApplyArgs} itself,
68
+ * minus the positional `root`, so `planFile` stays required at the call site
69
+ * and the activity's bare-apply refusal is a compile error rather than a
70
+ * runtime one. Defaults to the `longInfra` profile.
71
+ */
72
+ export const terraformApply = (
73
+ root: string,
74
+ opts: WithStepRefs<Omit<TerraformApplyArgs, "root">> & StepOpts,
75
+ ): NamedActivityStep => {
76
+ const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
77
+ return activity("terraformApply", { root, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
78
+ };
79
+
80
+ /**
81
+ * `terraform show` in the named root, over state or over a saved plan. `opts`
82
+ * is {@link TerraformShowArgs} itself, minus the positional `root`. Defaults
83
+ * to the `fastIdempotent` profile: show reads an artifact and calls no
84
+ * provider.
85
+ */
86
+ export const terraformShow = (
87
+ root: string,
88
+ opts?: WithStepRefs<Omit<TerraformShowArgs, "root">> & StepOpts,
89
+ ): NamedActivityStep => {
90
+ const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
91
+ return activity(
92
+ "terraformShow",
93
+ { root, ...args },
94
+ { profile: profile ?? "fastIdempotent", ...(id ? { id } : {}) },
95
+ );
96
+ };
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env tsx
2
+ import { generate, writeGeneratedFiles } from "./codegen/generate";
3
+ import { packageLexicon } from "./codegen/package";
4
+ import { writeBundleSpec } from "@intentius/chant/codegen/package";
5
+ import { join, dirname } from "path";
6
+ import { fileURLToPath } from "url";
7
+
8
+ const srcDir = dirname(fileURLToPath(import.meta.url));
9
+
10
+ // 1. Generate src/generated/ files (writeGeneratedFiles resolves its own target)
11
+ const genResult = await generate({ verbose: true });
12
+ writeGeneratedFiles(genResult);
13
+
14
+ // 2. Run package pipeline and write dist/
15
+ const { spec, stats } = await packageLexicon({ verbose: true });
16
+
17
+ const distDir = join(dirname(srcDir), "dist");
18
+ writeBundleSpec(spec, distDir);
19
+
20
+ console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
21
+ console.error(`dist/ written to ${distDir}`);