@intentius/chant 0.52.2 → 0.53.1
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/cdk/advise.d.ts +29 -0
- package/dist/cdk/advise.d.ts.map +1 -0
- package/dist/cdk/assembly.d.ts +38 -0
- package/dist/cdk/assembly.d.ts.map +1 -0
- package/dist/cdk/graph.d.ts +68 -0
- package/dist/cdk/graph.d.ts.map +1 -0
- package/dist/cdk/tier-map.d.ts +44 -0
- package/dist/cdk/tier-map.d.ts.map +1 -0
- package/dist/cdk/types.d.ts +114 -0
- package/dist/cdk/types.d.ts.map +1 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/carve.d.ts +48 -6
- package/dist/cli/commands/carve.d.ts.map +1 -1
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
- package/dist/cli/handlers/carve.d.ts +5 -4
- package/dist/cli/handlers/carve.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +10 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/op-progress.d.ts +37 -4
- package/dist/cli/handlers/op-progress.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +61 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +9 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/identity.d.ts +196 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +41 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +81 -1
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +43 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +17 -66
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts +1 -1
- package/dist/terraform/carve-provider.d.ts +142 -0
- package/dist/terraform/carve-provider.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +36 -3
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/emit-source.d.ts +25 -0
- package/dist/terraform/emit-source.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +11 -1
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/providers/aws.d.ts +19 -0
- package/dist/terraform/providers/aws.d.ts.map +1 -0
- package/dist/terraform/providers/gcp.d.ts +41 -0
- package/dist/terraform/providers/gcp.d.ts.map +1 -0
- package/dist/terraform/providers/index.d.ts +15 -0
- package/dist/terraform/providers/index.d.ts.map +1 -0
- package/dist/terraform/providers/kubernetes.d.ts +29 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +70 -4
- package/dist/terraform/score.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +30 -35
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +6 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
- package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
- package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
- package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
- package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
- package/src/cdk/advise.test.ts +208 -0
- package/src/cdk/advise.ts +44 -0
- package/src/cdk/assembly.ts +133 -0
- package/src/cdk/graph.test.ts +206 -0
- package/src/cdk/graph.ts +525 -0
- package/src/cdk/tier-map.ts +71 -0
- package/src/cdk/types.ts +115 -0
- package/src/cli/commands/carve-apply.ts +20 -4
- package/src/cli/commands/carve-bridge.ts +9 -5
- package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
- package/src/cli/commands/carve-emit-provider.test.ts +207 -0
- package/src/cli/commands/carve-emit.ts +28 -24
- package/src/cli/commands/carve.ts +139 -36
- package/src/cli/handlers/carve-emit.ts +8 -3
- package/src/cli/handlers/carve.ts +8 -7
- package/src/cli/handlers/lifecycle.test.ts +187 -1
- package/src/cli/handlers/lifecycle.ts +125 -1
- package/src/cli/handlers/op-progress.test.ts +67 -1
- package/src/cli/handlers/op-progress.ts +70 -14
- package/src/cli/handlers/operator.test.ts +302 -1
- package/src/cli/handlers/operator.ts +193 -5
- package/src/cli/handlers/run.ts +11 -13
- package/src/cli/main.test.ts +19 -0
- package/src/cli/main.ts +38 -10
- package/src/cli/registry.ts +9 -1
- package/src/identity.test.ts +199 -0
- package/src/identity.ts +346 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +53 -0
- package/src/lifecycle/converge-ledger.test.ts +110 -0
- package/src/lifecycle/converge-ledger.ts +110 -2
- package/src/lifecycle/gate-ledger.test.ts +81 -1
- package/src/lifecycle/gate-ledger.ts +64 -1
- package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
- package/src/terraform/adopt-state.test.ts +131 -0
- package/src/terraform/adopt-state.ts +19 -167
- package/src/terraform/aws-resources.test.ts +38 -22
- package/src/terraform/aws-resources.ts +1 -1
- package/src/terraform/carve-provider.test.ts +155 -0
- package/src/terraform/carve-provider.ts +237 -0
- package/src/terraform/carve.test.ts +55 -1
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/emit-source.ts +39 -0
- package/src/terraform/graduate.test.ts +37 -0
- package/src/terraform/graduate.ts +55 -7
- package/src/terraform/graph.ts +3 -3
- package/src/terraform/providers/aws.ts +169 -0
- package/src/terraform/providers/gcp.test.ts +228 -0
- package/src/terraform/providers/gcp.ts +329 -0
- package/src/terraform/providers/index.ts +21 -0
- package/src/terraform/providers/kubernetes.ts +224 -0
- package/src/terraform/score.ts +111 -25
- package/src/terraform/tier-map.ts +45 -107
- package/src/terraform/types.ts +6 -0
package/src/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ export * from "./import/parser";
|
|
|
53
53
|
export * from "./import/generator";
|
|
54
54
|
export * from "./lexicon";
|
|
55
55
|
export * from "./observation";
|
|
56
|
+
export * from "./identity";
|
|
56
57
|
export * from "./apply";
|
|
57
58
|
export * from "./deep-observation";
|
|
58
59
|
export * from "./owner-chain";
|
package/src/lexicon.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { RuleMeta } from "./audit/catalog";
|
|
|
18
18
|
import type { ReferenceCatalog } from "./graph-refs";
|
|
19
19
|
import type { IREdge } from "./graph-ir";
|
|
20
20
|
import type { DescribeResourcesResult, UnobservedReason } from "./observation";
|
|
21
|
+
import type { DescribeIdentityOptions, DescribeIdentityResult } from "./identity";
|
|
21
22
|
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
22
23
|
import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
|
|
23
24
|
import type { OwnerChainVerdict } from "./owner-chain";
|
|
@@ -46,6 +47,18 @@ export type {
|
|
|
46
47
|
UnobservedReason,
|
|
47
48
|
} from "./observation";
|
|
48
49
|
|
|
50
|
+
// The identity contract (#1982), re-exported from the same entry so a
|
|
51
|
+
// lexicon's `describeIdentity` types itself without a second import path.
|
|
52
|
+
// Runtime helpers live in `@intentius/chant/identity`.
|
|
53
|
+
export type {
|
|
54
|
+
DescribeIdentityOptions,
|
|
55
|
+
DescribeIdentityResult,
|
|
56
|
+
ResolvedIdentity,
|
|
57
|
+
UnresolvedIdentity,
|
|
58
|
+
IdentityRow,
|
|
59
|
+
IdentityStatus,
|
|
60
|
+
} from "./identity";
|
|
61
|
+
|
|
49
62
|
// Disruption classification (#1665), re-exported from the same entry so a
|
|
50
63
|
// lexicon's `classifyDisruption` types itself without a second import path.
|
|
51
64
|
// Runtime helpers live in `@intentius/chant/lifecycle/disruption`.
|
|
@@ -1083,6 +1096,46 @@ export interface LexiconPlugin {
|
|
|
1083
1096
|
*/
|
|
1084
1097
|
describeStackStatus?(options: { environment: string; stack: string }): Promise<StackStatusObservation | null>;
|
|
1085
1098
|
|
|
1099
|
+
/**
|
|
1100
|
+
* Report the principal chant would act as in this substrate, and the scope
|
|
1101
|
+
* that principal resolves to (#1982) — what `chant lifecycle whoami <env>`
|
|
1102
|
+
* prints, one row per lexicon, before anything acts.
|
|
1103
|
+
*
|
|
1104
|
+
* Every implementation of {@link describeResources} already resolves this
|
|
1105
|
+
* and throws it away: the bind reaches the provider on the applier's own
|
|
1106
|
+
* transport, the k8s connector resolves a context and knows which binding
|
|
1107
|
+
* produced it, the aws read client resolves a region and an endpoint. The
|
|
1108
|
+
* answer has only ever surfaced as a `no-credentials` hole or, when the
|
|
1109
|
+
* binding was wrong rather than missing, as a clean read of the wrong
|
|
1110
|
+
* account.
|
|
1111
|
+
*
|
|
1112
|
+
* Implement it with the substrate's own cheap self-query —
|
|
1113
|
+
* `sts:GetCallerIdentity`, `SelfSubjectReview`, the token introspection the
|
|
1114
|
+
* forge already exposes. Two rules bind the implementation:
|
|
1115
|
+
*
|
|
1116
|
+
* - **Read-only.** `whoami` is pre-flight and never a gate. The self-query
|
|
1117
|
+
* must be one the substrate treats as a read of the caller's own identity;
|
|
1118
|
+
* it creates nothing and changes nothing.
|
|
1119
|
+
* - **No credential in the answer.** An identity is a principal and a scope,
|
|
1120
|
+
* never a token, key or password. Where a substrate's only identity signal
|
|
1121
|
+
* IS a secret, report that fact in `source` without the value. Core
|
|
1122
|
+
* redacts credential material from every field as a backstop, which is not
|
|
1123
|
+
* a reason to put one there.
|
|
1124
|
+
*
|
|
1125
|
+
* `endpoint` must be the address this lexicon's live read resolves for the
|
|
1126
|
+
* same environment. A whoami that names a binding the read does not use is
|
|
1127
|
+
* worse than no whoami, so a test pins the two together.
|
|
1128
|
+
*
|
|
1129
|
+
* Returning `{ unresolved: { reason, detail } }` is a real answer and the
|
|
1130
|
+
* required one when the identity cannot be resolved — `no-credentials` for
|
|
1131
|
+
* "nothing is configured", `no-binding` for "this environment resolves to no
|
|
1132
|
+
* target", `read-failed` for a self-query that errored. A throw degrades to
|
|
1133
|
+
* `read-failed` for this lexicon alone and never fails the command. Omitting
|
|
1134
|
+
* the method entirely reports `not reported`, which is honest; it is never
|
|
1135
|
+
* rendered as an empty identity.
|
|
1136
|
+
*/
|
|
1137
|
+
describeIdentity?(options: DescribeIdentityOptions): Promise<DescribeIdentityResult>;
|
|
1138
|
+
|
|
1086
1139
|
/**
|
|
1087
1140
|
* Enumerate the resources this lexicon would delete for one marker identity
|
|
1088
1141
|
* (#1222). Opt-in, and read-only here: this method names the would-delete
|
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
appendConvergeRecord,
|
|
8
8
|
readConvergeLedger,
|
|
9
9
|
consecutiveRuleFires,
|
|
10
|
+
componentVerdicts,
|
|
10
11
|
type ConvergeTickRecordInput,
|
|
11
12
|
type ConvergeTickRecord,
|
|
12
13
|
} from "./converge-ledger";
|
|
14
|
+
import type { ComponentStatusRow } from "./status";
|
|
13
15
|
|
|
14
16
|
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
15
17
|
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
@@ -171,6 +173,114 @@ describe("converge-ledger", () => {
|
|
|
171
173
|
});
|
|
172
174
|
});
|
|
173
175
|
|
|
176
|
+
// ── Per-component verdicts and tick id (#2027) ─────────────────────────────
|
|
177
|
+
|
|
178
|
+
describe("tick id", () => {
|
|
179
|
+
test("mints one per record, and two ticks in the same ISO second are still distinguishable", async () => {
|
|
180
|
+
await withTestDir(async (dir) => {
|
|
181
|
+
await initRepo(dir);
|
|
182
|
+
const a = await appendConvergeRecord(makeInput(), { cwd: dir });
|
|
183
|
+
const b = await appendConvergeRecord(makeInput(), { cwd: dir });
|
|
184
|
+
|
|
185
|
+
expect(a.record.id).toMatch(/^[0-9a-f-]{36}$/);
|
|
186
|
+
expect(a.record.timestamp).toBe(b.record.timestamp);
|
|
187
|
+
expect(a.record.id).not.toBe(b.record.id);
|
|
188
|
+
|
|
189
|
+
const { records } = await readConvergeLedger("staging", { cwd: dir });
|
|
190
|
+
expect(records.map((r) => r.id)).toEqual([a.record.id, b.record.id]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("an explicitly supplied id wins over the mint", async () => {
|
|
195
|
+
await withTestDir(async (dir) => {
|
|
196
|
+
await initRepo(dir);
|
|
197
|
+
const { record } = await appendConvergeRecord(makeInput({ id: "tick-fixed" }), { cwd: dir });
|
|
198
|
+
expect(record.id).toBe("tick-fixed");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a pre-#2027 record with no id still reads, rather than being counted malformed", async () => {
|
|
203
|
+
await withTestDir(async (dir) => {
|
|
204
|
+
await initRepo(dir);
|
|
205
|
+
await appendConvergeRecord(makeInput(), { cwd: dir });
|
|
206
|
+
// Hand-write the pre-#2027 shape (no `id`) onto the same ledger,
|
|
207
|
+
// through the same git plumbing a real writer used before the field
|
|
208
|
+
// existed.
|
|
209
|
+
const { readBlobFromPath, writeBlobToPath } = await import("./git");
|
|
210
|
+
const legacy = JSON.stringify({ version: 1, ...makeInput({ timestamp: "2025-12-31T00:00:00.000Z" }) });
|
|
211
|
+
const existing = await readBlobFromPath("staging", "converge.jsonl", { cwd: dir });
|
|
212
|
+
await writeBlobToPath("staging", "converge.jsonl", `${existing}\n${legacy}`, "legacy", { cwd: dir });
|
|
213
|
+
|
|
214
|
+
const { records, malformed } = await readConvergeLedger("staging", { cwd: dir });
|
|
215
|
+
expect(malformed).toBe(0);
|
|
216
|
+
expect(records).toHaveLength(2);
|
|
217
|
+
expect(records[1].id).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("componentVerdicts", () => {
|
|
223
|
+
const row = (over: Partial<ComponentStatusRow>): ComponentStatusRow => ({
|
|
224
|
+
component: "svc",
|
|
225
|
+
env: "staging",
|
|
226
|
+
reconciliation: "reconciled",
|
|
227
|
+
detail: "digest matches live",
|
|
228
|
+
...over,
|
|
229
|
+
} as ComponentStatusRow);
|
|
230
|
+
|
|
231
|
+
test("keeps the verdict-bearing subset and drops the heavy release/build fields", () => {
|
|
232
|
+
const rows = [
|
|
233
|
+
row({ component: "api", reconciliation: "drifted", detail: "live digest differs", live: true,
|
|
234
|
+
recorded: { version: 1 } as unknown as ComponentStatusRow["recorded"] }),
|
|
235
|
+
row({ component: "worker", reconciliation: "unknown", detail: "could not read live state",
|
|
236
|
+
unobserved: { reason: "no-credentials", detail: "no role assumed" } }),
|
|
237
|
+
];
|
|
238
|
+
expect(componentVerdicts(rows)).toEqual([
|
|
239
|
+
{ component: "api", reconciliation: "drifted", detail: "live digest differs", live: true },
|
|
240
|
+
{
|
|
241
|
+
component: "worker",
|
|
242
|
+
reconciliation: "unknown",
|
|
243
|
+
detail: "could not read live state",
|
|
244
|
+
unobserved: { reason: "no-credentials", detail: "no role assumed" },
|
|
245
|
+
},
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("caps a multi-line or oversized detail to one line, so the record stays one line of JSON", () => {
|
|
250
|
+
const verdicts = componentVerdicts([
|
|
251
|
+
row({ detail: "first line\nsecond line" }),
|
|
252
|
+
row({ component: "big", detail: "x".repeat(500) }),
|
|
253
|
+
]);
|
|
254
|
+
expect(verdicts[0].detail).toBe("first line");
|
|
255
|
+
expect(verdicts[1].detail).toHaveLength(301); // 300 + the ellipsis
|
|
256
|
+
expect(JSON.stringify(verdicts)).not.toContain("\\n");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("round-trips on a record, naming the component that tripped the tick's aggregate unknown", async () => {
|
|
260
|
+
await withTestDir(async (dir) => {
|
|
261
|
+
await initRepo(dir);
|
|
262
|
+
const { record } = await appendConvergeRecord(
|
|
263
|
+
makeInput({
|
|
264
|
+
components: componentVerdicts([
|
|
265
|
+
row({ component: "api", reconciliation: "drifted", detail: "live digest differs", live: true }),
|
|
266
|
+
row({ component: "worker", reconciliation: "unknown", detail: "unreadable",
|
|
267
|
+
unobserved: { reason: "no-credentials" } }),
|
|
268
|
+
]),
|
|
269
|
+
summary: { drifted: 1, remediated: 0, reported: 1, skippedBudget: 0, skippedFlap: 0, unobserved: 1, adopted: 0 },
|
|
270
|
+
}),
|
|
271
|
+
{ cwd: dir },
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const { records } = await readConvergeLedger("staging", { cwd: dir });
|
|
275
|
+
expect(records).toEqual([record]);
|
|
276
|
+
// The count says "1 unobserved"; the verdicts say which one.
|
|
277
|
+
expect(records[0].summary.unobserved).toBe(1);
|
|
278
|
+
expect(records[0].components?.filter((c) => c.unobserved).map((c) => c.component)).toEqual(["worker"]);
|
|
279
|
+
expect(records[0].components?.find((c) => c.reconciliation === "drifted")?.component).toBe("api");
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
174
284
|
// ── Concurrent local writers (#1485) ────────────────────────────────────────
|
|
175
285
|
|
|
176
286
|
describe("appendConvergeRecord retries on RefCASConflictError", () => {
|
|
@@ -16,11 +16,29 @@
|
|
|
16
16
|
* row include a given rule id, stopping at the first tick where it didn't
|
|
17
17
|
* fire (the symptom cleared).
|
|
18
18
|
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
19
20
|
import { sortedJsonReplacer } from "../utils";
|
|
21
|
+
import type { ComponentStatusRow } from "./status";
|
|
20
22
|
import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
|
|
21
23
|
|
|
22
24
|
const FILENAME = "converge.jsonl";
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Cap a piece of free text to one sanitized line before it goes into a
|
|
28
|
+
* record. A record here is one line of JSON, so a multi-line or unbounded
|
|
29
|
+
* string folded into any field would break the line the ledger is built out
|
|
30
|
+
* of. Every free-text field a tick writes goes through this: a dispatch
|
|
31
|
+
* failure's `stderr` (lexicons/temporal's `sanitizeOneLine`, which delegates
|
|
32
|
+
* here) and a component verdict's lexicon-authored `detail`.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_LEDGER_TEXT_LEN = 300;
|
|
35
|
+
export function sanitizeLedgerText(raw: string, maxLen = MAX_LEDGER_TEXT_LEN): string {
|
|
36
|
+
const firstLine = raw.split(/\r?\n/, 1)[0] ?? "";
|
|
37
|
+
// Strips literal control bytes; it is not matching on a range boundary.
|
|
38
|
+
const stripped = firstLine.replace(/[\x00-\x1f\x7f]/g, " ").trim();
|
|
39
|
+
return stripped.length > maxLen ? `${stripped.slice(0, maxLen)}…` : stripped;
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
/**
|
|
25
43
|
* Read-modify-append retry budget for {@link appendConvergeRecord} (#1485).
|
|
26
44
|
* `writeBlobToPath`'s ref write is now CAS-guarded (./git.ts) — a conflict
|
|
@@ -50,14 +68,85 @@ export interface ConvergeRuleOutcome {
|
|
|
50
68
|
op?: string;
|
|
51
69
|
/** The gate's signal name, for `action: "gated"`. */
|
|
52
70
|
gateName?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Where this gate's approval happens (#2028), for `action: "gated"` — the
|
|
73
|
+
* PR carrying the change in a gate-as-PR flow, or whatever review surface
|
|
74
|
+
* the dispatching environment knows about.
|
|
75
|
+
*
|
|
76
|
+
* The pending fact is the one a human has to act on, and it used to carry
|
|
77
|
+
* no link at all: `chant operator status`'s pending row was
|
|
78
|
+
* `{rule, op, gate}` and the only affordance it could print was a shell
|
|
79
|
+
* command. Absent when there genuinely is no address — a local tick with no
|
|
80
|
+
* PR behind it — never a synthesized one. See
|
|
81
|
+
* `./gate-ledger.ts`'s `resolveApprovalUrl` for where it comes from, and
|
|
82
|
+
* `GateResolutionRecord.url` for the resolved counterpart.
|
|
83
|
+
*/
|
|
84
|
+
url?: string;
|
|
53
85
|
/** The report reason, for `action: "reported"` (including a flap-damped rule's forced report) — and the human-readable explanation for `action: "gated"`. */
|
|
54
86
|
reason?: string;
|
|
55
87
|
}
|
|
56
88
|
|
|
89
|
+
/**
|
|
90
|
+
* One component's verdict as the tick observed it (#2027) — the per-entity
|
|
91
|
+
* layer behind the tick's single aggregate `status`.
|
|
92
|
+
*
|
|
93
|
+
* A deliberate subset of `ComponentStatusRow`: the five verdict-bearing
|
|
94
|
+
* fields, joined on the same `component` key `chant components status --live
|
|
95
|
+
* --json` emits, and none of the heavy ones. `recorded`, `build` and
|
|
96
|
+
* `componentBom` are dropped on purpose — they are release/build-ledger
|
|
97
|
+
* content, already readable by that same key, and a tick record is one line
|
|
98
|
+
* of JSON appended every tick forever. `detail` goes through
|
|
99
|
+
* {@link sanitizeLedgerText} for the same reason.
|
|
100
|
+
*/
|
|
101
|
+
export interface ConvergeComponentVerdict {
|
|
102
|
+
component: string;
|
|
103
|
+
reconciliation: ComponentStatusRow["reconciliation"];
|
|
104
|
+
/** Human-readable detail backing the verdict, capped to one line. */
|
|
105
|
+
detail: string;
|
|
106
|
+
/** "Observed live", when live evidence was gathered. Absent means "did not look" or "could not look" — see `unobserved`. */
|
|
107
|
+
live?: boolean;
|
|
108
|
+
/** Why live state could not be read for this component (#1089). Mutually exclusive with `live`; this is the row that tripped an aggregate `status: "unknown"`. */
|
|
109
|
+
unobserved?: { reason: string; detail?: string };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Project the tick's `ComponentStatusRow[]` down to what the ledger keeps.
|
|
114
|
+
* Pure; row order is preserved, so a consumer sees components in the same
|
|
115
|
+
* order the status join produced them.
|
|
116
|
+
*/
|
|
117
|
+
export function componentVerdicts(rows: readonly ComponentStatusRow[]): ConvergeComponentVerdict[] {
|
|
118
|
+
return rows.map((row) => ({
|
|
119
|
+
component: row.component,
|
|
120
|
+
reconciliation: row.reconciliation,
|
|
121
|
+
detail: sanitizeLedgerText(row.detail ?? ""),
|
|
122
|
+
...(row.live !== undefined ? { live: row.live } : {}),
|
|
123
|
+
...(row.unobserved
|
|
124
|
+
? {
|
|
125
|
+
unobserved: {
|
|
126
|
+
reason: row.unobserved.reason,
|
|
127
|
+
...(row.unobserved.detail !== undefined ? { detail: sanitizeLedgerText(row.unobserved.detail) } : {}),
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
: {}),
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
57
134
|
/** One immutable converge-tick record. */
|
|
58
135
|
export interface ConvergeTickRecord {
|
|
59
136
|
/** Schema version, so an incompatible future shape is detected before being misread. */
|
|
60
137
|
version: 1;
|
|
138
|
+
/**
|
|
139
|
+
* Stable tick id (#2027) — the one thing an outcome, a gate fact or a
|
|
140
|
+
* remediation can point at. Before this, a tick's identity was the
|
|
141
|
+
* `(op, env, timestamp)` triple, so two ticks landing in the same ISO
|
|
142
|
+
* second were indistinguishable and nothing could reference one.
|
|
143
|
+
*
|
|
144
|
+
* Minted by {@link appendConvergeRecord} with `randomUUID()` (the same
|
|
145
|
+
* mint `./lease.ts` uses for a lease token) when the caller doesn't supply
|
|
146
|
+
* one. Optional on the type because `version: 1` records written before
|
|
147
|
+
* #2027 have none — a reader must handle its absence, not assume it.
|
|
148
|
+
*/
|
|
149
|
+
id?: string;
|
|
61
150
|
/** The ConvergeOp's name (`OpConfig.name`). */
|
|
62
151
|
op: string;
|
|
63
152
|
env: string;
|
|
@@ -67,6 +156,18 @@ export interface ConvergeTickRecord {
|
|
|
67
156
|
firedRuleIds: string[];
|
|
68
157
|
/** Per-rule outcome, for every fired rule. */
|
|
69
158
|
outcomes: ConvergeRuleOutcome[];
|
|
159
|
+
/**
|
|
160
|
+
* The per-component verdicts this tick observed (#2027), behind the
|
|
161
|
+
* aggregate counts in `summary`.
|
|
162
|
+
*
|
|
163
|
+
* The tick derives these to compute `summary.drifted` and the whole-tick
|
|
164
|
+
* `status`, and used to throw them away — so a reader could say "this
|
|
165
|
+
* environment drifted twice this hour" but could not colour the node that
|
|
166
|
+
* drifted, or name the single unobserved component whose `unknown` verdict
|
|
167
|
+
* refused remediation for everything else. Optional because `version: 1`
|
|
168
|
+
* records written before #2027 have none; absent is not "no components".
|
|
169
|
+
*/
|
|
170
|
+
components?: ConvergeComponentVerdict[];
|
|
70
171
|
/** Aggregate counts backing the tick's one log line. */
|
|
71
172
|
summary: {
|
|
72
173
|
drifted: number;
|
|
@@ -90,6 +191,13 @@ export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
|
|
|
90
191
|
* `pushLifecycle` (./git.ts) afterward, same two-step shape every other
|
|
91
192
|
* ledger write here uses.
|
|
92
193
|
*
|
|
194
|
+
* Mints `record.id` (#2027) when the input doesn't carry one, so every tick
|
|
195
|
+
* written from here on is referenceable. Unlike `timestamp`, which stays
|
|
196
|
+
* caller-supplied because library code never calls `Date.now()` internally,
|
|
197
|
+
* an id has nothing for a caller to decide — the mint lives here so no
|
|
198
|
+
* writer can forget it. An explicit `input.id` still wins, which is what a
|
|
199
|
+
* test asserting an exact record uses.
|
|
200
|
+
*
|
|
93
201
|
* Retries the whole read-modify-write cycle (#1485) on `RefCASConflictError`
|
|
94
202
|
* — `writeBlobToPath`'s ref write is CAS-guarded, so a concurrent writer to
|
|
95
203
|
* a *different* env's file on the same orphan branch (two operators ticking
|
|
@@ -108,8 +216,8 @@ export type ConvergeTickRecordInput = Omit<ConvergeTickRecord, "version">;
|
|
|
108
216
|
export async function appendConvergeRecord(
|
|
109
217
|
input: ConvergeTickRecordInput,
|
|
110
218
|
opts?: { cwd?: string },
|
|
111
|
-
): Promise<{ commit: string; record: ConvergeTickRecord }> {
|
|
112
|
-
const record: ConvergeTickRecord = { version: 1, ...input };
|
|
219
|
+
): Promise<{ commit: string; record: ConvergeTickRecord & { id: string } }> {
|
|
220
|
+
const record: ConvergeTickRecord & { id: string } = { version: 1, ...input, id: input.id ?? randomUUID() };
|
|
113
221
|
const json = JSON.stringify(record, sortedJsonReplacer);
|
|
114
222
|
|
|
115
223
|
let lastErr: unknown;
|
|
@@ -3,7 +3,7 @@ import { withTestDir } from "@intentius/chant-test-utils";
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { appendGateResolution, readGateResolutions, latestResolutionSince } from "./gate-ledger";
|
|
6
|
+
import { appendGateResolution, readGateResolutions, latestResolutionSince, resolveApprovalUrl, isApprovalUrl } from "./gate-ledger";
|
|
7
7
|
import { readBlobFromPath } from "./git";
|
|
8
8
|
|
|
9
9
|
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
@@ -100,4 +100,84 @@ describe("lifecycle/gate-ledger", () => {
|
|
|
100
100
|
expect(latestResolutionSince([], "g1", "2026-01-01T00:00:00.000Z")).toBeUndefined();
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
|
+
// ── Gate-as-fact carries an address (#2028) ──────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe("resolveApprovalUrl", () => {
|
|
106
|
+
test("a GitHub Actions pull_request run resolves to that PR", () => {
|
|
107
|
+
expect(resolveApprovalUrl({
|
|
108
|
+
GITHUB_SERVER_URL: "https://github.com",
|
|
109
|
+
GITHUB_REPOSITORY: "INTENTIUS/chant",
|
|
110
|
+
GITHUB_REF_NAME: "2028/merge",
|
|
111
|
+
})).toBe("https://github.com/INTENTIUS/chant/pull/2028");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("honours a GitHub Enterprise server url, trailing slash and all", () => {
|
|
115
|
+
expect(resolveApprovalUrl({
|
|
116
|
+
GITHUB_SERVER_URL: "https://ghe.example.com/",
|
|
117
|
+
GITHUB_REPOSITORY: "org/repo",
|
|
118
|
+
GITHUB_REF_NAME: "7/head",
|
|
119
|
+
})).toBe("https://ghe.example.com/org/repo/pull/7");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a GitLab merge-request pipeline resolves to that MR", () => {
|
|
123
|
+
expect(resolveApprovalUrl({
|
|
124
|
+
CI_MERGE_REQUEST_PROJECT_URL: "https://gitlab.com/org/repo",
|
|
125
|
+
CI_MERGE_REQUEST_IID: "42",
|
|
126
|
+
})).toBe("https://gitlab.com/org/repo/-/merge_requests/42");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a push-event CI run, or no CI at all, has no address — undefined, never a guess", () => {
|
|
130
|
+
expect(resolveApprovalUrl({ GITHUB_REPOSITORY: "org/repo", GITHUB_REF_NAME: "main" })).toBeUndefined();
|
|
131
|
+
expect(resolveApprovalUrl({ GITHUB_REF_NAME: "3/merge" })).toBeUndefined();
|
|
132
|
+
expect(resolveApprovalUrl({ CI_MERGE_REQUEST_PROJECT_URL: "https://gitlab.com/org/repo" })).toBeUndefined();
|
|
133
|
+
expect(resolveApprovalUrl({})).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("isApprovalUrl", () => {
|
|
138
|
+
test("accepts absolute http/https", () => {
|
|
139
|
+
expect(isApprovalUrl("https://github.com/org/repo/pull/1")).toBe(true);
|
|
140
|
+
expect(isApprovalUrl("http://localhost:3000/pr/1")).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("refuses anything a reader could not follow as a link", () => {
|
|
144
|
+
for (const bad of ["", "org/repo/pull/1", "/pull/1", "file:///etc/passwd", "javascript:alert(1)", "not a url"]) {
|
|
145
|
+
expect(isApprovalUrl(bad)).toBe(false);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a resolution round-trips its typed url alongside free-text note", async () => {
|
|
151
|
+
await withTestDir(async (dir) => {
|
|
152
|
+
await initRepo(dir);
|
|
153
|
+
const { record } = await appendGateResolution(
|
|
154
|
+
{
|
|
155
|
+
op: "fountain-apply",
|
|
156
|
+
gate: "rollout-gate",
|
|
157
|
+
resolvedBy: "alex",
|
|
158
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
159
|
+
note: "rolled staging first",
|
|
160
|
+
url: "https://github.com/INTENTIUS/chant/pull/2028",
|
|
161
|
+
},
|
|
162
|
+
{ cwd: dir },
|
|
163
|
+
);
|
|
164
|
+
const { records } = await readGateResolutions("fountain-apply", { cwd: dir });
|
|
165
|
+
expect(records).toEqual([record]);
|
|
166
|
+
expect(records[0].url).toBe("https://github.com/INTENTIUS/chant/pull/2028");
|
|
167
|
+
expect(records[0].note).toBe("rolled staging first");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a pre-#2028 resolution with no url still reads", async () => {
|
|
172
|
+
await withTestDir(async (dir) => {
|
|
173
|
+
await initRepo(dir);
|
|
174
|
+
await appendGateResolution(
|
|
175
|
+
{ op: "fountain-apply", gate: "g", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
176
|
+
{ cwd: dir },
|
|
177
|
+
);
|
|
178
|
+
const { records, malformed } = await readGateResolutions("fountain-apply", { cwd: dir });
|
|
179
|
+
expect(malformed).toBe(0);
|
|
180
|
+
expect(records[0].url).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
103
183
|
});
|
|
@@ -42,6 +42,58 @@ import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASCo
|
|
|
42
42
|
const DIR = "_gates";
|
|
43
43
|
const APPEND_RETRY_ATTEMPTS = 5;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The address of the approval surface for a gate, resolved from the CI
|
|
47
|
+
* environment (#2028).
|
|
48
|
+
*
|
|
49
|
+
* #1485's argument for gate-as-fact was that approval gets an address:
|
|
50
|
+
* "gate-as-PR gives approval a URL, a review surface, and CODEOWNERS as the
|
|
51
|
+
* authorization model." What shipped recorded the gate and not the address,
|
|
52
|
+
* so a pending-approval card had nothing to link to and gate-as-PR stayed a
|
|
53
|
+
* convention. This is the narrow, honest half of that: when a tick (or a
|
|
54
|
+
* `chant approve`) runs inside the PR/MR job that carries the change, the
|
|
55
|
+
* loop genuinely knows where approval happens, and says so. Anywhere else it
|
|
56
|
+
* returns `undefined` and the field is simply absent — never a guess, never a
|
|
57
|
+
* synthesized link.
|
|
58
|
+
*
|
|
59
|
+
* The env-var fallback chain is the same one `--actor` and `--run-id` already
|
|
60
|
+
* use (`../cli/handlers/components.ts`): GitHub Actions first, then GitLab CI.
|
|
61
|
+
*
|
|
62
|
+
* - GitHub Actions on a `pull_request` event: `GITHUB_SERVER_URL` +
|
|
63
|
+
* `GITHUB_REPOSITORY` + the PR number, which `GITHUB_REF_NAME` carries as
|
|
64
|
+
* `<n>/merge`. A push-event run has no PR, so it resolves to nothing.
|
|
65
|
+
* - GitLab CI on a merge-request pipeline: `CI_MERGE_REQUEST_PROJECT_URL` +
|
|
66
|
+
* `CI_MERGE_REQUEST_IID`.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveApprovalUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
69
|
+
const prNumber = /^(\d+)\/(merge|head)$/.exec(env.GITHUB_REF_NAME ?? "")?.[1];
|
|
70
|
+
if (prNumber && env.GITHUB_REPOSITORY) {
|
|
71
|
+
const server = (env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/$/, "");
|
|
72
|
+
return `${server}/${env.GITHUB_REPOSITORY}/pull/${prNumber}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (env.CI_MERGE_REQUEST_PROJECT_URL && env.CI_MERGE_REQUEST_IID) {
|
|
76
|
+
return `${env.CI_MERGE_REQUEST_PROJECT_URL.replace(/\/$/, "")}/-/merge_requests/${env.CI_MERGE_REQUEST_IID}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether `raw` is an address worth recording as one: an absolute `http`/
|
|
84
|
+
* `https` URL. A gate's address is a link a reader is expected to follow, so
|
|
85
|
+
* a relative path or a `file:`/`javascript:` scheme is refused at the CLI
|
|
86
|
+
* boundary rather than written into an immutable record.
|
|
87
|
+
*/
|
|
88
|
+
export function isApprovalUrl(raw: string): boolean {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(raw);
|
|
91
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
45
97
|
/** One immutable gate-resolution record. */
|
|
46
98
|
export interface GateResolutionRecord {
|
|
47
99
|
/** Schema version, so an incompatible future shape is detected before being misread. */
|
|
@@ -54,8 +106,19 @@ export interface GateResolutionRecord {
|
|
|
54
106
|
resolvedBy: string;
|
|
55
107
|
/** ISO-8601 timestamp, caller-supplied (library code never calls `Date.now()` internally). */
|
|
56
108
|
timestamp: string;
|
|
57
|
-
/** Optional free-text context
|
|
109
|
+
/** Optional free-text context. Before #2028 this was also where a PR link went by convention; put the link in {@link GateResolutionRecord.url} instead and leave this for prose. */
|
|
58
110
|
note?: string;
|
|
111
|
+
/**
|
|
112
|
+
* The address this resolution happened at (#2028) — the PR that carried the
|
|
113
|
+
* change, the review thread, whatever the approval surface was. Typed, so
|
|
114
|
+
* "resolved by this PR" is machine-readable instead of a reader sniffing
|
|
115
|
+
* `note` for something that looks like a link.
|
|
116
|
+
*
|
|
117
|
+
* `chant approve --url` sets it; absent when the resolver genuinely had no
|
|
118
|
+
* address (a human at a terminal, no PR). Always an absolute `http`/`https`
|
|
119
|
+
* URL — see {@link isApprovalUrl}.
|
|
120
|
+
*/
|
|
121
|
+
url?: string;
|
|
59
122
|
}
|
|
60
123
|
|
|
61
124
|
export type GateResolutionInput = Omit<GateResolutionRecord, "version">;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# A small google-provider estate for the carve-out advisor (#2017), mixed the
|
|
2
|
+
# same way the AWS sample estate is: clean leaves, a hub with boundary work, a
|
|
3
|
+
# tier-3 map, and one resource with no native mapping at all.
|
|
4
|
+
|
|
5
|
+
# Clean leaf: a bucket nothing else reads.
|
|
6
|
+
resource "google_storage_bucket" "assets" {
|
|
7
|
+
name = "myapp-assets-prod"
|
|
8
|
+
location = "US"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
# Clean leaf with one inbound edge: the subscription reads the topic.
|
|
12
|
+
resource "google_pubsub_topic" "events" {
|
|
13
|
+
name = "myapp-events"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
resource "google_pubsub_subscription" "worker" {
|
|
17
|
+
name = "myapp-worker"
|
|
18
|
+
topic = google_pubsub_topic.events.name
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# The hub: two subnets, a router and the cluster all reference the network, so
|
|
22
|
+
# carving it costs four data-source patches to the surviving Terraform.
|
|
23
|
+
resource "google_compute_network" "main" {
|
|
24
|
+
name = "myapp-vpc"
|
|
25
|
+
auto_create_subnetworks = false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
resource "google_compute_subnetwork" "a" {
|
|
29
|
+
name = "myapp-subnet-a"
|
|
30
|
+
ip_cidr_range = "10.0.1.0/24"
|
|
31
|
+
network = google_compute_network.main.id
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
resource "google_compute_subnetwork" "b" {
|
|
35
|
+
name = "myapp-subnet-b"
|
|
36
|
+
ip_cidr_range = "10.0.2.0/24"
|
|
37
|
+
network = google_compute_network.main.id
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
resource "google_compute_router" "nat" {
|
|
41
|
+
name = "myapp-router"
|
|
42
|
+
network = google_compute_network.main.id
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Tier 3: Config Connector inlines node pools the cluster also declares apart.
|
|
46
|
+
resource "google_container_cluster" "primary" {
|
|
47
|
+
name = "myapp-gke"
|
|
48
|
+
network = google_compute_network.main.id
|
|
49
|
+
subnetwork = google_compute_subnetwork.a.id
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Identity lives in account_id here, not name.
|
|
53
|
+
resource "google_service_account" "runner" {
|
|
54
|
+
account_id = "myapp-runner"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Leave in Terraform: no native mapping, scored 0.
|
|
58
|
+
resource "random_pet" "suffix" {
|
|
59
|
+
length = 2
|
|
60
|
+
}
|