@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,293 @@
1
+ /**
2
+ * #4653: an action block invoked on its own under a gated request, on an
3
+ * injected exec and an injected ledger, so no binary runs and no ledger is
4
+ * written.
5
+ */
6
+ import { describe, expect, test } from "vitest";
7
+ import { gatedRequestDigest, type GatedRequest } from "@intentius/chant/lifecycle/gated-request";
8
+ import type { BinaryExec } from "./state/exec";
9
+ import type { GatedRequestLedger } from "./state-ops";
10
+ import { ACTION_ADDRESS, ACTION_OP, actionInvocationsDigest, actionSubject, invokeAction, invokesActions, terraformActionCapability, type InvokeActionInput } from "./action-ops";
11
+ import { TERRAFORM_VERB_FAMILIES, terraformCapabilityPlugin } from "./components/capability-plugin";
12
+
13
+ const ROTATE = "action.aws_lambda_invoke.rotate";
14
+
15
+ const planWith = (address: string, payload: string) => ({
16
+ format_version: "1.2",
17
+ terraform_version: "1.14.9",
18
+ action_invocations: [{ address, type: "aws_lambda_invoke", name: "rotate", provider_name: "registry.terraform.io/hashicorp/aws", config_values: { function_name: "rotate", payload }, invoke_action_trigger: {} }],
19
+ });
20
+
21
+ /** An exec that records each argument list and prints `plan` for `show -json`. */
22
+ function recorder(state: { plan: unknown; fail?: string }): { exec: BinaryExec; calls: string[][]; binaries: string[] } {
23
+ const calls: string[][] = [];
24
+ const binaries: string[] = [];
25
+ const exec: BinaryExec = async (binary, args) => {
26
+ calls.push(args);
27
+ binaries.push(binary);
28
+ if (state.fail && args[0] === state.fail) return { code: 1, stdout: "", out: "Error: the action failed\nexit status 1" };
29
+ if (args[0] === "show") return { code: 0, stdout: JSON.stringify(state.plan), out: "" };
30
+ if (args[0] === "version") return { code: 0, stdout: JSON.stringify({ terraform_version: "1.14.9" }), out: "" };
31
+ if (args[0] === "apply") return { code: 0, stdout: "", out: "Action started: action.aws_lambda_invoke.rotate\nApply complete! Resources: 0 added, 0 changed, 0 destroyed." };
32
+ return { code: 0, stdout: "", out: "" };
33
+ };
34
+ return { exec, calls, binaries };
35
+ }
36
+
37
+ /**
38
+ * A ledger in memory with chant's rules: the digest is chant's over the
39
+ * request, an approval counts for its own digest, the requester's own
40
+ * approval stands and does not count, and a digest applies once.
41
+ */
42
+ function fakeLedger(): GatedRequestLedger & { approve(digest: string, by: string): void; lines: Array<Record<string, unknown>>; pending: number } {
43
+ const approvals = new Map<string, string[]>();
44
+ const asked: GatedRequest[] = [];
45
+ const spent = new Set<string>();
46
+ const lines: Array<Record<string, unknown>> = [];
47
+ const self = {
48
+ lines,
49
+ pending: 0,
50
+ approve(digest: string, by: string) {
51
+ approvals.set(digest, [...(approvals.get(digest) ?? []), by]);
52
+ },
53
+ async requestGate(input: Parameters<GatedRequestLedger["requestGate"]>[0]) {
54
+ const request = input.request;
55
+ const digest = gatedRequestDigest(input.op, request);
56
+ if (spent.has(digest)) return { status: "spent" as const, digest, request };
57
+ if (!asked.some((r) => gatedRequestDigest(input.op, r) === digest)) {
58
+ asked.push(request);
59
+ self.pending++;
60
+ }
61
+ const command = `chant approve ${input.op} ${request.subject} --plan ${digest}`;
62
+ const by = approvals.get(digest) ?? [];
63
+ const counting = by.find((b) => b !== request.by);
64
+ if (counting) return { status: "approved" as const, digest, request, approval: { version: 1, kind: "resolution", op: input.op, gate: request.subject, resolvedBy: counting, timestamp: "2026-10-28T10:00:00Z" } as never };
65
+ if (by.length > 0) return { status: "refused" as const, digest, request, by: by[0]!, reason: `${by[0]} made the request, and a requester's own approval does not count`, command };
66
+ return { status: "waiting" as const, digest, request, command };
67
+ },
68
+ async spendGatedRequest(input: Parameters<GatedRequestLedger["spendGatedRequest"]>[0]) {
69
+ spent.add(input.digest);
70
+ const line = { kind: "applied", op: input.op, gate: input.gate, planDigest: input.digest, approvedBy: input.approval.resolvedBy, timestamp: "2026-10-28T11:00:00Z" };
71
+ lines.push(line);
72
+ return line as never;
73
+ },
74
+ async finishGatedRequest(input: Parameters<GatedRequestLedger["finishGatedRequest"]>[0]) {
75
+ lines.push({ kind: "finished", op: input.op, gate: input.gate, planDigest: input.digest, result: input.result, detail: input.detail });
76
+ return { record: {} as never, pushed: true };
77
+ },
78
+ async readGatedRequests(op: string) {
79
+ return asked.map((request) => {
80
+ const digest = gatedRequestDigest(op, request);
81
+ return { op, gate: request.subject, digest, request, pending: { expiresAt: "2099-01-01T00:00:00Z" } as never, approvals: [], ...(spent.has(digest) ? { applied: {} as never } : {}) };
82
+ });
83
+ },
84
+ };
85
+ return self;
86
+ }
87
+
88
+ const base = (exec: BinaryExec, ledger: GatedRequestLedger, over: Partial<InvokeActionInput> = {}): InvokeActionInput => ({
89
+ root: "apps/orders",
90
+ dir: "/repo/apps/orders",
91
+ binary: "terraform",
92
+ version: "1.14.9",
93
+ address: ROTATE,
94
+ requester: "ana",
95
+ rules: { mode: "ledger" },
96
+ planFile: "/w/action.tfplan",
97
+ lockTimeout: false,
98
+ env: {},
99
+ exec,
100
+ ledger,
101
+ now: "2026-10-28T09:00:00Z",
102
+ ...over,
103
+ });
104
+
105
+ const PLAN_ARGS = ["plan", "-input=false", "-no-color", `-invoke=${ROTATE}`, "-out=/w/action.tfplan"];
106
+ const SHOW_ARGS = ["show", "-json", "/w/action.tfplan"];
107
+ const APPLY_ARGS = ["apply", "-input=false", "-no-color", "/w/action.tfplan"];
108
+
109
+ describe("invokeAction", () => {
110
+ test("a first call plans the invocation, asks, and answers gated with the digest, with no apply", async () => {
111
+ const { exec, calls } = recorder({ plan: planWith(ROTATE, "v1") });
112
+ const ledger = fakeLedger();
113
+ const first = await invokeAction(base(exec, ledger));
114
+ expect(first).toMatchObject({ outcome: "gated", subject: actionSubject("apps/orders", ROTATE), exit: 3 });
115
+ expect(first.outcome === "gated" && first.digest).toMatch(/^sha256:/);
116
+ expect(calls).toEqual([PLAN_ARGS, SHOW_ARGS]);
117
+ expect(ledger.pending).toBe(1);
118
+ expect(ledger.lines).toEqual([]);
119
+ });
120
+
121
+ test("after a second principal approves, it spends, applies once, and records ok; a third call is spent and runs nothing", async () => {
122
+ const state = { plan: planWith(ROTATE, "v1") };
123
+ const ledger = fakeLedger();
124
+ const first = recorder(state);
125
+ const gated = await invokeAction(base(first.exec, ledger));
126
+ if (gated.outcome !== "gated") throw new Error(gated.outcome);
127
+ ledger.approve(gated.digest, "bo");
128
+
129
+ const second = recorder(state);
130
+ const ok = await invokeAction(base(second.exec, ledger, { now: "2026-10-28T12:00:00Z" }));
131
+ expect(ok).toMatchObject({ outcome: "ok", digest: gated.digest, approvedBy: "bo", exit: 0, recorded: true });
132
+ expect(second.calls).toEqual([PLAN_ARGS, SHOW_ARGS, APPLY_ARGS]);
133
+ expect(ledger.lines.map((l) => l.kind)).toEqual(["applied", "finished"]);
134
+ const finished = ledger.lines[1]!;
135
+ expect(finished).toMatchObject({ op: ACTION_OP, gate: "apps/orders:" + ROTATE, result: "applied" });
136
+ expect(finished.detail).toMatchObject({ result: "ok", exit: 0, address: ROTATE });
137
+ expect(String((finished.detail as Record<string, unknown>).tail)).toContain("Apply complete!");
138
+
139
+ const third = recorder(state);
140
+ const spent = await invokeAction(base(third.exec, ledger, { now: "2026-10-28T13:00:00Z" }));
141
+ expect(spent).toMatchObject({ outcome: "spent", digest: gated.digest });
142
+ expect(third.calls.some((a) => a[0] === "apply")).toBe(false);
143
+ expect(ledger.lines).toHaveLength(2);
144
+
145
+ const anew = recorder(state);
146
+ const fourth = await invokeAction(base(anew.exec, ledger, { now: "2026-10-28T14:00:00Z", anew: true }));
147
+ expect(fourth.outcome).toBe("gated");
148
+ expect(fourth.outcome === "gated" && fourth.digest).not.toBe(gated.digest);
149
+ expect(anew.calls.some((a) => a[0] === "apply")).toBe(false);
150
+ });
151
+
152
+ test("an approval by the requester alone leaves the call gated", async () => {
153
+ const { exec, calls } = recorder({ plan: planWith(ROTATE, "v1") });
154
+ const ledger = fakeLedger();
155
+ const first = await invokeAction(base(exec, ledger));
156
+ if (first.outcome !== "gated") throw new Error(first.outcome);
157
+ ledger.approve(first.digest, "ana");
158
+ const again = await invokeAction(base(exec, ledger, { now: "2026-10-28T12:00:00Z" }));
159
+ expect(again).toMatchObject({ outcome: "gated", digest: first.digest, exit: 3 });
160
+ expect(again.outcome === "gated" && again.reason).toMatch(/requester's own approval/);
161
+ expect(calls.some((a) => a[0] === "apply")).toBe(false);
162
+ expect(ledger.lines).toEqual([]);
163
+ });
164
+
165
+ test("an address the plan does not invoke is refused with unknown-action and nothing applies", async () => {
166
+ const { exec, calls } = recorder({ plan: planWith("action.aws_lambda_invoke.flush", "v1") });
167
+ const ledger = fakeLedger();
168
+ const out = await invokeAction(base(exec, ledger));
169
+ expect(out).toMatchObject({ outcome: "refused", code: "unknown-action", exit: 1 });
170
+ expect(out.outcome === "refused" && out.reason).toContain("action.aws_lambda_invoke.flush");
171
+ expect(calls.some((a) => a[0] === "apply")).toBe(false);
172
+ expect(ledger.pending).toBe(0);
173
+ });
174
+
175
+ test("OpenTofu and choudoufu are refused with action-engine, by the engine's name, before any exec call", async () => {
176
+ for (const [binary, version, engine] of [
177
+ ["tofu", "1.13.1", "OpenTofu"],
178
+ ["/usr/local/bin/tofu", undefined, "OpenTofu"],
179
+ ["choudoufu", "0.31.0", "choudoufu"],
180
+ ] as const) {
181
+ const { exec, calls } = recorder({ plan: planWith(ROTATE, "v1") });
182
+ const out = await invokeAction(base(exec, fakeLedger(), { binary, ...(version ? { version } : { version: undefined }) }));
183
+ expect(out).toMatchObject({ outcome: "refused", code: "action-engine" });
184
+ expect(out.outcome === "refused" && out.reason).toContain(engine);
185
+ if (version) expect(out.outcome === "refused" && out.reason).toContain(version);
186
+ expect(calls).toEqual([]);
187
+ }
188
+ });
189
+
190
+ test("a Terraform older than 1.14 is refused before the plan, by the version given or read", async () => {
191
+ const given = recorder({ plan: planWith(ROTATE, "v1") });
192
+ expect(await invokeAction(base(given.exec, fakeLedger(), { version: "1.13.4" }))).toMatchObject({ outcome: "refused", code: "action-engine" });
193
+ expect(given.calls).toEqual([]);
194
+
195
+ const read: string[][] = [];
196
+ const old: BinaryExec = async (_b, args) => {
197
+ read.push(args);
198
+ return { code: 0, stdout: JSON.stringify({ terraform_version: "1.12.2" }), out: "" };
199
+ };
200
+ const out = await invokeAction(base(old, fakeLedger(), { version: undefined }));
201
+ expect(out).toMatchObject({ outcome: "refused", code: "action-engine" });
202
+ expect(out.outcome === "refused" && out.reason).toContain("1.12.2");
203
+ expect(read).toEqual([["version", "-json"]]);
204
+ });
205
+
206
+ test("with no version given, a 1.14 binary's version is read first, then the plan runs", async () => {
207
+ const { exec, calls } = recorder({ plan: planWith(ROTATE, "v1") });
208
+ await invokeAction(base(exec, fakeLedger(), { version: undefined }));
209
+ expect(calls).toEqual([["version", "-json"], PLAN_ARGS, SHOW_ARGS]);
210
+ });
211
+
212
+ test("a plan whose action_invocations changed after the approval has a new digest and is gated again", async () => {
213
+ const state = { plan: planWith(ROTATE, "v1") as unknown };
214
+ const ledger = fakeLedger();
215
+ const first = await invokeAction(base(recorder(state).exec, ledger));
216
+ if (first.outcome !== "gated") throw new Error(first.outcome);
217
+ ledger.approve(first.digest, "bo");
218
+ state.plan = planWith(ROTATE, "v2");
219
+ const moved = recorder(state);
220
+ const again = await invokeAction(base(moved.exec, ledger, { now: "2026-10-28T12:00:00Z" }));
221
+ expect(again.outcome).toBe("gated");
222
+ expect(again.outcome === "gated" && again.digest).not.toBe(first.digest);
223
+ expect(moved.calls.some((a) => a[0] === "apply")).toBe(false);
224
+ expect(ledger.lines).toEqual([]);
225
+ });
226
+
227
+ test("the commit joins the request, so another commit asks anew", async () => {
228
+ const state = { plan: planWith(ROTATE, "v1") };
229
+ const ledger = fakeLedger();
230
+ const a = await invokeAction(base(recorder(state).exec, ledger, { commit: "aaaa" }));
231
+ const b = await invokeAction(base(recorder(state).exec, ledger, { commit: "bbbb", now: "2026-10-28T12:00:00Z" }));
232
+ expect(a.outcome === "gated" && b.outcome === "gated" && a.digest !== b.digest).toBe(true);
233
+ });
234
+
235
+ test("an action in a module is invoked by its module address", async () => {
236
+ const address = "module.app.action.aws_lambda_invoke.rotate";
237
+ const { exec, calls } = recorder({ plan: planWith(address, "v1") });
238
+ const out = await invokeAction(base(exec, fakeLedger(), { address }));
239
+ expect(out).toMatchObject({ outcome: "gated", subject: `apps/orders:${address}` });
240
+ expect(calls[0]).toContain(`-invoke=${address}`);
241
+ });
242
+
243
+ test("an address that is not an action's is refused before anything runs", async () => {
244
+ for (const address of ["aws_lambda_function.rotate", "-help", "action.aws_lambda_invoke", "module.app.aws_s3_bucket.b", "action.x.y -target=z"]) {
245
+ const { exec, calls } = recorder({ plan: planWith(ROTATE, "v1") });
246
+ expect(await invokeAction(base(exec, fakeLedger(), { address }))).toMatchObject({ outcome: "refused", code: "action-address" });
247
+ expect(calls).toEqual([]);
248
+ }
249
+ });
250
+
251
+ test("a failed apply is recorded as failed, with the exit code and the last lines in the detail", async () => {
252
+ const state = { plan: planWith(ROTATE, "v1"), fail: "apply" };
253
+ const ledger = fakeLedger();
254
+ const first = await invokeAction(base(recorder(state).exec, ledger));
255
+ if (first.outcome !== "gated") throw new Error(first.outcome);
256
+ ledger.approve(first.digest, "bo");
257
+ const out = await invokeAction(base(recorder(state).exec, ledger, { now: "2026-10-28T12:00:00Z" }));
258
+ expect(out).toMatchObject({ outcome: "failed", exit: 1 });
259
+ expect(ledger.lines[1]).toMatchObject({ kind: "finished", result: "failed" });
260
+ expect(ledger.lines[1]!.detail).toMatchObject({ result: "failed", exit: 1, address: ROTATE });
261
+ });
262
+ });
263
+
264
+ describe("addresses and versions", () => {
265
+ test("the address pattern takes root and module addresses, instance keys included", () => {
266
+ for (const ok of [ROTATE, "module.app.action.aws_lambda_invoke.rotate", "module.a.module.b.action.t.l", "module.mod[1].action.t.l", 'module.m["blue"].action.t.l', "action.t.l[0]"]) expect(ACTION_ADDRESS.test(ok)).toBe(true);
267
+ for (const no of ["action.t", "resource.t.l", "module.app.t.l", "module..action.t.l", 'action.t.l["a b"]', "action.T-x.l"]) expect(ACTION_ADDRESS.test(no)).toBe(false);
268
+ });
269
+
270
+ test("Terraform 1.14 and newer invoke actions", () => {
271
+ expect(invokesActions("1.14.0")).toBe(true);
272
+ expect(invokesActions("1.14.9")).toBe(true);
273
+ expect(invokesActions("v1.15.8")).toBe(true);
274
+ expect(invokesActions("2.0.0")).toBe(true);
275
+ expect(invokesActions("1.13.9")).toBe(false);
276
+ expect(invokesActions("dev")).toBe(false);
277
+ });
278
+
279
+ test("the invocations digest does not follow key or entry order", () => {
280
+ const a = { action_invocations: [{ address: "action.t.a", type: "t" }, { type: "t", address: "action.t.b" }] };
281
+ const b = { action_invocations: [{ address: "action.t.b", type: "t" }, { type: "t", address: "action.t.a" }] };
282
+ expect(actionInvocationsDigest(a)).toBe(actionInvocationsDigest(b));
283
+ expect(actionInvocationsDigest(a)).not.toBe(actionInvocationsDigest({ action_invocations: [{ address: "action.t.a", type: "t" }] }));
284
+ });
285
+ });
286
+
287
+ describe("terraform-action capability", () => {
288
+ test("is registered in the plugin, in its own family", () => {
289
+ expect(terraformCapabilityPlugin.capabilities().map((c) => c.kind)).toContain("terraform-action");
290
+ expect(TERRAFORM_VERB_FAMILIES.action).toEqual(["terraform-action"]);
291
+ expect(terraformActionCapability.rollbackPolicy).toBe("none-by-design");
292
+ });
293
+ });
@@ -0,0 +1,370 @@
1
+ /**
2
+ * A day-2 action (#4653): one Terraform `action` block invoked on its own,
3
+ * under a gated request on the ledger, the way a state-lock release runs
4
+ * (./state-ops.ts).
5
+ *
6
+ * `invokeAction` runs fixed argument lists only. The caller names the root,
7
+ * the binary and the action's address, never extra arguments:
8
+ *
9
+ * 1. `plan -input=false -no-color -invoke=<address> -out=<file>`, then
10
+ * `show -json <file>`. The address must be one of the plan's
11
+ * `action_invocations`.
12
+ * 2. `requestGate` under op `tf-action`, subject `<root>:<address>`. The
13
+ * request's fields bind the address, the commit when given, and a digest
14
+ * of the plan's `action_invocations`, so a plan whose action input moved
15
+ * after the approval asks anew. Without an approval that counts (the
16
+ * requester's own never does) it answers `gated`, exit 3, and runs nothing.
17
+ * 3. With one, `spendGatedRequest`, then `apply <file>`, then
18
+ * `finishGatedRequest`. The finished line's `result` stays chant's
19
+ * `applied | failed`; `detail.result` carries `ok` or `failed`, with the
20
+ * exit code and the last lines of the binary's output.
21
+ *
22
+ * The address is checked before anything runs: `action.<type>.<label>`,
23
+ * after any number of `module.<name>.` steps, each with an optional instance
24
+ * key (`[0]`, `["a"]`). Terraform 1.14.9 parses a module address for
25
+ * `-invoke` (`addrs.ParseTargetAction` reads the module prefix first, and its
26
+ * tests "action invoke in module" and "action invoke in expanded module"
27
+ * expect success), so a templated app's root, which holds only `module
28
+ * "app"`, invokes `module.app.action.<type>.<label>` and needs no root-level
29
+ * action blocks.
30
+ *
31
+ * Only Terraform 1.14 or newer invokes an action. An OpenTofu or choudoufu
32
+ * binary is refused by its engine's name before any exec call (`action-engine`).
33
+ * A `terraform` binary is refused when its version, passed in or read with
34
+ * `version -json`, is older than 1.14.
35
+ *
36
+ * `-invoke` plans in refresh-only mode and refuses `-target`, so the plan
37
+ * changes no resource: the apply runs the action and nothing else.
38
+ *
39
+ * The exec and the ledger are injectable, so a test runs no binary and
40
+ * writes no ledger.
41
+ */
42
+ import { createHash } from "node:crypto";
43
+ import { basename, join } from "node:path";
44
+ import type { Capability, CapabilityPlan } from "@intentius/chant/components/capability";
45
+ import { CHANT_EXIT } from "@intentius/chant/exit-codes";
46
+ import { readGatedRequests, type GatedRequestRules } from "@intentius/chant/lifecycle/gated-request";
47
+ import { terraformChangeSetPart } from "./change-set";
48
+ import { DEFAULT_LOCK_TIMEOUT, lockTimeoutArgs } from "./lock-timeout";
49
+ import { lastLines, runBinary, type BinaryExec } from "./state/exec";
50
+ import { gatedStateOp, StateOpError, StateOpWaitingError, type GatedRequestLedger } from "./state-ops";
51
+
52
+ /** The op an action's request waits at. */
53
+ export const ACTION_OP = "tf-action";
54
+
55
+ /** The request kind of an action. */
56
+ export const ACTION_KIND = "action";
57
+
58
+ /** The oldest Terraform that invokes an action on its own. */
59
+ export const ACTION_MIN_TERRAFORM = "1.14.0";
60
+
61
+ /** Why an action was refused before it asked for anything. */
62
+ export type ActionRefusal = "action-engine" | "action-address" | "unknown-action";
63
+
64
+ const KEY = String.raw`\[(?:[0-9]+|"[A-Za-z0-9_-]+")\]`;
65
+ /** An action address `-invoke` takes: module steps, then `action.<type>.<label>`, instance keys allowed. */
66
+ export const ACTION_ADDRESS = new RegExp(String.raw`^(?:module\.[A-Za-z_][A-Za-z0-9_-]*(?:${KEY})?\.)*action\.[a-z0-9_]+\.[A-Za-z_][A-Za-z0-9_-]*(?:${KEY})?$`);
67
+
68
+ /** The subject an action's request is asked and approved under: `<root>:<address>`. */
69
+ export const actionSubject = (root: string, address: string): string => `${root}:${address}`;
70
+
71
+ export interface InvokeActionInput {
72
+ /** The root's name, as records and messages name it. */
73
+ root: string;
74
+ /** The root's directory. */
75
+ dir: string;
76
+ /** The binary. Default `terraform`. */
77
+ binary?: string;
78
+ /** Its version, when the caller knows it (an image's pin). Absent: read with `version -json` before the plan. */
79
+ version?: string;
80
+ /** The action's address: `action.<type>.<label>` or `module.<name>.action.<type>.<label>`. */
81
+ address: string;
82
+ /** Who asks. */
83
+ requester: string;
84
+ /** Which approvals count. */
85
+ rules: GatedRequestRules;
86
+ /** The commit the root is at, bound by the request. */
87
+ commit?: string;
88
+ /** The saved plan. Default `<dir>/.chant-action.tfplan`. */
89
+ planFile?: string;
90
+ lockTimeout?: string | false;
91
+ env?: NodeJS.ProcessEnv;
92
+ exec?: BinaryExec;
93
+ ledger?: GatedRequestLedger;
94
+ /** The project's directory, where the ledger is. */
95
+ cwd?: string;
96
+ now?: string;
97
+ runId?: string;
98
+ expiry?: string;
99
+ /**
100
+ * Ask anew even when the same request (same subject, requester and
101
+ * fields) applied already. Absent, a call that matches an applied request
102
+ * answers `spent`, so a retried job never runs the action twice.
103
+ */
104
+ anew?: boolean;
105
+ }
106
+
107
+ /** How an action call ended. `exit` is the code a command that ran it exits with. */
108
+ export type InvokeActionResult =
109
+ | { outcome: "refused"; code: ActionRefusal; reason: string; exit: number }
110
+ /** No approval counts yet; nothing ran past the plan. `reason` names an approval that stands and does not count. */
111
+ | { outcome: "gated"; digest: string; subject: string; command: string; reason?: string; exit: number }
112
+ /** The request applied already, and a request applies once; nothing ran past the plan. */
113
+ | { outcome: "spent"; digest: string; subject: string; exit: number }
114
+ | { outcome: "ok"; digest: string; subject: string; approvedBy: string; tail: string; recorded: boolean; exit: number }
115
+ | { outcome: "failed"; digest: string; subject: string; approvedBy: string; error: string; tail: string; recorded: boolean; exit: number };
116
+
117
+ const refused = (code: ActionRefusal, reason: string): InvokeActionResult => ({ outcome: "refused", code, reason, exit: CHANT_EXIT.failed });
118
+
119
+ /** The engine a binary name runs, when it is one that has no `-invoke`. */
120
+ function otherEngine(binary: string): string | undefined {
121
+ const name = basename(binary).toLowerCase().replace(/\.exe$/, "");
122
+ if (name === "tofu" || name === "opentofu" || name.startsWith("tofu-") || name.startsWith("opentofu")) return "OpenTofu";
123
+ if (name.startsWith("choudoufu")) return "choudoufu";
124
+ if (!/^terraform([-_.]?[0-9][0-9.]*)?$/.test(name)) return name;
125
+ return undefined;
126
+ }
127
+
128
+ /** Whether `version` is at least {@link ACTION_MIN_TERRAFORM}. */
129
+ export function invokesActions(version: string): boolean {
130
+ const m = /^v?(\d+)\.(\d+)/.exec(version.trim());
131
+ if (!m) return false;
132
+ const major = Number(m[1]);
133
+ const minor = Number(m[2]);
134
+ return major > 1 || (major === 1 && minor >= 14);
135
+ }
136
+
137
+ /**
138
+ * Refuse a binary that cannot invoke an action, before any exec call when
139
+ * its name or the given version settles it. Undefined: it may run.
140
+ */
141
+ export function actionEngineRefusal(root: string, binary: string, version: string | undefined): string | undefined {
142
+ const engine = otherEngine(binary);
143
+ const named = `${binary}${version ? ` ${version}` : ""}`;
144
+ if (engine) return `${root}: an action runs on Terraform ${ACTION_MIN_TERRAFORM.replace(/\.0$/, "")} or newer, and this root runs ${engine} (${named}), which has no action invocation`;
145
+ if (version !== undefined && !invokesActions(version)) return `${root}: an action runs on Terraform ${ACTION_MIN_TERRAFORM.replace(/\.0$/, "")} or newer, and this root runs Terraform ${version} (${binary})`;
146
+ return undefined;
147
+ }
148
+
149
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
150
+
151
+ /** JSON with every object's keys sorted, so a digest does not follow the emitter's key order. */
152
+ const canonical = (v: unknown): string =>
153
+ Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : isObject(v) ? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}` : JSON.stringify(v ?? null);
154
+
155
+ /** The plan's `action_invocations`, as a request binds them: each entry in canonical form, sorted, hashed. */
156
+ export function actionInvocationsDigest(plan: unknown): string {
157
+ const list = isObject(plan) && Array.isArray(plan.action_invocations) ? plan.action_invocations.map(canonical).sort() : [];
158
+ return `sha256:${createHash("sha256").update(`[${list.join(",")}]`).digest("hex")}`;
159
+ }
160
+
161
+ /** The addresses the plan's `action_invocations` name. */
162
+ export function invokedAddresses(plan: unknown): string[] {
163
+ const list = isObject(plan) && Array.isArray(plan.action_invocations) ? plan.action_invocations : [];
164
+ return list.flatMap((inv) => (isObject(inv) && typeof inv.address === "string" ? [inv.address] : []));
165
+ }
166
+
167
+ async function run(input: InvokeActionInput, binary: string, args: string[], label: string): Promise<{ stdout: string; out: string }> {
168
+ const exec = input.exec ?? runBinary;
169
+ const done = await exec(binary, args, input.dir, input.env ?? process.env);
170
+ if (done.code !== 0) throw new StateOpError(input.root, `${binary} ${label}`, done.code, lastLines(done.out, 10));
171
+ return done;
172
+ }
173
+
174
+ /** An action's plan read back: the saved plan, its JSON, and the request fields it binds. */
175
+ export interface ActionPlanRead {
176
+ planFile: string;
177
+ json: unknown;
178
+ fields: Record<string, string>;
179
+ }
180
+
181
+ /**
182
+ * Check the binary and the address, plan the invocation and read it back.
183
+ * A refusal comes back as a result; a binary run that fails throws
184
+ * {@link StateOpError}.
185
+ */
186
+ export async function planAction(input: InvokeActionInput): Promise<ActionPlanRead | Extract<InvokeActionResult, { outcome: "refused" }>> {
187
+ const binary = input.binary ?? "terraform";
188
+ const early = actionEngineRefusal(input.root, binary, input.version);
189
+ if (early) return refused("action-engine", early) as Extract<InvokeActionResult, { outcome: "refused" }>;
190
+ if (!ACTION_ADDRESS.test(input.address)) {
191
+ return refused("action-address", `${input.root}: "${input.address}" is not an action address; give action.<type>.<label>, after module.<name>. for an action in a module`) as Extract<InvokeActionResult, { outcome: "refused" }>;
192
+ }
193
+ if (input.version === undefined) {
194
+ const shown = await run(input, binary, ["version", "-json"], "version -json");
195
+ let version = "";
196
+ try {
197
+ const v = JSON.parse(shown.stdout) as { terraform_version?: unknown };
198
+ version = typeof v.terraform_version === "string" ? v.terraform_version : "";
199
+ } catch {
200
+ version = "";
201
+ }
202
+ const late = actionEngineRefusal(input.root, binary, version || "of no known version");
203
+ if (late) return refused("action-engine", late) as Extract<InvokeActionResult, { outcome: "refused" }>;
204
+ }
205
+ const planFile = input.planFile ?? join(input.dir, ".chant-action.tfplan");
206
+ await run(input, binary, ["plan", "-input=false", "-no-color", `-invoke=${input.address}`, `-out=${planFile}`], `plan -invoke=${input.address}`);
207
+ const show = await run(input, binary, ["show", "-json", planFile], "show -json");
208
+ let json: unknown;
209
+ try {
210
+ json = JSON.parse(show.stdout);
211
+ } catch {
212
+ throw new StateOpError(input.root, `${binary} show -json`, 0, `printed no plan: ${lastLines(show.out, 10)}`);
213
+ }
214
+ const invoked = invokedAddresses(json);
215
+ if (!invoked.includes(input.address)) {
216
+ return refused(
217
+ "unknown-action",
218
+ `${input.root}: the plan invokes ${invoked.length > 0 ? invoked.join(", ") : "no action"}, and not ${input.address}; nothing applied`,
219
+ ) as Extract<InvokeActionResult, { outcome: "refused" }>;
220
+ }
221
+ const fields: Record<string, string> = { address: input.address, invocations: actionInvocationsDigest(json), ...(input.commit ? { commit: input.commit } : {}) };
222
+ return { planFile, json, fields };
223
+ }
224
+
225
+ /** The time of the newest request with these fields that applied already, so asking again answers `spent`. */
226
+ async function appliedAt(input: InvokeActionInput, subject: string, fields: Record<string, string>): Promise<string | undefined> {
227
+ const read = input.ledger?.readGatedRequests ?? readGatedRequests;
228
+ const all = await read(ACTION_OP, input.cwd ? { cwd: input.cwd } : undefined).catch(() => []);
229
+ const same = JSON.stringify(Object.entries(fields).sort());
230
+ const last = all
231
+ .filter((g) => g.request.kind === ACTION_KIND && g.request.subject === subject && g.request.by === input.requester)
232
+ .filter((g) => JSON.stringify(Object.entries(g.request.fields).sort()) === same)
233
+ .at(-1);
234
+ return last?.applied ? last.request.at : undefined;
235
+ }
236
+
237
+ /**
238
+ * Invoke one action under a gated request: plan it, ask, and once an
239
+ * approval by someone other than the requester counts, spend it, apply the
240
+ * saved plan and record how it ended.
241
+ */
242
+ export async function invokeAction(input: InvokeActionInput): Promise<InvokeActionResult> {
243
+ const binary = input.binary ?? "terraform";
244
+ const planned = await planAction(input);
245
+ if ("outcome" in planned) return planned;
246
+ const subject = actionSubject(input.root, input.address);
247
+ const at = input.anew ? undefined : await appliedAt(input, subject, planned.fields);
248
+ let tail = "";
249
+ const result = await gatedStateOp<string>(
250
+ {
251
+ op: ACTION_OP,
252
+ request: { kind: ACTION_KIND, subject, by: input.requester, fields: planned.fields, ...(at ? { at } : {}) },
253
+ rules: input.rules,
254
+ description: `action ${input.address} on ${input.root}, requested by ${input.requester}`,
255
+ ...(input.expiry ? { expiry: input.expiry } : {}),
256
+ ...(input.cwd ? { cwd: input.cwd } : {}),
257
+ ...(input.now ? { now: input.now } : {}),
258
+ ...(input.runId ? { runId: input.runId } : {}),
259
+ ...(input.commit ? { commit: input.commit } : {}),
260
+ ...(input.ledger ? { ledger: input.ledger } : {}),
261
+ failedDetail: (error): Record<string, string | number | boolean> =>
262
+ error instanceof StateOpError ? { result: "failed", exit: error.code, tail: error.tail.slice(-400), address: input.address } : { result: "failed", address: input.address },
263
+ },
264
+ async () => {
265
+ const done = await run(
266
+ input,
267
+ binary,
268
+ ["apply", "-input=false", "-no-color", ...lockTimeoutArgs("apply", input.lockTimeout ?? DEFAULT_LOCK_TIMEOUT, input.env ?? process.env), planned.planFile],
269
+ "apply",
270
+ );
271
+ tail = lastLines(done.out, 10);
272
+ return { output: tail, detail: { result: "ok", exit: 0, tail: tail.slice(-400), address: input.address } };
273
+ },
274
+ );
275
+ switch (result.status) {
276
+ case "waiting":
277
+ return { outcome: "gated", digest: result.digest, subject, command: result.command, exit: CHANT_EXIT.waiting };
278
+ case "refused":
279
+ return { outcome: "gated", digest: result.digest, subject, command: result.command, reason: result.reason, exit: CHANT_EXIT.waiting };
280
+ case "spent":
281
+ return { outcome: "spent", digest: result.digest, subject, exit: CHANT_EXIT.failed };
282
+ case "held":
283
+ // No last check is passed, so nothing holds; kept for the union.
284
+ return { outcome: "gated", digest: result.digest, subject, command: "", reason: result.reason, exit: CHANT_EXIT.waiting };
285
+ case "done":
286
+ return { outcome: "ok", digest: result.digest, subject, approvedBy: result.approvedBy, tail: result.output, recorded: result.recorded, exit: CHANT_EXIT.done };
287
+ case "failed": {
288
+ const e = result.error;
289
+ return {
290
+ outcome: "failed",
291
+ digest: result.digest,
292
+ subject,
293
+ approvedBy: result.approvedBy,
294
+ error: e.message,
295
+ tail: e instanceof StateOpError ? e.tail : tail,
296
+ recorded: result.recorded,
297
+ exit: CHANT_EXIT.failed,
298
+ };
299
+ }
300
+ }
301
+ }
302
+
303
+ // ── capability ──────────────────────────────────────────────────────────────
304
+
305
+ /** `terraform-action`: invoke one action of a root under a gated request. */
306
+ export interface ActionStepInput {
307
+ root: string;
308
+ /** The action's address. */
309
+ address: string;
310
+ /** The project's directory, where the ledger is and roots are found. Default the process's. */
311
+ cwd?: string;
312
+ /** The root's directory. Default `root` under the project. */
313
+ dir?: string;
314
+ binary?: string;
315
+ /** The binary's version, when the step knows it. */
316
+ version?: string;
317
+ /** Who asks. Default the CI actor, else the user. */
318
+ by?: string;
319
+ /** Which approvals count. Default the ledger's. */
320
+ approval?: GatedRequestRules["mode"];
321
+ /** The commit the request binds. Default the CI's commit variable, when set. */
322
+ commit?: string;
323
+ lockTimeout?: string | false;
324
+ }
325
+
326
+ const actorOf = (env: NodeJS.ProcessEnv): string => env.GITHUB_ACTOR || env.GITLAB_USER_LOGIN || env.USER || env.USERNAME || "unknown";
327
+
328
+ function stepInvoke(input: ActionStepInput): InvokeActionInput {
329
+ const cwd = input.cwd ?? process.cwd();
330
+ const commit = input.commit ?? (process.env.GITHUB_SHA || process.env.CI_COMMIT_SHA || undefined);
331
+ return {
332
+ root: input.root,
333
+ dir: input.dir ?? join(cwd, input.root),
334
+ address: input.address,
335
+ requester: input.by ?? actorOf(process.env),
336
+ rules: { mode: input.approval ?? "ledger" },
337
+ cwd,
338
+ ...(input.binary ? { binary: input.binary } : {}),
339
+ ...(input.version ? { version: input.version } : {}),
340
+ ...(commit ? { commit } : {}),
341
+ ...(input.lockTimeout !== undefined ? { lockTimeout: input.lockTimeout } : {}),
342
+ };
343
+ }
344
+
345
+ export const terraformActionCapability: Capability<ActionStepInput, { root: string; address: string; digest: string }> = {
346
+ kind: "terraform-action",
347
+ rollbackPolicy: "none-by-design",
348
+ targets: { refuse: "an action is invoked by its own address" },
349
+ async plan(_ctx, input): Promise<CapabilityPlan> {
350
+ const planned = await planAction(stepInvoke(input));
351
+ if ("outcome" in planned) throw new Error(planned.reason);
352
+ const part = terraformChangeSetPart({ member: input.root, plan: planned.json });
353
+ return { member: input.root, planDigest: part.member.planDigest ?? "", changeSet: part };
354
+ },
355
+ async run(_ctx, input) {
356
+ const result = await invokeAction(stepInvoke(input));
357
+ switch (result.outcome) {
358
+ case "ok":
359
+ return { root: input.root, address: input.address, digest: result.digest };
360
+ case "gated":
361
+ throw new StateOpWaitingError(ACTION_OP, result.subject, result.digest, result.command);
362
+ case "spent":
363
+ throw new Error(`${ACTION_OP} ${result.subject}: the request ${result.digest} applied already, and a request applies once`);
364
+ case "refused":
365
+ throw new Error(result.reason);
366
+ case "failed":
367
+ throw new Error(result.error);
368
+ }
369
+ },
370
+ };