@intentius/chant 0.124.48 → 0.124.50
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/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +20 -12
- package/dist/cli/handlers/graph-reader.d.ts +26 -0
- package/dist/cli/handlers/graph-reader.d.ts.map +1 -0
- package/dist/cli/handlers/graph-reader.js +41 -0
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/graph.js +14 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +40 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +22 -0
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +11 -0
- package/dist/cli/registry.d.ts +8 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +3 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/gate-decision.d.ts +9 -0
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +22 -7
- package/dist/lexicon.d.ts +23 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/gate-approval-base.d.ts +25 -0
- package/dist/op/gate-approval-base.d.ts.map +1 -0
- package/dist/op/gate-approval-base.js +71 -0
- package/dist/op/gate-approval.d.ts +7 -4
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-approval.js +2 -25
- package/dist/op/gate-name.d.ts +13 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-name.js +14 -1
- package/dist/op/gate.d.ts +4 -3
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +9 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +24 -5
- package/dist/pr-loop.d.ts +10 -0
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +4 -0
- package/dist/pr-migrations.d.ts +62 -0
- package/dist/pr-migrations.d.ts.map +1 -0
- package/dist/pr-migrations.js +50 -0
- package/dist/pr-post.js +1 -1
- package/dist/wave-runtime.mjs +219 -218
- package/dist/workspace/check.schema.json +4 -2
- package/dist/workspace/checks/gate-approval.d.ts +58 -0
- package/dist/workspace/checks/gate-approval.d.ts.map +1 -0
- package/dist/workspace/checks/gate-approval.js +103 -0
- package/dist/workspace/checks.d.ts +1 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +4 -0
- package/dist/workspace/declaration.d.ts +7 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +13 -1
- package/dist/workspace/declaration.schema.json +33 -0
- package/dist/workspace/exit-cli.d.ts +44 -0
- package/dist/workspace/exit-cli.d.ts.map +1 -0
- package/dist/workspace/exit-cli.js +198 -0
- package/dist/workspace/exit.d.ts +159 -0
- package/dist/workspace/exit.d.ts.map +1 -0
- package/dist/workspace/exit.js +480 -0
- package/dist/workspace/exit.schema.json +257 -0
- package/dist/workspace/graph-cache.d.ts +3 -1
- package/dist/workspace/graph-cache.d.ts.map +1 -1
- package/dist/workspace/graph-cache.js +12 -1
- package/dist/workspace/import-fetch.d.ts +38 -0
- package/dist/workspace/import-fetch.d.ts.map +1 -0
- package/dist/workspace/import-fetch.js +99 -0
- package/dist/workspace/import-init.d.ts +26 -0
- package/dist/workspace/import-init.d.ts.map +1 -0
- package/dist/workspace/import-init.js +130 -0
- package/dist/workspace/import-source.d.ts +147 -0
- package/dist/workspace/import-source.d.ts.map +1 -0
- package/dist/workspace/import-source.js +370 -0
- package/dist/workspace/init.d.ts +4 -0
- package/dist/workspace/init.d.ts.map +1 -1
- package/dist/workspace/init.js +70 -3
- package/dist/workspace/kind-readers.d.ts +6 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -1
- package/dist/workspace/kind-readers.js +18 -0
- package/dist/workspace/kinds.d.ts +24 -1
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/kinds.js +64 -4
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +13 -4
- package/dist/workspace/new-migrations.d.ts +47 -0
- package/dist/workspace/new-migrations.d.ts.map +1 -0
- package/dist/workspace/new-migrations.js +91 -0
- package/dist/workspace/pr-report.schema.json +46 -0
- package/dist/workspace/promote.d.ts +96 -0
- package/dist/workspace/promote.d.ts.map +1 -0
- package/dist/workspace/promote.js +263 -0
- package/dist/workspace/reason-codes.d.ts +13 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +14 -0
- package/dist/workspace/search-contract.d.ts +1 -1
- package/dist/workspace/search-contract.d.ts.map +1 -1
- package/dist/workspace/workspace-kinds.schema.json +50 -7
- package/package.json +1 -1
- package/src/cli/handlers/approve.ts +10 -2
- package/src/cli/handlers/graph-reader.test.ts +31 -0
- package/src/cli/handlers/graph-reader.ts +51 -0
- package/src/cli/handlers/graph.ts +14 -0
- package/src/cli/handlers/pr.ts +38 -1
- package/src/cli/main.test.ts +2 -0
- package/src/cli/main.ts +22 -0
- package/src/cli/parse-args.ts +8 -0
- package/src/cli/registry.ts +8 -0
- package/src/components/pr-env-run-bundle.test.ts +1 -0
- package/src/components/pr-run.ts +4 -0
- package/src/config.ts +16 -0
- package/src/gate-decision-bundle.test.ts +4 -2
- package/src/gate-decision-identity.test.ts +11 -0
- package/src/gate-decision.ts +26 -6
- package/src/lexicon.ts +24 -0
- package/src/op/gate-approval-at-base.test.ts +97 -0
- package/src/op/gate-approval-base.ts +72 -0
- package/src/op/gate-approval.ts +10 -21
- package/src/op/gate-name.ts +16 -1
- package/src/op/gate.ts +4 -3
- package/src/op/local-executor.ts +25 -5
- package/src/pr-loop-bundle.test.ts +3 -2
- package/src/pr-loop.test.ts +52 -0
- package/src/pr-loop.ts +12 -0
- package/src/pr-migrations.ts +97 -0
- package/src/pr-post.ts +1 -1
- package/src/workspace/__fixtures__/import/spacelift.json +9 -0
- package/src/workspace/__fixtures__/import/tfc.json +13 -0
- package/src/workspace/behold-kinds.test.ts +1 -0
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +4 -2
- package/src/workspace/checks/gate-approval.test.ts +77 -0
- package/src/workspace/checks/gate-approval.ts +121 -0
- package/src/workspace/checks.ts +4 -0
- package/src/workspace/declaration.schema.json +33 -0
- package/src/workspace/declaration.ts +19 -2
- package/src/workspace/exit-cli.ts +219 -0
- package/src/workspace/exit.schema.json +257 -0
- package/src/workspace/exit.test.ts +185 -0
- package/src/workspace/exit.ts +560 -0
- package/src/workspace/graph-cache.ts +12 -2
- package/src/workspace/identity.test.ts +18 -3
- package/src/workspace/import-fetch.ts +151 -0
- package/src/workspace/import-init.ts +147 -0
- package/src/workspace/import-source.test.ts +183 -0
- package/src/workspace/import-source.ts +462 -0
- package/src/workspace/init.test.ts +55 -1
- package/src/workspace/init.ts +66 -4
- package/src/workspace/kind-readers.test.ts +36 -0
- package/src/workspace/kind-readers.ts +18 -1
- package/src/workspace/kinds.test.ts +23 -0
- package/src/workspace/kinds.ts +81 -5
- package/src/workspace/lineage-upgrade.test.ts +20 -0
- package/src/workspace/lineage-upgrade.ts +16 -7
- package/src/workspace/new-migrations.test.ts +98 -0
- package/src/workspace/new-migrations.ts +109 -0
- package/src/workspace/pr-report.schema.json +46 -0
- package/src/workspace/promote.test.ts +114 -0
- package/src/workspace/promote.ts +302 -0
- package/src/workspace/reason-codes.test.ts +4 -0
- package/src/workspace/reason-codes.ts +14 -0
- package/src/workspace/workspace-kinds.schema.json +50 -7
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace exit` (#3177): leave a member, or the workspace, in one
|
|
3
|
+
* step, and prove on every commit that leaving still works.
|
|
4
|
+
*
|
|
5
|
+
* Per member kind:
|
|
6
|
+
*
|
|
7
|
+
* - `chant`: build once per lexicon and commit the native output under
|
|
8
|
+
* `native/`, rewrite the member's CI files to call the platform's own CLI,
|
|
9
|
+
* drop the chant dependencies and `chant.config.*`, and set the member to
|
|
10
|
+
* `other` with its outputs listed. Where an Op's `op.json` sits in the
|
|
11
|
+
* member and the Op is linear (shell steps only), a plain CI job is written
|
|
12
|
+
* beside it. The `op.json` stays.
|
|
13
|
+
* - `choudoufu`: the roundtrip in choudoufu's `live/MIGRATE.md`. It needs
|
|
14
|
+
* stock OpenTofu and the estate's state, so the exit lists its steps and
|
|
15
|
+
* changes no file.
|
|
16
|
+
* - every other kind (`other`, records, ledgers, `workspace`, `repo`): kept
|
|
17
|
+
* as files.
|
|
18
|
+
*
|
|
19
|
+
* The functions here work on a plain directory, so the same code runs in the
|
|
20
|
+
* staging worktree (`exit-cli.ts`) and in the drill's scratch copy.
|
|
21
|
+
* {@link runExitDrill} runs the exit on a scratch copy of a workspace and
|
|
22
|
+
* checks the result statically: it proves each member holds what a stock
|
|
23
|
+
* tool needs and nothing that needs chant. It never installs chant or calls
|
|
24
|
+
* a cloud, so it is a unit-level check CI can run on every commit.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
30
|
+
import { readDeclaration, type Member } from "./declaration";
|
|
31
|
+
import { setProperty } from "./json-edit";
|
|
32
|
+
import type { ReasonCode } from "./reason-codes";
|
|
33
|
+
import { workingTree } from "./tree";
|
|
34
|
+
|
|
35
|
+
/** Runs `chant build` in a directory. The same shape as `ChantRunner` in lineage-upgrade.ts. */
|
|
36
|
+
export type ExitBuildRunner = (command: "build", cwd: string, args: readonly string[]) => Promise<{ exitCode: number; output: string }>;
|
|
37
|
+
|
|
38
|
+
// ── Native targets ───────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface NativeTarget {
|
|
41
|
+
/** What the output is. */
|
|
42
|
+
describes: string;
|
|
43
|
+
/** The file under the member's `native/` directory, or a path from the member's directory when `inMember`. */
|
|
44
|
+
file: string;
|
|
45
|
+
/** The file is written at this path from the member's directory rather than under `native/`. */
|
|
46
|
+
inMember?: boolean;
|
|
47
|
+
/** The stock command that deploys it, given the committed file's path from the member's directory and the member name. Null when there is nothing to deploy (CI files, collector config). */
|
|
48
|
+
deploy: ((file: string, member: string) => string) | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The lexicons `workspace exit` knows a native target for. */
|
|
52
|
+
export const NATIVE_TARGETS: Record<string, NativeTarget> = {
|
|
53
|
+
aws: {
|
|
54
|
+
describes: "CloudFormation template",
|
|
55
|
+
file: "template.json",
|
|
56
|
+
deploy: (f, m) => `aws cloudformation deploy --template-file ${f} --stack-name ${m} --capabilities CAPABILITY_NAMED_IAM`,
|
|
57
|
+
},
|
|
58
|
+
azure: {
|
|
59
|
+
describes: "ARM template",
|
|
60
|
+
file: "azuredeploy.json",
|
|
61
|
+
deploy: (f, m) => `az deployment group create --resource-group ${m} --template-file ${f}`,
|
|
62
|
+
},
|
|
63
|
+
gcp: { describes: "Config Connector objects", file: "config-connector.yaml", deploy: (f) => `kubectl apply -f ${f}` },
|
|
64
|
+
k8s: { describes: "Kubernetes YAML", file: "k8s.yaml", deploy: (f) => `kubectl apply -f ${f}` },
|
|
65
|
+
docker: { describes: "Compose file", file: "docker-compose.yml", deploy: (f) => `docker compose -f ${f} up -d` },
|
|
66
|
+
otel: { describes: "OpenTelemetry collector config", file: "otel-collector.yaml", deploy: null },
|
|
67
|
+
github: { describes: "GitHub Actions workflow", file: ".github/workflows/chant-exit.yml", inMember: true, deploy: null },
|
|
68
|
+
gitlab: { describes: "GitLab CI file", file: ".gitlab-ci.yml", inMember: true, deploy: null },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The lexicons a member's `chant.config.*` or `package.json` names, in order. */
|
|
72
|
+
export function memberLexicons(dir: string): string[] {
|
|
73
|
+
for (const name of ["chant.config.ts", "chant.config.js", "chant.config.mjs"]) {
|
|
74
|
+
const file = join(dir, name);
|
|
75
|
+
if (!existsSync(file)) continue;
|
|
76
|
+
const m = /lexicons\s*:\s*\[([^\]]*)\]/.exec(readFileSync(file, "utf-8"));
|
|
77
|
+
if (m) return [...m[1]!.matchAll(/["'`]([\w-]+)["'`]/g)].map((x) => x[1]!);
|
|
78
|
+
}
|
|
79
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
80
|
+
const found: string[] = [];
|
|
81
|
+
for (const section of ["dependencies", "devDependencies"]) {
|
|
82
|
+
for (const dep of Object.keys((pkg?.[section] as Record<string, string> | undefined) ?? {})) {
|
|
83
|
+
const m = /^@intentius\/chant-lexicon-([\w-]+)$/.exec(dep);
|
|
84
|
+
if (m && !found.includes(m[1]!)) found.push(m[1]!);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── The plan ─────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export type ExitAction = "native-build" | "choudoufu-roundtrip" | "keep";
|
|
93
|
+
|
|
94
|
+
/** Why a chant member cannot be exited. Closed; each is in `reason-codes.ts`. */
|
|
95
|
+
export const EXIT_REFUSAL_CODES = ["missing-dir", "no-lexicon", "lexicon-unsupported"] as const satisfies readonly ReasonCode[];
|
|
96
|
+
export type ExitRefusalCode = (typeof EXIT_REFUSAL_CODES)[number];
|
|
97
|
+
|
|
98
|
+
export interface MemberPlan {
|
|
99
|
+
name: string;
|
|
100
|
+
dir: string;
|
|
101
|
+
kind: string;
|
|
102
|
+
action: ExitAction;
|
|
103
|
+
lexicons: string[];
|
|
104
|
+
/** Set when the member cannot be exited, with the reason code. */
|
|
105
|
+
refused?: { code: ExitRefusalCode; message: string };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ExitPlan {
|
|
109
|
+
root: string;
|
|
110
|
+
members: MemberPlan[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class ExitError extends Error {}
|
|
114
|
+
|
|
115
|
+
/** What exiting `names` (every exitable member when none are named) does, read from the declaration. */
|
|
116
|
+
export function planExit(root: string, names: readonly string[] = []): ExitPlan {
|
|
117
|
+
const dec = readDeclaration(workingTree(root));
|
|
118
|
+
const real = dec.members.filter((m) => m.type === "member");
|
|
119
|
+
for (const n of names) {
|
|
120
|
+
if (!real.some((m) => m.name === n)) throw new ExitError(`no member named "${n}" (members: ${real.map((m) => m.name).join(", ") || "none"})`);
|
|
121
|
+
}
|
|
122
|
+
const chosen = names.length > 0 ? real.filter((m) => names.includes(m.name)) : real;
|
|
123
|
+
return { root, members: chosen.map((m) => planMember(root, m)) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function planMember(root: string, m: Member): MemberPlan {
|
|
127
|
+
const base = { name: m.name, dir: m.dir, kind: m.kind };
|
|
128
|
+
if (m.kind === "choudoufu") return { ...base, action: "choudoufu-roundtrip", lexicons: [] };
|
|
129
|
+
if (m.kind !== "chant") return { ...base, action: "keep", lexicons: [] };
|
|
130
|
+
const abs = resolve(root, m.dir);
|
|
131
|
+
if (!existsSync(abs)) return { ...base, action: "native-build", lexicons: [], refused: { code: "missing-dir", message: `${m.dir} does not exist` } };
|
|
132
|
+
const lexicons = memberLexicons(abs);
|
|
133
|
+
if (lexicons.length === 0) {
|
|
134
|
+
return { ...base, action: "native-build", lexicons, refused: { code: "no-lexicon", message: "no lexicon is named in chant.config.* or package.json, so there is nothing to build" } };
|
|
135
|
+
}
|
|
136
|
+
const unknown = lexicons.filter((l) => !NATIVE_TARGETS[l]);
|
|
137
|
+
if (unknown.length > 0) {
|
|
138
|
+
return {
|
|
139
|
+
...base,
|
|
140
|
+
action: "native-build",
|
|
141
|
+
lexicons,
|
|
142
|
+
refused: { code: "lexicon-unsupported", message: `no native target for lexicon ${unknown.join(", ")} (supported: ${Object.keys(NATIVE_TARGETS).join(", ")})` },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return { ...base, action: "native-build", lexicons };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Doing the exit in a directory ────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export interface MemberResult {
|
|
151
|
+
name: string;
|
|
152
|
+
dir: string;
|
|
153
|
+
kind: string;
|
|
154
|
+
status: "exited" | "kept" | "manual" | "refused" | "failed";
|
|
155
|
+
/** Why the member was refused; set only with status `refused`. */
|
|
156
|
+
code?: ExitRefusalCode;
|
|
157
|
+
/** Native files written, relative to the workspace root. */
|
|
158
|
+
native: string[];
|
|
159
|
+
/** The stock command for each native file, relative to the member's directory. */
|
|
160
|
+
deploy: string[];
|
|
161
|
+
/** CI files rewritten or written, relative to the workspace root. */
|
|
162
|
+
ci: string[];
|
|
163
|
+
/** `op.json` files left in place, relative to the workspace root. */
|
|
164
|
+
ops: string[];
|
|
165
|
+
/** Steps a person runs, for a member the exit cannot do alone. */
|
|
166
|
+
steps: string[];
|
|
167
|
+
detail?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface ExitResult {
|
|
171
|
+
members: MemberResult[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const CHANT_FILES = ["chant.config.ts", "chant.config.js", "chant.config.mjs", "chant.config.json"];
|
|
175
|
+
const CI_GLOBS = [".github/workflows", "ci", ".circleci"];
|
|
176
|
+
const CI_ROOT_FILES = [".gitlab-ci.yml", ".gitlab-ci.yaml"];
|
|
177
|
+
|
|
178
|
+
/** The choudoufu roundtrip, in order (`live/MIGRATE.md`). Run with stock OpenTofu. */
|
|
179
|
+
export function choudoufuSteps(member: string, dir: string): string[] {
|
|
180
|
+
return [
|
|
181
|
+
`cd ${dir}`,
|
|
182
|
+
"copy the cache to terraform.tfstate",
|
|
183
|
+
"remove the live block from the estate",
|
|
184
|
+
`CHOUDOUFU_UNMIGRATE=${member} tofu plan # stock's first plan removes the two marker tags`,
|
|
185
|
+
"tofu apply",
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Exit the planned members in the tree at `plan.root`, editing it in place.
|
|
191
|
+
* Builds through `runBuild`, so a test can supply output without chant.
|
|
192
|
+
*/
|
|
193
|
+
export async function exitInDir(plan: ExitPlan, runBuild: ExitBuildRunner): Promise<ExitResult> {
|
|
194
|
+
const results: MemberResult[] = [];
|
|
195
|
+
for (const m of plan.members) {
|
|
196
|
+
const result: MemberResult = { name: m.name, dir: m.dir, kind: m.kind, status: "kept", native: [], deploy: [], ci: [], ops: [], steps: [] };
|
|
197
|
+
results.push(result);
|
|
198
|
+
if (m.refused) {
|
|
199
|
+
result.status = "refused";
|
|
200
|
+
result.code = m.refused.code;
|
|
201
|
+
result.detail = `${m.refused.code}: ${m.refused.message}`;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (m.action === "choudoufu-roundtrip") {
|
|
205
|
+
result.status = "manual";
|
|
206
|
+
result.steps = choudoufuSteps(m.name, m.dir);
|
|
207
|
+
result.detail = "needs stock OpenTofu and the estate's state; the exit changes no file";
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (m.action === "keep") continue;
|
|
211
|
+
const abs = resolve(plan.root, m.dir);
|
|
212
|
+
const outputs: string[] = [];
|
|
213
|
+
for (const lexicon of m.lexicons) {
|
|
214
|
+
const target = NATIVE_TARGETS[lexicon]!;
|
|
215
|
+
const rel = target.inMember ? target.file : `native/${target.file}`;
|
|
216
|
+
const out = join(abs, rel);
|
|
217
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
218
|
+
const run = await runBuild("build", abs, [".", "--lexicon", lexicon, "--output", out]);
|
|
219
|
+
if (run.exitCode !== 0 || !existsSync(out) || statSync(out).size === 0) {
|
|
220
|
+
result.status = "failed";
|
|
221
|
+
result.detail = `chant build --lexicon ${lexicon} failed${run.output ? `: ${run.output.trim().split("\n").slice(-5).join("\n")}` : ""}`;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
result.native.push(relPath(plan.root, out));
|
|
225
|
+
if (target.deploy) result.deploy.push(target.deploy(rel, m.name));
|
|
226
|
+
outputs.push(rel);
|
|
227
|
+
}
|
|
228
|
+
if (result.status === "failed") continue;
|
|
229
|
+
result.ci = rewriteCi(plan.root, abs, result.deploy);
|
|
230
|
+
result.ops = collectOps(plan.root, abs);
|
|
231
|
+
for (const op of linearOpJobs(plan.root, abs)) result.ci.push(op);
|
|
232
|
+
dropChant(abs);
|
|
233
|
+
result.status = "exited";
|
|
234
|
+
}
|
|
235
|
+
setKindOther(plan.root, results.filter((r) => r.status === "exited"));
|
|
236
|
+
return { members: results };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function relPath(root: string, p: string): string {
|
|
240
|
+
return relative(root, p).split("\\").join("/");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readJson(file: string): Record<string, unknown> | undefined {
|
|
244
|
+
try {
|
|
245
|
+
return JSON.parse(readFileSync(file, "utf-8")) as Record<string, unknown>;
|
|
246
|
+
} catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function walk(dir: string, visit: (file: string) => void): void {
|
|
252
|
+
if (!existsSync(dir)) return;
|
|
253
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
254
|
+
if (e.name === "node_modules" || e.name === ".git" || e.name === "native") continue;
|
|
255
|
+
const p = join(dir, e.name);
|
|
256
|
+
if (e.isDirectory()) walk(p, visit);
|
|
257
|
+
else visit(p);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function ciFiles(abs: string): string[] {
|
|
262
|
+
const out: string[] = [];
|
|
263
|
+
for (const d of CI_GLOBS) walk(join(abs, d), (f) => /\.ya?ml$/.test(f) && out.push(f));
|
|
264
|
+
for (const f of CI_ROOT_FILES) if (existsSync(join(abs, f))) out.push(join(abs, f));
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const CHANT_CALL = /^(\s*(?:-\s+)?(?:run:\s*)?)(?:npx\s+|pnpm\s+exec\s+|npm\s+exec\s+(?:--\s+)?)?chant\s+(?:build|deploy|apply)\b.*$/;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Replace each `chant build|deploy|apply` line of the member's CI files with
|
|
272
|
+
* the stock commands that deploy its native output. A line with no stock
|
|
273
|
+
* command to replace it (CI output, a collector config) is removed.
|
|
274
|
+
*/
|
|
275
|
+
function rewriteCi(root: string, abs: string, deploy: readonly string[]): string[] {
|
|
276
|
+
const changed: string[] = [];
|
|
277
|
+
for (const file of ciFiles(abs)) {
|
|
278
|
+
const lines = readFileSync(file, "utf-8").split("\n");
|
|
279
|
+
let touched = false;
|
|
280
|
+
const next: string[] = [];
|
|
281
|
+
for (const line of lines) {
|
|
282
|
+
const m = CHANT_CALL.exec(line);
|
|
283
|
+
if (!m) {
|
|
284
|
+
next.push(line);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
touched = true;
|
|
288
|
+
const lead = m[1]!;
|
|
289
|
+
if (deploy.length === 0) continue;
|
|
290
|
+
const inline = /run:/.test(lead);
|
|
291
|
+
if (inline) {
|
|
292
|
+
next.push(`${lead}${deploy.join(" && ")}`);
|
|
293
|
+
} else {
|
|
294
|
+
const indent = lead.replace(/-\s+$/, "").replace(/\S.*$/, "");
|
|
295
|
+
deploy.forEach((d, i) => next.push(i === 0 ? `${lead}${d}` : `${indent}${d}`));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (touched) {
|
|
299
|
+
writeFileSync(file, next.join("\n"));
|
|
300
|
+
changed.push(relPath(root, file));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return changed;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** `op.json` files under the member's `dist/ops/<name>/` or anywhere beside generated Ops. They stay. */
|
|
307
|
+
function collectOps(root: string, abs: string): string[] {
|
|
308
|
+
const out: string[] = [];
|
|
309
|
+
walk(abs, (f) => f.endsWith(`${"/"}op.json`) && out.push(relPath(root, f)));
|
|
310
|
+
return out.sort();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
interface OpJson {
|
|
314
|
+
name?: string;
|
|
315
|
+
phases?: { parallel?: boolean; steps?: { kind?: string; fn?: string; args?: { cmd?: unknown } }[] }[];
|
|
316
|
+
onFailure?: unknown[];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The plain commands of a linear Op, or undefined when the Op is not linear. */
|
|
320
|
+
export function linearCommands(op: OpJson): string[] | undefined {
|
|
321
|
+
const cmds: string[] = [];
|
|
322
|
+
for (const phase of op.phases ?? []) {
|
|
323
|
+
if (phase.parallel) return undefined;
|
|
324
|
+
for (const step of phase.steps ?? []) {
|
|
325
|
+
if (step.kind !== "activity" || step.fn !== "shellCmd" || typeof step.args?.cmd !== "string") return undefined;
|
|
326
|
+
cmds.push(step.args.cmd);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if ((op.onFailure ?? []).length > 0) return undefined;
|
|
330
|
+
return cmds.length > 0 ? cmds : undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** For each linear Op, a plain GitHub Actions job beside its `op.json`. */
|
|
334
|
+
function linearOpJobs(root: string, abs: string): string[] {
|
|
335
|
+
const written: string[] = [];
|
|
336
|
+
walk(abs, (f) => {
|
|
337
|
+
if (!f.endsWith("/op.json")) return;
|
|
338
|
+
const op = readJson(f) as OpJson | undefined;
|
|
339
|
+
const cmds = op && linearCommands(op);
|
|
340
|
+
if (!op || !cmds) return;
|
|
341
|
+
const out = join(dirname(f), "job.yml");
|
|
342
|
+
const name = op.name ?? "op";
|
|
343
|
+
writeFileSync(
|
|
344
|
+
out,
|
|
345
|
+
[
|
|
346
|
+
`# Written by chant workspace exit from ${name}'s op.json. The op is linear, so it is one job.`,
|
|
347
|
+
`name: ${name}`,
|
|
348
|
+
"on: workflow_dispatch",
|
|
349
|
+
"jobs:",
|
|
350
|
+
` ${name.replace(/[^\w-]/g, "-")}:`,
|
|
351
|
+
" runs-on: ubuntu-latest",
|
|
352
|
+
" steps:",
|
|
353
|
+
" - uses: actions/checkout@v4",
|
|
354
|
+
...cmds.map((c, i) => ` - name: step ${i + 1}\n run: ${JSON.stringify(c)}`),
|
|
355
|
+
"",
|
|
356
|
+
].join("\n"),
|
|
357
|
+
);
|
|
358
|
+
written.push(relPath(root, out));
|
|
359
|
+
});
|
|
360
|
+
return written;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Remove chant.config.* and the chant dependencies and scripts from the member's package.json. */
|
|
364
|
+
function dropChant(abs: string): void {
|
|
365
|
+
for (const f of CHANT_FILES) rmSync(join(abs, f), { force: true });
|
|
366
|
+
const file = join(abs, "package.json");
|
|
367
|
+
const pkg = readJson(file);
|
|
368
|
+
if (!pkg) return;
|
|
369
|
+
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
370
|
+
const deps = pkg[section] as Record<string, string> | undefined;
|
|
371
|
+
if (!deps) continue;
|
|
372
|
+
for (const d of Object.keys(deps)) if (d === "@intentius/chant" || d.startsWith("@intentius/chant-")) delete deps[d];
|
|
373
|
+
if (Object.keys(deps).length === 0) delete pkg[section];
|
|
374
|
+
}
|
|
375
|
+
const scripts = pkg.scripts as Record<string, string> | undefined;
|
|
376
|
+
if (scripts) {
|
|
377
|
+
for (const [k, v] of Object.entries(scripts)) if (/\bchant\b/.test(v)) delete scripts[k];
|
|
378
|
+
if (Object.keys(scripts).length === 0) delete pkg.scripts;
|
|
379
|
+
}
|
|
380
|
+
writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Set each exited member to kind `other` in the declaration, with `because` and its outputs. */
|
|
384
|
+
function setKindOther(root: string, exited: MemberResult[]): void {
|
|
385
|
+
if (exited.length === 0) return;
|
|
386
|
+
const dec = readDeclaration(workingTree(root));
|
|
387
|
+
const file = ["chant.workspace.json", "chant.workspace.jsonc"].find((f) => existsSync(join(root, f)))!;
|
|
388
|
+
const jsonc = file.endsWith("c");
|
|
389
|
+
let text = readFileSync(join(root, file), "utf-8");
|
|
390
|
+
const raw = (JSON.parse(jsonc ? text.replace(/^\s*\/\/.*$/gm, "") : text) as { members: { name?: string }[] }).members;
|
|
391
|
+
for (const r of exited) {
|
|
392
|
+
const i = raw.findIndex((e) => e.name === r.name);
|
|
393
|
+
if (i < 0) continue;
|
|
394
|
+
const ptr = `/members/${i}`;
|
|
395
|
+
const member = dec.members.find((m) => m.type === "member" && m.name === r.name);
|
|
396
|
+
text = setProperty(text, jsonc, ptr, "kind", "other");
|
|
397
|
+
text = setProperty(text, jsonc, ptr, "because", `left chant with chant workspace exit; its native output is committed under ${r.native.map((n) => relPath(join(root, r.dir), join(root, n))).join(", ")}`);
|
|
398
|
+
const outputs = member?.outputs ?? [];
|
|
399
|
+
if (outputs.length > 0) text = setProperty(text, jsonc, ptr, "outputs", outputs);
|
|
400
|
+
}
|
|
401
|
+
writeFileSync(join(root, file), text);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── The drill ────────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
/** What the exit drill finds wrong with an exited member. Closed; each is in `reason-codes.ts`. */
|
|
407
|
+
export const DRILL_CODES = [
|
|
408
|
+
"native-missing",
|
|
409
|
+
"native-unreadable",
|
|
410
|
+
"chant-dependency",
|
|
411
|
+
"chant-config-left",
|
|
412
|
+
"chant-in-ci",
|
|
413
|
+
"no-deploy-command",
|
|
414
|
+
"declaration-not-other",
|
|
415
|
+
"exit-failed",
|
|
416
|
+
"choudoufu-steps-missing",
|
|
417
|
+
] as const satisfies readonly ReasonCode[];
|
|
418
|
+
export type DrillCode = (typeof DRILL_CODES)[number];
|
|
419
|
+
|
|
420
|
+
/** Every code `workspace exit` prints: why a member was refused, and what the drill found. */
|
|
421
|
+
export const EXIT_CODES = [...EXIT_REFUSAL_CODES, ...DRILL_CODES] as const;
|
|
422
|
+
|
|
423
|
+
export interface DrillFinding {
|
|
424
|
+
code: DrillCode;
|
|
425
|
+
message: string;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export interface DrillMember {
|
|
429
|
+
name: string;
|
|
430
|
+
kind: string;
|
|
431
|
+
status: "passed" | "failed" | "not-run" | "kept";
|
|
432
|
+
/** Why it is `not-run` or `kept`. */
|
|
433
|
+
note?: string;
|
|
434
|
+
findings: DrillFinding[];
|
|
435
|
+
native: string[];
|
|
436
|
+
deploy: string[];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export interface DrillReport {
|
|
440
|
+
/** True when no member failed. A `not-run` member does not fail the drill; it is listed. */
|
|
441
|
+
ok: boolean;
|
|
442
|
+
members: DrillMember[];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export interface DrillOptions {
|
|
446
|
+
/** The workspace root to drill. It is copied; the tree is not touched. */
|
|
447
|
+
root: string;
|
|
448
|
+
/** Limit to these members. */
|
|
449
|
+
members?: readonly string[];
|
|
450
|
+
/** Builds a member's native output. */
|
|
451
|
+
runBuild: ExitBuildRunner;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Copy a workspace, leaving out `node_modules`, `.git` and chant's own scratch. */
|
|
455
|
+
export function copyWorkspace(from: string, to: string): void {
|
|
456
|
+
const skip = new Set(["node_modules", ".git", ".chant"]);
|
|
457
|
+
const copy = (src: string, dst: string): void => {
|
|
458
|
+
mkdirSync(dst, { recursive: true });
|
|
459
|
+
for (const e of readdirSync(src, { withFileTypes: true })) {
|
|
460
|
+
if (skip.has(e.name)) continue;
|
|
461
|
+
const s = join(src, e.name);
|
|
462
|
+
const d = join(dst, e.name);
|
|
463
|
+
if (e.isDirectory()) copy(s, d);
|
|
464
|
+
else if (e.isFile()) copyFileSync(s, d);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
copy(from, to);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const CHANT_IN_CI = /\bchant\s+(?:build|lint|deploy|apply|run|workspace|ci)\b|@intentius\/chant\b/;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Run the exit on a scratch copy and check each member for what a stock tool
|
|
474
|
+
* needs. Nothing here runs chant or a cloud; the build is the one injected
|
|
475
|
+
* step, and its output is read as text.
|
|
476
|
+
*/
|
|
477
|
+
export async function runExitDrill(opts: DrillOptions): Promise<DrillReport> {
|
|
478
|
+
const scratch = mkdtempSync(join(tmpdir(), "chant-exit-drill-"));
|
|
479
|
+
try {
|
|
480
|
+
copyWorkspace(opts.root, scratch);
|
|
481
|
+
const plan = planExit(scratch, opts.members ?? []);
|
|
482
|
+
const result = await exitInDir(plan, opts.runBuild);
|
|
483
|
+
return verifyExit(scratch, result);
|
|
484
|
+
} finally {
|
|
485
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Check an exited tree at `root` against what {@link exitInDir} reported. */
|
|
490
|
+
export function verifyExit(root: string, result: ExitResult): DrillReport {
|
|
491
|
+
const declared = new Map(readDeclaration(workingTree(root)).members.filter((m) => m.type === "member").map((m) => [m.name, m]));
|
|
492
|
+
const members = result.members.map((r) => checkMember(root, r, declared.get(r.name)));
|
|
493
|
+
return { ok: members.every((m) => m.status !== "failed"), members };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function checkMember(root: string, r: MemberResult, declared: Member | undefined): DrillMember {
|
|
497
|
+
const base = { name: r.name, kind: r.kind, native: r.native, deploy: r.deploy };
|
|
498
|
+
const findings: DrillFinding[] = [];
|
|
499
|
+
if (r.status === "kept") return { ...base, status: "kept", note: `kind ${r.kind} stays as files`, findings };
|
|
500
|
+
if (r.status === "manual") {
|
|
501
|
+
if (r.steps.length === 0) findings.push({ code: "choudoufu-steps-missing", message: "the roundtrip steps were not listed" });
|
|
502
|
+
return findings.length > 0
|
|
503
|
+
? { ...base, status: "failed", findings }
|
|
504
|
+
: { ...base, status: "not-run", note: "the stock OpenTofu roundtrip (copy the cache to terraform.tfstate, remove the live block, plan) is not run, needs the maintainer", findings };
|
|
505
|
+
}
|
|
506
|
+
if (r.status !== "exited") {
|
|
507
|
+
return { ...base, status: "failed", findings: [{ code: "exit-failed", message: r.detail ?? `exit ${r.status}` }] };
|
|
508
|
+
}
|
|
509
|
+
const abs = resolve(root, r.dir);
|
|
510
|
+
for (const n of r.native) {
|
|
511
|
+
const p = join(root, n);
|
|
512
|
+
if (!existsSync(p) || statSync(p).size === 0) {
|
|
513
|
+
findings.push({ code: "native-missing", message: `${n} is missing or empty` });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const text = readFileSync(p, "utf-8");
|
|
517
|
+
if (p.endsWith(".json")) {
|
|
518
|
+
try {
|
|
519
|
+
JSON.parse(text);
|
|
520
|
+
} catch (e) {
|
|
521
|
+
findings.push({ code: "native-unreadable", message: `${n} is not valid JSON: ${(e as Error).message}` });
|
|
522
|
+
}
|
|
523
|
+
} else if (/^\s*$/.test(text) || /^\s*[{[]?\s*[}\]]?\s*$/.test(text)) {
|
|
524
|
+
findings.push({ code: "native-unreadable", message: `${n} has no content` });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const deployable = r.native.filter((n) => !/\.github\/|\.gitlab-ci|otel-collector/.test(n));
|
|
528
|
+
if (deployable.length > 0 && r.deploy.length === 0) findings.push({ code: "no-deploy-command", message: "a native file has no stock deploy command" });
|
|
529
|
+
|
|
530
|
+
for (const f of CHANT_FILES) if (existsSync(join(abs, f))) findings.push({ code: "chant-config-left", message: `${join(r.dir, f)} is still there` });
|
|
531
|
+
const pkg = readJson(join(abs, "package.json"));
|
|
532
|
+
if (pkg) {
|
|
533
|
+
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
534
|
+
for (const d of Object.keys((pkg[section] as Record<string, string> | undefined) ?? {})) {
|
|
535
|
+
if (d === "@intentius/chant" || d.startsWith("@intentius/chant-")) findings.push({ code: "chant-dependency", message: `package.json ${section} still has ${d}` });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
for (const [k, v] of Object.entries((pkg.scripts as Record<string, string> | undefined) ?? {})) {
|
|
539
|
+
if (/\bchant\b/.test(v)) findings.push({ code: "chant-dependency", message: `package.json script "${k}" still calls chant` });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
for (const f of ciFiles(abs)) {
|
|
543
|
+
const text = readFileSync(f, "utf-8");
|
|
544
|
+
const bad = text.split("\n").find((l) => CHANT_IN_CI.test(l) && !l.trim().startsWith("#"));
|
|
545
|
+
if (bad) findings.push({ code: "chant-in-ci", message: `${relPath(root, f)} still calls chant: ${bad.trim()}` });
|
|
546
|
+
}
|
|
547
|
+
if (declared && declared.kind !== "other") findings.push({ code: "declaration-not-other", message: `the declaration still has kind ${declared.kind}` });
|
|
548
|
+
return { ...base, status: findings.length > 0 ? "failed" : "passed", findings };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The drill report as lines for a terminal. */
|
|
552
|
+
export function describeDrill(report: DrillReport): string[] {
|
|
553
|
+
const lines: string[] = [];
|
|
554
|
+
for (const m of report.members) {
|
|
555
|
+
lines.push(`${m.name} ${m.kind} ${m.status}${m.note ? ` (${m.note})` : ""}`);
|
|
556
|
+
for (const f of m.findings) lines.push(` ${f.code}: ${f.message}`);
|
|
557
|
+
}
|
|
558
|
+
lines.push(report.ok ? "exit drill passed" : "exit drill failed");
|
|
559
|
+
return lines;
|
|
560
|
+
}
|
|
@@ -204,14 +204,24 @@ export function toolchainStamp(toolchain: Toolchain): string {
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
/** What a kind's reader adds to the key: its graph block, and the installed version of the package supplying it. */
|
|
207
|
-
export function readerStamp(reader: { lexicon
|
|
207
|
+
export function readerStamp(reader: { lexicon?: string; reader?: string; readerFile?: string; config: unknown; packageDir: string }): string {
|
|
208
208
|
let version = "";
|
|
209
209
|
try {
|
|
210
210
|
version = String((JSON.parse(readFileSync(join(reader.packageDir, "package.json"), "utf-8")) as { version?: unknown }).version ?? "");
|
|
211
211
|
} catch {
|
|
212
212
|
// An unreadable manifest keys on the directory alone.
|
|
213
213
|
}
|
|
214
|
-
|
|
214
|
+
// A reader module is code: its size and mtime key too, so an edit under the same version is a new read (#4413).
|
|
215
|
+
let module = "";
|
|
216
|
+
if (reader.readerFile) {
|
|
217
|
+
try {
|
|
218
|
+
const st = statSync(reader.readerFile);
|
|
219
|
+
module = `${reader.reader}:${st.size}:${st.mtimeMs}`;
|
|
220
|
+
} catch {
|
|
221
|
+
module = `${reader.reader}:unreadable`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return JSON.stringify([reader.lexicon, reader.config, reader.packageDir, version, ...(module ? [module] : [])]);
|
|
215
225
|
}
|
|
216
226
|
|
|
217
227
|
/** The ambient environment, less {@link VOLATILE_ENV}, as one digest. */
|
|
@@ -17,6 +17,7 @@ import { emptyPolicy, type TrustPolicy } from "./trust/policy";
|
|
|
17
17
|
import { sealGateApproval } from "./trust/seal";
|
|
18
18
|
import { hasSshKeygen, TestRepo, type Key } from "./trust/test-repo";
|
|
19
19
|
import { evaluateGate, memoryGateLedgerPort } from "../op/gate";
|
|
20
|
+
import { gatePolicyVersion } from "../op/gate-approval";
|
|
20
21
|
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
21
22
|
|
|
22
23
|
const decl = (identity: unknown): Declaration =>
|
|
@@ -71,8 +72,8 @@ describe("identity.attribution", () => {
|
|
|
71
72
|
expect(decl({ attribution: "identified", gates: { ship: { class: "operator" }, rollback: {} } }).identity).toEqual({
|
|
72
73
|
attribution: "identified",
|
|
73
74
|
gates: {
|
|
74
|
-
ship: { gate: "ship", class: "operator", quorum: null, pointer: "/identity/gates/ship" },
|
|
75
|
-
rollback: { gate: "rollback", class: null, quorum: null, pointer: "/identity/gates/rollback" },
|
|
75
|
+
ship: { gate: "ship", class: "operator", quorum: null, policy: null, mode: null, context: null, pointer: "/identity/gates/ship" },
|
|
76
|
+
rollback: { gate: "rollback", class: null, quorum: null, policy: null, mode: null, context: null, pointer: "/identity/gates/rollback" },
|
|
76
77
|
},
|
|
77
78
|
});
|
|
78
79
|
expect(() => decl({ attribution: "verified" })).toThrow();
|
|
@@ -82,7 +83,7 @@ describe("identity.attribution", () => {
|
|
|
82
83
|
test("a gate entry takes a quorum beside its class (#4324)", () => {
|
|
83
84
|
const rules = [{ targets: ["prod-*"], count: 2, roles: ["platform"] }, { count: 1 }];
|
|
84
85
|
const gates = decl({ gates: { ship: { class: "operator", quorum: { count: 2 } }, deploy: { quorum: rules } } }).identity!.gates;
|
|
85
|
-
expect(gates.ship).toEqual({ gate: "ship", class: "operator", quorum: { count: 2 }, pointer: "/identity/gates/ship" });
|
|
86
|
+
expect(gates.ship).toEqual({ gate: "ship", class: "operator", quorum: { count: 2 }, policy: null, mode: null, context: null, pointer: "/identity/gates/ship" });
|
|
86
87
|
expect(gates.deploy!.quorum).toEqual(rules);
|
|
87
88
|
// The schema refuses a malformed rule, and the reader refuses notAuthor, which an Op gate can't judge.
|
|
88
89
|
expect(() => decl({ gates: { ship: { quorum: { count: 0 } } } })).toThrow();
|
|
@@ -91,6 +92,20 @@ describe("identity.attribution", () => {
|
|
|
91
92
|
expect(() => decl({ gates: { ship: { quorum: { count: 1, notAuthor: true } } } })).toThrow(/identity\.gates\.ship\.quorum\.notAuthor is not read here/);
|
|
92
93
|
});
|
|
93
94
|
|
|
95
|
+
test("a gate entry takes a policy, a mode and a context beside its quorum (#4404)", () => {
|
|
96
|
+
const text = "permit (principal, action, resource);";
|
|
97
|
+
const policy = { kind: "gate-policy", lexicon: "cedar", name: "ship", version: gatePolicyVersion(text), text };
|
|
98
|
+
const gates = decl({ gates: { ship: { policy, mode: "enforce", context: { risk: "high", paths: ["a", "b"], reviewed: true, n: 2 } } } }).identity!.gates;
|
|
99
|
+
expect(gates.ship).toMatchObject({ policy, mode: "enforce", context: { risk: "high", paths: ["a", "b"], reviewed: true, n: 2 } });
|
|
100
|
+
// Each field stands alone: enforce with no base policy leaves the Op's policy to enforce.
|
|
101
|
+
expect(decl({ gates: { ship: { mode: "enforce" } } }).identity!.gates.ship!.policy).toBeNull();
|
|
102
|
+
expect(() => decl({ gates: { ship: { mode: "block" } } })).toThrow();
|
|
103
|
+
expect(() => decl({ gates: { ship: { policy: { ...policy, version: "sha256:other" } } } })).toThrow(/not the digest of its text/);
|
|
104
|
+
expect(() => decl({ gates: { ship: { policy: { kind: "gate-policy" } } } })).toThrow();
|
|
105
|
+
expect(() => decl({ gates: { ship: { context: { risk: { nested: 1 } } } } })).toThrow();
|
|
106
|
+
expect(() => decl({ gates: { ship: { context: { plan: "x" } } } })).toThrow(/identity\.gates\.ship\.context\.plan is reserved/);
|
|
107
|
+
});
|
|
108
|
+
|
|
94
109
|
test("identified refuses a bare name and takes a forge identity or a signer", () => {
|
|
95
110
|
const source = { declaration: decl({ attribution: "identified" }), policy: policyWith([{ principal: "dana@example.test", key: "ssh-ed25519 AAAA" }]) };
|
|
96
111
|
expect(() => refuseUnidentified(source, ["github:alice", "dana@example.test"], "--by")).not.toThrow();
|