@intentius/chant-lexicon-terraform 0.124.57 → 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 () => {
@@ -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 {
@@ -664,6 +665,8 @@ export const TERRAFORM_VERB_FAMILIES = {
664
665
  // #4618: the binary runs outside waves, each a fixed operation; the two that change state on their own run under a gated request.
665
666
  state: ["terraform-state-unlock", "terraform-state-push"],
666
667
  copy: ["terraform-copy"],
668
+ // #4653: one action block invoked on its own, under a gated request.
669
+ action: ["terraform-action"],
667
670
  } as const;
668
671
 
669
672
  export const terraformCapabilityPlugin: CapabilityPlugin = {
@@ -672,7 +675,7 @@ export const terraformCapabilityPlugin: CapabilityPlugin = {
672
675
  return ownPackageVersion(import.meta.url);
673
676
  },
674
677
  capabilities(): Array<Capability<never, unknown>> {
675
- 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>];
676
679
  },
677
680
  families(): Record<string, readonly string[]> {
678
681
  return TERRAFORM_VERB_FAMILIES;
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
+ });