@intentius/chant 0.46.0 → 0.49.0
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/audit/core.d.ts +21 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +3 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts +6 -0
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/reporters/stylish.d.ts +15 -1
- package/dist/cli/reporters/stylish.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts +2 -0
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/components/verbs/ensure-secret.d.ts +50 -0
- package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
- package/dist/components/verbs/index.d.ts +8 -0
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/r2-sync.d.ts +76 -0
- package/dist/components/verbs/r2-sync.d.ts.map +1 -0
- package/dist/components/verbs/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +14 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/fold/subset.d.ts +15 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +44 -3
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +33 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +21 -3
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.d.ts +62 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +20 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +6 -4
- package/dist/lifecycle/teardown.d.ts.map +1 -1
- package/dist/lifecycle/unobserved-gate.d.ts +67 -0
- package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
- package/dist/lint/knowledge-checks.d.ts +48 -0
- package/dist/lint/knowledge-checks.d.ts.map +1 -0
- package/dist/lint/output-checks.d.ts +5 -0
- package/dist/lint/output-checks.d.ts.map +1 -0
- package/dist/lint/pipeline-change-gate.d.ts +101 -0
- package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +12 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/receipt-checks.d.ts +9 -0
- package/dist/lint/receipt-checks.d.ts.map +1 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/index.d.ts +3 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/builders.d.ts +98 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +2 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/types.d.ts +31 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/secret-materialization.d.ts +138 -0
- package/dist/secret-materialization.d.ts.map +1 -0
- package/dist/secret-provenance.d.ts +218 -0
- package/dist/secret-provenance.d.ts.map +1 -0
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/audit/core.test.ts +57 -0
- package/src/audit/core.ts +0 -0
- package/src/audit/detect-bundle.test.ts +1 -1
- package/src/audit/discover.test.ts +24 -0
- package/src/audit/discover.ts +11 -2
- package/src/audit/rules-doc.ts +11 -1
- package/src/build.test.ts +41 -0
- package/src/build.ts +34 -6
- package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
- package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
- package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
- package/src/cli/commands/audit.test.ts +53 -0
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/build.test.ts +80 -0
- package/src/cli/commands/build.ts +106 -8
- package/src/cli/commands/lint.ts +15 -3
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/lifecycle.test.ts +115 -1
- package/src/cli/handlers/lifecycle.ts +84 -11
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +58 -1
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/plugins.ts +4 -2
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/components/auto-release.ts +6 -0
- package/src/components/registry.test.ts +7 -2
- package/src/components/starter-plugin.ts +17 -0
- package/src/components/verbs/ensure-secret.test.ts +130 -0
- package/src/components/verbs/ensure-secret.ts +79 -0
- package/src/components/verbs/index.ts +8 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- package/src/config.test.ts +15 -0
- package/src/config.ts +30 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +17 -0
- package/src/effect-receipt-exclusion.test.ts +190 -0
- package/src/effect-receipt.test.ts +419 -0
- package/src/effect-receipt.ts +412 -0
- package/src/fold/subset.test.ts +26 -0
- package/src/fold/subset.ts +45 -19
- package/src/index.ts +4 -0
- package/src/lexicon.ts +48 -3
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/observation-baseline.test.ts +46 -0
- package/src/lifecycle/observation-baseline.ts +33 -1
- package/src/lifecycle/receipt-plan.test.ts +250 -0
- package/src/lifecycle/receipt-plan.ts +249 -0
- package/src/lifecycle/release-ledger.ts +20 -0
- package/src/lifecycle/teardown.test.ts +31 -0
- package/src/lifecycle/teardown.ts +6 -4
- package/src/lifecycle/unobserved-gate.test.ts +109 -0
- package/src/lifecycle/unobserved-gate.ts +102 -0
- package/src/lint/knowledge-checks.test.ts +80 -0
- package/src/lint/knowledge-checks.ts +74 -0
- package/src/lint/output-checks.test.ts +85 -0
- package/src/lint/output-checks.ts +99 -0
- package/src/lint/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.ts +15 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
- package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
- package/src/lint/rules/index.ts +7 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/builders.ts +139 -1
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/index.ts +10 -3
- package/src/op/local-executor.ts +172 -25
- package/src/op/op.test.ts +25 -2
- package/src/op/receipt-store.ts +211 -0
- package/src/op/types.ts +33 -1
- package/src/secret-materialization.test.ts +199 -0
- package/src/secret-materialization.ts +235 -0
- package/src/secret-provenance.test.ts +388 -0
- package/src/secret-provenance.ts +475 -0
- package/src/serializer.ts +12 -0
- package/src/yaml.test.ts +88 -0
- package/src/yaml.ts +76 -6
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* effect() step + receipt store seam + local-executor semantics (#1834, epic
|
|
3
|
+
* #1703). The contract under test:
|
|
4
|
+
*
|
|
5
|
+
* - the builder takes the typed EffectReceipt declaration only (no string
|
|
6
|
+
* form — enforced at the type level and at runtime);
|
|
7
|
+
* - read-compare-run-write: a matching receipt skips the nested steps; a
|
|
8
|
+
* mismatch runs them and writes the receipt ONLY on their success, last;
|
|
9
|
+
* - a nested-step failure leaves the receipt untouched (stale), so a rerun
|
|
10
|
+
* re-proposes the effect;
|
|
11
|
+
* - `receiptStaleness` (WatchOp's phase) is read-only: it reports absent and
|
|
12
|
+
* differing receipts as findings and never writes.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, test, expect } from "vitest";
|
|
15
|
+
import { EffectReceipt, receiptExpectation, EXISTENCE_EXPECTATION } from "../effect-receipt";
|
|
16
|
+
import { INTRINSIC_MARKER, type Intrinsic } from "../intrinsic";
|
|
17
|
+
import { effect, phase } from "./builders";
|
|
18
|
+
import {
|
|
19
|
+
receiptActivities,
|
|
20
|
+
receiptCheckInput,
|
|
21
|
+
type ReceiptStore,
|
|
22
|
+
type EffectReceiptRef,
|
|
23
|
+
} from "./receipt-store";
|
|
24
|
+
import type { ActivityFn, ActivityProfile } from "./activity-registry";
|
|
25
|
+
import type { OpConfig, ActivityStep } from "./types";
|
|
26
|
+
import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure } from "./local-executor";
|
|
27
|
+
|
|
28
|
+
function fakeIntrinsic(json: unknown): Intrinsic {
|
|
29
|
+
return {
|
|
30
|
+
[INTRINSIC_MARKER]: true,
|
|
31
|
+
toJSON: () => json,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const seeded = EffectReceipt("seeded", {
|
|
36
|
+
effect: "db-seed",
|
|
37
|
+
flavor: "hash",
|
|
38
|
+
inputs: { file: "seed.sql", version: 3 },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const step = (fn: string, args?: Record<string, unknown>): ActivityStep => ({
|
|
42
|
+
kind: "activity",
|
|
43
|
+
fn,
|
|
44
|
+
...(args ? { args } : {}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── Builder ───────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe("effect() builder", () => {
|
|
50
|
+
test("wraps nested steps with the receipt's identity data and stamps a static expectation", () => {
|
|
51
|
+
const s = effect(seeded, [step("shellCmd", { cmd: "npm run db:seed" })]);
|
|
52
|
+
expect(s.kind).toBe("effect");
|
|
53
|
+
expect(s.receipt).toEqual({
|
|
54
|
+
name: "seeded",
|
|
55
|
+
effect: "db-seed",
|
|
56
|
+
flavor: "hash",
|
|
57
|
+
inputs: { file: "seed.sql", version: 3 },
|
|
58
|
+
});
|
|
59
|
+
expect(s.expectation).toBe(receiptExpectation(seeded));
|
|
60
|
+
expect(s.steps.map((n) => (n.kind === "activity" ? n.fn : n.signalName))).toEqual(["shellCmd"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("existence receipts always get the constant expectation, reference inputs or not", () => {
|
|
64
|
+
const r = EffectReceipt("booted", {
|
|
65
|
+
effect: "bootstrap",
|
|
66
|
+
flavor: "existence",
|
|
67
|
+
inputs: { endpoint: fakeIntrinsic({ __attrRef: { entity: "Db", attribute: "endpoint" } }) },
|
|
68
|
+
});
|
|
69
|
+
expect(effect(r, []).expectation).toBe(EXISTENCE_EXPECTATION);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a hash receipt with reference inputs carries no synthesis-time expectation (resolves at run, #1703 decision 5)", () => {
|
|
73
|
+
const r = EffectReceipt("migrated", {
|
|
74
|
+
effect: "db-migrate",
|
|
75
|
+
flavor: "hash",
|
|
76
|
+
inputs: { endpoint: fakeIntrinsic({ __attrRef: { entity: "Db", attribute: "endpoint" } }) },
|
|
77
|
+
});
|
|
78
|
+
const s = effect(r, []);
|
|
79
|
+
expect(s.expectation).toBeUndefined();
|
|
80
|
+
expect("expectation" in s).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("preserves authored order and nested gates", () => {
|
|
84
|
+
const s = effect(seeded, [
|
|
85
|
+
{ kind: "gate", signalName: "approve-seed" },
|
|
86
|
+
step("shellCmd", { cmd: "seed" }),
|
|
87
|
+
]);
|
|
88
|
+
expect(s.steps.map((n) => n.kind)).toEqual(["gate", "activity"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("refuses anything that is not the typed receipt declaration", () => {
|
|
92
|
+
expect(() => effect({ name: "seeded" } as never, [])).toThrow(/no string form/);
|
|
93
|
+
// Type level: a string receipt name must not compile.
|
|
94
|
+
// @ts-expect-error — receipt is the EffectReceipt declaration, never a string
|
|
95
|
+
expect(() => effect("seeded", [])).toThrow(/no string form/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("effect steps do not nest", () => {
|
|
99
|
+
const inner = effect(seeded, []);
|
|
100
|
+
expect(() => effect(seeded, [inner as never])).toThrow(/do not nest/);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── Receipt store seam ────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
function memStore(initial?: Record<string, string>): {
|
|
107
|
+
store: ReceiptStore;
|
|
108
|
+
values: Map<string, string>;
|
|
109
|
+
writes: Array<{ name: string; expectation: string }>;
|
|
110
|
+
} {
|
|
111
|
+
const values = new Map(Object.entries(initial ?? {}));
|
|
112
|
+
const writes: Array<{ name: string; expectation: string }> = [];
|
|
113
|
+
const store: ReceiptStore = {
|
|
114
|
+
async read(r: EffectReceiptRef) {
|
|
115
|
+
return values.get(r.name);
|
|
116
|
+
},
|
|
117
|
+
async write(r: EffectReceiptRef, expectation: string) {
|
|
118
|
+
writes.push({ name: r.name, expectation });
|
|
119
|
+
values.set(r.name, expectation);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
return { store, values, writes };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe("receiptActivities (the injectable store seam)", () => {
|
|
126
|
+
const input = receiptCheckInput(seeded);
|
|
127
|
+
const expected = receiptExpectation(seeded);
|
|
128
|
+
|
|
129
|
+
test("receiptRead: absent receipt reads as current null, applied false", async () => {
|
|
130
|
+
const { store } = memStore();
|
|
131
|
+
const { receiptRead } = receiptActivities(store);
|
|
132
|
+
expect(await receiptRead(input)).toEqual({ current: null, expectation: expected, applied: false });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("receiptRead: matching receipt reads as applied true", async () => {
|
|
136
|
+
const { store } = memStore({ seeded: expected });
|
|
137
|
+
const { receiptRead } = receiptActivities(store);
|
|
138
|
+
expect(await receiptRead(input)).toEqual({ current: expected, expectation: expected, applied: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("receiptRead: refuses to invent an expectation for a reference-carrying hash receipt without a resolver", async () => {
|
|
142
|
+
const r = EffectReceipt("migrated", {
|
|
143
|
+
effect: "db-migrate",
|
|
144
|
+
flavor: "hash",
|
|
145
|
+
inputs: { endpoint: fakeIntrinsic("placeholder") },
|
|
146
|
+
});
|
|
147
|
+
const { store } = memStore();
|
|
148
|
+
const { receiptRead } = receiptActivities(store);
|
|
149
|
+
await expect(receiptRead(receiptCheckInput(r))).rejects.toThrow(/resolveExpectation/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("receiptWrite stores the expectation", async () => {
|
|
153
|
+
const { store, writes, values } = memStore();
|
|
154
|
+
const { receiptWrite } = receiptActivities(store);
|
|
155
|
+
await receiptWrite({ receipt: input.receipt, expectation: expected });
|
|
156
|
+
expect(writes).toEqual([{ name: "seeded", expectation: expected }]);
|
|
157
|
+
expect(values.get("seeded")).toBe(expected);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("receiptStaleness reports absent and differing receipts as findings, and never writes", async () => {
|
|
161
|
+
const fresh = EffectReceipt("fresh", { effect: "e1", flavor: "hash", inputs: { v: 1 } });
|
|
162
|
+
const differs = EffectReceipt("differs", { effect: "e2", flavor: "hash", inputs: { v: 2 } });
|
|
163
|
+
const absent = EffectReceipt("absent", { effect: "e3", flavor: "existence" });
|
|
164
|
+
const { store, writes } = memStore({
|
|
165
|
+
fresh: receiptExpectation(fresh),
|
|
166
|
+
differs: "sha256:stale",
|
|
167
|
+
});
|
|
168
|
+
const { receiptStaleness } = receiptActivities(store);
|
|
169
|
+
const result = await receiptStaleness({
|
|
170
|
+
receipts: [fresh, differs, absent].map(receiptCheckInput),
|
|
171
|
+
});
|
|
172
|
+
expect(result.stale).toBe(true);
|
|
173
|
+
expect(result.findings).toEqual([
|
|
174
|
+
{ receipt: "differs", effect: "e2", kind: "differs", expected: receiptExpectation(differs), current: "sha256:stale" },
|
|
175
|
+
{ receipt: "absent", effect: "e3", kind: "absent", expected: EXISTENCE_EXPECTATION },
|
|
176
|
+
]);
|
|
177
|
+
expect(writes).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("receiptStaleness with everything fresh reports stale false", async () => {
|
|
181
|
+
const { store } = memStore({ seeded: expected });
|
|
182
|
+
const { receiptStaleness } = receiptActivities(store);
|
|
183
|
+
expect(await receiptStaleness({ receipts: [input] })).toEqual({ stale: false, findings: [] });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── Local executor: read-compare-run-write ────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
const PROFILES: Record<string, ActivityProfile> = {
|
|
190
|
+
fastIdempotent: { startToCloseTimeout: "5m", retry: { maximumAttempts: 1 } },
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
function activityMap(
|
|
194
|
+
store: ReceiptStore,
|
|
195
|
+
extra: Record<string, ActivityFn>,
|
|
196
|
+
): Map<string, ActivityFn> {
|
|
197
|
+
const map = new Map<string, ActivityFn>();
|
|
198
|
+
for (const [name, fn] of Object.entries(receiptActivities(store))) {
|
|
199
|
+
map.set(name, fn as unknown as ActivityFn);
|
|
200
|
+
}
|
|
201
|
+
for (const [name, fn] of Object.entries(extra)) map.set(name, fn);
|
|
202
|
+
return map;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function seedOp(): OpConfig {
|
|
206
|
+
return {
|
|
207
|
+
name: "seed-op",
|
|
208
|
+
overview: "",
|
|
209
|
+
phases: [phase("Seed", [effect(seeded, [step("runSeed")])])],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
describe("runOpLocally — effect steps", () => {
|
|
214
|
+
test("matching receipt skips the nested steps and writes nothing", async () => {
|
|
215
|
+
const { store, writes } = memStore({ seeded: receiptExpectation(seeded) });
|
|
216
|
+
const ran: string[] = [];
|
|
217
|
+
const activities = activityMap(store, { runSeed: async () => void ran.push("runSeed") });
|
|
218
|
+
const result = await runOpLocally(seedOp(), activities, PROFILES);
|
|
219
|
+
expect(result.ok).toBe(true);
|
|
220
|
+
expect(ran).toEqual([]);
|
|
221
|
+
expect(writes).toEqual([]);
|
|
222
|
+
// The read records the effect as already applied; the nested step is skipped.
|
|
223
|
+
expect(result.records.map((r) => [r.fn, r.status])).toEqual([
|
|
224
|
+
["receiptRead", "ok"],
|
|
225
|
+
["runSeed", "skipped"],
|
|
226
|
+
]);
|
|
227
|
+
expect(result.records[0].outcome).toEqual({ name: "EffectApplied", value: true });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("mismatch runs the nested steps, then writes the receipt once, last", async () => {
|
|
231
|
+
const { store, writes } = memStore();
|
|
232
|
+
const order: string[] = [];
|
|
233
|
+
const activities = activityMap(
|
|
234
|
+
{ read: store.read, write: async (r, e) => { order.push("write"); await store.write(r, e); } },
|
|
235
|
+
{ runSeed: async () => void order.push("runSeed") },
|
|
236
|
+
);
|
|
237
|
+
const result = await runOpLocally(seedOp(), activities, PROFILES);
|
|
238
|
+
expect(result.ok).toBe(true);
|
|
239
|
+
expect(order).toEqual(["runSeed", "write"]);
|
|
240
|
+
expect(writes).toEqual([{ name: "seeded", expectation: receiptExpectation(seeded) }]);
|
|
241
|
+
expect(result.records.map((r) => [r.fn, r.status])).toEqual([
|
|
242
|
+
["receiptRead", "ok"],
|
|
243
|
+
["runSeed", "ok"],
|
|
244
|
+
["receiptWrite", "ok"],
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a nested failure leaves the receipt untouched, and a rerun re-proposes the effect", async () => {
|
|
249
|
+
const { store, writes, values } = memStore();
|
|
250
|
+
let healthy = false;
|
|
251
|
+
const activities = activityMap(store, {
|
|
252
|
+
runSeed: async () => {
|
|
253
|
+
if (!healthy) throw new Error("seed blew up");
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await expect(runOpLocally(seedOp(), activities, PROFILES)).rejects.toThrow(OpRunFailure);
|
|
258
|
+
expect(writes).toEqual([]);
|
|
259
|
+
expect(values.has("seeded")).toBe(false);
|
|
260
|
+
|
|
261
|
+
// The receipt is stale, so the next run runs the effect again — and only
|
|
262
|
+
// then writes.
|
|
263
|
+
healthy = true;
|
|
264
|
+
const rerun = await runOpLocally(seedOp(), activities, PROFILES);
|
|
265
|
+
expect(rerun.ok).toBe(true);
|
|
266
|
+
expect(writes).toEqual([{ name: "seeded", expectation: receiptExpectation(seeded) }]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("the failed run records the write as skipped, not run", async () => {
|
|
270
|
+
const { store } = memStore();
|
|
271
|
+
const activities = activityMap(store, {
|
|
272
|
+
runSeed: async () => {
|
|
273
|
+
throw new Error("boom");
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
const failure = await runOpLocally(seedOp(), activities, PROFILES).catch((e: OpRunFailure) => e);
|
|
277
|
+
expect(failure).toBeInstanceOf(OpRunFailure);
|
|
278
|
+
expect((failure as OpRunFailure).result.records.map((r) => [r.fn, r.status])).toEqual([
|
|
279
|
+
["receiptRead", "ok"],
|
|
280
|
+
["runSeed", "fail"],
|
|
281
|
+
["receiptWrite", "skipped"],
|
|
282
|
+
]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a gate nested in an effect step is rejected up front, like any other gate", () => {
|
|
286
|
+
const config: OpConfig = {
|
|
287
|
+
name: "gated",
|
|
288
|
+
overview: "",
|
|
289
|
+
phases: [
|
|
290
|
+
phase("Seed", [effect(seeded, [{ kind: "gate", signalName: "approve-seed" }, step("runSeed")])]),
|
|
291
|
+
],
|
|
292
|
+
};
|
|
293
|
+
expect(findGate(config)?.signalName).toBe("approve-seed");
|
|
294
|
+
const { store } = memStore();
|
|
295
|
+
return expect(
|
|
296
|
+
runOpLocally(config, activityMap(store, { runSeed: async () => {} }), PROFILES),
|
|
297
|
+
).rejects.toThrow(LocalGateUnsupportedError);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("an effect step in a parallel phase is refused — read-compare-run-write is ordered", async () => {
|
|
301
|
+
const config: OpConfig = {
|
|
302
|
+
name: "par",
|
|
303
|
+
overview: "",
|
|
304
|
+
phases: [phase("P", [effect(seeded, [step("runSeed")])], { parallel: true })],
|
|
305
|
+
};
|
|
306
|
+
const { store } = memStore();
|
|
307
|
+
await expect(
|
|
308
|
+
runOpLocally(config, activityMap(store, { runSeed: async () => {} }), PROFILES),
|
|
309
|
+
).rejects.toThrow(/parallel phase/);
|
|
310
|
+
});
|
|
311
|
+
});
|
package/src/op/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { Op, phase, activity, gate, build, kubectlApply, helmInstall, waitForStack, waitForReady,
|
|
2
|
-
gitlabPipeline, lifecycleSnapshot, shell, teardown, envTeardown, k3dUp, k3dDown,
|
|
1
|
+
export { Op, phase, activity, gate, effect, build, kubectlApply, helmInstall, helmInstallPinned, waitForStack, waitForReady,
|
|
2
|
+
gitlabPipeline, lifecycleSnapshot, shell, ensureSecret, teardown, envTeardown, k3dUp, k3dDown,
|
|
3
|
+
k3sInstall, k3sUninstall, flociUp, flociDown,
|
|
3
4
|
flociAzUp, flociAzDown, flociGcpUp, flociGcpDown, httpCheck,
|
|
4
5
|
azGroupEnsure, azGroupDelete, azApply, azDelete, awsApply, awsDelete, gcpApply, gcpDelete, policyGate,
|
|
5
6
|
spriteCreate, spriteExec, spriteCheckpoint, spriteRestore, listCheckpoints, spriteDestroy,
|
|
@@ -13,7 +14,13 @@ export { emulatorLifecycle, emulatorsOf, endpointEnvVars } from "./emulator-life
|
|
|
13
14
|
export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle";
|
|
14
15
|
export { checkFreshness, compare, formatResult, latestRelease, parseVersion, unpinned } from "./emulator-freshness";
|
|
15
16
|
export type { FreshnessResult } from "./emulator-freshness";
|
|
16
|
-
export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./types";
|
|
17
|
+
export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep } from "./types";
|
|
18
|
+
export { receiptActivities, receiptCheckInput } from "./receipt-store";
|
|
19
|
+
export type {
|
|
20
|
+
ReceiptStore, EffectReceiptRef, ReceiptCheckInput, ReceiptActivities, ReceiptActivityOptions,
|
|
21
|
+
ReceiptReadArgs, ReceiptReadResult, ReceiptWriteArgs,
|
|
22
|
+
ReceiptStalenessArgs, ReceiptStalenessResult, ReceiptStaleFinding,
|
|
23
|
+
} from "./receipt-store";
|
|
17
24
|
export { discoverOps } from "./discover";
|
|
18
25
|
export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
|
|
19
26
|
export { loadActivities, loadProfiles, resolveActivity } from "./activity-registry";
|
package/src/op/local-executor.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* so core never statically depends on `@intentius/chant-lexicon-temporal`.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { OpConfig, PhaseDefinition, ActivityStep, GateStep, StepDefinition } from "./types";
|
|
14
|
+
import type { OpConfig, PhaseDefinition, ActivityStep, GateStep, EffectStep, StepDefinition } from "./types";
|
|
15
15
|
import { resolveActivity, type ActivityFn, type ActivityProfile } from "./activity-registry";
|
|
16
|
+
import type { ReceiptReadResult } from "./receipt-store";
|
|
16
17
|
|
|
17
18
|
// ── Records ─────────────────────────────────────────────────────────────────
|
|
18
19
|
|
|
@@ -69,6 +70,7 @@ const FALLBACK_TIMEOUT_MS = 5 * 60_000;
|
|
|
69
70
|
|
|
70
71
|
const isActivity = (s: StepDefinition): s is ActivityStep => s.kind === "activity";
|
|
71
72
|
const isGate = (s: StepDefinition): s is GateStep => s.kind === "gate";
|
|
73
|
+
const isEffect = (s: StepDefinition): s is EffectStep => s.kind === "effect";
|
|
72
74
|
|
|
73
75
|
/** Parse a Temporal duration string ("5m", "30s", "1h30m", "100ms") to ms. */
|
|
74
76
|
export function parseDuration(s: string): number {
|
|
@@ -92,12 +94,18 @@ function resolvePath(value: unknown, path?: string): unknown {
|
|
|
92
94
|
);
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
/** Find the first gate step anywhere in the Op (phases + onFailure
|
|
97
|
+
/** Find the first gate step anywhere in the Op (phases + onFailure, including
|
|
98
|
+
* gates nested inside effect steps), if any. */
|
|
96
99
|
export function findGate(config: OpConfig): GateStep | undefined {
|
|
97
100
|
const all = [...config.phases, ...(config.onFailure ?? [])];
|
|
98
101
|
for (const phase of all) {
|
|
99
|
-
const
|
|
100
|
-
|
|
102
|
+
for (const step of phase.steps) {
|
|
103
|
+
if (isGate(step)) return step;
|
|
104
|
+
if (isEffect(step)) {
|
|
105
|
+
const nested = step.steps.find(isGate);
|
|
106
|
+
if (nested) return nested;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
101
109
|
}
|
|
102
110
|
return undefined;
|
|
103
111
|
}
|
|
@@ -145,6 +153,13 @@ async function callWithTimeout(
|
|
|
145
153
|
|
|
146
154
|
// ── Step + phase execution ────────────────────────────────────────────────��─
|
|
147
155
|
|
|
156
|
+
/** A finished step: its record plus (on success) the activity's return value —
|
|
157
|
+
* the effect-step path needs `receiptRead`'s result, not just its status. */
|
|
158
|
+
interface RanStep {
|
|
159
|
+
record: StepRecord;
|
|
160
|
+
result?: unknown;
|
|
161
|
+
}
|
|
162
|
+
|
|
148
163
|
/** Run one activity step with retry + timeout. Never throws — returns a record. */
|
|
149
164
|
async function runStep(
|
|
150
165
|
step: ActivityStep,
|
|
@@ -152,7 +167,7 @@ async function runStep(
|
|
|
152
167
|
activities: Map<string, ActivityFn>,
|
|
153
168
|
profiles: Record<string, ActivityProfile>,
|
|
154
169
|
signal?: AbortSignal,
|
|
155
|
-
): Promise<
|
|
170
|
+
): Promise<RanStep> {
|
|
156
171
|
const args = step.args ?? {};
|
|
157
172
|
const base = { phase: phaseName, fn: step.fn, args };
|
|
158
173
|
const start = Date.now();
|
|
@@ -161,7 +176,7 @@ async function runStep(
|
|
|
161
176
|
try {
|
|
162
177
|
fn = resolveActivity(activities, step.fn);
|
|
163
178
|
} catch (err) {
|
|
164
|
-
return { ...base, status: "fail", durationMs: 0, error: errMessage(err) };
|
|
179
|
+
return { record: { ...base, status: "fail", durationMs: 0, error: errMessage(err) } };
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
const profile = profiles[step.profile ?? DEFAULT_PROFILE] ?? {};
|
|
@@ -190,7 +205,7 @@ async function runStep(
|
|
|
190
205
|
value: resolvePath(result, step.outcomeAttribute.from),
|
|
191
206
|
};
|
|
192
207
|
}
|
|
193
|
-
return record;
|
|
208
|
+
return { record, result };
|
|
194
209
|
} catch (err) {
|
|
195
210
|
lastErr = err;
|
|
196
211
|
// Stop retrying on abort (Ctrl-C / timeout cascade) or a non-retryable error.
|
|
@@ -204,7 +219,105 @@ async function runStep(
|
|
|
204
219
|
break;
|
|
205
220
|
}
|
|
206
221
|
}
|
|
207
|
-
return { ...base, status: "fail", durationMs: Date.now() - start, error: errMessage(lastErr) };
|
|
222
|
+
return { record: { ...base, status: "fail", durationMs: Date.now() - start, error: errMessage(lastErr) } };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Effect steps (#1834) ──────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/** The step data the executor synthesizes to read a receipt through the store
|
|
228
|
+
* activities (`receiptRead`/`receiptWrite` — provided by the receipt row's
|
|
229
|
+
* lexicon, #1835, or a mock store in tests via `receiptActivities`). */
|
|
230
|
+
function receiptReadStep(step: EffectStep): ActivityStep {
|
|
231
|
+
return {
|
|
232
|
+
kind: "activity",
|
|
233
|
+
fn: "receiptRead",
|
|
234
|
+
args: {
|
|
235
|
+
receipt: step.receipt,
|
|
236
|
+
...(step.expectation !== undefined ? { expectation: step.expectation } : {}),
|
|
237
|
+
},
|
|
238
|
+
profile: "fastIdempotent",
|
|
239
|
+
outcomeAttribute: { name: "EffectApplied", from: "applied" },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A skipped-record for a step that will not run. */
|
|
244
|
+
function skippedRecord(phaseName: string, fn: string, args?: Record<string, unknown>): StepRecord {
|
|
245
|
+
return { phase: phaseName, fn, args: args ?? {}, status: "skipped", durationMs: 0 };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Run one effect step: read-compare-run-write. On a match the nested steps are
|
|
250
|
+
* recorded as skipped ("effect already applied") and nothing is written. On a
|
|
251
|
+
* mismatch the nested steps run in authored order; only when every one
|
|
252
|
+
* succeeds is the receipt written — last, once (the sole writer, #1703
|
|
253
|
+
* decision 3). Any failure leaves the receipt untouched (stale), so the next
|
|
254
|
+
* run re-proposes the effect.
|
|
255
|
+
*/
|
|
256
|
+
async function runEffectStep(
|
|
257
|
+
step: EffectStep,
|
|
258
|
+
phaseName: string,
|
|
259
|
+
activities: Map<string, ActivityFn>,
|
|
260
|
+
profiles: Record<string, ActivityProfile>,
|
|
261
|
+
signal?: AbortSignal,
|
|
262
|
+
): Promise<{ records: StepRecord[]; failed: boolean }> {
|
|
263
|
+
const records: StepRecord[] = [];
|
|
264
|
+
|
|
265
|
+
const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, signal);
|
|
266
|
+
records.push(read.record);
|
|
267
|
+
if (read.record.status === "fail") return { records, failed: true };
|
|
268
|
+
|
|
269
|
+
const result = read.result as Partial<ReceiptReadResult> | undefined;
|
|
270
|
+
if (typeof result?.expectation !== "string") {
|
|
271
|
+
records.push({
|
|
272
|
+
phase: phaseName,
|
|
273
|
+
fn: `effect:${step.receipt.name}`,
|
|
274
|
+
args: {},
|
|
275
|
+
status: "fail",
|
|
276
|
+
durationMs: 0,
|
|
277
|
+
error: "receiptRead returned no expectation — the receipt store activity must return { current, expectation }",
|
|
278
|
+
});
|
|
279
|
+
return { records, failed: true };
|
|
280
|
+
}
|
|
281
|
+
const expectation = result.expectation;
|
|
282
|
+
|
|
283
|
+
if (result.current === expectation) {
|
|
284
|
+
// Effect already applied — skip the nested steps, write nothing.
|
|
285
|
+
for (const nested of step.steps) {
|
|
286
|
+
if (nested.kind === "activity") records.push(skippedRecord(phaseName, nested.fn, nested.args));
|
|
287
|
+
}
|
|
288
|
+
return { records, failed: false };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Gates are pre-flighted by findGate; only activities remain here.
|
|
292
|
+
const nestedActivities = step.steps.filter(isActivity);
|
|
293
|
+
for (let i = 0; i < nestedActivities.length; i++) {
|
|
294
|
+
const ran = await runStep(nestedActivities[i], phaseName, activities, profiles, signal);
|
|
295
|
+
records.push(ran.record);
|
|
296
|
+
if (ran.record.status === "fail") {
|
|
297
|
+
// Receipt left untouched (stale) — the next run re-proposes the effect.
|
|
298
|
+
for (const skipped of nestedActivities.slice(i + 1)) {
|
|
299
|
+
records.push(skippedRecord(phaseName, skipped.fn, skipped.args));
|
|
300
|
+
}
|
|
301
|
+
records.push(skippedRecord(phaseName, "receiptWrite"));
|
|
302
|
+
return { records, failed: true };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Sole writer of the receipt: on success of every nested step, last.
|
|
307
|
+
const wrote = await runStep(
|
|
308
|
+
{
|
|
309
|
+
kind: "activity",
|
|
310
|
+
fn: "receiptWrite",
|
|
311
|
+
args: { receipt: step.receipt, expectation },
|
|
312
|
+
profile: "fastIdempotent",
|
|
313
|
+
},
|
|
314
|
+
phaseName,
|
|
315
|
+
activities,
|
|
316
|
+
profiles,
|
|
317
|
+
signal,
|
|
318
|
+
);
|
|
319
|
+
records.push(wrote.record);
|
|
320
|
+
return { records, failed: wrote.record.status === "fail" };
|
|
208
321
|
}
|
|
209
322
|
|
|
210
323
|
/** Run a phase. Throws PhaseFailure (with records so far) if any step fails. */
|
|
@@ -214,35 +327,57 @@ async function runPhase(
|
|
|
214
327
|
profiles: Record<string, ActivityProfile>,
|
|
215
328
|
signal?: AbortSignal,
|
|
216
329
|
): Promise<StepRecord[]> {
|
|
217
|
-
// Defensive: gates are pre-flighted, but never execute one if it slips
|
|
218
|
-
|
|
330
|
+
// Defensive: gates are pre-flighted, but never execute one if it slips
|
|
331
|
+
// through — including a gate nested inside an effect step.
|
|
332
|
+
const gate =
|
|
333
|
+
phase.steps.find(isGate) ??
|
|
334
|
+
phase.steps.filter(isEffect).flatMap((e) => e.steps).find(isGate);
|
|
219
335
|
if (gate) throw new LocalGateUnsupportedError(gate.signalName);
|
|
220
336
|
|
|
221
|
-
const steps = phase.steps.filter(isActivity);
|
|
222
|
-
|
|
223
337
|
if (phase.parallel) {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
338
|
+
const eff = phase.steps.find(isEffect);
|
|
339
|
+
if (eff) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`effect step "${eff.receipt.name}" cannot run in a parallel phase — read-compare-run-write is ordered`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const steps = phase.steps.filter(isActivity);
|
|
345
|
+
const records = (
|
|
346
|
+
await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, signal)))
|
|
347
|
+
).map((r) => r.record);
|
|
227
348
|
if (records.some((r) => r.status === "fail")) throw new PhaseFailure(records);
|
|
228
349
|
return records;
|
|
229
350
|
}
|
|
230
351
|
|
|
352
|
+
const steps = phase.steps.filter((s): s is ActivityStep | EffectStep => !isGate(s));
|
|
231
353
|
const records: StepRecord[] = [];
|
|
354
|
+
|
|
355
|
+
const skipRemaining = (from: number) => {
|
|
356
|
+
for (const skipped of steps.slice(from)) {
|
|
357
|
+
if (isEffect(skipped)) {
|
|
358
|
+
records.push(skippedRecord(phase.name, `effect:${skipped.receipt.name}`));
|
|
359
|
+
} else {
|
|
360
|
+
records.push(skippedRecord(phase.name, skipped.fn, skipped.args));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
232
365
|
for (let i = 0; i < steps.length; i++) {
|
|
233
|
-
const
|
|
366
|
+
const step = steps[i];
|
|
367
|
+
if (isEffect(step)) {
|
|
368
|
+
const { records: effRecords, failed } = await runEffectStep(step, phase.name, activities, profiles, signal);
|
|
369
|
+
records.push(...effRecords);
|
|
370
|
+
if (failed) {
|
|
371
|
+
skipRemaining(i + 1);
|
|
372
|
+
throw new PhaseFailure(records);
|
|
373
|
+
}
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const { record } = await runStep(step, phase.name, activities, profiles, signal);
|
|
234
377
|
records.push(record);
|
|
235
378
|
if (record.status === "fail") {
|
|
236
379
|
// Mark the remaining steps in this phase as skipped, then abort.
|
|
237
|
-
|
|
238
|
-
records.push({
|
|
239
|
-
phase: phase.name,
|
|
240
|
-
fn: skipped.fn,
|
|
241
|
-
args: skipped.args ?? {},
|
|
242
|
-
status: "skipped",
|
|
243
|
-
durationMs: 0,
|
|
244
|
-
});
|
|
245
|
-
}
|
|
380
|
+
skipRemaining(i + 1);
|
|
246
381
|
throw new PhaseFailure(records);
|
|
247
382
|
}
|
|
248
383
|
}
|
|
@@ -266,6 +401,18 @@ export async function runOpLocally(
|
|
|
266
401
|
const gate = findGate(config);
|
|
267
402
|
if (gate) throw new LocalGateUnsupportedError(gate.signalName);
|
|
268
403
|
|
|
404
|
+
// Effect steps are ordered (read-compare-run-write): refuse them in a
|
|
405
|
+
// parallel phase up front, with the phase named, rather than mid-run.
|
|
406
|
+
for (const phase of [...config.phases, ...(config.onFailure ?? [])]) {
|
|
407
|
+
const eff = phase.parallel ? phase.steps.find(isEffect) : undefined;
|
|
408
|
+
if (eff) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`phase "${phase.name}": effect step "${eff.receipt.name}" cannot run in a ` +
|
|
411
|
+
`parallel phase — read-compare-run-write is ordered`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
269
416
|
const records: StepRecord[] = [];
|
|
270
417
|
const start = Date.now();
|
|
271
418
|
|
package/src/op/op.test.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, it } from "vitest";
|
|
6
|
-
import { Op, phase, activity, gate, build, kubectlApply, helmInstall,
|
|
7
|
-
waitForStack, gitlabPipeline, lifecycleSnapshot, shell, teardown, policyGate } from "./builders";
|
|
6
|
+
import { Op, phase, activity, gate, build, kubectlApply, helmInstall, helmInstallPinned,
|
|
7
|
+
waitForStack, gitlabPipeline, lifecycleSnapshot, shell, ensureSecret, teardown, policyGate } from "./builders";
|
|
8
8
|
import { DECLARABLE_MARKER, type Declarable } from "../declarable";
|
|
9
9
|
|
|
10
10
|
// ── Op() ──────────────────────────────────────────────────────────────────────
|
|
@@ -163,6 +163,16 @@ describe("pre-built shortcuts", () => {
|
|
|
163
163
|
expect(a.profile).toBe("longInfra");
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
+
it("helmInstallPinned() produces a helmInstall activity keyed by contentDigest, no chart", () => {
|
|
167
|
+
const a = helmInstallPinned("my-release", "sha256:abc", { namespace: "web" });
|
|
168
|
+
expect(a.fn).toBe("helmInstall");
|
|
169
|
+
expect(a.args?.name).toBe("my-release");
|
|
170
|
+
expect(a.args?.contentDigest).toBe("sha256:abc");
|
|
171
|
+
expect(a.args?.namespace).toBe("web");
|
|
172
|
+
expect(a.args?.chart).toBeUndefined();
|
|
173
|
+
expect(a.profile).toBe("longInfra");
|
|
174
|
+
});
|
|
175
|
+
|
|
166
176
|
it("waitForStack() produces waitForStack activity with k8sWait profile", () => {
|
|
167
177
|
const a = waitForStack("my-stack");
|
|
168
178
|
expect(a.fn).toBe("waitForStack");
|
|
@@ -190,6 +200,19 @@ describe("pre-built shortcuts", () => {
|
|
|
190
200
|
expect(a.args?.cmd).toBe("echo hello");
|
|
191
201
|
});
|
|
192
202
|
|
|
203
|
+
it("ensureSecret() produces ensureSecret activity with name/keys args and no material-shaped field", () => {
|
|
204
|
+
const a = ensureSecret("master-key", ["MASTER_SECRETS_KEY"], {
|
|
205
|
+
metadata: { "chant.dev/provenance": "generated-once" },
|
|
206
|
+
});
|
|
207
|
+
expect(a.fn).toBe("ensureSecret");
|
|
208
|
+
expect(a.args?.name).toBe("master-key");
|
|
209
|
+
expect(a.args?.keys).toEqual(["MASTER_SECRETS_KEY"]);
|
|
210
|
+
expect(a.args?.metadata).toEqual({ "chant.dev/provenance": "generated-once" });
|
|
211
|
+
// The step is contract-only: nothing on it could carry secret material.
|
|
212
|
+
expect(a.args && "value" in a.args).toBe(false);
|
|
213
|
+
expect(a.args && "data" in a.args).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
193
216
|
it("teardown() produces chantTeardown activity with longInfra profile", () => {
|
|
194
217
|
const a = teardown("./project");
|
|
195
218
|
expect(a.fn).toBe("chantTeardown");
|