@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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A template request as Terraform files (#4652, epic #4642): a new root for
3
+ * an app, or one more module call in an app's root for an add-on.
4
+ *
5
+ * {@link renderAppRoot} writes `main.tf` with one `module "app"` block. Its
6
+ * `source` is the template's registry address and its `version` is the
7
+ * requested version, pinned exactly (`version = "1.2.0"`), which is the form
8
+ * the pin editor (`../pin/edit.ts`) reads and moves, so a rollout finds the
9
+ * app. The module's outputs are re-exported as `app_<output>`. Day-2 actions
10
+ * the template declares are addressed through the call, as
11
+ * `module.app.action.<type>.<name>`, so nothing is written for them here.
12
+ *
13
+ * {@link renderAddonCall} writes `addon-<name>.tf` for the app's own root
14
+ * (ruling C of #4642) with one `module "<name>"` block. An input whose value
15
+ * is the string `app.<output>` becomes the reference `module.app.<output>`.
16
+ *
17
+ * Each input is written as an HCL literal of its engine-neutral kind
18
+ * (template.yml/v1, `@intentius/chant/template-request`), in the order the
19
+ * template lists them, with two-space indent and the `=` signs lined up as
20
+ * `terraform fmt` lines them up. A sensitive input never travels in a
21
+ * request: the file declares a sensitive root variable of the kind's
22
+ * Terraform type ({@link terraformInputType}) and passes `var.<name>`, so
23
+ * the value comes from the root's own secret store.
24
+ *
25
+ * The backend, provider and version files are not written here; the caller
26
+ * writes them in the same pull request. Nothing here reads the disk, so the
27
+ * output depends on the arguments alone: the same request gives the same
28
+ * bytes.
29
+ */
30
+ import { type TemplateInput, type TemplateRequest } from "@intentius/chant/template-request";
31
+ /** The module block an app root calls its template through. Actions are addressed as `module.app.action...`. */
32
+ export declare const APP_MODULE_NAME = "app";
33
+ /** The prefix of the root outputs an app root re-exports its module's outputs under. */
34
+ export declare const APP_OUTPUT_PREFIX = "app_";
35
+ /** The file an app root's module call is written to. */
36
+ export declare const APP_ROOT_FILE = "main.tf";
37
+ /** The file in the app's root an add-on's module call is written to. */
38
+ export declare function addonFile(name: string): string;
39
+ /** Why a request cannot be rendered. */
40
+ export type TemplateRenderErrorCode = "wrong-kind" | "bad-name" | "not-a-version" | "unknown-input" | "missing-input" | "sensitive-input" | "input-type" | "unknown-output";
41
+ /** Thrown when a request cannot become files. `subject` names the input, output or field at fault. */
42
+ export declare class TemplateRenderError extends Error {
43
+ readonly code: TemplateRenderErrorCode;
44
+ readonly subject: string;
45
+ constructor(code: TemplateRenderErrorCode, subject: string, message: string);
46
+ }
47
+ /** One output of the template's module, as the catalog's `terraform.outputs` lists it. */
48
+ export interface TemplateOutput {
49
+ name: string;
50
+ description?: string | null;
51
+ sensitive?: boolean;
52
+ }
53
+ /** One file to write, its path relative to the app's root directory. */
54
+ export interface RenderedFile {
55
+ path: string;
56
+ text: string;
57
+ }
58
+ export interface RenderAppRootOptions {
59
+ /** A request of kind `app`. */
60
+ request: TemplateRequest;
61
+ /** The template's registry address, such as `registry.example.com/acme/orders/aws`. */
62
+ source: string;
63
+ /** The template's inputs, from its template.yml, in the order it lists them. */
64
+ inputs: readonly TemplateInput[];
65
+ /** The module's outputs, each re-exported as `app_<name>`. */
66
+ outputs?: readonly TemplateOutput[];
67
+ }
68
+ export interface RenderAddonCallOptions {
69
+ /** A request of kind `addon`, naming the add-on in `addon`. */
70
+ request: TemplateRequest;
71
+ /** The add-on template's registry address. */
72
+ source: string;
73
+ /** The add-on template's inputs, from its template.yml, in the order it lists them. */
74
+ inputs: readonly TemplateInput[];
75
+ /** The outputs of the app's own module, which `app.<output>` values may name. */
76
+ appOutputs: readonly (string | TemplateOutput)[];
77
+ }
78
+ /**
79
+ * The Terraform type an input's kind maps to: `string`, `number`, `bool`,
80
+ * `list(<of>)`, `map(<of>)`, and `any` for an object or for a list or map
81
+ * whose element kind is not given (`list(any)`, `map(any)`).
82
+ */
83
+ export declare function terraformInputType(input: Pick<TemplateInput, "kind" | "of">): string;
84
+ /**
85
+ * The root of a new app: `main.tf` with one `module "app"` block calling
86
+ * the template at the requested version, and an `app_<output>` root output
87
+ * for each of the module's outputs. Throws {@link TemplateRenderError}.
88
+ */
89
+ export declare function renderAppRoot(options: RenderAppRootOptions): RenderedFile[];
90
+ /**
91
+ * An add-on's module call for the app's root: `addon-<name>.tf` with one
92
+ * `module "<name>"` block. A value `app.<output>` becomes
93
+ * `module.app.<output>`, refused with `unknown-output` when the app module
94
+ * has no such output. Throws {@link TemplateRenderError}.
95
+ */
96
+ export declare function renderAddonCall(options: RenderAddonCallOptions): RenderedFile;
97
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/template/render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAiD,KAAK,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAI5I,gHAAgH;AAChH,eAAO,MAAM,eAAe,QAAQ,CAAC;AAErC,wFAAwF;AACxF,eAAO,MAAM,iBAAiB,SAAS,CAAC;AAExC,wDAAwD;AACxD,eAAO,MAAM,aAAa,YAAY,CAAC;AAEvC,wEAAwE;AACxE,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,wCAAwC;AACxC,MAAM,MAAM,uBAAuB,GAC/B,YAAY,GACZ,UAAU,GACV,eAAe,GACf,eAAe,GACf,eAAe,GACf,iBAAiB,GACjB,YAAY,GACZ,gBAAgB,CAAC;AAErB,sGAAsG;AACtG,qBAAa,mBAAoB,SAAQ,KAAK;aAE1B,IAAI,EAAE,uBAAuB;aAC7B,OAAO,EAAE,MAAM;IAFjC,YACkB,IAAI,EAAE,uBAAuB,EAC7B,OAAO,EAAE,MAAM,EAC/B,OAAO,EAAE,MAAM,EAIhB;CACF;AAED,0FAA0F;AAC1F,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,wEAAwE;AACxE,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,oBAAoB;IACnC,+BAA+B;IAC/B,OAAO,EAAE,eAAe,CAAC;IACzB,uFAAuF;IACvF,MAAM,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,MAAM,EAAE,SAAS,aAAa,EAAE,CAAC;IACjC,8DAA8D;IAC9D,OAAO,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;CACrC;AAED,MAAM,WAAW,sBAAsB;IACrC,+DAA+D;IAC/D,OAAO,EAAE,eAAe,CAAC;IACzB,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf,uFAAuF;IACvF,MAAM,EAAE,SAAS,aAAa,EAAE,CAAC;IACjC,iFAAiF;IACjF,UAAU,EAAE,SAAS,CAAC,MAAM,GAAG,cAAc,CAAC,EAAE,CAAC;CAClD;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,IAAI,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC,GAAG,MAAM,CAYpF;AA2LD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,YAAY,EAAE,CAgB3E;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,YAAY,CAY7E"}
@@ -0,0 +1,276 @@
1
+ /**
2
+ * A template request as Terraform files (#4652, epic #4642): a new root for
3
+ * an app, or one more module call in an app's root for an add-on.
4
+ *
5
+ * {@link renderAppRoot} writes `main.tf` with one `module "app"` block. Its
6
+ * `source` is the template's registry address and its `version` is the
7
+ * requested version, pinned exactly (`version = "1.2.0"`), which is the form
8
+ * the pin editor (`../pin/edit.ts`) reads and moves, so a rollout finds the
9
+ * app. The module's outputs are re-exported as `app_<output>`. Day-2 actions
10
+ * the template declares are addressed through the call, as
11
+ * `module.app.action.<type>.<name>`, so nothing is written for them here.
12
+ *
13
+ * {@link renderAddonCall} writes `addon-<name>.tf` for the app's own root
14
+ * (ruling C of #4642) with one `module "<name>"` block. An input whose value
15
+ * is the string `app.<output>` becomes the reference `module.app.<output>`.
16
+ *
17
+ * Each input is written as an HCL literal of its engine-neutral kind
18
+ * (template.yml/v1, `@intentius/chant/template-request`), in the order the
19
+ * template lists them, with two-space indent and the `=` signs lined up as
20
+ * `terraform fmt` lines them up. A sensitive input never travels in a
21
+ * request: the file declares a sensitive root variable of the kind's
22
+ * Terraform type ({@link terraformInputType}) and passes `var.<name>`, so
23
+ * the value comes from the root's own secret store.
24
+ *
25
+ * The backend, provider and version files are not written here; the caller
26
+ * writes them in the same pull request. Nothing here reads the disk, so the
27
+ * output depends on the arguments alone: the same request gives the same
28
+ * bytes.
29
+ */
30
+ import { appOutputRef, inputKindProblem, inputRequired } from "@intentius/chant/template-request";
31
+ import { escapeHcl } from "../pin/scan.js";
32
+ import { exactVersion } from "../pin/source.js";
33
+ /** The module block an app root calls its template through. Actions are addressed as `module.app.action...`. */
34
+ export const APP_MODULE_NAME = "app";
35
+ /** The prefix of the root outputs an app root re-exports its module's outputs under. */
36
+ export const APP_OUTPUT_PREFIX = "app_";
37
+ /** The file an app root's module call is written to. */
38
+ export const APP_ROOT_FILE = "main.tf";
39
+ /** The file in the app's root an add-on's module call is written to. */
40
+ export function addonFile(name) {
41
+ return `addon-${name}.tf`;
42
+ }
43
+ /** Thrown when a request cannot become files. `subject` names the input, output or field at fault. */
44
+ export class TemplateRenderError extends Error {
45
+ constructor(code, subject, message) {
46
+ super(`${code}: ${message}`);
47
+ this.code = code;
48
+ this.subject = subject;
49
+ this.name = "TemplateRenderError";
50
+ }
51
+ }
52
+ /**
53
+ * The Terraform type an input's kind maps to: `string`, `number`, `bool`,
54
+ * `list(<of>)`, `map(<of>)`, and `any` for an object or for a list or map
55
+ * whose element kind is not given (`list(any)`, `map(any)`).
56
+ */
57
+ export function terraformInputType(input) {
58
+ switch (input.kind) {
59
+ case "string":
60
+ case "number":
61
+ case "bool":
62
+ return input.kind;
63
+ case "list":
64
+ case "map":
65
+ return `${input.kind}(${input.of ?? "any"})`;
66
+ case "object":
67
+ return "any";
68
+ }
69
+ }
70
+ // ── HCL text ──────────────────────────────────────────────────────────────
71
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_-]*$/;
72
+ /** An expression that is already HCL: a reference or a type, written as it is. */
73
+ class RawExpr {
74
+ constructor(raw) {
75
+ this.raw = raw;
76
+ }
77
+ }
78
+ const expr = (text) => new RawExpr(text);
79
+ const isScalar = (v) => v === null || typeof v !== "object" || v instanceof RawExpr;
80
+ /** Code-unit order, so the bytes never depend on a locale. */
81
+ const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
82
+ function scalar(value) {
83
+ if (value instanceof RawExpr)
84
+ return value.raw;
85
+ if (value === null)
86
+ return "null";
87
+ if (typeof value === "string")
88
+ return `"${escapeHcl(value)}"`;
89
+ if (typeof value === "number" || typeof value === "boolean")
90
+ return String(value);
91
+ throw new Error(`not an HCL scalar: ${typeof value}`);
92
+ }
93
+ /** An object key: bare when it is an identifier, quoted otherwise. */
94
+ const key = (k) => (IDENT.test(k) ? k : `"${escapeHcl(k)}"`);
95
+ /**
96
+ * A JSON value as an HCL expression at `indent`. A list of scalars sits on
97
+ * one line; a map, an object and a list holding either open one line per
98
+ * entry. Map keys are sorted, so the order a request's JSON happened to
99
+ * carry them in never changes the bytes.
100
+ */
101
+ function literal(value, indent) {
102
+ if (isScalar(value))
103
+ return scalar(value);
104
+ const inner = `${indent} `;
105
+ if (Array.isArray(value)) {
106
+ if (value.length === 0)
107
+ return "[]";
108
+ if (value.every(isScalar))
109
+ return `[${value.map(scalar).join(", ")}]`;
110
+ return `[\n${value.map((v) => `${inner}${literal(v, inner)},\n`).join("")}${indent}]`;
111
+ }
112
+ const obj = value;
113
+ const keys = Object.keys(obj).sort(byCodeUnit);
114
+ if (keys.length === 0)
115
+ return "{}";
116
+ return `{\n${attributes(keys.map((k) => [key(k), obj[k]]), inner)}${indent}}`;
117
+ }
118
+ /**
119
+ * Attribute lines at `indent`, with the `=` of each run of one-line
120
+ * attributes lined up, as `terraform fmt` does. An attribute whose value
121
+ * spans lines ends the run before it and is lined up with nothing.
122
+ */
123
+ function attributes(entries, indent) {
124
+ const rendered = entries.map(([name, value]) => ({ name, text: literal(value, indent) }));
125
+ let out = "";
126
+ let run = [];
127
+ const flush = () => {
128
+ const width = Math.max(...run.map((r) => r.name.length));
129
+ for (const r of run)
130
+ out += `${indent}${r.name.padEnd(width)} = ${r.text}\n`;
131
+ run = [];
132
+ };
133
+ for (const r of rendered) {
134
+ if (r.text.includes("\n")) {
135
+ if (run.length > 0)
136
+ flush();
137
+ run.push(r);
138
+ flush();
139
+ }
140
+ else
141
+ run.push(r);
142
+ }
143
+ if (run.length > 0)
144
+ flush();
145
+ return out;
146
+ }
147
+ function block(header, groups) {
148
+ const body = groups
149
+ .filter((g) => g.length > 0)
150
+ .map((g) => attributes(g, " "))
151
+ .join("\n");
152
+ return `${header} {\n${body}}\n`;
153
+ }
154
+ function checkVersion(request) {
155
+ const version = exactVersion(request.version);
156
+ if (!version) {
157
+ throw new TemplateRenderError("not-a-version", "version", `the request names ${JSON.stringify(request.version)}, and a module call pins an exact version such as "1.2.0"`);
158
+ }
159
+ return version;
160
+ }
161
+ function checkSource(source) {
162
+ if (source.length === 0 || /[\s"]/.test(source)) {
163
+ throw new TemplateRenderError("bad-name", "source", `the source ${JSON.stringify(source)} is not a registry address`);
164
+ }
165
+ }
166
+ /** The module arguments for each input the template lists, and a root variable for each sensitive one. */
167
+ function renderInputs(request, inputs, call) {
168
+ const given = request.inputs ?? {};
169
+ const known = new Set(inputs.map((i) => i.name));
170
+ const extra = Object.keys(given)
171
+ .filter((k) => !known.has(k))
172
+ .sort(byCodeUnit);
173
+ if (extra.length > 0) {
174
+ throw new TemplateRenderError("unknown-input", extra[0], `the template has no input ${extra.map((e) => JSON.stringify(e)).join(", ")}`);
175
+ }
176
+ const variables = [];
177
+ const args = [];
178
+ for (const input of inputs) {
179
+ const has = Object.prototype.hasOwnProperty.call(given, input.name);
180
+ if (input.sensitive) {
181
+ if (has)
182
+ throw new TemplateRenderError("sensitive-input", input.name, `${input.name} is sensitive, and a request never carries a secret`);
183
+ const variable = `${call.varPrefix}${input.name}`;
184
+ const fields = [
185
+ ["type", expr(terraformInputType(input))],
186
+ ["sensitive", true],
187
+ ];
188
+ if (input.description)
189
+ fields.push(["description", input.description]);
190
+ if (!inputRequired(input))
191
+ fields.push(["default", "default" in input ? input.default : null]);
192
+ variables.push(block(`variable "${variable}"`, [fields]));
193
+ args.push({ name: input.name, value: expr(`var.${variable}`) });
194
+ continue;
195
+ }
196
+ const value = has ? given[input.name] : input.default;
197
+ if (value === undefined) {
198
+ if (inputRequired(input))
199
+ throw new TemplateRenderError("missing-input", input.name, `the request gives no ${input.name}, and the input is required`);
200
+ continue;
201
+ }
202
+ const output = call.appOutputs ? appOutputRef(value) : undefined;
203
+ if (output !== undefined) {
204
+ if (!call.appOutputs.has(output)) {
205
+ throw new TemplateRenderError("unknown-output", output, `${input.name} names app.${output}, and the app module has no output ${output}`);
206
+ }
207
+ args.push({ name: input.name, value: expr(`module.${APP_MODULE_NAME}.${output}`) });
208
+ continue;
209
+ }
210
+ const problem = inputKindProblem(input, value);
211
+ if (problem)
212
+ throw new TemplateRenderError("input-type", input.name, `${input.name} ${problem}`);
213
+ args.push({ name: input.name, value });
214
+ }
215
+ return { variables, args };
216
+ }
217
+ function moduleBlock(name, source, version, args) {
218
+ return block(`module "${name}"`, [
219
+ [
220
+ ["source", source],
221
+ ["version", version],
222
+ ],
223
+ args.map((a) => [a.name, a.value]),
224
+ ]);
225
+ }
226
+ function header(request, what) {
227
+ const template = request.template.replace(/[\r\n]/g, " ");
228
+ return `# ${what}, from the template ${template}. Written by chant from a template request; a rollout moves the version.\n`;
229
+ }
230
+ /**
231
+ * The root of a new app: `main.tf` with one `module "app"` block calling
232
+ * the template at the requested version, and an `app_<output>` root output
233
+ * for each of the module's outputs. Throws {@link TemplateRenderError}.
234
+ */
235
+ export function renderAppRoot(options) {
236
+ const { request, source, inputs, outputs = [] } = options;
237
+ if (request.kind !== "app")
238
+ throw new TemplateRenderError("wrong-kind", "kind", `the request is for an ${request.kind}, and an app root renders an app`);
239
+ checkSource(source);
240
+ const version = checkVersion(request);
241
+ const { variables, args } = renderInputs(request, inputs, { name: APP_MODULE_NAME, varPrefix: `${APP_MODULE_NAME}_` });
242
+ const parts = [header(request, `The app ${request.app}`), ...variables, moduleBlock(APP_MODULE_NAME, source, version, args)];
243
+ for (const o of outputs) {
244
+ if (!IDENT.test(o.name))
245
+ throw new TemplateRenderError("bad-name", o.name, `the module output ${JSON.stringify(o.name)} is not a Terraform name`);
246
+ const fields = [["value", expr(`module.${APP_MODULE_NAME}.${o.name}`)]];
247
+ if (o.description)
248
+ fields.push(["description", o.description]);
249
+ if (o.sensitive)
250
+ fields.push(["sensitive", true]);
251
+ parts.push(block(`output "${APP_OUTPUT_PREFIX}${o.name}"`, [fields]));
252
+ }
253
+ return [{ path: APP_ROOT_FILE, text: parts.join("\n") }];
254
+ }
255
+ /**
256
+ * An add-on's module call for the app's root: `addon-<name>.tf` with one
257
+ * `module "<name>"` block. A value `app.<output>` becomes
258
+ * `module.app.<output>`, refused with `unknown-output` when the app module
259
+ * has no such output. Throws {@link TemplateRenderError}.
260
+ */
261
+ export function renderAddonCall(options) {
262
+ const { request, source, inputs, appOutputs } = options;
263
+ if (request.kind !== "addon")
264
+ throw new TemplateRenderError("wrong-kind", "kind", `the request is for an ${request.kind}, and an add-on call renders an addon`);
265
+ const name = request.addon;
266
+ if (!name || !IDENT.test(name))
267
+ throw new TemplateRenderError("bad-name", "addon", `an add-on request names its add-on, as a Terraform name; it gave ${JSON.stringify(name ?? null)}`);
268
+ if (name === APP_MODULE_NAME)
269
+ throw new TemplateRenderError("bad-name", "addon", `an add-on cannot be named ${APP_MODULE_NAME}: the app's own module call has that name`);
270
+ checkSource(source);
271
+ const version = checkVersion(request);
272
+ const outputs = new Set(appOutputs.map((o) => (typeof o === "string" ? o : o.name)));
273
+ const { variables, args } = renderInputs(request, inputs, { name, varPrefix: `${name}_`, appOutputs: outputs });
274
+ const parts = [header(request, `The add-on ${name} of the app ${request.app}`), ...variables, moduleBlock(name, source, version, args)];
275
+ return { path: addonFile(name), text: parts.join("\n") };
276
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-terraform",
3
- "version": "0.124.56",
3
+ "version": "0.124.58",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -34,6 +34,11 @@
34
34
  "types": "./dist/pin/index.d.ts",
35
35
  "default": "./dist/pin/index.js"
36
36
  },
37
+ "./template": {
38
+ "development": "./src/template/index.ts",
39
+ "types": "./dist/template/index.d.ts",
40
+ "default": "./dist/template/index.js"
41
+ },
37
42
  "./module-catalog": {
38
43
  "development": "./src/module-catalog/index.ts",
39
44
  "types": "./dist/module-catalog/index.d.ts",
@@ -88,7 +93,7 @@
88
93
  "access": "public"
89
94
  },
90
95
  "peerDependencies": {
91
- "@intentius/chant": "^0.124.56",
96
+ "@intentius/chant": "^0.124.58",
92
97
  "zod": "^4.3.6"
93
98
  },
94
99
  "description": "terraform lexicon for chant, existing Terraform root modules as declarable entities",