@intentius/chant-lexicon-terraform 0.124.56 → 0.124.58

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.
@@ -208,7 +208,7 @@ describe("terraform-apply on a choudoufu root in a gated wave (#3459)", () => {
208
208
 
209
209
  describe("terraform-apply", () => {
210
210
  test("the plugin contributes it", () => {
211
- expect(terraformCapabilityPlugin.capabilities().map((c) => c.kind)).toEqual(["terraform-apply", "terraform-state-unlock", "terraform-state-push", "terraform-copy"]);
211
+ expect(terraformCapabilityPlugin.capabilities().map((c) => c.kind)).toEqual(["terraform-apply", "terraform-state-unlock", "terraform-state-push", "terraform-copy", "terraform-action"]);
212
212
  });
213
213
 
214
214
  test("plan inits and plans the root, with its vars, and names the root as the member", async () => {
@@ -153,7 +153,7 @@ import { terraformPlanMoved } from "../exit-codes";
153
153
  import { terraformPlanShape } from "../plan-shape";
154
154
  import { plannerForBinary, terraformChangeSetPart } from "../change-set";
155
155
  import { terraformDefaultLockGrain, terraformLockGrainRefusal, terraformLockRows, terraformLockRowsFromPart } from "../lock-rows";
156
- import type { LockRow } from "@intentius/chant/lifecycle/lock-rows";
156
+ import { rootRowKey, type LockRow } from "@intentius/chant/lifecycle/lock-rows";
157
157
  import type { TerraformConfig } from "../config";
158
158
  import { rootTexts } from "../compose/hcl";
159
159
  import { declaredVariables } from "../compose/root";
@@ -165,6 +165,7 @@ import { DEFAULT_LOCK_TIMEOUT, isLockTimeout } from "../lock-timeout";
165
165
  import { conePlanDigest, type PlanCone } from "../cone";
166
166
  import { fillStateReads, stateReadsProblems, waitingReadsReason, type TerraformStateRead } from "../state-reads";
167
167
  import { terraformStateOpCapabilities } from "../state-ops";
168
+ import { terraformActionCapability } from "../action-ops";
168
169
 
169
170
  /** The `terraform-apply` step's input. */
170
171
  export interface TerraformApplyStepInput {
@@ -610,6 +611,11 @@ export const terraformApplyCapability: Capability<TerraformApplyStepInput, Terra
610
611
  const binary = (await rootDir(input).catch(() => undefined))?.binary;
611
612
  return terraformLockGrainRefusal(grain, await plannerOf(input, plan), binary);
612
613
  },
614
+ // The ruling for ci-products (2026-10-28): a Terraform, tofu or Terragrunt root waits for its root row on the ledger in place of
615
+ // the backend's lock. A choudoufu root stays on the resource grain and never asks for this row.
616
+ targetLockRow(input) {
617
+ return typeof input?.root === "string" && input.root !== "" ? { key: rootRowKey(input.root), target: input.root, address: input.root } : undefined;
618
+ },
613
619
  lockRows(input, plan) {
614
620
  const kept = (plan.artifact as { lockRows?: LockRow[] } | undefined)?.lockRows;
615
621
  if (Array.isArray(kept)) return kept;
@@ -659,6 +665,8 @@ export const TERRAFORM_VERB_FAMILIES = {
659
665
  // #4618: the binary runs outside waves, each a fixed operation; the two that change state on their own run under a gated request.
660
666
  state: ["terraform-state-unlock", "terraform-state-push"],
661
667
  copy: ["terraform-copy"],
668
+ // #4653: one action block invoked on its own, under a gated request.
669
+ action: ["terraform-action"],
662
670
  } as const;
663
671
 
664
672
  export const terraformCapabilityPlugin: CapabilityPlugin = {
@@ -667,7 +675,7 @@ export const terraformCapabilityPlugin: CapabilityPlugin = {
667
675
  return ownPackageVersion(import.meta.url);
668
676
  },
669
677
  capabilities(): Array<Capability<never, unknown>> {
670
- return [terraformApplyCapability as Capability<never, unknown>, ...terraformStateOpCapabilities.map((c) => c as unknown as Capability<never, unknown>)];
678
+ return [terraformApplyCapability as Capability<never, unknown>, ...terraformStateOpCapabilities.map((c) => c as unknown as Capability<never, unknown>), terraformActionCapability as unknown as Capability<never, unknown>];
671
679
  },
672
680
  families(): Record<string, readonly string[]> {
673
681
  return TERRAFORM_VERB_FAMILIES;
@@ -6,6 +6,7 @@
6
6
  import { describe, expect, test } from "vitest";
7
7
  import type { ChangeSetPart } from "@intentius/chant/change-set";
8
8
  import { terraformDefaultLockGrain, terraformLockGrainRefusal, terraformLockRows, terraformLockRowsFromPart } from "./lock-rows";
9
+ import { terraformApplyCapability } from "./components/capability-plugin";
9
10
 
10
11
  const plan = {
11
12
  resource_changes: [
@@ -68,3 +69,10 @@ describe("the grain", () => {
68
69
  expect(terraformLockGrainRefusal("repo", "tofu")).toBeUndefined();
69
70
  });
70
71
  });
72
+
73
+ describe("the ledger lock (ruling for ci-products 2026-10-28)", () => {
74
+ test("a root's target row is `root <root>`, which a Terraform, tofu or Terragrunt apply waits for", async () => {
75
+ expect(await terraformApplyCapability.targetLockRow!({ root: "stacks/net" } as never)).toEqual({ key: "root stacks/net", target: "stacks/net", address: "stacks/net" });
76
+ expect(await terraformApplyCapability.targetLockRow!({ root: "" } as never)).toBeUndefined();
77
+ });
78
+ });
package/src/pin/scan.ts CHANGED
@@ -303,7 +303,18 @@ export function unescapeHcl(raw: string): string {
303
303
  return out;
304
304
  }
305
305
 
306
- /** Write a value as the content of an HCL string literal. */
306
+ const CONTROL_ESCAPES: Record<string, string> = { "\n": "\\n", "\r": "\\r", "\t": "\\t" };
307
+
308
+ /**
309
+ * Write a value as the content of an HCL string literal: `\` and `"`
310
+ * escaped, a control character as `\n`, `\r`, `\t` or `\uNNNN`, and `${`
311
+ * and `%{` doubled so nothing in it is a template. The replacements are
312
+ * functions because a replacement string reads `$$` as one `$`.
313
+ */
307
314
  export function escapeHcl(value: string): string {
308
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\$\{/g, "$${").replace(/%\{/g, "%%{");
315
+ return value
316
+ .replace(/[\\"]/g, (c) => `\\${c}`)
317
+ .replace(/[\u0000-\u001f\u007f]/g, (c) => CONTROL_ESCAPES[c] ?? `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`)
318
+ .replace(/\$\{/g, () => "$${")
319
+ .replace(/%\{/g, () => "%%{");
309
320
  }
package/src/state-ops.ts CHANGED
@@ -259,6 +259,11 @@ export interface GatedStateOpInput {
259
259
  * the operation with that reason, and the approval is not spent.
260
260
  */
261
261
  beforeSpend?: () => Promise<string | undefined>;
262
+ /**
263
+ * The flat facts of a failed act's finished line, beside its `error`, such
264
+ * as the binary's exit code and last lines (#4653). Default none.
265
+ */
266
+ failedDetail?: (error: Error) => Record<string, string | number | boolean>;
262
267
  }
263
268
 
264
269
  /** How a gated operation ended. */
@@ -324,7 +329,7 @@ export async function gatedStateOp<T>(
324
329
  return { status: "done", digest, request: asked.request, approvedBy, output, recorded: await finish("applied", detail) };
325
330
  } catch (e) {
326
331
  const error = e instanceof Error ? e : new Error(String(e));
327
- return { status: "failed", digest, request: asked.request, approvedBy, error, recorded: await finish("failed", { error: error.message.slice(0, 400) }) };
332
+ return { status: "failed", digest, request: asked.request, approvedBy, error, recorded: await finish("failed", { ...(input.failedDetail?.(error) ?? {}), error: error.message.slice(0, 400) }) };
328
333
  }
329
334
  }
330
335
 
@@ -0,0 +1,25 @@
1
+ # The app orders, from the template acme/orders/aws. Written by chant from a template request; a rollout moves the version.
2
+
3
+ module "app" {
4
+ source = "registry.example.com/acme/orders/aws"
5
+ version = "1.2.0"
6
+
7
+ bucket_name = "orders-data"
8
+ replicas = 3
9
+ public = true
10
+ readers = ["svc-a", "svc-b"]
11
+ tags = {
12
+ cost-center = "42"
13
+ team = "payments"
14
+ }
15
+ retention_days = 30
16
+ }
17
+
18
+ output "app_endpoint" {
19
+ value = module.app.endpoint
20
+ }
21
+
22
+ output "app_bucket_arn" {
23
+ value = module.app.bucket_arn
24
+ description = "The bucket's ARN."
25
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@intentius/chant-lexicon-terraform/template` (#4652): a template request
3
+ * (`@intentius/chant/template-request`) as Terraform files, an app's new
4
+ * root or an add-on's module call in it. Nothing here reads the disk.
5
+ */
6
+
7
+ export {
8
+ renderAppRoot,
9
+ renderAddonCall,
10
+ terraformInputType,
11
+ addonFile,
12
+ TemplateRenderError,
13
+ APP_MODULE_NAME,
14
+ APP_OUTPUT_PREFIX,
15
+ APP_ROOT_FILE,
16
+ } from "./render";
17
+ export type { RenderAppRootOptions, RenderAddonCallOptions, RenderedFile, TemplateOutput, TemplateRenderErrorCode } from "./render";
@@ -0,0 +1,230 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, test } from "vitest";
5
+ import { loadHcl2json, parseTerraformDir } from "@intentius/chant/terraform/parse";
6
+ import { makeTemplateRequest, type TemplateInput, type TemplateRequest } from "@intentius/chant/template-request";
7
+ import { editPins } from "../pin/edit";
8
+ import { scanBlocks } from "../pin/scan";
9
+ import { readModulePin } from "../pin/source";
10
+ import { renderAddonCall, renderAppRoot, terraformInputType, TemplateRenderError } from "./index";
11
+
12
+ const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__");
13
+ const GOLDEN = join(FIXTURES, "app-root.golden.tf");
14
+ const SOURCE = "registry.example.com/acme/orders/aws";
15
+ const CACHE = "registry.example.com/acme/cache/aws";
16
+
17
+ const parser = await loadHcl2json();
18
+
19
+ const APP_INPUTS: TemplateInput[] = [
20
+ { name: "bucket_name", kind: "string" },
21
+ { name: "replicas", kind: "number" },
22
+ { name: "public", kind: "bool", default: false },
23
+ { name: "readers", kind: "list", of: "string" },
24
+ { name: "tags", kind: "map", of: "string" },
25
+ { name: "retention_days", kind: "number", default: 30 },
26
+ { name: "note", kind: "string", required: false },
27
+ ];
28
+
29
+ const appRequest = (inputs: Record<string, unknown> = {}): TemplateRequest =>
30
+ makeTemplateRequest({
31
+ kind: "app",
32
+ template: "acme/orders/aws",
33
+ version: "1.2.0",
34
+ app: "orders",
35
+ inputs: {
36
+ // Out of the template's order on purpose: the file follows the template.
37
+ tags: { team: "payments", "cost-center": "42" },
38
+ readers: ["svc-a", "svc-b"],
39
+ public: true,
40
+ replicas: 3,
41
+ bucket_name: "orders-data",
42
+ ...inputs,
43
+ },
44
+ requestedBy: "alice",
45
+ via: "form",
46
+ });
47
+
48
+ const OUTPUTS = [{ name: "endpoint" }, { name: "bucket_arn", description: "The bucket's ARN." }];
49
+
50
+ const renderApp = (request = appRequest()) => renderAppRoot({ request, source: SOURCE, inputs: APP_INPUTS, outputs: OUTPUTS });
51
+
52
+ const addonRequest = (inputs: Record<string, unknown>): TemplateRequest =>
53
+ makeTemplateRequest({ kind: "addon", template: "acme/cache/aws", version: "2.0.1", app: "orders", addon: "cache", inputs, requestedBy: "bob", via: "cli" });
54
+
55
+ const ADDON_INPUTS: TemplateInput[] = [
56
+ { name: "endpoint", kind: "string" },
57
+ { name: "size", kind: "number", default: 1 },
58
+ ];
59
+
60
+ const renderAddon = (inputs: Record<string, unknown>, appOutputs: string[] = ["endpoint", "bucket_arn"]) =>
61
+ renderAddonCall({ request: addonRequest(inputs), source: CACHE, inputs: ADDON_INPUTS, appOutputs });
62
+
63
+ const refusal = (fn: () => unknown): TemplateRenderError => {
64
+ try {
65
+ fn();
66
+ } catch (e) {
67
+ if (e instanceof TemplateRenderError) return e;
68
+ throw e;
69
+ }
70
+ throw new Error("expected a TemplateRenderError");
71
+ };
72
+
73
+ describe("renderAppRoot", () => {
74
+ test("a request with a string, a number, a bool, a list of strings and a map matches the golden file", () => {
75
+ const files = renderApp();
76
+ expect(files.map((f) => f.path)).toEqual(["main.tf"]);
77
+ expect(files[0]!.text).toBe(readFileSync(GOLDEN, "utf-8"));
78
+ });
79
+
80
+ test("the golden file parses with zero errors, into one module call and the re-exported outputs", async () => {
81
+ const tree = (await parser.parse("main.tf", readFileSync(GOLDEN, "utf-8"))) as { module?: Record<string, unknown>; output?: Record<string, unknown> };
82
+ expect(Object.keys(tree.module ?? {})).toEqual(["app"]);
83
+ expect(Object.keys(tree.output ?? {}).sort()).toEqual(["app_bucket_arn", "app_endpoint"]);
84
+ const graph = await parseTerraformDir(FIXTURES);
85
+ expect(graph).toBeDefined();
86
+ });
87
+
88
+ test("the version is read back as 1.2.0, and the pin editor moves it", async () => {
89
+ const [file] = renderApp();
90
+ const call = scanBlocks(file!.text, "main.tf").find((b) => b.type === "module" && b.labels[0] === "app")!;
91
+ const attr = (name: string) => call.attributes.find((a) => a.name === name)!.literal!.raw;
92
+ expect(readModulePin(attr("source"), attr("version"))).toEqual({ module: SOURCE, at: "version", pin: "1.2.0" });
93
+
94
+ const moved = await editPins(file!.text, "main.tf", { module: SOURCE, from: "1.2.0", to: "1.3.0" }, parser);
95
+ expect(moved.calls.map((c) => c.outcome)).toEqual(["moved"]);
96
+ expect(moved.content).toBe(file!.text.replace('version = "1.2.0"', 'version = "1.3.0"'));
97
+ });
98
+
99
+ test("the same request renders to the same bytes, whatever order its inputs and map keys came in", () => {
100
+ const a = renderApp();
101
+ const b = renderApp();
102
+ expect(b).toEqual(a);
103
+ const reordered = appRequest({ tags: { "cost-center": "42", team: "payments" } });
104
+ expect(renderApp(reordered)).toEqual(a);
105
+ });
106
+
107
+ test("an input the request leaves out takes the template's default, or is left to the module", () => {
108
+ const text = renderApp()[0]!.text;
109
+ expect(text).toContain("retention_days = 30\n");
110
+ expect(text).not.toContain("note");
111
+ expect(renderApp(appRequest({ note: "hi ${x}" }))[0]!.text).toContain('note = "hi $${x}"\n');
112
+ });
113
+
114
+ test("a string with quotes, a backslash, a template and control characters reads back as itself", async () => {
115
+ const note = 'say "hi" \\ ${var.x} %{if} \n\r\t\u0001';
116
+ const text = renderApp(appRequest({ note }))[0]!.text;
117
+ expect(text).toContain('"say \\"hi\\" \\\\ $${var.x} %%{if} \\n\\r\\t\\u0001"');
118
+ const tree = (await parser.parse("main.tf", text)) as { module: { app: Array<{ note: string }> } };
119
+ // hcl2json hands a literal `${` back escaped, as Terraform's JSON syntax would write it.
120
+ expect(tree.module.app[0]!.note.replace(/\$\$\{/g, "${").replace(/%%\{/g, "%{")).toBe(note);
121
+ });
122
+
123
+ test("a sensitive input becomes a sensitive root variable of its Terraform type", async () => {
124
+ const inputs: TemplateInput[] = [{ name: "readers", kind: "list", of: "string" }, { name: "db_password", kind: "string", sensitive: true }];
125
+ const request = makeTemplateRequest({ kind: "app", template: "acme/orders/aws", version: "1.2.0", app: "orders", inputs: { readers: [] }, requestedBy: "a", via: "cli" });
126
+ const text = renderAppRoot({ request, source: SOURCE, inputs })[0]!.text;
127
+ expect(text).toContain('variable "app_db_password" {\n type = string\n sensitive = true\n}\n');
128
+ expect(text).toContain(" readers = []\n db_password = var.app_db_password\n");
129
+ await parser.parse("main.tf", text);
130
+ expect(refusal(() => renderAppRoot({ request: { ...request, inputs: { readers: [], db_password: "x" } }, source: SOURCE, inputs })).code).toBe("sensitive-input");
131
+ });
132
+
133
+ test("objects and lists of maps open one line per entry, and still parse", async () => {
134
+ const inputs: TemplateInput[] = [{ name: "settings", kind: "object" }];
135
+ const request = makeTemplateRequest({
136
+ kind: "app",
137
+ template: "acme/orders/aws",
138
+ version: "1.2.0",
139
+ app: "orders",
140
+ inputs: { settings: { rules: [{ port: 80 }], empty: {}, "a b": null } },
141
+ requestedBy: "a",
142
+ via: "cli",
143
+ });
144
+ const text = renderAppRoot({ request, source: SOURCE, inputs })[0]!.text;
145
+ expect(text).toContain(' settings = {\n "a b" = null\n empty = {}\n rules = [\n {\n port = 80\n },\n ]\n }\n');
146
+ const tree = (await parser.parse("main.tf", text)) as { module: { app: Array<{ settings: unknown }> } };
147
+ expect(tree.module.app[0]!.settings).toEqual({ "a b": null, empty: {}, rules: [{ port: 80 }] });
148
+ });
149
+
150
+ test("refusals: wrong kind, a version that is not exact, unknown, missing and mistyped inputs", () => {
151
+ expect(refusal(() => renderAppRoot({ request: addonRequest({}), source: SOURCE, inputs: APP_INPUTS })).code).toBe("wrong-kind");
152
+ expect(refusal(() => renderApp({ ...appRequest(), version: "~> 1.2" })).code).toBe("not-a-version");
153
+ expect(refusal(() => renderApp({ ...appRequest(), version: `sha256:${"a".repeat(64)}` })).code).toBe("not-a-version");
154
+ const unknown = refusal(() => renderApp(appRequest({ colour: "red" })));
155
+ expect([unknown.code, unknown.subject]).toEqual(["unknown-input", "colour"]);
156
+ const { bucket_name: _b, ...rest } = appRequest().inputs;
157
+ const missing = refusal(() => renderApp({ ...appRequest(), inputs: rest }));
158
+ expect([missing.code, missing.subject]).toEqual(["missing-input", "bucket_name"]);
159
+ const typed = refusal(() => renderApp(appRequest({ replicas: "3" })));
160
+ expect([typed.code, typed.subject]).toEqual(["input-type", "replicas"]);
161
+ // An app root has no app module to reference yet.
162
+ expect(refusal(() => renderApp(appRequest({ replicas: "app.endpoint" }))).code).toBe("input-type");
163
+ });
164
+ });
165
+
166
+ describe("renderAddonCall", () => {
167
+ test("an input app.endpoint becomes module.app.endpoint in addon-<name>.tf", async () => {
168
+ const file = renderAddon({ endpoint: "app.endpoint" });
169
+ expect(file.path).toBe("addon-cache.tf");
170
+ expect(file.text).toBe(
171
+ [
172
+ "# The add-on cache of the app orders, from the template acme/cache/aws. Written by chant from a template request; a rollout moves the version.",
173
+ "",
174
+ 'module "cache" {',
175
+ ` source = "${CACHE}"`,
176
+ ' version = "2.0.1"',
177
+ "",
178
+ " endpoint = module.app.endpoint",
179
+ " size = 1",
180
+ "}",
181
+ "",
182
+ ].join("\n"),
183
+ );
184
+ const tree = (await parser.parse("addon-cache.tf", file.text)) as { module: { cache: Array<{ endpoint: string }> } };
185
+ expect(tree.module.cache[0]!.endpoint).toBe("${module.app.endpoint}");
186
+ });
187
+
188
+ test("app.missing is refused with unknown-output naming missing", () => {
189
+ const e = refusal(() => renderAddon({ endpoint: "app.missing" }));
190
+ expect(e.code).toBe("unknown-output");
191
+ expect(e.subject).toBe("missing");
192
+ expect(e.message).toMatch(/^unknown-output: .*\bmissing\b/);
193
+ });
194
+
195
+ test("the app's outputs may be given as the catalog lists them", () => {
196
+ const file = renderAddonCall({ request: addonRequest({ endpoint: "app.endpoint", size: 2 }), source: CACHE, inputs: ADDON_INPUTS, appOutputs: [{ name: "endpoint", description: null, sensitive: false }] });
197
+ expect(file.text).toContain(" endpoint = module.app.endpoint\n size = 2\n");
198
+ });
199
+
200
+ test("a string that only looks like a reference stays a string", () => {
201
+ expect(renderAddon({ endpoint: "app.endpoint.extra" }).text).toContain(' endpoint = "app.endpoint.extra"\n');
202
+ });
203
+
204
+ test("the same add-on request renders to the same bytes", () => {
205
+ expect(renderAddon({ endpoint: "app.endpoint", size: 3 })).toEqual(renderAddon({ size: 3, endpoint: "app.endpoint" }));
206
+ });
207
+
208
+ test("refusals: an add-on named app, no add-on name, and a request for an app", () => {
209
+ expect(refusal(() => renderAddonCall({ request: { ...addonRequest({ endpoint: "x" }), addon: "app" }, source: CACHE, inputs: ADDON_INPUTS, appOutputs: [] })).code).toBe("bad-name");
210
+ const { addon: _a, ...noName } = addonRequest({ endpoint: "x" });
211
+ expect(refusal(() => renderAddonCall({ request: noName as TemplateRequest, source: CACHE, inputs: ADDON_INPUTS, appOutputs: [] })).code).toBe("bad-name");
212
+ expect(refusal(() => renderAddonCall({ request: appRequest(), source: CACHE, inputs: APP_INPUTS, appOutputs: [] })).code).toBe("wrong-kind");
213
+ });
214
+ });
215
+
216
+ describe("terraformInputType", () => {
217
+ test("each engine-neutral kind maps to a Terraform type", () => {
218
+ const rows: Array<[Pick<TemplateInput, "kind" | "of">, string]> = [
219
+ [{ kind: "string" }, "string"],
220
+ [{ kind: "number" }, "number"],
221
+ [{ kind: "bool" }, "bool"],
222
+ [{ kind: "list", of: "string" }, "list(string)"],
223
+ [{ kind: "list" }, "list(any)"],
224
+ [{ kind: "map", of: "number" }, "map(number)"],
225
+ [{ kind: "map" }, "map(any)"],
226
+ [{ kind: "object" }, "any"],
227
+ ];
228
+ for (const [input, type] of rows) expect(terraformInputType(input)).toBe(type);
229
+ });
230
+ });