@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.
- package/dist/action-ops.d.ts +148 -0
- package/dist/action-ops.d.ts.map +1 -0
- package/dist/action-ops.js +270 -0
- package/dist/components/capability-plugin.d.ts +1 -0
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/capability-plugin.js +10 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/pin/scan.d.ts +6 -1
- package/dist/pin/scan.d.ts.map +1 -1
- package/dist/pin/scan.js +12 -2
- package/dist/state-ops.d.ts +5 -0
- package/dist/state-ops.d.ts.map +1 -1
- package/dist/state-ops.js +1 -1
- package/dist/template/index.d.ts +8 -0
- package/dist/template/index.d.ts.map +1 -0
- package/dist/template/index.js +6 -0
- package/dist/template/render.d.ts +97 -0
- package/dist/template/render.d.ts.map +1 -0
- package/dist/template/render.js +276 -0
- package/package.json +7 -2
- package/src/action-ops.test.ts +293 -0
- package/src/action-ops.ts +370 -0
- package/src/components/capability-plugin.test.ts +1 -1
- package/src/components/capability-plugin.ts +10 -2
- package/src/lock-rows.test.ts +8 -0
- package/src/pin/scan.ts +13 -2
- package/src/state-ops.ts +6 -1
- package/src/template/__fixtures__/app-root.golden.tf +25 -0
- package/src/template/index.ts +17 -0
- package/src/template/render.test.ts +230 -0
- package/src/template/render.ts +352 -0
|
@@ -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
|
|
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;
|
package/src/lock-rows.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
});
|