Compare commits

...
14 Commits
Author SHA1 Message Date
mesh-tools 3feeefc70f Merge pull request 'gitea: set any field of a branch's protection or a repository's settings, saying whether each trunk stays protected (issue 1126, ADR 0566)' (#549) from feat/protect-as-trunk into main 2026-10-28 16:02:32 +00:00
jschoubben 16ea3824b5 gitea: gate and judge every trunk the gate's source names, not main alone
mesh/delivery merged: source.main moved to novox/mesh-catalog@3feeefc70f7c
mesh/check checking 16ea3824b5ea on 3feeefc70f7c
The operator, 2026-10-28: the gates and trunk_rule apply to every trunk,
the branches the mesh installs from, and later there may be more than
main. The trunks are read at the call from mesh-delivery-gate's source,
in one function (trunksOf), and fall back to main, said, only when it
names none. A force-push onto any trunk is refused, and trunk_rule says
whether the branch asked is a trunk.
2026-10-28 16:52:50 +01:00
jschoubben fabb62da00 gitea: refuse a force-push onto a rule covering main until the module can ask the operator's Approve for it
The operator, 2026-10-28: a call waits for their Approve only if it removes
branch protection or allows a force-push to main. No TypeScript tool can
ask yet (no asker in the TypeScript SDK; ADR 0286's mark is not built in
the runner), so such a call is refused with nothing sent, and every other
call runs at once. No tool removes a rule.
2026-10-28 16:48:55 +01:00
jschoubben 8068cf74a7 gitea: read a repository's settings back in the forge's own form, and take the settings it was missing
enable_prune is never in the forge's answer and mirror_interval comes back
as a Go duration, so a write that worked failed its read-back. The first is
now said as not read back, the second compared as a duration.
allow_merge_update, default_update_style and has_code are taken,
projects_mode needs has_projects true, and the description names what is
left out: the name, the tracker and wiki objects, and the mirror's
credentials.
2026-10-28 16:44:11 +01:00
jschoubben 6e97ba3867 gitea: fail a protection or settings write the forge answered and did not apply, and take every field gitea 1.27.3 edits
Gitea reads the push whitelist and force-push allowlist flags only beside
their parent set true and passes over a missing default branch, both with
a 200, so the tool said set for what did not change. It now refuses a
dependent field without its parent, reads the rule back and names each
field not applied, and says what the forge changed beside the call. The
bypass allowlist fields complete the 32. gitea_repo_settings_set_full sets
a repository's settings, its default branch among them, as the forge's
administrator: gitea_default_branch_set is refused as mesh-tools.
2026-10-28 16:39:54 +01:00
jschoubben 448310908c gitea: set any field of a branch protection rule through a tool, so a trunk's merge whitelist and override need no hand at the forge
gitea_branch_protection_set_full takes every field of gitea's branch
protection API by its own name, changes only those given, writes as the
forge's administrator, logs the required why, answers the rule before and
after, and says whether the controller's trunk rule (judgedRule in
mesh-control build_source.go) now holds. The operator, 2026-10-28: a
generic tool for any configuration; design 47 L2 needs de-spiegel's main
set as a trunk.
2026-10-28 16:25:16 +01:00
mesh-tools 58309a9729 Merge pull request 'power: every reboot, poweroff and halt is bounded at 10 minutes (group fix/shutdown-bound, issue 1126)' (#547) from fix/shutdown-bound into main 2026-10-28 15:05:06 +00:00
jschoubben 6724dab0cf power: bound every shutdown at ten minutes, not systemd's thirty (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-catalog@58309a972945
mesh/delivery-group group fix/shutdown-bound merged: every member is merged
mesh/check pass 6724dab0cf96 on 387ee59140c4 | unit 2192 run, 3794 kept, 57 skipped | integration 2208 run, 3829 kept, 57 skipped | over bound: unit, …
On ace's reboot of 2026-10-28 every unit had stopped within 17 s, yet the bus and
polkit were started again during the shutdown, nothing stopped them a second
time, and the reboot waited on reboot.target's 30-minute job timeout, silent,
until the operator forced it. node-power owns the shutdown on every machine, so
its drop-ins on reboot, poweroff and halt shorten the bound; each target keeps
its own forcing action, so the last phase still ends every process, unmounts
and syncs. The cause, node-tools starting bundles again while it stops, is
fixed in mesh-tools.
2026-10-28 15:30:54 +01:00
mesh-tools 387ee59140 Merge pull request 'mesh-build-image: a node that cannot build here now stays healthy and says not eligible, so it never holds the delivery (issue 1126)' (#546) from fix/1126-ineligible-healthy into main 2026-10-28 14:20:40 +00:00
mesh-tools 85bbeb37e3 Merge pull request 'mesh-vault: own answers a node within its bound — assignments from a watched mirror, one read per module, one synced record per answer (issue 1126)' (#545) from fix/1126-vault-own into main 2026-10-28 14:20:35 +00:00
jschoubben 363039f337 mesh-build-image README: a node without tun is healthy and not eligible, as the module now says (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-catalog@387ee59140c4
mesh/check checking 363039f337ad on 387ee59140c4
2026-10-28 15:01:42 +01:00
jschoubben a9d96b30be mesh-vault: a watch of the assignments begun again keeps nothing an earlier attempt took, and a module the mirror lacks is read directly before a credential is refused (issue 1126, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-catalog@85bbeb37e3e0
mesh/delivery-group group fix/1126-vault-own merged: every member is merged
mesh/check pass a9d96b30be8d on 69056948bfa8 | unit 1639 run, 4201 kept, 46 skipped | integration 1674 run, 4215 kept, 47 skipped | over bound: unit, …
A watch that failed partway kept what it had taken; begun again, the runtime does not hand a key deleted meanwhile, so
a removed assignment read as standing and credentialRefusal could hand a consumer credential to a node no longer
running its module. Each attempt now begins from nothing held, and a late delivery to an older attempt changes nothing.
A 'not on the node' refusal is confirmed by a direct read first, since a watched mirror can be behind.
2026-10-28 14:55:36 +01:00
jschoubben 644a34cf49 mesh-build-image stays healthy on a node that cannot build here now (no tun) and says it is not eligible: the assigner read unhealthy as a failed delivery and held the build (issue 1126) 2026-10-28 14:50:28 +01:00
jschoubben 277a4d0060 mesh-vault answers own in one pass: assignments from its watch, each module's once, one synced record per answer (issue 1126)
Seed-9's probe (13:31-13:36Z): novox's own of 57 secrets and 146 credentials took 26 s, past the node-engine's 15 s,
and ace and novox refused their whole shadow answer. own read the controller's assignment over the bus once per secret
and per credential (203 reads), read every record of every node, and synced the hand-out record once per value, under
the one lock every node's answers share.

Now the vault watches the assignments mirror (a watch, never a poll) and reads it from memory once caught up, over the
bus until then; one answer reads each module's assignment once; only own/<node>/ records are read; and every hand-out
of one answer is recorded in one synced write, none handed when it cannot be.

Quick fix weighed: a longer bound in the node-engine (VaultWithin). Thorough fix chosen: the cost grows with every
module and every node, and the lock is shared by every shadow answer and every apply.
2026-10-28 14:47:20 +01:00
23 changed files with 1207 additions and 47 deletions
+34
View File
@@ -758,6 +758,15 @@ export class GiteaClient {
return { created: true, rule };
}
/** Write a branch's rule with exactly the fields given (gitea_branch_protection_set_full): an edit of the rule named
* for the branch, or a new one. As this client's account, which the forge refuses where it is no administrator. */
async writeBranchProtection(owner: string, repo: string, branch: string, body: Record<string, unknown>, creating: boolean): Promise<BranchProtection> {
return creating
? this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections`, { method: "POST", body: JSON.stringify({ rule_name: branch, ...body }) })
: this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`,
{ method: "PATCH", body: JSON.stringify(body) });
}
async createPullRequest(
owner: string,
repo: string,
@@ -1052,6 +1061,31 @@ export class GiteaAdmin {
return Array.isArray(res.body) ? res.body : [];
}
/** Write a branch's rule with exactly the fields given, as the forge's administrator (gitea_branch_protection_set_full;
* the operator, 2026-10-28): an edit of the rule named for the branch, or a new one. */
async writeBranchProtection(owner: string, repo: string, branch: string, body: Record<string, unknown>, creating: boolean): Promise<BranchProtection> {
const path = creating ? `${repoPath(owner, repo)}/branch_protections` : `${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`;
const res = await this.request(path, { method: creating ? "POST" : "PATCH", body: JSON.stringify(creating ? { rule_name: branch, ...body } : body) });
if (res.status !== 200 && res.status !== 201) GiteaAdmin.fail(path, res);
return res.body as BranchProtection;
}
/** A repository as the forge answers it to its administrator: every setting (gitea_repo_settings_set_full). */
async repoSettings(owner: string, repo: string): Promise<Record<string, unknown>> {
const path = repoPath(owner, repo);
const res = await this.request(path);
if (res.status !== 200) GiteaAdmin.fail(path, res);
return res.body as Record<string, unknown>;
}
/** Edit a repository's settings with exactly the fields given, as the forge's administrator. */
async editRepoSettings(owner: string, repo: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
const path = repoPath(owner, repo);
const res = await this.request(path, { method: "PATCH", body: JSON.stringify(body) });
if (res.status !== 200) GiteaAdmin.fail(path, res);
return res.body as Record<string, unknown>;
}
/** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, only
* the tools' account merges, and no status required (ADR 0534 §1). The rule there is edited; a repository with no
* rule for main gets one. Answers the repositories it touched. With the administrator's login:
+2 -1
View File
@@ -569,6 +569,7 @@
"mesh-build-node.keys",
"mesh-build-image.keys",
"mesh-delivery-checker.owed",
"mesh-delivery-indexer.modules"
"mesh-delivery-indexer.modules",
"mesh-delivery-gate.source"
]
}
+257
View File
@@ -0,0 +1,257 @@
// Every field of a branch protection rule, set through one tool (gitea_branch_protection_set_full; the operator,
// 2026-10-28: "just make a generic tool so you can setup any configuration"). Pure, so it is tested without a forge:
// which fields the forge's rule takes and of what kind, the body a call sends, and whether the rule the forge then
// applies to the branch is one the controller registers a module from (judgedRule, mesh-control
// cmd/mesh-controller/build_source.go).
import { TOOLS_ACCOUNT } from "./tools-account.js";
import { MAIN_BRANCH, type BranchProtection } from "./protection.js";
import { ruleCovers } from "./guard.js";
type Kind = "boolean" | "logins" | "strings" | "number" | "string";
/** unread: the forge never answers it, so it is not read back; duration: a Go duration, compared as one ("8h" is the
* forge's "8h0m0s"). */
type Field = { kind: Kind; says: string; parent?: string; unread?: boolean; duration?: boolean };
/** Every field gitea's branch protection API takes on an edit at 1.27.3 (EditBranchProtectionOption, 32 fields), by its
* own name, and its kind; rule_name is the branch argument, never a field. A field with a parent is read by the forge
* only when the same call sets that parent true, and setting the parent false clears it (gitea's
* routers/api/v1/repo/branch.go): such a field is refused without its parent true in the call. */
export const PROTECTION_FIELDS: Record<string, Field> = {
enable_push: { kind: "boolean", says: "whether anyone who writes may push to the branch directly (false: no direct push; also clears enable_push_whitelist and push_whitelist_deploy_keys)" },
enable_push_whitelist: { kind: "boolean", says: "only the push whitelist may push; needs enable_push: true in the same call", parent: "enable_push" },
push_whitelist_usernames: { kind: "logins", says: "the logins that may push directly" },
push_whitelist_teams: { kind: "strings", says: "the teams that may push directly" },
push_whitelist_deploy_keys: { kind: "boolean", says: "whether deploy keys may push; needs enable_push: true in the same call", parent: "enable_push" },
enable_force_push: { kind: "boolean", says: "whether a force push is taken (false also clears the force-push allowlist flags)" },
enable_force_push_allowlist: { kind: "boolean", says: "only the force-push allowlist may force-push; needs enable_force_push: true in the same call", parent: "enable_force_push" },
force_push_allowlist_usernames: { kind: "logins", says: "the logins that may force-push" },
force_push_allowlist_teams: { kind: "strings", says: "the teams that may force-push" },
force_push_allowlist_deploy_keys: { kind: "boolean", says: "whether deploy keys may force-push; needs enable_force_push: true in the same call", parent: "enable_force_push" },
enable_merge_whitelist: { kind: "boolean", says: "whether only the merge whitelist may merge" },
merge_whitelist_usernames: { kind: "logins", says: "the logins that may merge (the controller's trunk rule wants mesh-tools, no agent-<node>)" },
merge_whitelist_teams: { kind: "strings", says: "the teams that may merge" },
enable_status_check: { kind: "boolean", says: "whether the statuses in status_check_contexts must pass to merge" },
status_check_contexts: { kind: "strings", says: "the required commit statuses, e.g. mesh/check" },
required_approvals: { kind: "number", says: "how many approving reviews a merge needs" },
enable_approvals_whitelist: { kind: "boolean", says: "whether only the approvals whitelist's reviews count" },
approvals_whitelist_username: { kind: "logins", says: "the logins whose approvals count" },
approvals_whitelist_teams: { kind: "strings", says: "the teams whose approvals count" },
block_on_rejected_reviews: { kind: "boolean", says: "a review asking changes blocks the merge" },
block_on_official_review_requests: { kind: "boolean", says: "an open official review request blocks the merge" },
block_on_outdated_branch: { kind: "boolean", says: "a head behind the branch blocks the merge" },
dismiss_stale_approvals: { kind: "boolean", says: "a new commit dismisses earlier approvals" },
ignore_stale_approvals: { kind: "boolean", says: "approvals of an earlier commit do not count" },
require_signed_commits: { kind: "boolean", says: "every commit must be signed" },
protected_file_patterns: { kind: "string", says: "files no push may change, ';'-separated globs" },
unprotected_file_patterns: { kind: "string", says: "files a push may change even without push rights, ';'-separated globs" },
block_admin_merge_override: { kind: "boolean", says: "true: an administrator cannot merge past the rule (the controller's trunk rule wants true)" },
enable_bypass_allowlist: { kind: "boolean", says: "whether the bypass allowlist may merge past the rule's checks" },
bypass_allowlist_usernames: { kind: "logins", says: "the logins that may merge past the rule's checks" },
bypass_allowlist_teams: { kind: "strings", says: "the teams that may merge past the rule's checks" },
priority: { kind: "number", says: "the rule's order among glob rules" },
};
/** The repository settings gitea_repo_settings_set_full sets (gitea's EditRepoOption at 1.27.3), by their own names. Left
* out: name (a rename moves every module's source address), the internal tracker, external tracker and external wiki
* objects, and mirror_username, mirror_password and mirror_token, which are credentials. The forge ignores a
* default_branch that does not exist; the read-back below says so. */
export const REPO_FIELDS: Record<string, Field> = Object.fromEntries(([
["description", "string"], ["website", "string"], ["private", "boolean"], ["template", "boolean"],
["default_branch", "string"], ["has_code", "boolean"], ["has_issues", "boolean"], ["has_wiki", "boolean"],
["has_pull_requests", "boolean"], ["has_projects", "boolean"], ["projects_mode", "string"], ["has_releases", "boolean"],
["has_packages", "boolean"], ["has_actions", "boolean"], ["ignore_whitespace_conflicts", "boolean"],
["allow_merge_commits", "boolean"], ["allow_rebase", "boolean"], ["allow_rebase_explicit", "boolean"],
["allow_squash_merge", "boolean"], ["allow_fast_forward_only_merge", "boolean"], ["allow_manual_merge", "boolean"],
["autodetect_manual_merge", "boolean"], ["allow_rebase_update", "boolean"], ["allow_merge_update", "boolean"],
["default_update_style", "string"], ["default_delete_branch_after_merge", "boolean"], ["default_merge_style", "string"],
["default_allow_maintainer_edit", "boolean"], ["archived", "boolean"], ["mirror_interval", "string"], ["enable_prune", "boolean"],
] as [string, Kind][]).map(([name, kind]) => [name, {
kind, says: `the repository's ${name.replace(/_/g, " ")}`,
...(name === "projects_mode" ? { parent: "has_projects", says: "the repository's projects mode; needs has_projects: true in the same call" } : {}),
...(name === "enable_prune" ? { unread: true, says: "a mirror's prune setting; the forge never answers it, so it is not read back" } : {}),
...(name === "mirror_interval" ? { duration: true, says: "a mirror's interval, a Go duration such as 8h (the forge answers 8h0m0s)" } : {}),
}]));
/** A Go duration ("8h", "1h30m", "8h0m0s", "90s") in milliseconds; NaN when it is none. */
export function durationMs(v: unknown): number {
if (typeof v !== "string" || !/^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/.test(v)) return v === "0" ? 0 : NaN;
const unit: Record<string, number> = { ns: 1e-6, us: 1e-3, "µs": 1e-3, ms: 1, s: 1e3, m: 6e4, h: 3.6e6 };
let ms = 0;
for (const m of v.matchAll(/(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g)) ms += Number(m[1]) * unit[m[2]];
return ms;
}
/** Fields the forge was sent but does not hold after the write, each with what was sent and what it holds: a write it
* answered 200 and did not apply (gitea drops a field whose parent is not set, or a branch that does not exist). */
export function unapplied(sent: Record<string, unknown>, held: Record<string, unknown>, fields: Record<string, Field> = PROTECTION_FIELDS): string[] {
const norm = (k: string, v: unknown) => fields[k]?.duration ? String(durationMs(v))
: Array.isArray(v) ? JSON.stringify([...v].map(String).sort()) : JSON.stringify(v ?? null);
return Object.keys(sent).filter((k) => !fields[k]?.unread && norm(k, sent[k]) !== norm(k, held[k]))
.map((k) => `${k} (sent ${JSON.stringify(sent[k])}, the forge holds ${JSON.stringify(held[k] ?? null)})`);
}
/** Fields that changed between two reads but were not sent: what the forge changed beside the call (enable_push: false
* clears the push whitelist flags). */
export function alsoChanged(before: Record<string, unknown> | null, after: Record<string, unknown>, sent: Record<string, unknown>): string[] {
if (!before) return [];
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
return [...keys].filter((k) => !(k in sent) && !["updated_at", "created_at"].includes(k) &&
JSON.stringify(before[k] ?? null) !== JSON.stringify(after[k] ?? null));
}
/** A forge login. */
const LOGIN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;
/** The forge body for the fields given: each checked against its kind, a field not given left out (the forge keeps it on
* an edit), an unknown field refused before anything is sent. Throws on the first wrong field. */
export function fullProtectionBody(args: Record<string, unknown>, given: string[],
fields: Record<string, Field> = PROTECTION_FIELDS, what = "a branch protection rule"): Record<string, unknown> {
const body: Record<string, unknown> = {};
for (const name of given) {
const field = fields[name];
if (!field) throw new Error(`${name} is no field of ${what}; the fields are ${Object.keys(fields).join(", ")}`);
// The parent false clears the field, so only a field false goes beside a parent false.
if (field.parent && args[field.parent] !== true && !(args[field.parent] === false && args[name] === false)) {
throw new Error(`${name} is read by the forge only when the same call sets ${field.parent}: true (gitea ignores it otherwise). Nothing was sent`);
}
const v = args[name];
switch (field.kind) {
case "boolean":
if (typeof v !== "boolean") throw new Error(`${name} is true or false`);
body[name] = v;
break;
case "number":
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) throw new Error(`${name} is a whole number, 0 or more`);
body[name] = v;
break;
case "string":
if (typeof v !== "string") throw new Error(`${name} is a string`);
if (field.duration && Number.isNaN(durationMs(v))) throw new Error(`${name} is a duration, such as 8h or 1h30m`);
body[name] = v;
break;
case "strings":
case "logins": {
if (!Array.isArray(v) || v.some((s) => typeof s !== "string" || s.trim() === "")) throw new Error(`${name} is a list of names`);
const names = [...new Set((v as string[]).map((s) => s.trim()))];
if (field.kind === "logins") {
const bad = names.find((s) => !LOGIN.test(s) || s.includes(".."));
if (bad !== undefined) throw new Error(`${name}: ${JSON.stringify(bad)} is no forge login`);
}
body[name] = names;
break;
}
}
}
return body;
}
/** The fields of a call that are protection fields: every argument but owner, repo, branch and why. */
export function givenFields(args: Record<string, unknown>): string[] {
return Object.keys(args).filter((k) => !["owner", "repo", "branch", "why"].includes(k) && args[k] !== undefined);
}
/** A rule as the controller reads it from gitea_branch_protection_get (protectionRule in build_source.go). */
interface ControllerRule { rule: string; push: boolean; admin_may_override: boolean; merge_whitelist: string[] | null }
function asControllerRule(p: BranchProtection): ControllerRule {
return {
rule: String(p.rule_name ?? p.branch_name ?? ""),
push: p.enable_push ?? false,
admin_may_override: !(p.block_admin_merge_override ?? false),
merge_whitelist: p.enable_merge_whitelist ? (p.merge_whitelist_usernames ?? []) : null,
};
}
/** ruleCovers of build_source.go, as the controller reads a rule's glob: `**` any, `*` within a segment, `?` one. */
function controllerCovers(rule: string, branch: string): boolean {
if (rule === branch) return true;
if (!/[*?[]/.test(rule)) return false;
let re = "^";
for (let i = 0; i < rule.length; i++) {
const c = rule[i];
if (c === "*" && rule[i + 1] === "*") { re += ".*"; i++; }
else if (c === "*") re += "[^/]*";
else if (c === "?") re += "[^/]";
else re += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
return new RegExp(re + "$").test(branch);
}
/** Whether the controller registers a module from this branch, judged as judgedRule in mesh-control
* cmd/mesh-controller/build_source.go does (ADR 0534 §6): the rule named for the branch, else the first glob rule that
* covers it; it takes no push, its merge whitelist names mesh-tools and no agent-<node>, and no administrator merges
* past it. A mirror, not the judge: the controller's own verdict is the one that counts. */
export function trunkRule(rules: BranchProtection[], branch: string, trunks: string[] = [MAIN_BRANCH]): { holds: boolean; why: string; is_trunk: boolean } {
const judged = judgedTrunkRule(rules, branch);
return { ...judged, is_trunk: trunks.includes(branch) };
}
function judgedTrunkRule(rules: BranchProtection[], branch: string): { holds: boolean; why: string } {
const all = rules.map(asControllerRule);
const r = all.find((x) => x.rule === branch) ?? all.find((x) => controllerCovers(x.rule, branch));
const no = (why: string) => ({ holds: false, why });
if (!r) return no(`no protection rule covers ${branch}`);
if (r.push) return no(`the rule ${r.rule} lets a person push to ${branch} directly`);
if (!r.merge_whitelist || r.merge_whitelist.length === 0) {
return no(`the rule ${r.rule} keeps no merge whitelist naming ${TOOLS_ACCOUNT} alone for ${branch}`);
}
if (!r.merge_whitelist.includes(TOOLS_ACCOUNT)) {
return no(`the rule ${r.rule} lets ${r.merge_whitelist.join(", ")} merge into ${branch}, and not ${TOOLS_ACCOUNT}`);
}
if (r.merge_whitelist.some((l) => l.startsWith("agent-"))) {
return no(`the rule ${r.rule} lets an agent's account merge into ${branch}: ${r.merge_whitelist.join(", ")}`);
}
if (r.admin_may_override) return no(`the rule ${r.rule} lets an administrator merge into ${branch} past it`);
return { holds: true, why: `the rule ${r.rule} takes no push, only ${r.merge_whitelist.join(", ")} merge, and no administrator merges past it` };
}
/** The gate's source as it is read: one key per trunk, each with the heads of the repositories installed from it. */
export type SourceRead = () => Promise<Record<string, unknown>>;
/** The state the trunks are read from: mesh-delivery-gate's source, whose key `main` is today's one trunk of each
* repository it names (each repository's trunk head the checker judges on); a later trunk is a key of its own. */
export const TRUNK_SOURCE = "mesh-delivery-gate.source";
/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): each key of the gate's
* source whose heads name the repository. "main" only when the source names none of its trunks or cannot be read, and
* `from` says which. The one place trunks are read: several trunks later change only this. */
export async function trunksOf(owner: string, repo: string, read: SourceRead): Promise<{ names: string[]; from: string }> {
const full = `${owner}/${repo}`;
let src: Record<string, unknown>;
try {
src = await read();
} catch (err) {
return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} could not be read (${err instanceof Error ? err.message : String(err)}): ${MAIN_BRANCH} assumed` };
}
const names = Object.keys(src).filter((k) => {
const heads = (src[k] as { heads?: Record<string, unknown> } | null)?.heads;
return !!heads && typeof heads === "object" && full in heads;
}).sort();
if (names.length) return { names, from: TRUNK_SOURCE };
return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} names no trunk of ${full}: ${MAIN_BRANCH} assumed` };
}
/** The gate's source read from the bus, every key and its value (the tools bundle's state channel). */
export function sourceFromBus(get: (name: string) => { keys(): Promise<string[]>; get(k: string): Promise<{ value: unknown } | null> }): SourceRead {
return async () => {
const st = get(TRUNK_SOURCE);
const out: Record<string, unknown> = {};
for (const k of await st.keys()) out[k] = (await st.get(k))?.value ?? null;
return out;
};
}
/** Why a call is the operator's, or null: it lets a force-push onto a rule covering a trunk (the operator, 2026-10-28
* 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to a trunk). No
* tool of this module can ask for that Approve yet, so such a call is refused with nothing sent. */
export function forcePushToTrunk(rule: string, body: Record<string, unknown>, trunks: string[]): string | null {
const covered = trunks.filter((t) => ruleCovers(rule, t));
if (!covered.length) return null;
const opens = body.enable_force_push === true || body.force_push_allowlist_deploy_keys === true ||
(["force_push_allowlist_usernames", "force_push_allowlist_teams"] as const).some((k) => Array.isArray(body[k]) && (body[k] as unknown[]).length > 0);
if (!opens) return null;
return `the rule ${rule} covers ${covered.join(", ")}, and this call lets a force-push onto it: a force-push to a trunk is ` +
`the operator's Approve (the operator, 2026-10-28), and no tool of this module can ask it yet, so it is the operator's ` +
`at the forge. Nothing was sent`;
}
+250
View File
@@ -0,0 +1,250 @@
// gitea_branch_protection_set_full (the operator, 2026-10-28: "just make a generic tool so you can setup any
// configuration"): any field of a branch protection rule, only the fields given, as the forge's administrator; it
// answers the rule before and after, and whether the controller's trunk rule (judgedRule, mesh-control
// build_source.go) now holds. Against a fake forge that keeps the rules and answers them to the administrator alone.
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { GiteaAdmin, GiteaClient } from "../dist/client.js";
import { ConfiguredToken } from "../dist/token.js";
import { getGiteaTools } from "../dist/tools/index.js";
import { trunkRule } from "../dist/protectfull.js";
const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64");
let rules: any[] = [];
let repoState: any = {};
const branches = ["master", "main"];
let sent: { method: string; path: string; body: any }[] = [];
beforeEach(() => {
// de-spiegel's main as found: no push, but the operator alone merges and an administrator merges past it.
rules = [{ rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"],
enable_status_check: false, status_check_contexts: [], block_admin_merge_override: false, required_approvals: 0,
enable_push_whitelist: false, push_whitelist_deploy_keys: false, enable_force_push: false, enable_force_push_allowlist: false,
force_push_allowlist_deploy_keys: false, merge_whitelist_teams: [] }];
source = {};
repoState = { full_name: "novox/de-spiegel", default_branch: "master", description: "", has_wiki: true };
sent = [];
});
// What gitea 1.27.3's EditBranchProtection does with a body (routers/api/v1/repo/branch.go): the push whitelist flags are
// read only beside enable_push true and cleared by enable_push false, the force-push allowlist flags likewise with
// enable_force_push; teams are passed over on a repository no organisation owns (de-spiegel here, for the test).
function giteaEdit(r: any, body: any) {
const nested = ["enable_push", "enable_push_whitelist", "push_whitelist_deploy_keys", "enable_force_push",
"enable_force_push_allowlist", "force_push_allowlist_deploy_keys", "merge_whitelist_teams", "push_whitelist_teams"];
for (const [k, v] of Object.entries(body)) if (!nested.includes(k) && k !== "rule_name") r[k] = v;
if (body.enable_push === false) Object.assign(r, { enable_push: false, enable_push_whitelist: false, push_whitelist_deploy_keys: false });
if (body.enable_push === true) {
r.enable_push = true;
if (body.enable_push_whitelist !== undefined) r.enable_push_whitelist = body.enable_push_whitelist;
if (body.push_whitelist_deploy_keys !== undefined) r.push_whitelist_deploy_keys = body.push_whitelist_deploy_keys;
}
if (body.enable_force_push === false) Object.assign(r, { enable_force_push: false, enable_force_push_allowlist: false, force_push_allowlist_deploy_keys: false });
if (body.enable_force_push === true) {
r.enable_force_push = true;
if (body.enable_force_push_allowlist !== undefined) r.enable_force_push_allowlist = body.enable_force_push_allowlist;
if (body.force_push_allowlist_deploy_keys !== undefined) r.force_push_allowlist_deploy_keys = body.force_push_allowlist_deploy_keys;
}
}
const server = createServer((req, res) => {
const path = (req.url ?? "").replace(/^\/api\/v1/, "");
const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); };
if (req.headers.authorization !== adminBasic) return send(403, { message: "not an administrator of the repository" });
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
const body = raw ? JSON.parse(raw) : undefined;
if (req.method !== "GET") sent.push({ method: req.method!, path, body });
if (path === "/repos/novox/de-spiegel") {
if (req.method === "GET") return send(200, repoState);
if (req.method === "PATCH") {
// gitea's updateBasicProperties: a default branch that does not exist is passed over, the call still 200.
// enable_prune is a mirror's, kept beside the repository and never in its answer; mirror_interval is answered in Go's
// duration form.
const { default_branch, enable_prune, mirror_interval, ...rest } = body;
Object.assign(repoState, rest);
if (mirror_interval !== undefined) repoState.mirror_interval = mirror_interval === "8h" ? "8h0m0s" : mirror_interval;
if (default_branch !== undefined && branches.includes(default_branch)) repoState.default_branch = default_branch;
return send(200, repoState);
}
}
const m = path.match(/^\/repos\/novox\/de-spiegel\/branch_protections(?:\/(.+))?$/);
if (!m) return send(404, { message: "not found" });
if (req.method === "GET" && !m[1]) return send(200, rules);
if (req.method === "PATCH" && m[1]) {
const r = rules.find((x) => x.rule_name === decodeURIComponent(m[1]));
if (!r) return send(404, { message: "no rule" });
giteaEdit(r, body);
return send(200, r);
}
if (req.method === "POST" && !m[1]) { const r = { rule_name: body.rule_name }; giteaEdit(r, body); rules.push(r); return send(201, r); }
send(405, { message: "no" });
});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
after(() => new Promise<void>((r) => server.close(() => r())));
const { port } = server.address() as { port: number };
const url = `http://127.0.0.1:${port}`;
// The gate's source as the tools read it (mesh-delivery-gate.source): one key per trunk, each naming the repositories
// installed from it. A test sets it; empty is a source that names no trunk of de-spiegel.
let source: Record<string, { heads: Record<string, string> }> = {};
const readSource = async () => source;
const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"), readSource);
const setRepo = (args: Record<string, unknown>) =>
tools.find((t) => t.name === "gitea_repo_settings_set_full")!.run({ owner: "novox", repo: "de-spiegel", why: "L2", ...args });
const setFull = (args: Record<string, unknown>) =>
tools.find((t) => t.name === "gitea_branch_protection_set_full")!.run({ owner: "novox", repo: "de-spiegel", branch: "main", why: "L2", ...args });
test("it sets the merge whitelist and the administrator's override, only the fields given, and says the trunk rule now holds", async () => {
const out: any = await setFull({ merge_whitelist_usernames: ["mesh-tools", "jschoubben"], block_admin_merge_override: true });
assert.deepEqual(sent, [{ method: "PATCH", path: "/repos/novox/de-spiegel/branch_protections/main",
body: { merge_whitelist_usernames: ["mesh-tools", "jschoubben"], block_admin_merge_override: true } }]);
assert.deepEqual(out.before.merge_whitelist, ["jschoubben"]);
assert.equal(out.before.admin_may_override, true);
assert.deepEqual(out.after.merge_whitelist, ["mesh-tools", "jschoubben"]);
assert.equal(out.after.admin_may_override, false);
assert.equal(out.why, "L2");
assert.equal(out.trunk_rule.holds, true, out.trunk_rule.why);
});
test("it loosens as told too, and says the trunk rule no longer holds and why", async () => {
const out: any = await setFull({ enable_push: true, status_check_contexts: ["mesh/check"], enable_status_check: true, required_approvals: 1 });
assert.equal(out.after.push, true);
assert.deepEqual(out.after.required_statuses, ["mesh/check"]);
assert.equal(out.after.required_approvals, 1);
assert.equal(out.trunk_rule.holds, false);
assert.match(out.trunk_rule.why, /push to main directly/);
});
test("a branch with no rule gets one with the fields given", async () => {
const out: any = await setFull({ branch: "release/*", enable_push: false, protected_file_patterns: "*.lock" });
assert.equal(out.created, true);
assert.deepEqual(sent[0], { method: "POST", path: "/repos/novox/de-spiegel/branch_protections",
body: { rule_name: "release/*", enable_push: false, protected_file_patterns: "*.lock" } });
});
test("an unknown field, a field of the wrong kind, no field, no reason or a login that is none: refused, nothing sent", async () => {
await assert.rejects(setFull({ enable_pushes: true }), /no field of a branch protection rule/);
await assert.rejects(setFull({ enable_push: "yes" }), /true or false/);
await assert.rejects(setFull({ required_approvals: -1 }), /whole number/);
await assert.rejects(setFull({ merge_whitelist_usernames: ["../x"] }), /no forge login/);
await assert.rejects(setFull({}), /at least one field/);
await assert.rejects(setFull({ why: "", enable_push: false }), /say why/);
assert.deepEqual(sent, []);
});
// judgedRule's cases (mesh-control cmd/mesh-controller/build_source.go), each in its own words.
test("the trunk rule is judged as the controller judges it", () => {
const ok = { rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true };
const cases: [any[], string, boolean, RegExp][] = [
[[], "main", false, /no protection rule covers main/],
[[{ ...ok, enable_push: true }], "main", false, /push to main directly/],
[[{ ...ok, enable_merge_whitelist: false }], "main", false, /no merge whitelist/],
[[{ ...ok, merge_whitelist_usernames: ["jschoubben"] }], "main", false, /and not mesh-tools/],
[[{ ...ok, merge_whitelist_usernames: ["mesh-tools", "agent-g14"] }], "main", false, /agent's account/],
[[{ ...ok, block_admin_merge_override: false }], "main", false, /administrator/],
[[ok], "main", true, /takes no push/],
[[{ ...ok, rule_name: "ma*" }], "main", true, /ma\*/],
[[{ ...ok, rule_name: "ma*", enable_push: true }, { ...ok }], "main", true, /rule main/],
];
for (const [rs, branch, holds, why] of cases) {
const got = trunkRule(rs, branch);
assert.equal(got.holds, holds, `${JSON.stringify(rs)}: ${got.why}`);
assert.match(got.why, why);
}
});
// Review round 1 of feat/protect-as-trunk: gitea passes over a dependent field without its parent set true, and answers 200.
test("a field the forge reads only beside its parent is refused without it; with it, it is applied", async () => {
await assert.rejects(setFull({ enable_push_whitelist: true }), /only when the same call sets enable_push: true/);
await assert.rejects(setFull({ force_push_allowlist_deploy_keys: true, enable_force_push: false }), /enable_force_push: true/);
assert.deepEqual(sent, []);
const out: any = await setFull({ enable_push: true, enable_push_whitelist: true, push_whitelist_usernames: ["jschoubben"] });
assert.equal(out.after.fields.enable_push_whitelist, true);
await setFull({ enable_push: false, enable_push_whitelist: false }); // a false beside a false parent is what the forge does
});
test("a field the forge answered and did not apply fails the call, naming it; what it changed beside the call is said", async () => {
await assert.rejects(setFull({ merge_whitelist_teams: ["owners"] }), /did not apply merge_whitelist_teams \(sent \["owners"\], the forge holds \[\]\)/);
rules[0].enable_push = true; rules[0].enable_push_whitelist = true;
const out: any = await setFull({ enable_push: false });
assert.deepEqual(out.also_changed, ["enable_push_whitelist"]);
});
test("gitea_repo_settings_set_full sets the default branch, and fails loudly on a branch the forge passed over", async () => {
const out: any = await setRepo({ default_branch: "main" });
assert.deepEqual(sent.at(-1), { method: "PATCH", path: "/repos/novox/de-spiegel", body: { default_branch: "main" } });
assert.equal(out.before.default_branch, "master");
assert.equal(out.after.default_branch, "main");
await assert.rejects(setRepo({ default_branch: "trunk" }), /did not apply default_branch/);
await assert.rejects(setRepo({ name: "x" }), /no field of a repository's settings/);
await assert.rejects(setRepo({ why: "", has_wiki: false }), /say why/);
});
test("every field gitea 1.27.3 edits on a rule is taken: 32, the bypass allowlist among them", async () => {
const { PROTECTION_FIELDS } = await import("../dist/protectfull.js");
assert.equal(Object.keys(PROTECTION_FIELDS).length, 32);
for (const f of ["enable_bypass_allowlist", "bypass_allowlist_usernames", "bypass_allowlist_teams"]) assert.ok(f in PROTECTION_FIELDS, f);
});
// Review round 2: the settings tool's read-back meets the forge's own answer.
test("A: enable_prune, which the forge never answers, is said as not read back, never failed", async () => {
const pruned: any = await setRepo({ enable_prune: true });
assert.deepEqual(pruned.not_read_back, ["enable_prune"]);
});
test("B: mirror_interval is compared as a duration: 8h and the forge's 8h0m0s are one", async () => {
const mirror: any = await setRepo({ mirror_interval: "8h" });
assert.equal(mirror.after.mirror_interval, "8h0m0s");
await assert.rejects(setRepo({ mirror_interval: "often" }), /duration/);
});
test("C: allow_merge_update, default_update_style and has_code are taken; projects_mode needs has_projects; what is left out is named", async () => {
const more: any = await setRepo({ allow_merge_update: true, default_update_style: "rebase", has_code: true });
assert.deepEqual(more.set, { allow_merge_update: true, default_update_style: "rebase", has_code: true });
await assert.rejects(setRepo({ projects_mode: "repo" }), /only when the same call sets has_projects: true/);
await setRepo({ has_projects: true, projects_mode: "repo" });
const desc = tools.find((t) => t.name === "gitea_repo_settings_set_full")!.description;
for (const w of ["name", "mirror_username", "mirror_password", "mirror_token", "tracker", "wiki"]) assert.match(desc, new RegExp(w));
});
// The operator, 2026-10-28 16:4x: a call waits for their Approve only if it removes branch protection or allows a
// force-push to main. No tool of this module can ask yet (no TypeScript asker; ADR 0286's mark is unbuilt in the
// runner), so until one can, such a call is refused, with nothing sent, and says it is the operator's; everything else
// runs at once.
test("a force-push onto a rule covering main is refused, nothing sent; on another branch, and tightening, it runs", async () => {
for (const args of [{ enable_force_push: true }, { enable_force_push: true, enable_force_push_allowlist: true },
{ force_push_allowlist_usernames: ["jschoubben"] }, { force_push_allowlist_teams: ["owners"] },
{ branch: "ma*", enable_force_push: true }, { branch: "**", force_push_allowlist_usernames: ["x"] }]) {
await assert.rejects(setFull(args), /force-push to a trunk is the operator's/, JSON.stringify(args));
}
assert.deepEqual(sent, []);
await setFull({ branch: "release/*", enable_force_push: true });
const tight: any = await setFull({ enable_force_push: false, force_push_allowlist_usernames: [] });
assert.equal(tight.trunk_rule.holds, false, "trunk_rule is in the answer");
});
// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branches the mesh installs from, as
// the gate's source names them; "main" only when it names none, and said.
test("a trunk the gate's source names, beta, is gated and judged as main is; main is then no trunk of the repository", async () => {
source = { main: { heads: { "novox/okioki": "a1" } }, beta: { heads: { "novox/de-spiegel": "b2" } } };
await assert.rejects(setFull({ branch: "beta", enable_force_push: true }), /force-push to a trunk is the operator's/);
await assert.rejects(setFull({ branch: "b*", force_push_allowlist_usernames: ["x"] }), /covers beta/);
assert.deepEqual(sent, []);
const main: any = await setFull({ enable_force_push: true });
assert.deepEqual(main.trunks, { names: ["beta"], from: "mesh-delivery-gate.source" });
assert.equal(main.trunk_rule.is_trunk, false);
const beta: any = await setFull({ branch: "beta", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true });
assert.equal(beta.trunk_rule.is_trunk, true);
assert.equal(beta.trunk_rule.holds, true, beta.trunk_rule.why);
});
test("a source that names no trunk of the repository falls back to main, and says so", async () => {
const out: any = await setFull({ block_admin_merge_override: true });
assert.equal(out.trunks.names[0], "main");
assert.match(out.trunks.from, /names no trunk of novox\/de-spiegel: main assumed/);
assert.equal(out.trunk_rule.is_trunk, true);
});
+1 -1
View File
@@ -538,7 +538,7 @@ test("the tools register once there is a way to a token, and the first call mint
"gitea_get_file",
"gitea_list_branches",
"gitea_delete_branch",
"gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set",
"gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_repo_settings_set_full",
"gitea_default_branch_set", "gitea_pull_base_set",
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains",
"gitea_list_labels", "gitea_create_label",
+101 -5
View File
@@ -12,6 +12,8 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"
import { emit } from "@novox/mesh-sdk/events";
import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js";
import { readTeamRights } from "../teamrights.js";
import { state } from "@novox/mesh-sdk/state";
import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, sourceFromBus, trunksOf, type SourceRead, givenFields, trunkRule, unapplied } from "../protectfull.js";
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
import {
@@ -88,13 +90,13 @@ function listedDirs(raw: unknown): string[] | undefined {
return raw as string[];
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin));
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin, source));
}
// gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one
// writer (novox/hq issue 456, ADR 0323).
function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined, source: SourceRead): ToolDefinition[] {
return [
// ---- Repositories ----
{
@@ -431,8 +433,9 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo);
// The one read made as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the
// repository administrator's, which the tools' account is not. No other tool uses this account.
// Read as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the repository
// administrator's, which the tools' account is not. gitea_team_rights reads, and gitea_branch_protection_set_full
// and gitea_repo_settings_set_full write, with the same account.
const rules = await (admin ? admin() : gitea).branchProtections(owner, repo);
if (!args.branch) return { rules: rules.map(summarised) };
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null;
@@ -488,6 +491,99 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
},
{
name: "gitea_branch_protection_set_full",
description: "Set any field of a branch protection rule — protect a branch as a trunk, set its merge whitelist (users and teams), " +
"push and push whitelist, force push and its allowlist, the bypass allowlist, required status checks, required approvals and " +
"the approvals whitelist, block on rejected reviews, block on an outdated branch, dismiss stale approvals, signed commits, " +
"protected and unprotected file patterns, and the administrator's merge override (block_admin_merge_override): the 32 " +
"fields gitea 1.27.3's API edits, by their own names. Only the fields given change; a branch with no rule gets one. " +
"enable_push_whitelist and push_whitelist_deploy_keys need enable_push: true in the same call, the force-push allowlist " +
"flags enable_force_push: true (the forge ignores them otherwise). The rule is read back after the write: a field the " +
"forge did not apply fails the call, naming it; also_changed names what the forge changed beside the call. Generic: it " +
"tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunks, the branches the mesh installs from, are read from mesh-delivery-gate's source at the call (main when it names none, said in trunks.from). As the forge's " +
"administrator. Answers the rule before and after, the repository's trunks, and trunk_rule: whether the branch is a trunk (is_trunk) and whether the controller now registers a module from " +
"this branch (judgedRule, mesh-control build_source.go: no push, a merge whitelist naming mesh-tools and no " +
"agent-<node>, no administrator override) and why not.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
branch: { type: "string", description: "the branch (the rule's name, or a glob) whose rule is set" },
why: { type: "string", description: "why, in a sentence: logged and answered" },
...Object.fromEntries(Object.entries(PROTECTION_FIELDS).map(([name, f]) => [name, f.kind === "logins" || f.kind === "strings"
? { type: "array", items: { type: "string" }, description: f.says }
: { type: f.kind === "number" ? "number" : f.kind, description: f.says }])),
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
const why = String(args.why ?? "").trim();
if (!branch) throw new Error("name the branch whose rule is set");
if (!why) throw new Error("say why (why): a branch's protection is changed for a reason, which is logged");
pathSegments("branch", branch);
const given = givenFields(args);
if (!given.length) throw new Error(`name at least one field to set: ${Object.keys(PROTECTION_FIELDS).join(", ")}`);
const body = fullProtectionBody(args, given);
const trunks = await trunksOf(owner, repo, source);
const operators = forcePushToTrunk(branch, body, trunks.names);
if (operators) throw new Refused(operators);
const forge = admin ? admin() : gitea;
const rules = await forge.branchProtections(owner, repo);
const before = rules.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? null;
console.log(`[gitea] gitea_branch_protection_set_full ${owner}/${repo} ${branch}: ${JSON.stringify(body)} — why: ${why}`);
const rule = await forge.writeBranchProtection(owner, repo, branch, body, !before);
const after = await forge.branchProtections(owner, repo);
const read = after.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? rule;
const missed = unapplied(body, read);
if (missed.length) {
throw new Error(`the forge answered the write of ${owner}/${repo} ${branch}'s rule but did not apply ${missed.join("; ")}. ` +
`The rule it holds now: ${JSON.stringify(summarised(read))}`);
}
return {
branch, created: !before, why, set: body, also_changed: alsoChanged(before, read, body),
before: before ? { ...summarised(before), fields: before } : null,
after: { ...summarised(read), fields: read },
trunks, trunk_rule: trunkRule(after, branch, trunks.names),
};
},
},
{
name: "gitea_repo_settings_set_full",
description: "Set a repository's settings as the forge's administrator — its default branch (e.g. master to main), " +
"description, website, private, template, archived, the issues, wiki, pull requests, projects, releases, packages and " +
"actions units, the merge styles it allows and its default merge style, delete the branch after merge, and the mirror " +
"settings: gitea 1.27.3's EditRepoOption by its own names. Left out: name (a rename moves every module's source " +
"address), the internal tracker, external tracker and external wiki objects, and mirror_username, mirror_password " +
"and mirror_token, which are credentials. projects_mode needs has_projects: true in the same call. Only the fields " +
"given change. The repository is read back: a field the forge did not apply (a default branch that does not exist) " +
"fails the call, naming it; mirror_interval is compared as a duration, and enable_prune, which the forge never " +
"answers, is listed in not_read_back. A reason (why) is required and logged. Answers the settings before and after.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
why: { type: "string", description: "why, in a sentence: logged and answered" },
...Object.fromEntries(Object.entries(REPO_FIELDS).map(([name, f]) => [name, { type: f.kind, description: f.says }])),
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), why = String(args.why ?? "").trim();
if (!why) throw new Error("say why (why): a repository's settings are changed for a reason, which is logged");
if (!admin) throw new Error("the forge's administrator login is not set: a repository's settings are written with it");
const given = givenFields(args);
if (!given.length) throw new Error(`name at least one setting: ${Object.keys(REPO_FIELDS).join(", ")}`);
const body = fullProtectionBody(args, given, REPO_FIELDS, "a repository's settings");
const forge = admin();
const before = await forge.repoSettings(owner, repo);
console.log(`[gitea] gitea_repo_settings_set_full ${owner}/${repo}: ${JSON.stringify(body)} — why: ${why}`);
await forge.editRepoSettings(owner, repo, body);
const after = await forge.repoSettings(owner, repo);
const missed = unapplied(body, after, REPO_FIELDS);
if (missed.length) throw new Error(`the forge answered the write of ${owner}/${repo}'s settings but did not apply ${missed.join("; ")}`);
const pick = (r: Record<string, unknown>) => Object.fromEntries(Object.keys(REPO_FIELDS).filter((k) => k in r).map((k) => [k, r[k]]));
return { repository: `${owner}/${repo}`, why, set: body, before: pick(before), after: pick(after),
not_read_back: Object.keys(body).filter((k) => REPO_FIELDS[k]?.unread) };
},
},
// ---- Two settings gitea_api refuses (issue 1422 note 7; guard.ts), each its own named tool ----
{
name: "gitea_default_branch_set",
+8 -6
View File
@@ -16,12 +16,14 @@ credential and an environment built from scratch.
The account's subordinate user and group ranges are not declared anywhere: the node-engine makes every `user` account
with `useradd`, which allocates them. The module's health reads `/etc/subuid` and `/etc/subgid` and is unhealthy
without a range for the account, without buildah, when a user namespace cannot be made, or when `/dev/net/tun`
cannot be opened (buildah makes a RUN line's network with pasta, which needs the tun driver; on 2026-10-28 a build node had the
device node and no driver, so every image build there failed with "Failed to open() /dev/net/tun: No such device"). The
kernel module loads `tun` at boot. Whatever the health refuses, the node is **not eligible**: the seat's puller judges
by a capacity of 0 with that reason (`capacity.<node>`, which `build_usage` shows), so the asks go to a node that can
build them; a look that passes again wakes the puller.
when the module cannot run: without a range for the account, without buildah, when a user namespace cannot be made,
or when the disk is under its floor. A node where `/dev/net/tun` cannot be opened stays **healthy** and is **not
eligible** (buildah makes a RUN line's network with pasta, which needs the tun driver; on 2026-10-28 a build node had
the device node and no driver, so every image build there failed with "Failed to open() /dev/net/tun: No such
device"): the seat's puller judges by a capacity of 0 with that reason (`capacity.<node>`, which `build_usage` shows),
so the asks go to a node that can build them, and its health says "not eligible" with the cause, so a node that
cannot build here now never holds the module's delivery for every node. The kernel module loads `tun` at boot; a look
that passes again wakes the puller.
The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can read holds a key.
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
@@ -97,11 +98,25 @@ func (m *Module) look(ctx context.Context) (Look, error) {
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
}
if err := m.tunDevice(); err != nil {
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
return l, &notEligible{&buildstep.Refusal{Text: err.Error(), Have: l.Buildah}}
}
return l, nil
}
// notEligible is a refusal of a machine that runs the module fine but cannot build here now (no tun device: its asks go
// to another node). It is a *buildstep.Refusal for a step, and the health stays healthy for it (issue 1126): the
// assigner reads unhealthy as a failed delivery and holds the build for every node. What cannot run (buildah missing,
// no ranges, no namespace, no runtime directory, no room) stays unhealthy.
type notEligible struct{ *buildstep.Refusal }
func (e *notEligible) Unwrap() error { return e.Refusal }
// isNotEligible says whether err is a refusal that leaves the module healthy.
func isNotEligible(err error) bool {
var n *notEligible
return errors.As(err, &n)
}
// Check is the guard: nil when the machine can build, else the refusal. Every look is kept for Eligible, and a change
// wakes the puller, so a node that can build again takes asks at once.
func (m *Module) Check(ctx context.Context) error {
@@ -120,6 +135,13 @@ func (m *Module) keep(err error) {
changed := m.last != why
m.last = why
m.lastMu.Unlock()
if changed && m.Say != nil {
if why == "" {
m.Say("this machine is eligible to build images again")
} else {
m.Say("this machine takes no image build now: %s", why)
}
}
if changed && m.Held != nil {
m.Held.Wake()
}
@@ -155,12 +177,16 @@ func (m *Module) Start(ctx context.Context) {
func (m *Module) Health(ctx context.Context) health.Answer {
l, err := m.look(ctx)
m.keep(err)
if err != nil {
if err != nil && !isNotEligible(err) {
return health.Unhealthy(err.Error())
}
if err := m.room(); err != nil {
return health.Unhealthy(err.Error())
}
if err != nil {
// Not eligible: the module runs, it takes no image ask here now, and says why; the capacity record says it too.
return health.Healthy("not eligible to build images, so it takes no image ask: " + err.Error())
}
return health.Healthy(fmt.Sprintf("buildah %s installed; %s holds %d subordinate uids and %d gids; a user namespace can be made; %s opens", l.Buildah, l.Account, l.UIDs, l.GIDs, m.tunPath()))
}
@@ -168,7 +194,7 @@ func (m *Module) Health(ctx context.Context) health.Answer {
func (m *Module) Status(ctx context.Context) map[string]any {
l, err := m.look(ctx)
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil,
"cpu": m.cpuStatus()}
"eligible": err == nil, "cpu": m.cpuStatus()}
if err != nil {
out["why"] = err.Error()
}
@@ -2,7 +2,9 @@ package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -54,7 +56,6 @@ func TestHealthIsUnhealthyWithoutWhatABuildNeeds(t *testing.T) {
}{
{"subuid", func(m *Module, p string) { m.SubUIDFile = p }, []string{"/subuid", "mesh-build-image"}},
{"subgid", func(m *Module, p string) { m.SubGIDFile = p }, []string{"/subgid", "mesh-build-image"}},
{"tun", func(m *Module, p string) { m.Tun = p + "/dev/net/tun" }, []string{"not eligible: ", "/dev/net/tun cannot be opened", "tun"}},
} {
f := newFake(t)
m := moduleWith(t, f, newStates())
@@ -74,6 +75,61 @@ func TestHealthIsUnhealthyWithoutWhatABuildNeeds(t *testing.T) {
}
}
// 1126: a node that cannot build here now (no tun device) stays healthy, saying "not eligible" and why; the assigner reads
// unhealthy as a failed delivery and held the new build for every node. The eligibility, the status and the log say it too.
func TestANodeWithoutTunIsHealthyAndSaysItIsNotEligible(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
var said []string
m.Say = func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) }
m.Tun = t.TempDir() + "/dev/net/tun"
a := m.Health(context.Background())
if a.State != health.StateHealthy || !a.Healthy {
t.Fatalf("health = %+v, want healthy", a)
}
for _, want := range []string{"not eligible", "/dev/net/tun cannot be opened", "tun"} {
if !strings.Contains(a.Why, want) {
t.Errorf("health says %q, lacks %q", a.Why, want)
}
}
if got := m.Eligible(); !strings.Contains(got, "not eligible: ") {
t.Errorf("eligible says %q", got)
}
if s := m.Status(context.Background()); s["eligible"] != false || s["refusing"] != true {
t.Errorf("status = %v, want not eligible", s)
}
// One log line for the change, none for the same look again; one when it can build again.
m.Health(context.Background())
if len(said) != 1 || !strings.Contains(said[0], "takes no image build now") {
t.Errorf("said = %q, want one line for the change", said)
}
m.Tun = filepath.Join(t.TempDir(), "tun")
os.WriteFile(m.Tun, nil, 0o644)
if a := m.Health(context.Background()); !a.Healthy || strings.Contains(a.Why, "not eligible") {
t.Errorf("health = %+v, want plain healthy", a)
}
if len(said) != 2 || !strings.Contains(said[1], "eligible to build images again") {
t.Errorf("said = %q, want the return said", said)
}
// A step still ends refused with the cause named.
m.Tun = t.TempDir() + "/none"
if err := m.Check(context.Background()); err == nil || !strings.Contains(err.Error(), "not eligible") {
t.Errorf("check = %v", err)
}
}
// 1126: what cannot run stays unhealthy even beside a missing tun: buildah missing is not "not eligible".
func TestWithoutBuildahTheNodeIsUnhealthyNotIneligible(t *testing.T) {
m := moduleWith(t, newFake(t), newStates())
m.Tun = t.TempDir() + "/none"
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
return forgekey.Ran{Status: 127, Err: "exec: \"buildah\": executable file not found in $PATH"}
}
if a := m.Health(context.Background()); a.Healthy {
t.Errorf("health = %+v, want unhealthy", a)
}
}
func TestHealthIsUnhealthyWhenANamespaceCannotBeMade(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
@@ -54,7 +54,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
Run: func(map[string]any) (any, error) { return m.Status(m.Ctx), nil }},
stdio.Tool{Name: HealthTool,
Description: "How the image build module is: unhealthy while buildah cannot be run, while its account holds no " +
"subordinate user or group range, or while a user namespace cannot be made, saying which; healthy otherwise. Read-only.",
"subordinate user or group range, or while a user namespace cannot be made, saying which; healthy otherwise. A machine that cannot build here now (no tun device) stays healthy and says in its words \"not eligible\", with the cause; it takes no image ask meanwhile (issue 1126). Read-only.",
Run: func(map[string]any) (any, error) { return m.Health(m.Ctx), nil }},
)
}
@@ -4,6 +4,9 @@ import (
"encoding/json"
"fmt"
"strings"
"sync"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Who may ask `get` for which secret (novox/hq ADR 0349 §11, design 57 V4, issue 647).
@@ -108,6 +111,9 @@ func fieldSet(f json.RawMessage) bool {
// that the module's plan there names.
type Declared struct {
Read AssignmentReader
// Confirm, when set, reads an assignment directly (over the bus): a module Read does not hold is confirmed through it
// before a credential is refused, since a watched mirror can be behind (issue 1126, review round 1).
Confirm AssignmentReader
// Store is the vault's store, whose record of who holds a provision's one credential (pair.go) the rule reads.
Store *Store
}
@@ -207,3 +213,77 @@ func (d Declared) May(caller, name string) (bool, string) {
// bus error rather than an answer. The vault says "holds no secret named ... yet", which the client waits on and asks
// again, and records no refusal.
const notYetPrefix = "not yet: "
// WatchedAssignments is the assignments mirror as the vault's watch of it hands it over (issue 1126, 2026-10-28: novox's
// `own` read one assignment over the bus per own secret and per credential, 203 reads, and answered after 26 s, past
// the node-engine's 15 s). The state is the bus's; the vault watches it, never polls it, and reads it from memory once the
// watch has handed every current value. Until then, and when the watch could not be begun, a read goes to the bus
// (Fallback), as before.
type WatchedAssignments struct {
mu sync.Mutex
held map[string][]byte
caught bool
attempt uint64
Fallback AssignmentReader
}
// WatchAttempt is one attempt at the watch: what it hands over counts only while it is the latest attempt, so a watch
// that failed partway and is begun again keeps nothing it took (a key deleted meanwhile is never handed again), and a
// late delivery to an older attempt changes nothing (issue 1126, review round 1).
type WatchAttempt struct {
w *WatchedAssignments
n uint64
}
// Begin starts a new attempt from nothing held: reads go to the bus until it has caught up.
func (w *WatchedAssignments) Begin() WatchAttempt {
w.mu.Lock()
defer w.mu.Unlock()
w.attempt++
w.held, w.caught = map[string][]byte{}, false
return WatchAttempt{w: w, n: w.attempt}
}
// Take keeps one change the attempt handed over, a value put or a key deleted, while it is the latest attempt.
func (a WatchAttempt) Take(c stdio.StateChange) error {
w := a.w
w.mu.Lock()
defer w.mu.Unlock()
if a.n != w.attempt {
return nil
}
if c.Op == "delete" || c.Op == "purge" || len(c.Value) == 0 {
delete(w.held, c.Key)
return nil
}
w.held[c.Key] = append([]byte(nil), c.Value...)
return nil
}
// Caught says the attempt handed every current value: reads are answered from memory from now on, if it is the latest.
func (a WatchAttempt) Caught() {
w := a.w
w.mu.Lock()
defer w.mu.Unlock()
if a.n == w.attempt {
w.caught = true
}
}
// Read is one value of the mirror, `<node>.<module>`: nil when there is none.
func (w *WatchedAssignments) Read(key string) ([]byte, error) {
w.mu.Lock()
if w.caught {
raw, ok := w.held[key]
w.mu.Unlock()
if !ok {
return nil, nil
}
return append([]byte(nil), raw...), nil
}
w.mu.Unlock()
if w.Fallback == nil {
return nil, fmt.Errorf("the assignments mirror is not watched yet, and nothing reads it from the bus")
}
return w.Fallback(key)
}
+29 -2
View File
@@ -49,13 +49,37 @@ func build() *Env {
}
// Who may ask (askers.go): a node's provisioner client, for what its module's plan there names.
// Where it sees a consumer bind a provider (pair.go): the same mirror, every key.
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store},
// The assignments as the vault's watch keeps them (watchAssignments), read over the bus until it has caught up.
assigned = &WatchedAssignments{Fallback: readAssignment}
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: assigned.Read, Confirm: readAssignment, Store: store},
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave,
HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }}
return env
}
// assigned is the assignments mirror as the vault's watch keeps it.
var assigned *WatchedAssignments
// watchAssignments begins the watch of the assignments mirror the vault's rules read (issue 1126, 2026-10-28): a
// watch, never a poll, so `own` and `get` read no assignment over the bus once it has caught up. It answers whether the
// watch began; one that did not is begun again at the next minute pass, and reads go to the bus meanwhile.
func watchAssignments() bool {
if assigned == nil {
return true
}
// Each attempt begins from nothing held: one that failed partway leaves nothing a later one would not hand again.
attempt := assigned.Begin()
if err := stdio.State(AssignmentsRead).Watch("", attempt.Take); err != nil {
assigned.Begin() // what the failed attempt took counts for nothing, and reads go to the bus
say("the assignments are not watched, so each is read over the bus; begun again at the next pass: %v", err)
return false
}
attempt.Caught()
say("the assignments are watched: the vault's rules read them from memory")
return true
}
// readHave is one key of the node-engines' have bucket, read through the runtime under the vault's own account (its
// manifest `reads` it), or nil when there is none.
func readHave(key string) ([]byte, error) {
@@ -149,11 +173,14 @@ func main() {
written, failed := env.Vault.HashHeld()
say("hashed %d bus accounts at start; %d could not be, tried again every minute: %v", len(written), len(failed), failed)
}
watched := watchHandings(env)
watched, watching := watchHandings(env), watchAssignments()
for {
if !watched {
watched = watchHandings(env)
}
if !watching {
watching = watchAssignments()
}
env.pass()
if env.Vault != nil {
env.Vault.HashOwed()
+74 -15
View File
@@ -70,7 +70,7 @@ func credentialNames(c CredentialAsked) []string {
// credentialRefusal says why node may not have c, or "": a consumer's credential to the machine its consuming module
// stands on, a provider's to the providing machine, and nothing to a machine that is neither.
func (v *Vault) credentialRefusal(node string, c CredentialAsked) string {
func (v *Vault) credentialRefusal(node string, c CredentialAsked, plan *planReader) string {
for _, part := range []string{c.Provision, c.Consumer, c.ConsumerModule, c.Provider} {
if !namePart.MatchString(part) {
return fmt.Sprintf("%q is not a name the mesh writes", part)
@@ -85,14 +85,19 @@ func (v *Vault) credentialRefusal(node string, c CredentialAsked) string {
return fmt.Sprintf("%s asks as a consumer on %s, and is not that machine", node, c.Consumer)
}
// The consuming module stands on the machine: read, never assumed (an assignment that cannot be read refuses).
read, why := v.assignmentReader(node)
raw, why, err := plan.read(c.ConsumerModule)
if why != "" {
return why
}
raw, err := read(node + "." + c.ConsumerModule)
if err != nil {
return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", node, c.ConsumerModule, err)
}
if raw == nil {
// Confirmed directly before refusing: the mirror may not hold a module assigned a moment ago.
if raw, err = plan.confirmed(c.ConsumerModule); err != nil {
return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", node, c.ConsumerModule, err)
}
}
if raw == nil {
return fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", c.ConsumerModule, node)
}
@@ -106,6 +111,56 @@ func (v *Vault) credentialRefusal(node string, c CredentialAsked) string {
return fmt.Sprintf("the role %q is not consumer or provider", c.Role)
}
// planReader reads the assignments of one machine's modules for one answer of `own`, each module's once (issue 1126,
// 2026-10-28: one read per own secret and per credential, 203 on novox, held the answer past the node-engine's bound).
type planReader struct {
node string
reader AssignmentReader
confirm AssignmentReader
why string
got map[string]planRead
}
type planRead struct {
raw []byte
err error
}
func (v *Vault) plansOf(node string) *planReader {
read, why := v.assignmentReader(node)
var confirm AssignmentReader
if d, ok := v.Askers.(Declared); ok && d.Confirm != nil {
confirm = standing(d.Confirm)
}
return &planReader{node: node, confirm: confirm, reader: read, why: why, got: map[string]planRead{}}
}
// read is module's assignment on the machine, nil when there is none, or why the vault reads none.
func (p *planReader) read(module string) ([]byte, string, error) {
if p.why != "" {
return nil, p.why, nil
}
r, ok := p.got[module]
if !ok {
r.raw, r.err = p.reader(p.node + "." + module)
p.got[module] = r
}
return r.raw, "", r.err
}
// confirmed is module's assignment read directly, for one the reader does not hold (a watched mirror can be behind),
// nil when there is none or nothing reads directly.
func (p *planReader) confirmed(module string) ([]byte, error) {
if p.confirm == nil {
return nil, nil
}
raw, err := p.confirm(p.node + "." + module)
if err == nil && raw != nil {
p.got[module] = planRead{raw: raw}
}
return raw, err
}
// assignmentReader is the vault's reader of the assignments, or why it has none: the rule `own` keeps reads them,
// and without them it refuses rather than hands (a rule that cannot be read is no rule).
func (v *Vault) assignmentReader(node string) (AssignmentReader, string) {
@@ -177,17 +232,21 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
if err != nil {
return OwnAnswer{}, fmt.Errorf("%s's sealing key as recorded is not usable: %w", node, err)
}
all, err := v.Store.List()
// Only this machine's own records are read, each module's assignment once, and every hand-out recorded in one synced
// write after all are sealed: none is handed when that record cannot be written (issue 1126, 2026-10-28).
all, err := v.Store.ListPrefix("own/" + node + "/")
if err != nil {
return OwnAnswer{}, err
}
plan := v.plansOf(node)
var handings []Handing
out := OwnAnswer{Node: node, Key: key}
for _, r := range all {
parts := strings.Split(r.Name, "/")
if len(parts) != 4 || parts[0] != "own" || parts[1] != node {
continue
}
handed, err := v.handed(node, parts[2])
handed, err := plan.handed(parts[2])
if err != nil {
return OwnAnswer{}, err
}
@@ -202,9 +261,7 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
if err != nil {
return OwnAnswer{}, err
}
if err := v.recordHanding(Handing{To: caller, Name: r.Name, Generation: r.Generation, Outcome: "handed"}); err != nil {
return OwnAnswer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", r.Name, err)
}
handings = append(handings, Handing{To: caller, Name: r.Name, Generation: r.Generation, Outcome: "handed"})
out.Own = append(out.Own, NodeOwnSecret{Module: parts[2], Name: parts[3], Origin: composeOrigin(r.Origin),
Generation: r.Generation, Sealed: sealed})
}
@@ -215,7 +272,7 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
return out.Own[i].Name < out.Own[j].Name
})
for _, c := range asked {
if why := v.credentialRefusal(node, c); why != "" {
if why := v.credentialRefusal(node, c, plan); why != "" {
return OwnAnswer{}, fmt.Errorf("%s may not ask for the credential of %s from %s to %s on %s: %s", caller,
c.Provision, c.Provider, c.ConsumerModule, c.Consumer, why)
}
@@ -248,9 +305,7 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
if err != nil {
return OwnAnswer{}, err
}
if err := v.recordHanding(Handing{To: caller, Name: name, Generation: r.Generation, Outcome: "handed"}); err != nil {
return OwnAnswer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", name, err)
}
handings = append(handings, Handing{To: caller, Name: name, Generation: r.Generation, Outcome: "handed"})
out.Credentials = append(out.Credentials, NodeCredential{CredentialAsked: c, Name: name, Generation: r.Generation, Sealed: sealed})
found = true
break
@@ -259,6 +314,10 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
out.Missing = append(out.Missing, c)
}
}
if err := v.recordHandings(handings); err != nil {
return OwnAnswer{}, fmt.Errorf("nothing is handed out to %s: who was handed its %d values could not be recorded: %w",
caller, len(handings), err)
}
v.log("handed %s's node-engine the %d own secrets of its modules and %d credentials (%d not held), sealed to its key",
node, len(out.Own), len(out.Credentials), len(out.Missing))
return out, nil
@@ -267,12 +326,12 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
// handed says the plan of module on node hands it to the provisioner, so its secrets are filled from `get`, not composed.
// A plan that cannot be read is an error, never "not handed": `own` refuses rather than hands a secret the provisioner
// holds.
func (v *Vault) handed(node, module string) (bool, error) {
read, why := v.assignmentReader(node)
func (p *planReader) handed(module string) (bool, error) {
node := p.node
raw, why, err := p.read(module)
if why != "" {
return false, errors.New(why)
}
raw, err := read(node + "." + module)
if err != nil {
return false, fmt.Errorf(notYetPrefix+"the assignment %s.%s could not be read, so whether it is handed to the "+
"provisioner is not known: %w", node, module, err)
@@ -2,9 +2,12 @@ package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal"
)
@@ -119,3 +122,117 @@ func TestOwnAnswersAMachineItsModulesSecretsSealedToItsKey(t *testing.T) {
t.Errorf("own with no reader of the assignments: %v", err)
}
}
// Issue 1126, 2026-10-28 (seed-9's probe: novox's 57 own secrets and 146 credentials took 26 s, past the node-engine's
// 15 s): one answer of `own` reads each module's assignment once, and records every hand-out in one synced write.
func TestOwnReadsEachAssignmentOnceAndSyncsItsRecordOnce(t *testing.T) {
plans := assignments{"anchor.web": `{"node":"anchor","module":"web"}`, "anchor.db": `{"node":"anchor","module":"db"}`}
reads := map[string]int{}
v := newVault(t, nil)
v.Askers = Declared{Read: func(key string) ([]byte, error) { reads[key]++; return plans.read(key) }, Store: v.Store}
pub, _ := aSealingKey(t)
for _, m := range []MoveOf{
{Node: "anchor", Module: "web", Name: "password", Origin: "made", Generation: "g-1", SealingKey: pub},
{Node: "anchor", Module: "web", Name: "api-key", Origin: "made", Generation: "g-1", SealingKey: pub},
{Node: "anchor", Module: "web", Name: "cookie", Origin: "made", Generation: "g-1", SealingKey: pub},
{Node: "anchor", Module: "db", Name: "root", Origin: "made", Generation: "g-1", SealingKey: pub},
{Provider: "anchor", Provision: "postgres", Consumer: "anchor", ConsumerModule: "web", Origin: "made", Generation: "g-1", SealingKey: pub},
{Provider: "anchor", Provision: "redis", Consumer: "anchor", ConsumerModule: "web", Origin: "made", Generation: "g-1", SealingKey: pub},
} {
if _, err := moveIn(t, v, "node.anchor", m, "v-"+m.Name+m.Provision); err != nil {
t.Fatal(err)
}
}
for k := range reads {
delete(reads, k)
}
syncs, handed := v.Store.syncs, len(mustHanded(t, v))
got, err := v.OwnOfNode("node.anchor",
CredentialAsked{Role: "consumer", Provision: "postgres", Consumer: "anchor", ConsumerModule: "web", Provider: "anchor"},
CredentialAsked{Role: "consumer", Provision: "redis", Consumer: "anchor", ConsumerModule: "web", Provider: "anchor"})
if err != nil || len(got.Own) != 4 || len(got.Credentials) != 2 {
t.Fatalf("anchor was answered %+v, %v", got, err)
}
for key, n := range reads {
if n != 1 {
t.Errorf("the assignment %s was read %d times in one answer", key, n)
}
}
if len(reads) != 2 {
t.Errorf("the answer read %v, want each of anchor's two modules once", reads)
}
if n := v.Store.syncs - syncs; n != 1 {
t.Errorf("one answer synced the record %d times", n)
}
if n := len(mustHanded(t, v)) - handed; n != 6 {
t.Errorf("one answer recorded %d hand-outs, want 6", n)
}
// Review round 1, note (b): a consumer module the mirror does not hold yet (its watch behind) is read directly before
// the credential is refused.
v.Askers = Declared{Read: func(string) ([]byte, error) { return nil, nil }, Confirm: plans.read, Store: v.Store}
if got, err := v.OwnOfNode("node.anchor", CredentialAsked{Role: "consumer", Provision: "postgres", Consumer: "anchor",
ConsumerModule: "web", Provider: "anchor"}); err != nil || len(got.Credentials) != 1 {
t.Errorf("a module the mirror does not hold yet, read directly: %+v, %v", got, err)
}
v.Askers = Declared{Read: plans.read, Store: v.Store}
// A record that cannot be written hands nothing, the batch as one.
if err := os.Remove(filepath.Join(v.dir, "store", "handed.jsonl")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(v.dir, "store", "handed.jsonl"), 0o700); err != nil {
t.Fatal(err)
}
if a, err := v.OwnOfNode("node.anchor"); err == nil {
t.Errorf("handed with no record of it: %+v", a)
}
}
func mustHanded(t *testing.T, v testVault) []Handing {
t.Helper()
h, err := v.Store.Handed(1000)
if err != nil {
t.Fatal(err)
}
return h
}
// Issue 1126, 2026-10-28: the vault keeps the assignments mirror as its watch hands it over, so `own` and `get` read
// no assignment over the bus once the watch has caught up; before that a read goes to the bus.
func TestTheWatchedAssignmentsAnswerWithoutTheBusOnceCaughtUp(t *testing.T) {
asked := 0
w := &WatchedAssignments{Fallback: func(key string) ([]byte, error) { asked++; return []byte(`{"from":"bus"}`), nil }}
if raw, err := w.Read("anchor.web"); err != nil || string(raw) != `{"from":"bus"}` || asked != 1 {
t.Errorf("before the watch caught up: %s, %v, %d asks", raw, err, asked)
}
// A first watch takes two values and fails before it caught up; a late value still arrives on it.
first := w.Begin()
for _, c := range []stdio.StateChange{
{Key: "anchor.web", Op: "put", Value: []byte(`{"node":"anchor","module":"web"}`), Current: true},
{Key: "anchor.db", Op: "put", Value: []byte(`{"node":"anchor","module":"db"}`), Current: true},
} {
if err := first.Take(c); err != nil {
t.Fatal(err)
}
}
// Review round 1: the watch begun again holds anchor.db no more (removed meanwhile); what the first took is gone.
second := w.Begin()
_ = second.Take(stdio.StateChange{Key: "anchor.web", Op: "put", Value: []byte(`{"node":"anchor","module":"web"}`), Current: true})
_ = first.Take(stdio.StateChange{Key: "anchor.db", Op: "put", Value: []byte(`{"node":"anchor","module":"db"}`)})
first.Caught()
if raw, _ := w.Read("anchor.db"); string(raw) != `{"from":"bus"}` {
t.Errorf("the failed watch's caught up counted: %s", raw)
}
second.Caught()
_ = first.Take(stdio.StateChange{Key: "anchor.db", Op: "put", Value: []byte(`{"node":"anchor","module":"db"}`)})
_ = second.Take(stdio.StateChange{Key: "anchor.web", Op: "put", Value: []byte(`{"node":"anchor","module":"web","v":2}`)})
asked = 0
for key, want := range map[string]string{"anchor.web": `{"node":"anchor","module":"web","v":2}`, "anchor.db": "", "laptop.x": ""} {
raw, err := w.Read(key)
if err != nil || string(raw) != want || (want == "") != (raw == nil) {
t.Errorf("%s read %q, %v; want %q", key, raw, err, want)
}
}
if asked != 0 {
t.Errorf("caught up, the mirror asked the bus %d times", asked)
}
}
+27 -7
View File
@@ -81,6 +81,8 @@ type Store struct {
handedMu sync.Mutex
rolled int
// syncs counts the record's synced appends since the vault started: how a test proves one sync per answer.
syncs int
// asksMu serialises the record of asks, so two asks for one secret at once do not both pass its bounds.
asksMu sync.Mutex
}
@@ -173,22 +175,37 @@ type Handing struct {
// RecordHanding appends one handing to the store's record, synced before it returns. It says whether the
// record was rolled to make room.
func (s *Store) RecordHanding(h Handing) (rolled bool, err error) {
raw, err := json.Marshal(h)
if err != nil {
return false, err
return s.RecordHandings([]Handing{h})
}
// RecordHandings appends every handing of one answer to the store's record in one write, synced once before it returns
// (issue 1126, 2026-10-28: one sync per secret, under the one lock every node's answers share, held `own` past the
// node-engine's bound). It says whether the record was rolled to make room; nothing is written when one cannot be.
func (s *Store) RecordHandings(hs []Handing) (rolled bool, err error) {
if len(hs) == 0 {
return false, nil
}
var raw []byte
for _, h := range hs {
line, err := json.Marshal(h)
if err != nil {
return false, err
}
raw = append(append(raw, line...), '\n')
}
s.handedMu.Lock()
defer s.handedMu.Unlock()
path := filepath.Join(s.dir, "handed.jsonl")
max := s.HandedBound()
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > max {
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw)) > max {
if err := s.roll(path); err != nil {
return false, err
}
s.rolled++
rolled = true
}
return rolled, appendSynced(path, append(raw, '\n'))
s.syncs++
return rolled, appendSynced(path, raw)
}
// roll moves handed.jsonl to .1, .1 to .2 and so on, dropping what falls past HandedKept.
@@ -266,7 +283,10 @@ func (s *Store) HandedBound() int64 {
}
// List is every record, by name.
func (s *Store) List() ([]Record, error) {
func (s *Store) List() ([]Record, error) { return s.ListPrefix("") }
// ListPrefix is every record whose name begins with prefix, by name: only those files are read.
func (s *Store) ListPrefix(prefix string) ([]Record, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, "secrets"))
if err != nil {
return nil, err
@@ -277,7 +297,7 @@ func (s *Store) List() ([]Record, error) {
continue
}
name, err := url.PathUnescape(strings.TrimSuffix(e.Name(), ".json"))
if err != nil {
if err != nil || !strings.HasPrefix(name, prefix) {
continue
}
r, ok, err := s.Read(name)
+17 -4
View File
@@ -235,11 +235,24 @@ func (v *Vault) announce(c Changed) {
// recordHanding keeps who asked for what, and what they got, in the vault's own store, and says loudly
// when the record had to be rolled to make room.
func (v *Vault) recordHanding(h Handing) error {
h.At = v.Now().UTC()
rolled, err := v.Store.RecordHanding(h)
func (v *Vault) recordHanding(h Handing) error { return v.recordHandings([]Handing{h}) }
// recordHandings keeps every handing of one answer in one synced write, as recordHanding keeps one.
func (v *Vault) recordHandings(hs []Handing) error {
if len(hs) == 0 {
return nil
}
at := v.Now().UTC()
for i := range hs {
hs[i].At = at
}
rolled, err := v.Store.RecordHandings(hs)
if err != nil {
v.log("the record of %s asking for %s could not be written: %v", h.To, h.Name, err)
names := make([]string, len(hs))
for i, h := range hs {
names[i] = h.Name
}
v.log("the record of %s asking for %s could not be written: %v", hs[0].To, strings.Join(names, ", "), err)
return err
}
if rolled {
@@ -167,8 +167,10 @@ func TestNoBucketHoldsASecret(t *testing.T) {
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Put(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Keys(", "", 1))
}
// The haves, in main.go alone: one read, one watch and their keys (own_drift), never a write.
// The haves, in main.go alone: one read, one watch and their keys (own_drift), never a write; and one watch of
// the assignments, which the vault's rules read from memory once it caught up (issue 1126).
if f == "main.go" {
code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Watch(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Get(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Watch(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Keys(", "", 1))
+4
View File
@@ -14,6 +14,7 @@ states on the bus.
| `/usr/local/lib/mesh-power/bin/power-moment` | the runner: each module's piece on its own, as root, with `sh`, bounded (`MESH_POWER_BOUND`, default 30 s); every outcome in the journal under `mesh-power` |
| five units | `mesh-power-after-boot`, `-before-shutdown` (its stop is the shutdown, while the network is still up), `-before-sleep`, `-after-wake`, `-supply` |
| `mesh-power-draw` (process) | samples the power drawn for the module's bundle, which puts it on the bus as `power.draw` (below) |
| `reboot.target.d`, `poweroff.target.d`, `halt.target.d` (`mesh-power.conf`) | a shutdown is bounded at ten minutes, not systemd's thirty; the target keeps its own forcing action (issue 1126: on ace's reboot of 2026-10-28 the bus was started again during the shutdown, nothing stopped it a second time, and the reboot waited 29 minutes) |
| `/etc/udev/rules.d/90-mesh-power.rules` | a power supply's change starts `mesh-power-supply`, which runs `on-mains` or `on-battery` once per change of source |
## The moments, and how a module adds code
@@ -146,4 +147,7 @@ Each directory or archive this module declares says how it treats a file it did
- `/etc/systemd/system/hibernate.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/hybrid-sleep.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/suspend-then-hibernate.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/reboot.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/poweroff.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/halt.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/run/mesh-power`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
+50
View File
@@ -127,3 +127,53 @@ func TestTheBarBlocksAreTwoItemsOfTheBarsCollection(t *testing.T) {
}
}
}
// A shutdown is bounded at ten minutes, not systemd's thirty (issue 1126, ace's reboot of 2026-10-28): every unit had
// stopped within 17 s, yet the bus and the authorization manager were started again during the shutdown, nothing
// stopped them a second time, and the reboot waited on its job's own timeout of 30 min with nothing on the console.
// The bound keeps each target's own forcing action (reboot-force and the like): systemd's last phase still ends
// every process, unmounts and syncs. Ten minutes stays above the longest stop the catalogue declares (120 s) chained.
func TestEveryShutdownIsBoundedAtTenMinutes(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
byPath := map[string]map[string]any{}
for _, r := range m.Resources {
if p, ok := r["path"].(string); ok {
byPath[p] = r
}
}
for _, target := range []string{"reboot", "poweroff", "halt"} {
dir := "/etc/systemd/system/" + target + ".target.d"
if d := byPath[dir]; d == nil || d["type"] != "directory" || d["unknown"] != "program" {
t.Errorf("%s: no directory resource %s whose unknown files are the program's: %v", target, dir, d)
}
f := byPath[dir+"/mesh-power.conf"]
if f == nil || f["type"] != "file" {
t.Errorf("%s: no file resource %s/mesh-power.conf", target, dir)
continue
}
name, _ := f["template"].(string)
text, err := os.ReadFile(filepath.Join("../..", name))
if err != nil {
t.Errorf("%s: %v", target, err)
continue
}
var lines []string
for _, l := range strings.Split(string(text), "\n") {
if l != "" && !strings.HasPrefix(l, "#") {
lines = append(lines, l)
}
}
if strings.Join(lines, "\n") != "[Unit]\nJobTimeoutSec=10min" {
t.Errorf("%s: %s sets %q, want only [Unit] JobTimeoutSec=10min (the target keeps its own action)", target, name, lines)
}
}
}
+42
View File
@@ -314,6 +314,48 @@
"mode": "0644",
"template": "templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf"
},
{
"id": "reboot-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/reboot.target.d",
"mode": "0755",
"unknown": "program"
},
{
"id": "reboot-bound",
"type": "file",
"path": "/etc/systemd/system/reboot.target.d/mesh-power.conf",
"mode": "0644",
"template": "templates/systemd/system/reboot.target.d/mesh-power.conf"
},
{
"id": "poweroff-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/poweroff.target.d",
"mode": "0755",
"unknown": "program"
},
{
"id": "poweroff-bound",
"type": "file",
"path": "/etc/systemd/system/poweroff.target.d/mesh-power.conf",
"mode": "0644",
"template": "templates/systemd/system/poweroff.target.d/mesh-power.conf"
},
{
"id": "halt-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/halt.target.d",
"mode": "0755",
"unknown": "program"
},
{
"id": "halt-bound",
"type": "file",
"path": "/etc/systemd/system/halt.target.d/mesh-power.conf",
"mode": "0644",
"template": "templates/systemd/system/halt.target.d/mesh-power.conf"
},
{
"id": "udev-rule",
"type": "file",
@@ -0,0 +1,8 @@
# Written by the mesh (module power, novox/hq ADR 0211; issue 1126). Replaced on every push.
#
# A shutdown is bounded at ten minutes, not systemd's thirty. A unit started again during the shutdown
# (on ace, 2026-10-28, the bus and the authorization manager) is never stopped a second time, and the
# shutdown then waits on this job's timeout with nothing on the console. The target keeps its own
# forcing action: the last phase still ends every process, unmounts and syncs.
[Unit]
JobTimeoutSec=10min
@@ -0,0 +1,8 @@
# Written by the mesh (module power, novox/hq ADR 0211; issue 1126). Replaced on every push.
#
# A shutdown is bounded at ten minutes, not systemd's thirty. A unit started again during the shutdown
# (on ace, 2026-10-28, the bus and the authorization manager) is never stopped a second time, and the
# shutdown then waits on this job's timeout with nothing on the console. The target keeps its own
# forcing action: the last phase still ends every process, unmounts and syncs.
[Unit]
JobTimeoutSec=10min
@@ -0,0 +1,8 @@
# Written by the mesh (module power, novox/hq ADR 0211; issue 1126). Replaced on every push.
#
# A shutdown is bounded at ten minutes, not systemd's thirty. A unit started again during the shutdown
# (on ace, 2026-10-28, the bus and the authorization manager) is never stopped a second time, and the
# shutdown then waits on this job's timeout with nothing on the console. The target keeps its own
# forcing action: the last phase still ends every process, unmounts and syncs.
[Unit]
JobTimeoutSec=10min