Compare commits

..
2 Commits
Author SHA1 Message Date
jochen 950e37a46e Keep the nats watch alive and heard: recover a faulted look, beat after every look, read only what closed since (hq issue 270, review 274)
mesh/delivery rejected: the gate failed or could not run, or the repository's own check failed
mesh/merge-gate out of date: judged against trunk a44d490d974c, the trunk is now b15846dd6e78; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk a44d490d974c, the trunk is now b15846dd6e78; judged again on a recheck or when it is merged
A watch that panicked stopped for good, and one that could not read said so
only to stderr, so a dead watch read as no slow consumers. A faulted look is
now recovered and the loop goes on; every look ends with a bus.watch
heartbeat saying whether the watch is healthy, which the controller raises
when it stops or says it cannot see. The record is read newest first back to
the last look, and closures that could not be published are forgotten after
the hour.
2026-10-14 09:19:56 +02:00
jochen 9e50c1eb36 Say every bus user's slow consumer and closed connection on the bus (hq issue 270, ADR 0357)
mesh/delivery-group group fix/270-the-bus-module-says-every-users-slow-consumer rejected: a member's own check failed
mesh/delivery stopped: the pull request closed unmerged
mesh/merge-gate head 9e50c1eb36f0 merged into trunk a44d490d974c — fail: builds nats, sent nowhere; merging this needs a person's bus upgrade; 1 wait(s) fo…
mesh/repo-check head 9e50c1eb36f0 merged into trunk a44d490d974c — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
The server records the user of every connection it closes as a slow consumer
or for breaking its rules, and says it nowhere the controller hears. The nats
tools bundle now reads that record every 30 seconds and publishes
bus.slow-consumer and bus.connection-closed, naming the user, never a credential.
2026-10-14 08:41:47 +02:00
100 changed files with 1088 additions and 10594 deletions
+2 -42
View File
@@ -23,51 +23,9 @@
#
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
#
# **In an agent's session, outside the toolchain image** (novox/hq issue 742), the same node_modules is
# assembled first, in a throwaway directory under the check's own workspace, the way the image's `deps` stage
# assembles it: the toolchain's own package.json and lock (mesh-tools' node-tools/, read from the forge this
# clone came from, so the list is never copied here) installed with `npm ci`, then the SDK the mesh last
# published (`@novox/mesh-sdk@latest`), the @novox scope from the forge's package registry, read without a
# credential. Node and npm are the dev-tools module's; nothing is installed on the machine, no npm
# configuration or cache of the account's is read or written. Only when neither TOOLCHAIN_MODULES nor
# /app/node_modules is there: the forge's check, in the image, fetches nothing, as before. The forge is
# MESH_FORGE (default: the origin remote's), the owner MESH_FORGE_OWNER (default: the origin's owner).
set -eu
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
if [ -z "${TOOLCHAIN_MODULES:-}" ] && [ ! -f "$deps/typescript/bin/tsc" ]; then
origin=$(git remote get-url origin 2>/dev/null || true)
forge="${MESH_FORGE:-$(printf '%s\n' "$origin" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')}"
owner="${MESH_FORGE_OWNER:-$(printf '%s\n' "$origin" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')}"
if [ -z "$forge" ] || [ -z "$owner" ]; then
echo "NOT CHECKED: no TypeScript toolchain at $deps, and no forge to assemble one from (origin is '$origin'; set MESH_FORGE and MESH_FORGE_OWNER, or TOOLCHAIN_MODULES)"
exit 1
fi
echo "no TypeScript toolchain at $deps: assembling one from $forge/$owner/mesh-tools node-tools/ and $owner's package registry (novox/hq issue 742)"
tc="$work/toolchain"
mkdir -p "$tc"
printf '@%s:registry=%s/api/packages/%s/npm/\n' "$owner" "$forge" "$owner" >"$work/npmrc"
# Read with Node's own fetch (dev-tools declares Node, not curl); a status that is not 2xx fails.
if ! { node -e '
const [base, dir] = process.argv.slice(1);
Promise.all(["package.json", "package-lock.json"].map(async (f) => {
const r = await fetch(base + f);
if (!r.ok) throw new Error(base + f + ": " + r.status);
require("fs").writeFileSync(dir + "/" + f, await r.text());
})).catch((e) => { console.error(e.message); process.exit(1); });' \
"$forge/$owner/mesh-tools/raw/branch/main/node-tools/" "$tc" &&
(cd "$tc" && export npm_config_userconfig="$work/npmrc" npm_config_cache="$work/npm-cache" &&
npm ci --ignore-scripts --no-audit --no-fund --loglevel=error &&
npm install --ignore-scripts --no-audit --no-fund --no-save --loglevel=error "@$owner/mesh-sdk@latest"); }; then
echo "NOT CHECKED: the TypeScript toolchain could not be assembled from $forge (novox/hq issue 742)"
exit 1
fi
deps="$tc/node_modules"
fi
tsc="$deps/typescript/bin/tsc"
if [ ! -f "$tsc" ]; then
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
@@ -81,6 +39,8 @@ touched=$({
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
} | sed '/^$/d' | sort -u)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
failed=""
for m in $touched; do
d="modules/$m"
+1 -1
View File
@@ -14,7 +14,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-builder ./cmd/mesh-builder
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder
# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build
# machine's whole job is shelling out to git and docker — it needs a real userland to do that in,
-54
View File
@@ -115,57 +115,3 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
}
t.Fatal("the build agent has no environment file")
}
// **The judge cache is the agent's own, beside its workspace and never in it** (novox/hq ADR 0358 §6, issue 704): a
// directory resource of its own, kept as the agent's data, mounted into the agent at the same path as on the host —
// the container runtime resolves a judge build's mounts on the host — and handed to the agent as MESH_JUDGE_CACHE.
func TestTheJudgeCacheIsADirectoryOfItsOwnMountedAtTheSamePath(t *testing.T) {
raw, err := os.ReadFile("module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Data struct {
Own []struct {
ID string `json:"id"`
Path string `json:"path"`
Class string `json:"class"`
} `json:"own"`
} `json:"data"`
Resources []struct {
ID string `json:"id"`
Type string `json:"type"`
Content string `json:"content"`
Volumes []string `json:"volumes"`
} `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
dir, data, env, mounted := false, false, false, false
for _, d := range m.Data.Own {
if d.ID == "judge-cache" && d.Path == "${dir:judge-cache}" && d.Class == "cache" {
data = true
}
}
for _, r := range m.Resources {
switch {
case r.ID == "judge-cache" && r.Type == "directory":
dir = true
case r.ID == "agent-env":
env = strings.Contains(r.Content, "MESH_JUDGE_CACHE=${dir:judge-cache}\n")
case r.ID == "server":
for _, v := range r.Volumes {
if v == "${dir:judge-cache}:${dir:judge-cache}" {
mounted = true
}
if strings.HasPrefix(v, "${dir:workspace}") && strings.Contains(v, "judge-cache") {
t.Errorf("the judge cache is mounted inside the workspace: %s", v)
}
}
}
}
if !dir || !data || !env || !mounted {
t.Fatalf("the judge cache: directory %v, data %v, env %v, mounted at the same path %v", dir, data, env, mounted)
}
}
+1 -13
View File
@@ -39,12 +39,6 @@
"path": "${dir:workspace}",
"class": "cache",
"why": "a build's working copy, cloned again for every build"
},
{
"id": "judge-cache",
"path": "${dir:judge-cache}",
"class": "cache",
"why": "the Go cache every merge check's judge is built with and the release-branch judges kept per head; mounted into judge builds only, never into a check's containers (novox/hq ADR 0358 §6)"
}
]
},
@@ -60,17 +54,12 @@
"type": "directory",
"mode": "0700"
},
{
"id": "judge-cache",
"type": "directory",
"mode": "0700"
},
{
"id": "agent-env",
"type": "file",
"path": "${dir:mesh-state}/build-agent.env",
"mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\nMESH_JUDGE_CACHE=${dir:judge-cache}\n"
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n"
},
{
"id": "server",
@@ -83,7 +72,6 @@
"volumes": [
"${dir:mesh-state}:/run/mesh:ro",
"${dir:workspace}:${dir:workspace}",
"${dir:judge-cache}:${dir:judge-cache}",
"/var/run/docker.sock:/var/run/docker.sock"
],
"restart-on": [
+4 -9
View File
@@ -52,10 +52,7 @@ base64-encoded. The Go bundle `cmd/forge` serves the commit, and the reads an ag
| `gitea_search_code` | r | the lines of a ref's text files matching a literal string or a regular expression, optionally under a path |
**Where a commit may go** is the rule `gitea_api` already holds for a file written (novox/hq ADR 0266):
never the repository's default branch, never a branch a protection rule names. The tools' account may not read the
rules themselves (the forge keeps them for a repository's administrator, novox/hq ADR 0365): for it an existing branch
is judged by the protection the forge says it applies there, and a branch not made yet by the forge itself, which
refuses the commit that would make a branch a rule guards. A rule covers a branch as the
never the repository's default branch, never a branch a protection rule names. A rule covers a branch as the
forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob
(`*`, `**`, `?`, `[abc]`, `[!abc]`, `{a,b}`, `\` escaping) covers case-sensitively with `/` as separator; a glob
the forge cannot compile covers its own text. `cmd/forge/rules.go` and `guard.ts` hold this one rule. The rules are read before anything is sent; rules that cannot be read refuse the
@@ -69,7 +66,7 @@ the commit the branch (or, for a new branch, its base) was read at, never at its
forge's diff endpoint, which applies it; for a small change to a large file this is far shorter than the
file. Neither endpoint takes a commit to build on, only a branch (Gitea 1.27), so the parent answered is the
one the forge says it made the commit on; when the branch moved between the read and the commit, the answer
says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the tools' account, so its sha is not the
says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the forge's admin account, so its sha is not the
local commit's: the merge check judges the head on the forge. After the write the branch is read back, and a
branch not at the commit the forge named is a failure, never a success.
@@ -78,10 +75,8 @@ is read once and searched in memory; a binary file is passed over, a file over 4
`skipped`, and at most `max` matches (default 100) are answered, with `truncated` when more were found. A
search that reaches the call's deadline stops there and answers what it found, with `stopped_at_deadline`.
It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), as the
tools' own account (novox/hq ADR 0365 §2, issue 469): the token the TypeScript bundle mints for `mesh-tools` and
keeps (`MESH_GITEA_TOKEN_FILE`), sent only when its scopes line names that account, never the admin login. One tool
call takes at most 50 seconds.
It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), and one
tool call takes at most 50 seconds.
`go test ./cmd/forge` runs it against a fake forge: the files of one commit created, updated and deleted on
a new branch from its base; an existing branch committed on; the default branch, a protected branch and
unreadable rules refused before anything is sent; a patch sent to the forge; a branch read back at another
+14 -178
View File
@@ -8,7 +8,6 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js";
import { isCommit } from "./checks.js";
import { TOOLS_ACCOUNT } from "./tools-account.js";
/** A repository's path on the forge, its owner and name each one segment (guard.ts pathSegment): uniform for every
* call, whether the names came from a caller or from the forge's own answer. */
@@ -102,94 +101,6 @@ export interface OpenPull {
/** The head's statuses by context, the newest of each, with its state (novox/hq issue 438): what tells a check
* that started and never answered — pending — from one that answered. */
checks: Record<string, HeadCheck>;
/** The pull request's labels, by name. */
labels?: string[];
/** Its ready mark (novox/hq issue 722): the label `mesh/ready` and the newest comment "ready at <full head sha>", the
* head a reviewer approved. Absent without the label; `head` empty when the label names no head in a comment. */
ready?: ReadyMark;
}
/** The label that marks a pull request ready to merge at the head a comment names (novox/hq issue 722). */
export const READY_LABEL = "mesh/ready";
/** A comment that names the head a pull request is marked ready at: "ready at <full sha>", on a line of its own. */
const READY_AT = /^\s*ready at ([0-9a-f]{40}|[0-9a-f]{64})\b/im;
/** A pull request's ready mark: the head it names, who said it and when, and the label's id, to take it off. */
export interface ReadyMark {
head: string;
by?: string;
at?: string;
label_id: number;
}
/** **The accounts whose "ready at" comment counts** (novox/hq issue 722): the trusted setting ready-authors, which
* the mesh writes to the file MESH_GITEA_READY_AUTHORS_FILE names as a line `authors=<account> <account> …`. A file
* that cannot be read leaves the mesh's own forge accounts alone, said; never an empty list read as everyone: the
* account the tools act as (novox/hq ADR 0365) and the admin account, whose marks were made before it. */
export function readyAuthors(file = process.env.MESH_GITEA_READY_AUTHORS_FILE, own = process.env.MESH_GITEA_ADMIN_USER,
read: (f: string) => string = (f) => readFileSync(f, "utf8"),
tools = process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT): string[] {
const fallback = [...new Set([tools, own].filter((a): a is string => !!a))];
if (!file) return fallback;
try {
const line = read(file).split("\n").find((l) => l.startsWith("authors="));
const authors = (line ?? "").slice("authors=".length).split(/[\s,]+/).filter(Boolean);
if (authors.length) return authors;
console.error(`[gitea] ${file} names no account that may mark a pull request ready; only ${fallback.join(", ") || "none"} may`);
} catch (err) {
console.error(`[gitea] ${file} cannot be read (${err instanceof Error ? err.message : err}); only ` +
`${fallback.join(", ") || "none"} may mark a pull request ready`);
}
return fallback;
}
/** One event of an issue's or pull request's timeline, as the forge keeps it, oldest first: a comment, a push to a
* pull request's branch (`pull_push`), a label added (body "1") or taken off (body ""), and the rest. */
export interface TimelineEvent {
id: number;
type: string;
body: string;
created_at?: string;
user?: string;
label?: { id: number; name: string };
}
/** The comments that wrote "ready at" without counting, by id: each said once in the log (novox/hq issue 722). */
const ignoredSaid = new Set<number>();
/** **The ready mark a pull request's timeline makes** (novox/hq issue 722), read in the forge's order — every event is
* one row of one table, numbered as it happened, so the order is exact where times share a second. A "ready at"
* comment counts only when its author is one of the accounts given, and only when it was written after the label
* mesh/ready was last added and after the last push to the pull request's branch: a push or the label taken off
* undoes every mark before it, so a mark older than the latest push never counts, even with the label still on. A
* comment by any other account is ignored, and said once. */
export function readyMarkOf(labelID: number, events: TimelineEvent[], authors: string[],
say: (line: string) => void = (l) => console.error(l)): ReadyMark {
const listed = new Set(authors.map((a) => a.toLowerCase()));
let labelled = false;
let mark: ReadyMark | undefined;
for (const e of [...events].sort((x, y) => x.id - y.id)) {
if (e.type === "pull_push") {
mark = undefined;
} else if (e.type === "label" && e.label?.name === READY_LABEL) {
labelled = e.body === "1";
mark = undefined;
} else if (e.type === "comment") {
const m = READY_AT.exec(e.body);
if (!m) continue;
if (!listed.has(String(e.user ?? "").toLowerCase())) {
if (!ignoredSaid.has(e.id)) {
ignoredSaid.add(e.id);
say(`[gitea] a "ready at ${m[1].slice(0, 12)}" comment (${e.id}) by ${e.user ?? "an unknown account"} is ignored: ` +
`only ${authors.join(", ") || "no account"} may mark a pull request ready (novox/hq issue 722)`);
}
continue;
}
if (labelled) mark = { head: m[1].toLowerCase(), by: e.user, at: e.created_at, label_id: labelID };
}
}
return mark ?? { head: "", label_id: labelID };
}
/** One status on a head, the newest of its context: its state, what it said, and when it was set. */
@@ -421,9 +332,8 @@ export class GiteaClient {
/** The open pull requests of every repository the mesh's account sees, updated since a moment when one is given,
* page by page (novox/hq issue 347): each with its repository and its own update time. */
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string;
labels?: { id: number; name: string }[] }[]> {
const out: { owner: string; name: string; number: number; updated_at?: string; labels?: { id: number; name: string }[] }[] = [];
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string }[]> {
const out: { owner: string; name: string; number: number; updated_at?: string }[] = [];
for (let page = 1; page <= 20; page++) {
const qs = new URLSearchParams({ type: "pulls", state: "open", limit: "50", page: String(page) });
if (since) qs.set("since", since);
@@ -433,8 +343,7 @@ export class GiteaClient {
const [fo, fn] = String(r.full_name ?? "").split("/");
const owner = String(r.owner ?? fo ?? "");
const name = String(r.name ?? fn ?? "");
const labels = (Array.isArray(i?.labels) ? i.labels : []).map((l: any) => ({ id: Number(l?.id ?? 0), name: String(l?.name ?? "") }));
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at, labels });
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at });
}
if (!found || found.length < 50) break;
}
@@ -445,26 +354,19 @@ export class GiteaClient {
* requires and the contexts its head has a status of, in any state. What mesh-delivery reads to say a pull
* request the mesh was never asked to check; asked of the forge, never of what the announcer remembers, so a
* fault in the announcer cannot hide itself. Reads only. */
async openPulls(readyAuthors: string[] = []): Promise<OpenPull[]> {
async openPulls(): Promise<OpenPull[]> {
const out: OpenPull[] = [];
const rules = new Map<string, BranchProtection | null | undefined>();
const rules = new Map<string, BranchProtection[] | undefined>();
for (const found of await this.searchOpenPulls()) {
const full = `${found.owner}/${found.name}`;
const pull = await this.getPullRequest(found.owner, found.name, found.number);
if (pull.state !== "open" || !pull.head_sha) continue;
// The base's protection as the forge applies it to that branch (appliedProtection): readable without administration.
const base = `${full}:${pull.base || "main"}`;
if (!rules.has(base)) rules.set(base, await this.appliedProtection(found.owner, found.name, pull.base || "main").catch(() => undefined));
const rule = rules.get(base);
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
const labels = found.labels ?? [];
// The ready mark (novox/hq issue 722): its timeline is read, whole, only for a pull request that carries the label.
const label = labels.find((l) => l.name === READY_LABEL);
const ready = label ? readyMarkOf(label.id, await this.timeline(found.owner, found.name, pull.number), readyAuthors)
: undefined;
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha,
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [],
statuses: Object.keys(checks), checks, labels: labels.map((l) => l.name), ...(ready ? { ready } : {}) });
statuses: Object.keys(checks), checks });
}
return out;
}
@@ -678,44 +580,16 @@ export class GiteaClient {
// ---- Branch protection ----
/** A repository's branch protection rules. The forge answers them to a repository's administrator alone, which the
* tools' account is not (novox/hq ADR 0365 §2): for it this is refused (403), and what a branch's protection asks
* is read with appliedProtection. */
/** A repository's branch protection rules. */
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
return (await this.request<BranchProtection[]>(`${repoPath(owner, repo)}/branch_protections`)) ?? [];
}
/** The rule named for a branch, null when it has none — from the rules' list, so a repository administrator's. */
/** The rule named for a branch, null when it has none. */
async branchProtection(owner: string, repo: string, rule: string): Promise<BranchProtection | null> {
return (await this.branchProtections(owner, repo)).find((p) => (p.rule_name ?? p.branch_name) === rule) ?? null;
}
/**
* The protection that covers an existing branch, as the forge applies it, or null when none does. Read from the
* branch itself, which anyone who reads the code may read — the rules' own list is a repository administrator's
* (novox/hq ADR 0365 §2) — and as the forge matches it, a glob rule included (its name is the rule's). The
* branch's `user_can_push` stands for `enable_push`: whether this account may push there. A branch that does not
* exist is null: nothing covers it yet, and the forge judges a push that makes it.
*/
async appliedProtection(owner: string, repo: string, branch: string): Promise<BranchProtection | null> {
let b: any;
try {
b = await this.request<any>(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`);
} catch (err) {
if (/: 404 /.test(String((err as Error)?.message))) return null;
throw err;
}
if (!b?.protected) return null;
return {
rule_name: String(b.effective_branch_protection_name || branch),
branch_name: branch,
enable_push: Boolean(b.user_can_push),
enable_status_check: Boolean(b.enable_status_check),
status_check_contexts: Array.isArray(b.status_check_contexts) ? b.status_check_contexts.map(String) : [],
required_approvals: Number(b.required_approvals ?? 0),
};
}
/** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the
* branch is edited — its required statuses replaced by these, everything else it says kept unless
* asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */
@@ -765,24 +639,6 @@ export class GiteaClient {
return this.requestText(`${repoPath(owner, repo)}/pulls/${index}.diff`);
}
/** **Every event of an issue's or pull request's timeline**, oldest first, page by page (novox/hq issue 722): a long
* thread hides nothing. Bounded at 100 pages of 50; a longer one is said, and its first 5000 events read. */
async timeline(owner: string, repo: string, index: number): Promise<TimelineEvent[]> {
indexOf(index);
const out: TimelineEvent[] = [];
for (let page = 1; page <= 100; page++) {
const raw = await this.request<any[]>(`${repoPath(owner, repo)}/issues/${index}/timeline?page=${page}&limit=50`);
for (const e of raw ?? []) {
out.push({ id: Number(e?.id ?? 0), type: String(e?.type ?? ""), body: String(e?.body ?? ""),
created_at: e?.created_at, user: e?.user?.login,
...(e?.label ? { label: { id: Number(e.label.id ?? 0), name: String(e.label.name ?? "") } } : {}) });
}
if (!raw || raw.length < 50) return out;
}
console.error(`[gitea] ${owner}/${repo}#${index} has more than 5000 timeline events; only the first 5000 are read`);
return out;
}
/** Every comment on an issue or pull request, oldest first. */
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
indexOf(index);
@@ -947,26 +803,12 @@ export class GiteaAdmin {
return { status: res.status, body };
}
/** One admin-API call with a JSON body, answering the status and body (the system webhook, novox/hq issue 729). */
call(method: string, path: string, body?: unknown): Promise<AdminResponse> {
return this.request(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
}
/** Fail with the forge's own message when a status the caller did not expect comes back. */
private static fail(path: string, res: AdminResponse): never {
const detail = typeof res.body === "string" ? res.body : JSON.stringify(res.body);
throw new Error(`Gitea admin ${path}: ${res.status} ${detail}`);
}
/** A repository's branch protection rules, read with the administrator account — the forge answers them to a
* repository's administrator alone, which the tools' account is not (novox/hq ADR 0365 §2, ADR 0375). Only a read. */
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
const path = `${repoPath(owner, repo)}/branch_protections`;
const res = await this.request(path);
if (res.status !== 200) GiteaAdmin.fail(path, res);
return Array.isArray(res.body) ? res.body : [];
}
/** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent
* create won the race) are all success. */
async ensureOrg(name: string): Promise<void> {
@@ -990,16 +832,12 @@ export class GiteaAdmin {
// unit the team does not have — so code read must be said here: without it gitea answers a
// member's clone of a private repository with "not found", which is how the builder's first
// credentialed clone failed against a team that named only packages.
return this.ensureTeamWith(org, team, {
const units = {
permission: "read",
units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" },
includes_all_repositories: true,
can_create_org_repo: false,
});
}
/** Ensure a team exists with exactly these units, and return its id (ensureTeam; the tools' team, tools-account.ts). */
async ensureTeamWith(org: string, team: string, units: Record<string, unknown>): Promise<number> {
};
const found = await this.findTeam(org, team);
if (found !== null) {
const patch = await this.request(`/teams/${found}`, {
@@ -1034,7 +872,7 @@ export class GiteaAdmin {
* The edit path is taken only when the user actually exists. A 422 from the create is also what
* a plain validation failure returns, and reading it as "already there" made the follow-up edit
* 404 — burying the create's own message, which is the one that says what is actually wrong. */
async ensureUser(username: string, password: string, email: string, plain = false): Promise<void> {
async ensureUser(username: string, password: string, email: string): Promise<void> {
const res = await this.request("/admin/users", {
method: "POST",
body: JSON.stringify({ username, email, password, must_change_password: false }),
@@ -1048,9 +886,7 @@ export class GiteaAdmin {
// login_name is required by the admin edit endpoint; for a local user it is the username.
// active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong
// password, so the provisioner's check reports it lost; applying again must undo both.
// plain: never a site administrator — one made so by hand is made a plain account again (novox/hq ADR 0365).
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false,
...(plain ? { admin: false } : {}) }),
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }),
});
if (patch.status === 200) return;
GiteaAdmin.fail(`/admin/users/${username}`, patch);
-120
View File
@@ -1,120 +0,0 @@
package main
// The commit and the reads act as the tools' own account (novox/hq ADR 0365 §2, issue 469): the token the module
// keeps for `mesh-tools`, never the admin login. That account is no repository's administrator, so the forge
// refuses it the protection rules (403, as gitea's reqAdmin does); the commit then judges an existing branch by the
// protection the forge says it applies, and leaves a branch not made yet to the forge's own judgement.
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea/internal/forgehttp"
)
const toolsToken = "tools-token-never-shown"
// asTools serves the fake forge to a bundle holding the tools' token: only that token is taken, the rules are
// refused as to a non-administrator, and the branches named protected say so as the forge does.
func asTools(t *testing.T, f *fakeForge, protected map[string]string, scopes string) (*Forge, *[]string) {
dir := t.TempDir()
file := filepath.Join(dir, "token")
if err := os.WriteFile(file, []byte(toolsToken+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file+".scopes", []byte(scopes+"\n"), 0o600); err != nil {
t.Fatal(err)
}
var seen []string
const repo = "/api/v1/repos/acme/app"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Method+" "+r.URL.Path)
if _, _, basic := r.BasicAuth(); basic || r.Header.Get("Authorization") != "token "+toolsToken {
http.Error(w, "unauthorized", 401)
return
}
if r.URL.Path == repo+"/branch_protections" {
http.Error(w, `{"message":"token does not have the administration of this repository"}`, 403)
return
}
if name := strings.TrimPrefix(r.URL.Path, repo+"/branches/"); r.Method == "GET" && name != r.URL.Path {
if rule, ok := protected[name]; ok {
_, _ = w.Write([]byte(`{"name":"` + name + `","commit":{"id":"c0"},"protected":true,"effective_branch_protection_name":"` + rule + `"}`))
return
}
}
r.SetBasicAuth("mesh-admin", f.password) // the fake's own door, past the token checked above
f.handle(w, r)
}))
t.Cleanup(srv.Close)
return &Forge{Client: forgehttp.Client{URL: srv.URL, TokenFile: file, Account: "mesh-tools"}}, &seen
}
func TestTheToolsAccountCommitsOnANewBranchWithItsTokenThoughTheRulesAreNotItsToRead(t *testing.T) {
f := newFake(t)
forge, _ := asTools(t, f, nil, "read:user,write:issue,write:repository for mesh-tools")
got, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365",
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
if err != nil {
t.Fatal(err)
}
if len(f.writes) != 1 || !got.Created {
t.Fatalf("%d writes, %+v", len(f.writes), got)
}
}
func TestTheToolsAccountIsRefusedAProtectedBranchTheForgeSaysItGuardsAndTheDefaultBranch(t *testing.T) {
for branch, rule := range map[string]string{"release/1.0": "release/*", "fix/1-old": "fix/1-old", "main": ""} {
f := newFake(t)
protected := map[string]string{}
if rule != "" {
protected[branch] = rule
}
forge, _ := asTools(t, f, protected, "read:user,write:issue,write:repository for mesh-tools")
_, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": branch,
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "ADR 0266") {
t.Errorf("%s: %v", branch, err)
}
if rule != "" && !strings.Contains(err.Error(), rule) {
t.Errorf("%s: the rule %s is not named: %v", branch, rule, err)
}
if len(f.writes)+len(f.patches) != 0 {
t.Errorf("%s: something was written", branch)
}
}
}
func TestATokenKeptForTheAdministratorIsNeverSent(t *testing.T) {
f := newFake(t)
// What the module kept before ADR 0365: the admin's token, its scopes line naming no account.
forge, seen := asTools(t, f, nil, "read:user,write:issue,write:repository")
_, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365",
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
if err == nil || !strings.Contains(err.Error(), "not mesh-tools's") {
t.Fatalf("%v", err)
}
if len(*seen) != 0 {
t.Fatalf("the forge was asked %v", *seen)
}
}
func TestTheBundleHandedATokenFileNeedsNoAdminLogin(t *testing.T) {
t.Setenv("MESH_GITEA_URL", "http://127.0.0.1:3000")
t.Setenv("MESH_GITEA_TOKEN_FILE", "/state/token")
t.Setenv("MESH_GITEA_TOOLS_USER", "")
t.Setenv("MESH_GITEA_ADMIN_USER", "")
t.Setenv("MESH_GITEA_ADMIN_PASSWORD_FILE", "")
f, err := ForgeFromEnv()
if err != nil {
t.Fatal(err)
}
if f.TokenFile != "/state/token" || f.Account != "mesh-tools" || f.User != "" || f.PasswordFile != "" {
t.Fatalf("%+v", f.Client)
}
}
+1 -28
View File
@@ -9,8 +9,6 @@ import (
"net/url"
"strings"
"unicode/utf8"
"gitea/internal/forgehttp"
)
// gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull
@@ -213,32 +211,7 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
// Read the rules, or refuse: a branch the rules could not be read for is never written (fails closed).
var rules []Protection
if err := f.GetJSON(ctx, repo+"/branch_protections", &rules); err != nil {
var status *forgehttp.StatusError
if !errors.As(err, &status) || status.Status != http.StatusForbidden {
return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err)
}
// The rules are a repository administrator's to read, and the tools' account is none (novox/hq ADR 0365
// §2). What it may read is the protection the forge applies to an existing branch: one protected is
// refused here. A branch not made yet is covered by no rule this account can see, and the forge itself
// judges the commit that makes it against every rule, refusing one a rule guards.
if err := targetRefused(a.Branch, r.DefaultBranch, nil); err != nil {
return nil, err
}
b, err := f.branch(ctx, repo, a.Branch)
if err != nil {
return nil, fmt.Errorf("whether a protection rule guards %s of %s/%s could not be read, so nothing was sent: %w", a.Branch, a.Owner, a.Repo, err)
}
if b != nil && b.Protected {
rule := b.Rule
if rule == "" {
rule = a.Branch
}
rules = []Protection{{RuleName: rule}}
if !ruleCovers(rule, a.Branch) {
return nil, refuse("a commit on %s is refused: the protection rule %s guards it (novox/hq ADR 0266). Put it on a "+
"branch of its own. Nothing was sent", a.Branch, rule)
}
}
return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err)
}
if err := targetRefused(a.Branch, r.DefaultBranch, rules); err != nil {
return nil, err
+3 -6
View File
@@ -11,8 +11,8 @@ import (
"gitea/internal/forgehttp"
)
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the tools' own account (novox/hq
// ADR 0365 §2; internal/forgehttp, shared with the npm-registry bundle beside it).
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin account
// (internal/forgehttp, shared with the npm-registry bundle beside it).
// Forge is the forge's HTTP interface.
type Forge struct {
@@ -91,15 +91,12 @@ func (p Protection) name() string {
return p.BranchName
}
// Branch is one branch with the commit it points at, and the protection the forge applies to it: what an account
// that may not read the rules themselves (the tools' account, novox/hq ADR 0365) knows of them.
// Branch is one branch with the commit it points at.
type Branch struct {
Name string `json:"name"`
Commit struct {
ID string `json:"id"`
} `json:"commit"`
Protected bool `json:"protected"`
Rule string `json:"effective_branch_protection_name"`
}
// branch is the named branch, nil when the forge has none of that name.
+1 -7
View File
@@ -619,17 +619,11 @@ func TestToolsAreTheOnesTheReadmeNamesAndTheManifestBuilds(t *testing.T) {
if len(a.Loads) != 1 || a.Loads[0] != a.Binary {
t.Errorf("the bundle loads %v, not its binary %s", a.Loads, a.Binary)
}
// The tools' own account, never the admin login (novox/hq ADR 0365 §2).
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE"} {
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} {
if a.Env[k] == "" {
t.Errorf("the bundle is not given %s", k)
}
}
for _, k := range []string{"MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} {
if a.Env[k] != "" {
t.Errorf("the bundle is given %s: it acts as the tools' account, not the administrator", k)
}
}
}
}
if !found {
+83 -167
View File
@@ -40,11 +40,10 @@ import { emit, on } from "@novox/mesh-sdk/events";
import { registerModuleTools } from "@novox/mesh-sdk/tools";
import { commitStatusTool } from "./delivery.js";
import { mergeTool } from "./merge.js";
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
import { GiteaClient, movedSince } from "./client.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
repoCheckFacts, reposToLook } from "./pulls.js";
type RepoCheckFacts, reposToLook } from "./pulls.js";
import { GateWriter, clonedAs, type BuildStarted } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
@@ -146,9 +145,57 @@ async function pollMerged(client: GiteaClient): Promise<void> {
// at once.
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
if (primedMerges && fresh) {
if (await announceMerge(client, repo, pull)) pace.miss(`the merge of ${repo.full_name}#${pull.number}`);
// What it changed, asked for only now: a module is rebuilt because a file inside its own
// directory moved, and without this every module built from a repository is rebuilt for a
// change to any of them (novox/hq 04-ISSUES/131).
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
// Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a
// change inside one is that module's, held or not, and only a file in none is shared code. Not
// said when the list is cut or the forge could not be asked; the mesh then keeps its old rule.
let moduleDirs: string[] | null = null;
if (!changed.truncated) {
try {
moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` +
`so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`);
}
}
// The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made
// from the forge's word when its owner never heard the head.
let headChecks: Record<string, string> | null = null;
if (pull.head_sha) {
try {
headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`);
}
}
await emit("pull.merged", {
owner: repo.owner,
repo: repo.name,
number: pull.number,
title: pull.title,
body: pull.body,
head_sha: pull.head_sha,
...(headChecks ? { head_checks: headChecks } : {}),
head: pull.head,
base: pull.base,
merge_commit_sha: pull.merge_commit_sha,
merged_at: pull.merged_at,
clone_url: repo.clone_url,
html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
// Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten,
// not built.
removed: changed.removed,
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
});
// Said, because a trigger that fires silently is indistinguishable from one that did not
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
}
else announced.add(pull.merge_commit_sha);
announced.add(pull.merge_commit_sha);
changed = true;
}
@@ -159,73 +206,6 @@ async function pollMerged(client: GiteaClient): Promise<void> {
lastLook = began;
}
type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string };
type PullOf = Awaited<ReturnType<GiteaClient["listPullRequests"]>>[number];
/** **A merge, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) return false;
// Recorded before the first wait, so the other path finds it taken while this one is still asking the forge.
announced.add(pull.merge_commit_sha);
try {
// What it changed, asked for only now: a module is rebuilt because a file inside its own
// directory moved, and without this every module built from a repository is rebuilt for a
// change to any of them (novox/hq 04-ISSUES/131).
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
// Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a
// change inside one is that module's, held or not, and only a file in none is shared code. Not
// said when the list is cut or the forge could not be asked; the mesh then keeps its old rule.
let moduleDirs: string[] | null = null;
if (!changed.truncated) {
try {
moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` +
`so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`);
}
}
// The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made
// from the forge's word when its owner never heard the head.
let headChecks: Record<string, string> | null = null;
if (pull.head_sha) {
try {
headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`);
}
}
await emit("pull.merged", {
owner: repo.owner,
repo: repo.name,
number: pull.number,
title: pull.title,
body: pull.body,
head_sha: pull.head_sha,
...(headChecks ? { head_checks: headChecks } : {}),
head: pull.head,
base: pull.base,
merge_commit_sha: pull.merge_commit_sha,
merged_at: pull.merged_at,
clone_url: repo.clone_url,
html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
// Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten,
// not built.
removed: changed.removed,
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
});
// Said, because a trigger that fires silently is indistinguishable from one that did not
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
} catch (err) {
announced.delete(pull.merge_commit_sha);
throw err;
}
keepAnnounced();
return true;
}
// **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it
// against every machine of its facts before it merges. Kept beside the merges' record, so a restart
// announces nothing twice; the first look on a machine with no record announces only what moved in the
@@ -248,31 +228,6 @@ function keepHeads(): void {
writeFileSync(tmp, JSON.stringify({ heads }));
renameSync(tmp, pullsRecord);
}
/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
const key = `${repo.full_name}#${pull.number}`;
const sha = String(pull.head_sha ?? "");
const before = heads[key];
// Claimed before the first wait, so the other path finds it taken while this one is still announcing.
if (!claimHead(heads, key, sha)) return false;
try {
// The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5).
await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull));
} catch (err) {
if (before === undefined) delete heads[key];
else heads[key] = before;
throw err;
}
keepHeads();
// Said, so an operator knows the mesh was asked to check it.
console.log(`[gitea] announced ${key} at ${sha.slice(0, 8)} to be checked before it merges`);
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446).
await gates.pending(repo.owner, repo.name, sha);
return true;
}
type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
let lastPullLook = "";
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
// heuristic, and a heuristic that misses is caught by the next sweep rather than never.
@@ -304,8 +259,15 @@ async function pollPulls(client: GiteaClient): Promise<void> {
const key = `${repo.full_name}#${pull.number}`;
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
if (fresh) {
if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
} else heads[key] = String(pull.head_sha);
// The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5).
await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url, pull));
// Said, so an operator knows the mesh was asked to check it.
console.log(`[gitea] announced ${key} at ${String(pull.head_sha).slice(0, 8)} to be checked before it merges`);
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446).
await gates.pending(repo.owner, repo.name, String(pull.head_sha));
}
heads[key] = String(pull.head_sha);
changed = true;
}
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
@@ -400,6 +362,22 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
}
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
// required would let an untested repository merge.
async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
if (c["repo-check"]?.verdict !== "warning") return {};
const defined = await client.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
if (defined !== false) return { defined };
const rules = await client.branchProtections(c.owner, c.repo).catch(() => undefined);
if (!rules) return { defined };
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === (base || "main"));
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
return { defined, required };
}
// **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words,
// where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last
// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending.
@@ -413,16 +391,10 @@ const gates = new GateWriter({
// **The status tool is served here, beside the writer** (novox/hq issue 456): the runtime starts each bundle as its own
// process, so a recheck's pending written from the tools' bundle bypassed this writer's chain. Without a forge to
// reach, nothing is served, as the tools' bundle serves nothing then.
/** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what
* gitea_events says of them (webhook.ts). */
const pace = new Pace();
if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729).
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
registerModuleTools("gitea", () => (gitea ? [commitStatusTool({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
gates,
}), mergeTool(gitea, { gates })] : [])]);
}), mergeTool(gitea, { gates })] : []));
// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when
// the head is an open pull request's this module announced. Any other build — a branch, a merge's — says nothing.
@@ -441,65 +413,12 @@ async function buildStarted(event: { body: unknown }): Promise<void> {
}
}
/** What the forge's event asks of the announcer at once (issue 729): a new head is announced, and a merge. */
async function actOn(client: GiteaClient, event: ForgeEvent): Promise<void> {
const b = event.body as { owner?: string; repo?: string; number?: number };
if (!b.owner || !b.repo || !b.number) return;
const head = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"].includes(event.name);
const merged = event.name === "forge.pull-request.merged";
if (!head && !merged) return;
const [repo, pull] = await Promise.all([client.getRepo(b.owner, b.repo), client.getPullRequest(b.owner, b.repo, b.number)]);
if (head && pull.state === "open") await announceHead(client, repo, pull);
if (merged) await announceMerge(client, repo, pull);
}
/**
* **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside
* the token; the webhook is made or corrected with the admin account (ADR 0266: the tools' token holds no admin scope).
* Answers whether events are heard; a forge that refuses is said, and the polls keep their pace.
*/
async function listenForForge(client: GiteaClient): Promise<boolean> {
const stateDir = process.env.MESH_GITEA_STATE_DIR;
const url = process.env.MESH_GITEA_HOOK_URL;
if (!stateDir || !url) {
console.log("[gitea] no webhook receiver: MESH_GITEA_STATE_DIR or MESH_GITEA_HOOK_URL unset, so the polls alone watch the forge");
pace.unheard("MESH_GITEA_STATE_DIR or MESH_GITEA_HOOK_URL unset", false);
return false;
}
try {
const secret = keptSecret(join(stateDir, "webhook.secret"));
const port = Number(process.env.MESH_GITEA_HOOK_PORT ?? HOOK_PORT);
const host = process.env.MESH_GITEA_HOOK_HOST ?? "0.0.0.0";
const server = receiver({ secret, port, heard: (kind, delivery) => pace.heard(kind, delivery),
refused: (reason, from) => pace.refused(reason, from),
publish: (event) => emit(event.name, event.body),
act: (event) => actOn(client, event) });
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => resolve());
});
const hook = await ensureSystemHook(GiteaAdmin.fromEnv(), url.replace(/\/+$/, "") + HOOK_PATH, secret);
pace.listening(host, port, hook.url, hook);
console.log(`[gitea] listening for the forge's events on ${host}:${port}; its system webhook ${hook.id} ${hook.did} ` +
`at ${hook.url} (in place of ${hook.removed.system} system, ${hook.removed.default} default and ` +
`${hook.removed.repository} repository webhook(s)${hook.removed.repositories.length ? ` in ${hook.removed.repositories.join(", ")}` : ""}), ` +
`${hook.events.length} event families, signed`);
return true;
} catch (err) {
const why = err instanceof Error ? err.message : String(err);
pace.unheard(why);
console.error(`[gitea] the forge's events are not heard, so the polls keep their pace — ${why}`);
return false;
}
}
if (gitea) {
const client = gitea;
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
let failing: string | null = null;
const tick = (fn: () => Promise<void>, every: number | (() => number)): void => {
const everyMs = (): number => (typeof every === "number" ? every : every());
const tick = (fn: () => Promise<void>, everyMs: number): void => {
// **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a
// pass that outlasts its interval delays the next instead of running beside it — two passes at once
// could each announce the same merge before either recorded it.
@@ -514,15 +433,12 @@ if (gitea) {
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
failing = why;
})
.finally(() => setTimeout(run, everyMs()));
.finally(() => setTimeout(run, everyMs));
run();
};
// **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
await listenForForge(client);
tick(() => pollRepos(client), 60_000);
tick(() => pollMerged(client), () => pace.every());
tick(() => pollPulls(client), () => pace.every());
tick(() => pollMerged(client), 30_000);
tick(() => pollPulls(client), 30_000);
await on("mesh-controller.checked", (event) => {
const c = (event.body ?? {}) as Checked;
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
+11 -76
View File
@@ -1,17 +1,7 @@
// Package forgehttp is the forge's HTTP interface as this module's Go bundles reach it (cmd/forge,
// cmd/npm-registry), on the forge's own machine. One copy for both bundles (novox/hq issue 539).
//
// Two ways in, by what the manifest hands the bundle:
//
// - **as the tools' account** (novox/hq ADR 0365 §2), when MESH_GITEA_TOKEN_FILE names the token the module
// keeps for it (token.ts mints it, for `mesh-tools`, and keeps it at 0600 with its scopes line beside it). A
// token whose scopes line does not say it is the tools' account's — one kept from before, the administrator's —
// is never sent: the call fails, saying so, until the module has minted the account's own. This bundle mints
// nothing; a token the forge rejects is renewed by the module's next call, and said here.
// - **as the admin account**, whose password the vault delivered into a file this module owns (novox/hq ADR
// 0086), by basic authentication, when no token file is named.
//
// A credential is read when a call is made and never logged, answered or put in an error.
// cmd/npm-registry): the forge on its own machine, as its admin account, whose password the vault delivered
// into a file this module owns (novox/hq ADR 0086), by basic authentication. The password is read when a call
// is made and never logged, answered or put in an error. One copy for both bundles (novox/hq issue 539).
package forgehttp
import (
@@ -35,31 +25,12 @@ type Client struct {
URL string // e.g. http://127.0.0.1:3000
User string
PasswordFile string
// TokenFile, when set, is the tools' account's token (MESH_GITEA_TOKEN_FILE), sent instead of the admin login;
// Account the account its scopes line must name (MESH_GITEA_TOOLS_USER, default mesh-tools).
TokenFile string
Account string
HTTP *http.Client // when nil, a client with Timeout
Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero
HTTP *http.Client // when nil, a client with Timeout
Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero
}
// ToolsAccount is the account the forge's tools act as when the manifest names none (token.ts, tools-account.ts).
const ToolsAccount = "mesh-tools"
// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset. A bundle
// handed MESH_GITEA_TOKEN_FILE acts as the tools' account and needs no admin login.
// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset.
func FromEnv() (Client, []string) {
if file := os.Getenv("MESH_GITEA_TOKEN_FILE"); file != "" {
c := Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), TokenFile: file,
Account: os.Getenv("MESH_GITEA_TOOLS_USER")}
if c.Account == "" {
c.Account = ToolsAccount
}
if c.URL == "" {
return c, []string{"MESH_GITEA_URL"}
}
return c, nil
}
c := Client{
URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"),
User: os.Getenv("MESH_GITEA_ADMIN_USER"),
@@ -109,45 +80,14 @@ func (c *Client) password() (string, error) {
return strings.TrimSpace(string(raw)), nil
}
// token is the tools' account's token, refused unless its scopes line names that account.
func (c *Client) token() (string, error) {
raw, err := os.ReadFile(c.TokenFile)
if err != nil {
return "", fmt.Errorf("the tools' token is not kept yet (the module mints it for %s at its first call): %w", c.Account, err)
}
line, err := os.ReadFile(c.TokenFile + ".scopes")
if err != nil || !strings.HasSuffix(strings.TrimSpace(string(line)), " for "+c.Account) {
return "", fmt.Errorf("the token kept at %s is not %s's (novox/hq ADR 0365): the module mints that account's own at its next call; nothing was sent", c.TokenFile, c.Account)
}
token := strings.TrimSpace(string(raw))
if token == "" {
return "", fmt.Errorf("the token kept at %s is empty; nothing was sent", c.TokenFile)
}
return token, nil
}
// authorize puts the bundle's credential on a request: the tools' token, or the admin login.
func (c *Client) authorize(req *http.Request) error {
if c.TokenFile != "" {
token, err := c.token()
if err != nil {
return err
}
req.Header.Set("Authorization", "token "+token)
return nil
}
password, err := c.password()
if err != nil {
return err
}
req.SetBasicAuth(c.User, password)
return nil
}
// Do sends one request, with payload as its JSON body when not nil, and answers the body, read up to limit;
// 404 is ErrNotFound. One byte past the limit is read, so a body the limit cut is told from one that fits it
// exactly: a cut body is refused, never read as if it were whole.
func (c *Client) Do(ctx context.Context, method, path string, payload any, limit int64) ([]byte, error) {
password, err := c.password()
if err != nil {
return nil, err
}
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
@@ -160,9 +100,7 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit
if err != nil {
return nil, err
}
if err := c.authorize(req); err != nil {
return nil, err
}
req.SetBasicAuth(c.User, password)
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
@@ -180,9 +118,6 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit
return nil, fmt.Errorf("the forge's answer to %s %s is larger than %d MiB and was not read whole", method, path, limit>>20)
}
switch {
case res.StatusCode == http.StatusUnauthorized && c.TokenFile != "":
return nil, fmt.Errorf("the forge rejected the tools' token for %s (401): the module mints it afresh at its next call; nothing was done: %w",
method+" "+path, &StatusError{method, path, res.StatusCode, "unauthorized"})
case res.StatusCode == http.StatusNotFound:
return nil, fmt.Errorf("%s %s: %w", method, path, ErrNotFound)
case res.StatusCode >= 300:
+4 -5
View File
@@ -54,10 +54,9 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
const method = args.method ? String(args.method) : "merge";
if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`);
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here as well as by the
// forge, which holds the tools' own account to it (novox/hq ADR 0365 §2). The protection is the one the forge
// applies to the base, read from the branch: the rules' own list is a repository administrator's. A rule that
// cannot be read is no rule passed.
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here rather than left
// to the forge, whose administrator — the account this tool's token is minted for — may merge past a
// rule that lets it. A rule that cannot be read is no rule passed.
// **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d,
// force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the
// head was and nothing told it which head the review had approved. The caller names it; a head that moved
@@ -81,7 +80,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` +
`${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`);
}
const rule = await gitea.appliedProtection(owner, repo, pull.base);
const rule = await gitea.branchProtection(owner, repo, pull.base);
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
// branch nothing guards would merge past every check (novox/hq ADR 0266).
if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) {
+7 -75
View File
@@ -42,47 +42,7 @@
"issue.opened",
"pull.merged",
"pull.updated",
"pull.closed",
"forge.push",
"forge.create",
"forge.delete",
"forge.fork",
"forge.status",
"forge.pull-request.opened",
"forge.pull-request.reopened",
"forge.pull-request.synchronized",
"forge.pull-request.edited",
"forge.pull-request.closed",
"forge.pull-request.merged",
"forge.pull-request.labeled",
"forge.pull-request.unlabeled",
"forge.pull-request.assigned",
"forge.pull-request.unassigned",
"forge.pull-request.reviewed",
"forge.pull-request.review-requested",
"forge.pull-request.commented",
"forge.pull-request.changed",
"forge.issue-comment.created",
"forge.issue-comment.edited",
"forge.issue-comment.deleted",
"forge.issues.opened",
"forge.issues.reopened",
"forge.issues.closed",
"forge.issues.edited",
"forge.issues.labeled",
"forge.issues.unlabeled",
"forge.issues.assigned",
"forge.issues.unassigned",
"forge.issues.changed",
"forge.release.published",
"forge.release.updated",
"forge.release.deleted",
"forge.release.changed",
"forge.repository.created",
"forge.repository.deleted",
"forge.repository.changed",
"forge.wiki.changed",
"forge.other"
"pull.closed"
],
"consumes": [
"mesh-controller.checked",
@@ -101,14 +61,7 @@
"port": 22,
"protocol": "tcp",
"from": "mesh",
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take"
},
{
"name": "forge-events",
"port": 3047,
"protocol": "tcp",
"from": "machine",
"why": "the forge's system webhook, every event signed with a secret the module keeps, from the forge's container on the docker bridge to the module's receiver at the bridge's gateway 172.17.0.1 (novox/hq issue 729)"
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention \u2014 not 22, which the machine's own daemon holds and a module does not take"
}
],
"serves": {
@@ -156,13 +109,6 @@
}
}
},
"settings": {
"ready-authors": {
"kind": "preference",
"default": "mesh-tools mesh-admin jschoubben",
"why": "the forge accounts whose comment \"ready at <full head sha>\" marks a pull request ready for mesh-delivery to merge, separated by spaces: the account the forge's tools act as (novox/hq ADR 0365), the admin account for marks made before the tools acted as their own, and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word"
}
},
"resources": [
{
"id": "mesh-state",
@@ -176,14 +122,6 @@
"path": "${dir:mesh-state}/state",
"mode": "0700"
},
{
"id": "ready-authors",
"type": "file",
"path": "${dir:mesh-state}/ready-authors",
"mode": "0600",
"trusted": true,
"content": "# The accounts whose \"ready at <sha>\" comment marks a pull request ready (novox/hq issue 722), from the setting\n# ready-authors, written by the mesh. Replaced on every push; change the setting, never this file.\nauthors=${setting:ready-authors}\n"
},
{
"id": "state",
"type": "directory",
@@ -200,7 +138,7 @@
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\nGITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1\n"
"content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n"
},
{
"id": "data",
@@ -269,8 +207,7 @@
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json",
"takes-settings": []
"merge": "json"
}
],
"provides": [
@@ -308,13 +245,8 @@
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_GITEA_TOOLS_USER": "mesh-tools",
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
"MESH_RECEIVES": "${dir:grants}/npm.json",
"MESH_GITEA_READY_AUTHORS_FILE": "${dir:mesh-state}/ready-authors",
"MESH_GITEA_HOOK_URL": "http://172.17.0.1:3047",
"MESH_GITEA_HOOK_PORT": "3047",
"MESH_GITEA_HOOK_HOST": "172.17.0.1"
"MESH_RECEIVES": "${dir:grants}/npm.json"
}
},
{
@@ -346,8 +278,8 @@
],
"env": {
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GITEA_TOOLS_USER": "mesh-tools",
"MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token"
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret"
}
}
]
+1 -19
View File
@@ -9,7 +9,7 @@
//
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
import type { CommitStatus, GiteaClient, GiteaPull, GiteaRepo } from "./client.js";
import type { CommitStatus, GiteaPull, GiteaRepo } from "./client.js";
import { movedSince } from "./client.js";
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
@@ -176,24 +176,6 @@ export interface RepoCheckFacts {
required?: boolean;
}
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
// required would let an untested repository merge. The base's protection is the one the forge applies, read
// from the branch: the rules' own list is a repository administrator's, which the tools' account is not
// (novox/hq ADR 0365).
export async function repoCheckFacts(
forge: Pick<GiteaClient, "holdsFile" | "appliedProtection">, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
if (c["repo-check"]?.verdict !== "warning") return {};
const defined = await forge.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
if (defined !== false) return { defined };
const rule = await forge.appliedProtection(c.owner, c.repo, base || "main").catch(() => undefined);
if (rule === undefined) return { defined };
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
return { defined, required };
}
/** The description of a required repository check that is not defined. */
export const UNDEFINED_REQUIRED = "fail: mesh/repo-check is required here and this head defines no merge-check.sh — " +
"a person adds one, or lifts the requirement from the branch's protection";
+4 -130
View File
@@ -47,18 +47,11 @@ test("every open pull request is read with what its base requires and what its h
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/pulls/14")) {
return send({ number: 14, state: "open", base: { ref: "main" }, head: { ref: "y", sha: "bbb2222" }, html_url: "s" });
}
// The base's protection, read from the branch as the tools' account may (novox/hq ADR 0365); the rules' list is refused it.
if (url.includes("/branch_protections")) {
res.statusCode = 403;
return res.end(JSON.stringify({ message: "the rules are a repository administrator's" }));
if (url.startsWith("/api/v1/repos/novox/hq/branch_protections")) {
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/repo-check"] }]);
}
if (url.startsWith("/api/v1/repos/novox/hq/branches/main")) {
return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true,
status_check_contexts: ["mesh/repo-check"] });
}
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branches/main")) {
return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true,
status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] });
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branch_protections")) {
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }]);
}
if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] });
if (url.includes("/commits/bbb2222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending",
@@ -144,122 +137,3 @@ test("a merged pull request whose branch was deleted is announced with its branc
assert.equal(headBranchOf({ ref: "refs/pull/7/head" }), "refs/pull/7/head");
assert.equal(headBranchOf(undefined), undefined);
});
// **A pull request marked ready says the head it is marked at** (novox/hq issue 722): the label mesh/ready and the
// newest comment "ready at <full head sha>" by a listed account, written after the label was added and after the last
// push. Read from the pull request's whole timeline, page by page; without the label nothing is read.
const head = "c".repeat(40), older = "d".repeat(40);
const ev = (id: number, type: string, body: string, user = "mesh-admin", label?: { id: number; name: string }) =>
({ id, type, body, user: { login: user }, created_at: `2026-10-14T10:${String(id % 60).padStart(2, "0")}:00Z`,
...(label ? { label } : {}) });
const readyLabel = { id: 31, name: "mesh/ready" };
function forgeWith(timeline: unknown[]) {
const asked: string[] = [];
const server = createServer((req, res) => {
const url = req.url ?? "";
asked.push(url);
const send = (v: unknown) => res.end(JSON.stringify(v));
if (url.startsWith("/api/v1/repos/issues/search")) {
return send(url.includes("page=1") ? [
{ number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" },
labels: [{ id: 31, name: "mesh/ready" }, { id: 2, name: "bug" }] },
{ number: 8, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, labels: [] },
] : []);
}
if (url.startsWith("/api/v1/repos/novox/hq/pulls/7")) {
return send({ number: 7, state: "open", base: { ref: "main" }, head: { ref: "x", sha: head }, html_url: "h" });
}
if (url.startsWith("/api/v1/repos/novox/hq/pulls/8")) {
return send({ number: 8, state: "open", base: { ref: "main" }, head: { ref: "y", sha: older }, html_url: "h" });
}
const t = /\/issues\/7\/timeline\?page=(\d+)&limit=50/.exec(url);
if (t) {
const page = Number(t[1]);
return send(timeline.slice((page - 1) * 50, page * 50));
}
if (url.includes("/branch_protections")) return send([]);
if (url.includes("/status")) return send({ statuses: [] });
res.statusCode = 404;
res.end("{}");
});
return { server, asked };
}
async function readOpen(timeline: unknown[], authors = ["mesh-admin", "jschoubben"]) {
const { GiteaClient } = await import("../client.ts");
const { server, asked } = forgeWith(timeline);
await new Promise<void>((r) => server.listen(0, r));
try {
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
return { open: await client.openPulls(authors), asked };
} finally {
server.close();
}
}
test("a pull request's ready mark is read: the label and the head the newest listed ready comment names", async () => {
const { open, asked } = await readOpen([
ev(1, "pull_push", JSON.stringify({ commit_ids: [head] })),
ev(2, "label", "1", "mesh-admin", readyLabel),
ev(3, "comment", `ready at ${older}`),
ev(4, "comment", `Reviewed: approve.\nready at ${head}\n`),
ev(5, "comment", "thanks", "jschoubben"),
]);
assert.deepEqual(open[0].labels, ["mesh/ready", "bug"]);
assert.deepEqual(open[0].ready, { head, by: "mesh-admin", at: "2026-10-14T10:04:00Z", label_id: 31 });
assert.equal(open[1].ready, undefined, "no label, no mark");
assert.ok(!asked.some((u) => u.includes("/issues/8/")), "no timeline read without the label");
});
test("a ready comment by an account not listed is ignored, and said", async () => {
const { readyMarkOf } = await import("../client.ts");
const said: string[] = [];
const mark = readyMarkOf(31, [
ev(1, "pull_push", "{}") as any, ev(2, "label", "1", "mesh-admin", readyLabel) as any,
{ ...ev(3, "comment", `ready at ${head}`, "outsider"), user: "outsider" },
].map((e: any) => ({ ...e, user: typeof e.user === "string" ? e.user : e.user.login })), ["mesh-admin"], (l) => said.push(l));
assert.equal(mark.head, "", "an outsider's mark counts");
assert.equal(said.length, 1);
assert.match(said[0], /by outsider is ignored/);
});
test("a ready comment older than the latest push, or than the label, never counts", async () => {
const pushedAfter = await readOpen([
ev(1, "label", "1", "mesh-admin", readyLabel),
ev(2, "comment", `ready at ${head}`),
ev(3, "pull_push", JSON.stringify({ commit_ids: [head] })),
]);
assert.equal(pushedAfter.open[0].ready?.head, "", "a mark written before the latest push counts, the label still on");
const beforeLabel = await readOpen([
ev(1, "pull_push", "{}"),
ev(2, "comment", `ready at ${head}`),
ev(3, "label", "1", "mesh-admin", readyLabel),
]);
assert.equal(beforeLabel.open[0].ready?.head, "", "a mark written before the label was added counts");
const takenOff = await readOpen([
ev(1, "label", "1", "mesh-admin", readyLabel),
ev(2, "comment", `ready at ${head}`),
ev(3, "label", "", "mesh-admin", readyLabel),
]);
assert.equal(takenOff.open[0].ready?.head, "", "a mark whose label was taken off since counts");
});
test("a long thread hides nothing: every page of the timeline is read", async () => {
const events: unknown[] = [ev(1, "pull_push", "{}"), ev(2, "label", "1", "mesh-admin", readyLabel),
ev(3, "comment", `ready at ${older}`)];
for (let id = 4; id < 130; id++) events.push(ev(id, "comment", "a long discussion", "jschoubben"));
events.push(ev(130, "comment", `ready at ${head}`, "jschoubben"));
const { open, asked } = await readOpen(events);
assert.equal(open[0].ready?.head, head, "the newest mark, on the third page, is not read");
assert.equal(asked.filter((u) => u.includes("/issues/7/timeline")).length, 3);
});
test("the accounts that may mark are the setting's, and only the mesh's own when it cannot be read", async () => {
const { readyAuthors } = await import("../client.ts");
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "# x\nauthors=mesh-admin jschoubben\n"), ["mesh-admin", "jschoubben"]);
// The tools' account and the admin's, whose marks were made before ADR 0365 (issue 469).
assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }, "mesh-tools"), ["mesh-tools", "mesh-admin"]);
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n", "mesh-tools"), ["mesh-tools", "mesh-admin"]);
assert.deepEqual(readyAuthors(undefined, "mesh-admin", undefined, "mesh-tools"), ["mesh-tools", "mesh-admin"]);
});
+1 -19
View File
@@ -30,9 +30,6 @@ const state = {
// What the tools said on the bus.
emitted: [] as { type: string; body: any }[],
rules: [] as any[],
// Whether the token is a repository administrator's. The tools' account is not (novox/hq ADR 0365): the forge
// refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch.
admin: false,
lastCommit: "2026-01-01T00:00:00Z",
// The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
@@ -62,7 +59,6 @@ beforeEach(() => {
state.trunk = TRUNK;
state.emitted = [];
state.rules = [protectedMain()];
state.admin = false;
state.head = REVIEWED;
state.landed = "";
state.merged = false;
@@ -91,18 +87,8 @@ const server = createServer(async (req, res) => {
if (method !== "GET") state.sent.push({ method, path, body });
const repo = "/repos/novox/mesh-catalog";
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
if (path.startsWith(`${repo}/branch_protections`) && !state.admin) {
return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
}
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null;
if (branch !== null) {
// As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it.
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
return json(res, 200, { name: branch, commit: { id: branch === "main" ? state.trunk : "c0ffee" }, protected: !!rule,
...(rule ? { effective_branch_protection_name: rule.rule_name ?? rule.branch_name, enable_status_check: !!rule.enable_status_check,
status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: false } : {}) });
}
if (method === "GET" && path === `${repo}/branches/main`) return json(res, 200, { name: "main", commit: { id: state.trunk } });
if (method === "GET" && path === `${repo}/pulls/7/files`) {
return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []);
}
@@ -213,7 +199,6 @@ test("a protection rule covers a branch as the forge decides it: classes, altern
});
test("gitea_api still writes what an author writes", async () => {
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments", method: "POST", body: { body: "hi" } });
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT" } });
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/branch_protections" });
@@ -221,7 +206,6 @@ test("gitea_api still writes what an author writes", async () => {
});
test("gitea_branch_protection_set only strengthens", async () => {
state.admin = true; // a repository administrator's token: the tools' account is refused the rules by the forge
const set = (args: Record<string, unknown>) =>
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args });
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/);
@@ -412,7 +396,6 @@ test("gitea_merge_pull_request reads back a fast-forward, and says it cannot rea
});
test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => {
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
const del = (owner: string, name: string, confirm: unknown) => tool("gitea_delete_repo").run({ owner, name, confirm });
await assert.rejects(del("novox", "mesh-catalog", true), /confirm must be the repository's full name/);
await assert.rejects(del("novox", "mesh-catalog", "novox/mesh-catalog"), /not a repository of the tools' own user/);
@@ -444,7 +427,6 @@ test("gitea_merge_pull_request merges only into a branch whose protection requir
});
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true,
status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true });
const set = (args: Record<string, unknown>) =>
@@ -1,59 +0,0 @@
// The controller's build-source check asks gitea_branch_protection_get for a repository's rules, which the forge
// answers to a repository's administrator alone. The tools' account is not one (novox/hq ADR 0365 §2), so the read
// goes with the forge's administrator account, and only the read (ADR 0375, issue 772): a protection edit through the
// tools still gets the forge's refusal.
import { test, after } 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";
const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64");
const rules = [
{ rule_name: "main", enable_push: false, enable_status_check: true, status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true },
{ branch_name: "release", enable_push: false },
];
const seen: string[] = [];
const server = createServer((req, res) => {
const path = (req.url ?? "").replace(/^\/api\/v1/, "");
const as = req.headers.authorization === adminBasic ? "admin" : "tools";
seen.push(`${req.method} ${path} as ${as}`);
const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); };
if (path.includes("/branch_protections")) {
if (as !== "admin") return send(403, { message: "user should be an owner or a collaborator with admin write of a repository" });
if (req.method === "GET") return send(200, rules);
}
send(404, { message: "not found" });
});
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}`;
const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"));
const tool = (name: string) => tools.find((t) => t.name === name)!;
test("gitea_branch_protection_get answers every rule, read with the administrator account the tools' token cannot read with", async () => {
const out: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller" });
assert.deepEqual(out.rules.map((r: any) => r.rule), ["main", "release"]);
});
test("gitea_branch_protection_get answers the rule for a branch, null when it has none", async () => {
const main: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller", branch: "main" });
assert.equal(main.branch, "main");
assert.equal(main.rule.rule, "main");
assert.equal(main.rule.admin_may_override, false);
const none: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller", branch: "dev" });
assert.equal(none.rule, null);
});
test("gitea_branch_protection_set still gets the forge's refusal, as the tools' account", async () => {
await assert.rejects(
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-controller", branch: "main", status_checks: "mesh/merge-gate" }),
/403/,
);
assert.ok(!seen.some((s) => /^(PATCH|POST|PUT|DELETE) .*branch_protections.* as admin$/.test(s)), seen.join("\n"));
});
+6 -116
View File
@@ -10,7 +10,7 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -29,12 +29,6 @@ interface Forge {
mints: number;
lastScopes: string[] | null;
tokens: Map<string, string>;
/** Tokens beside `tokens` that carry a name already held there: what two mints at once leave behind. */
twins: { id: number; name: string; sha1: string }[];
/** How each DELETE named its token: "id" or "name". */
deletedBy: string[];
/** Plant a token by name, beside any other of the same name, as a raced mint would. */
plant(name: string): void;
scopesOf: Map<string, string[]>;
admins: Map<string, string>;
pullState: string;
@@ -45,14 +39,9 @@ interface Forge {
function fakeForge(): Promise<Forge> {
const forge = {
mintedFor: "",
mints: 0,
lastScopes: null as string[] | null,
tokens: new Map<string, string>(), // name -> value
twins: [] as { id: number; name: string; sha1: string }[],
deletedBy: [] as string[],
ids: new Map<string, number>(), // value -> id, as gitea numbers its tokens
nextId: 0,
scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does
admins: new Map([[ADMIN, PASSWORD]]),
pullState: "open",
@@ -84,14 +73,7 @@ function fakeForge(): Promise<Forge> {
const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/);
if (tokens) {
const user = basic(req);
// gitea's reqSelfOrAdmin: the account itself, or the administrator for another (the tools' account, ADR 0365).
if (user === null || (user !== decodeURIComponent(tokens[1]) && user !== ADMIN)) return json(res, 401, { message: "auth required" });
// This fake keeps one account's tokens: the tools' account's when the module mints for it (ADR 0365), and the
// admin's own list is then empty — nothing of it is retired.
if (decodeURIComponent(tokens[1]) === ADMIN && forge.mintedFor === "mesh-tools") {
return req.method === "GET" ? json(res, 200, []) : json(res, 404, { message: "token not found" });
}
if (req.method === "POST") forge.mintedFor = decodeURIComponent(tokens[1]);
if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" });
if (req.method === "POST") {
const { name, scopes } = await body(req);
if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" });
@@ -100,35 +82,12 @@ function fakeForge(): Promise<Forge> {
const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`;
forge.tokens.set(name, sha1);
forge.scopesOf.set(sha1, scopes);
forge.ids.set(sha1, ++forge.nextId);
return json(res, 201, { id: forge.ids.get(sha1), name, sha1, scopes, token_last_eight: sha1.slice(-8) });
}
// gitea's list: every token of the account, a page at a time (at most three to a page here, so a
// client that reads only the first page misses some), the values never shown.
const listed = (): { id: number; name: string; sha1: string }[] => [
...[...forge.tokens].map(([name, sha1]) => ({ id: forge.ids.get(sha1) ?? 0, name, sha1 })),
...forge.twins,
];
if (req.method === "GET" && !tokens[2]) {
const page = Math.max(1, Number(url.searchParams.get("page") ?? "1"));
const limit = Math.min(3, Math.max(1, Number(url.searchParams.get("limit") ?? "3")));
const all = listed();
res.setHeader("X-Total-Count", String(all.length));
return json(res, 200, all.slice((page - 1) * limit, page * limit)
.map((t) => ({ id: t.id, name: t.name, sha1: "", token_last_eight: t.sha1.slice(-8), scopes: [] })));
return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) });
}
if (req.method === "DELETE" && tokens[2]) {
// gitea: "identified by ID and if not available by name" — and a name held twice is refused.
const key = decodeURIComponent(tokens[2]);
const byId = /^\d+$/.test(key);
const matches = listed().filter((t) => (byId ? String(t.id) === key : t.name === key));
if (matches.length === 0) return json(res, 404, { message: "token not found" });
if (matches.length > 1) return json(res, 422, { message: `multiple matches for token name '${key}'` });
const t = matches[0];
forge.deletedBy.push(byId ? "id" : "name");
const twin = forge.twins.findIndex((w) => w.id === t.id);
if (twin >= 0) forge.twins.splice(twin, 1);
else forge.tokens.delete(t.name);
const name = decodeURIComponent(tokens[2]);
if (!forge.tokens.has(name)) return json(res, 404, { message: "token not found" });
forge.tokens.delete(name);
return json(res, 204, null);
}
return json(res, 405, { message: "method not allowed" });
@@ -194,17 +153,6 @@ function fakeForge(): Promise<Forge> {
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
]);
}
// What setting up the tools' account asks with the admin login (tools-account.ts, ADR 0365): answered plainly.
if (basic(req) === ADMIN) {
const p = url.pathname;
if (req.method === "GET" && p === "/api/v1/user") return json(res, 200, { login: ADMIN, is_admin: true });
if (req.method === "GET" && p === "/api/v1/users/mesh-tools") return json(res, 200, { login: "mesh-tools", is_admin: false });
if (req.method === "POST" && p === "/api/v1/admin/users") return json(res, 422, { message: "user already exists" });
if (req.method === "PATCH" && p === "/api/v1/admin/users/mesh-tools") return json(res, 200, { login: "mesh-tools" });
if (req.method === "GET" && p === "/api/v1/orgs/novox/teams") return json(res, 200, [{ id: 7, name: "mesh-tools" }]);
if (req.method === "PATCH" && p === "/api/v1/teams/7") return json(res, 200, { id: 7 });
if (req.method === "PUT" && p === "/api/v1/teams/7/members/mesh-tools") return json(res, 204, null);
}
const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/);
if (adminUser && req.method === "PATCH") {
const h = req.headers.authorization ?? "";
@@ -233,14 +181,6 @@ function fakeForge(): Promise<Forge> {
get pullTitle() { return forge.pullTitle; },
get branchDeleted() { return forge.branchDeleted; },
tokens: forge.tokens,
twins: forge.twins,
deletedBy: forge.deletedBy,
plant: (name: string) => {
const sha1 = `planted-${++forge.nextId}`;
forge.ids.set(sha1, forge.nextId);
if (forge.tokens.has(name)) forge.twins.push({ id: forge.nextId, name, sha1 });
else forge.tokens.set(name, sha1);
},
scopesOf: forge.scopesOf,
admins: forge.admins,
close: () => new Promise((r) => server.close(() => r())),
@@ -395,56 +335,6 @@ test("concurrent first calls share one mint", async () => {
assert.equal(forge.mints, before + 1);
});
test("two tokens hold the name (two mints raced): both are deleted by their id, and one fresh token is minted", async () => {
const { env, file, logs } = await delivered(forge);
forge.tokens.clear();
forge.twins.length = 0;
for (const other of ["laptop", "backup", "ci"]) forge.plant(other); // the account's other tokens: kept
forge.plant("mesh-tools");
forge.plant("mesh-tools"); // gitea refuses to delete either by name from here on
forge.deletedBy.length = 0;
const before = forge.mints;
const repos = await minted(env, logs).listRepos();
assert.equal(repos.length, 1);
assert.equal(forge.mints, before + 1);
assert.deepEqual(forge.deletedBy, ["id", "id"]);
assert.equal(forge.twins.length, 0);
assert.deepEqual([...forge.tokens.keys()].sort(), ["backup", "ci", "laptop", "mesh-tools"]);
assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n");
assert.ok(logs.some((l) => l.includes('holds 2 tokens named "mesh-tools"')), logs.join("\n"));
});
test("two processes on one kept file, both without a token, mint once: the second waits for the first and reuses it", async () => {
const { env, logs } = await delivered(forge);
forge.tokens.clear();
forge.twins.length = 0;
const before = forge.mints;
// Two sources over one state directory, as the tools bundle and the watcher are two processes.
const first = minted(env, logs);
const second = minted(env, logs);
await Promise.all([first.listRepos(), second.listRepos()]);
assert.equal(forge.mints, before + 1);
assert.equal(forge.twins.length, 0);
assert.ok(logs.some((l) => l.includes("another process minted it")), logs.join("\n"));
});
test("a lock left by a process that died mid-mint is taken over, not waited on for ever", async () => {
const { env, file, logs } = await delivered(forge);
forge.tokens.clear();
forge.twins.length = 0;
await mkdir(join(env.MESH_GITEA_STATE_DIR!), { recursive: true });
await writeFile(`${file}.lock`, `2147483646 ${Date.now()} gone\n`); // a pid no process holds
const before = forge.mints;
assert.equal((await minted(env, logs).listRepos()).length, 1);
assert.equal(forge.mints, before + 1);
await assert.rejects(stat(`${file}.lock`), /ENOENT/);
});
test("the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing", async () => {
const { env, file, logs } = await delivered(forge);
forge.admins.delete(ADMIN); // the forge's data came from a predecessor; mesh-admin was never created there
-332
View File
@@ -1,332 +0,0 @@
// The forge's tools act as an account of their own, not the site administrator (novox/hq ADR 0365 §2, issue 469):
// the token the tools and the watcher use is minted for `mesh-tools`, a plain account made by the module with the
// administrator's login and given write on the mesh's repositories through one team; the administrator's own
// token by that name goes; a token kept from before, the administrator's, is minted again for the account.
//
// The forge is a fake that keeps accounts, their tokens and the organisation's teams apart, and answers as gitea
// does: an administrator may mint for another account (reqSelfOrAdmin), anyone else only for itself; a protection
// edit needs the repository's administration, which neither a plain account nor its write team holds.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { mkdtemp, readFile, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MintedToken } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js";
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } from "../dist/tools-account.js";
import { repoCheckFacts } from "../dist/pulls.js";
const ADMIN = "mesh-admin";
const PASSWORD = "the-vault-minted-this";
interface Account { admin: boolean; password: string; active: boolean }
interface Token { id: number; name: string; sha1: string; user: string; scopes: string[] }
interface Team { id: number; name: string; units: any; members: Set<string> }
function fakeForge() {
const users = new Map<string, Account>([[ADMIN, { admin: true, password: PASSWORD, active: true }]]);
const tokens: Token[] = [];
const teams: Team[] = [];
const calls: string[] = [];
// What the forge does wrong when a test says so: a token mint or delete it fails, a team another process made
// between this one's look and its create (gitea answers the create 422).
const faults = { mint: false, deleteToken: false, teamRace: false };
let next = 0;
const json = (res: ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(body === null ? "" : JSON.stringify(body));
};
const read = (req: IncomingMessage): Promise<any> =>
new Promise((resolve) => {
let t = "";
req.on("data", (c) => (t += c));
req.on("end", () => resolve(t ? JSON.parse(t) : null));
});
const who = (req: IncomingMessage): string | null => {
const h = req.headers.authorization ?? "";
if (h.startsWith("Basic ")) {
const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":");
return users.get(u)?.password === p ? u : null;
}
if (h.startsWith("token ")) return tokens.find((t) => t.sha1 === h.slice(6))?.user ?? null;
return null;
};
const isAdmin = (u: string | null) => u !== null && users.get(u)?.admin === true;
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake");
const p = url.pathname.replace(/^\/api\/v1/, "");
const me = who(req);
calls.push(`${req.method} ${p} as ${me}`);
if (me === null) return json(res, 401, { message: "unauthorized" });
let m = p.match(/^\/users\/([^/]+)\/tokens(?:\/(\d+))?$/);
if (m) {
const owner = decodeURIComponent(m[1]);
if (!(req.headers.authorization ?? "").startsWith("Basic ")) return json(res, 401, { message: "basic auth required" });
if (owner !== me && !isAdmin(me)) return json(res, 403, { message: "reqSelfOrAdmin" });
if (!users.has(owner)) return json(res, 404, { message: "user does not exist" });
if (req.method === "POST") {
if (faults.mint) return json(res, 500, { message: "the forge is not well" });
const { name, scopes } = await read(req);
if (tokens.some((t) => t.user === owner && t.name === name)) return json(res, 400, { message: "token name has already been used" });
const t = { id: ++next, name, sha1: `sha-${owner}-${next}`, user: owner, scopes };
tokens.push(t);
return json(res, 201, { id: t.id, name, sha1: t.sha1, scopes });
}
if (req.method === "GET") {
const page = Number(url.searchParams.get("page") ?? "1");
const mine = tokens.filter((t) => t.user === owner);
return json(res, 200, page === 1 ? mine.map((t) => ({ id: t.id, name: t.name, sha1: "" })) : []);
}
if (req.method === "DELETE" && m[2]) {
if (faults.deleteToken && owner === ADMIN) return json(res, 500, { message: "the forge is not well" });
const i = tokens.findIndex((t) => t.user === owner && t.id === Number(m![2]));
if (i < 0) return json(res, 404, { message: "not found" });
tokens.splice(i, 1);
return json(res, 204, null);
}
}
if (req.method === "GET" && p === "/user") return json(res, 200, { login: me, is_admin: isAdmin(me) });
m = p.match(/^\/users\/([^/]+)$/);
if (m && req.method === "GET") {
const u = users.get(decodeURIComponent(m[1]));
if (!u) return json(res, 404, { message: "user does not exist" });
return json(res, 200, { login: m[1], is_admin: u.admin, active: u.active, prohibit_login: false });
}
if (p.startsWith("/admin/") && !isAdmin(me)) return json(res, 403, { message: "must be a site administrator" });
if (req.method === "POST" && p === "/admin/users") {
const b = await read(req);
if (users.has(b.username)) return json(res, 422, { message: "user already exists" });
if (!b.password || b.password.length < 8) return json(res, 422, { message: "password is too short" });
users.set(b.username, { admin: b.admin === true, password: b.password, active: true });
return json(res, 201, { login: b.username, is_admin: b.admin === true });
}
m = p.match(/^\/admin\/users\/([^/]+)$/);
if (m && req.method === "PATCH") {
const u = users.get(decodeURIComponent(m[1]));
if (!u) return json(res, 404, { message: "user does not exist" });
const b = await read(req);
if (b.admin !== undefined) u.admin = b.admin;
if (b.active !== undefined) u.active = b.active;
return json(res, 200, { login: m[1], is_admin: u.admin });
}
if (p === "/orgs/novox/teams") {
if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" });
if (req.method === "GET") return json(res, 200, teams.map((t) => ({ id: t.id, name: t.name })));
const b = await read(req);
const t = { id: ++next, name: b.name, units: b, members: new Set<string>() };
teams.push(t);
if (faults.teamRace) return json(res, 422, { message: "team already exists" });
return json(res, 201, { id: t.id, name: t.name });
}
m = p.match(/^\/teams\/(\d+)(?:\/members\/([^/]+))?$/);
if (m) {
if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" });
const t = teams.find((x) => x.id === Number(m![1]));
if (!t) return json(res, 404, { message: "team does not exist" });
if (req.method === "PATCH") { t.units = await read(req); return json(res, 200, { id: t.id }); }
if (req.method === "PUT" && m[2]) { t.members.add(decodeURIComponent(m[2])); return json(res, 204, null); }
}
// Repository administration: a site administrator, or a team member whose team administers the repository.
m = p.match(/^\/repos\/novox\/([^/]+)\/branches\/(.+)$/);
if (m && req.method === "GET") {
if (m[2] === "main") return json(res, 200, { name: "main", protected: true, effective_branch_protection_name: "main",
enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], user_can_push: false });
if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false });
return json(res, 404, { message: "branch does not exist" });
}
// gitea's reqAdmin on the whole group: reading the rules is administration too.
m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/.*)?$/);
if (m) {
const admins = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.permission === "admin");
if (!admins) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
return json(res, 201, { rule_name: "main" });
}
m = p.match(/^\/repos\/novox\/([^/]+)\/statuses\/([0-9a-f]+)$/);
if (m && req.method === "POST") {
const writes = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.units_map?.["repo.code"] === "write");
if (!writes) return json(res, 403, { message: "write on code required" });
return json(res, 201, { context: (await read(req)).context, creator: { login: me } });
}
if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] });
return json(res, 404, { message: "no such route in the fake" });
});
return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[];
faults: typeof faults; close(): Promise<void> }>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as { port: number };
resolve({ url: `http://127.0.0.1:${port}`, users, tokens, teams, calls, faults,
close: () => new Promise((r) => server.close(() => r())) });
});
});
}
const forge = await fakeForge();
after(() => forge.close());
async function environment(): Promise<NodeJS.ProcessEnv & { MESH_GITEA_STATE_DIR: string }> {
const dir = await mkdtemp(join(tmpdir(), "gitea-tools-account-"));
const passwordFile = join(dir, "admin.secret");
await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 });
return {
MESH_GITEA_URL: forge.url,
MESH_GITEA_ADMIN_USER: ADMIN,
MESH_GITEA_ADMIN_PASSWORD_FILE: passwordFile,
MESH_GITEA_STATE_DIR: join(dir, "state"),
};
}
/** A source as a fresh process builds it from the manifest's environment (fromEnv shares one per file). */
function fresh(env: NodeJS.ProcessEnv): GiteaClient {
return GiteaClient.fromEnv(env);
}
test("the tools' token is minted for an account of their own, which is no administrator and writes through its team", async () => {
const env = await environment();
await fresh(env).listRepos();
const account = forge.users.get(TOOLS_ACCOUNT);
assert.ok(account, "the module made the tools' account");
assert.equal(account!.admin, false, "the tools' account is no administrator");
const held = forge.tokens.filter((t) => t.name === "mesh-tools");
assert.deepEqual(held.map((t) => t.user), [TOOLS_ACCOUNT], "the one token is the tools' account's, none is the administrator's");
assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8"), held[0].sha1 + "\n");
const team = forge.teams.find((t) => t.name === "mesh-tools");
assert.ok(team, "the team is made in the mesh's organisation");
assert.ok(team!.members.has(TOOLS_ACCOUNT));
assert.equal(team!.units.permission, "write");
assert.equal(team!.units.includes_all_repositories, true);
assert.equal(team!.units.can_create_org_repo, false, "a repository's maker would administer it");
assert.deepEqual(team!.units.units_map, TOOLS_TEAM_UNITS.units_map);
assert.ok(!Object.values(team!.units.units_map).includes("admin"));
});
test("with the tools' token, the forge writes a status and refuses a protection edit — the forge itself, not a guard", async () => {
const env = await environment();
const client = fresh(env);
await client.setCommitStatus("novox", "hq", "abc123def4567890abc123def4567890abc12345", { state: "success", context: "mesh/merge-gate", description: "ok" } as any);
await assert.rejects(
client.setBranchProtection("novox", "hq", "main", { statusChecks: ["mesh/merge-gate"] }),
/403/,
);
});
test("what a base's protection asks is read from the branch, which the tools' account may read, not from the rules' list it may not", async () => {
const env = await environment();
const client = fresh(env);
await assert.rejects(client.branchProtections("novox", "hq"), /403/);
const rule = await client.appliedProtection("novox", "hq", "main");
assert.deepEqual(rule?.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]);
assert.equal(rule?.enable_status_check, true);
assert.equal(rule?.rule_name, "main");
assert.equal(await client.appliedProtection("novox", "hq", "feat/unguarded"), null);
assert.equal(await client.appliedProtection("novox", "hq", "no/such-branch"), null);
});
test("the administrator's token from before goes: a kept admin token is minted again for the tools' account, and the forge drops the old one", async () => {
const env = await environment();
// What the module kept before ADR 0365: a token of the administrator's own, by the same name, its scopes beside it.
const old = { id: 900, name: "mesh-tools", sha1: "sha-admin-old", user: ADMIN, scopes: ["write:repository", "write:issue", "read:user"] };
forge.tokens.push(old);
await mkdir(env.MESH_GITEA_STATE_DIR, { recursive: true });
await writeFile(join(env.MESH_GITEA_STATE_DIR, "token"), old.sha1 + "\n", { mode: 0o600 });
await writeFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "read:user,write:issue,write:repository\n", { mode: 0o600 });
await fresh(env).listRepos();
assert.ok(!forge.tokens.some((t) => t.user === ADMIN), "the administrator holds no tools' token any more");
const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
assert.notEqual(kept, old.sha1);
assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT);
assert.match(await readFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "utf8"), / for mesh-tools\n$/);
});
test("a tools' account someone made an administrator is made a plain account again at the next mint", async () => {
const env = await environment();
forge.users.get(TOOLS_ACCOUNT)!.admin = true;
await fresh(env).listRepos();
assert.equal(forge.users.get(TOOLS_ACCOUNT)!.admin, false);
});
test("the tools never act as the administrator: MESH_GITEA_TOOLS_USER naming it is refused", async () => {
const env = await environment();
assert.throws(() => MintedToken.fromEnv(forge.url, { ...env, MESH_GITEA_TOOLS_USER: ADMIN }), /names the admin account/);
});
test("the manifest hands the tools their account, and the merge-ready authors name it rather than the administrator", async () => {
const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8"));
const code = manifest.build.artifacts.find((a: any) => a.name === "code");
assert.equal(code.env.MESH_GITEA_TOOLS_USER, TOOLS_ACCOUNT);
const authors = String(manifest.settings["ready-authors"].default).split(/\s+/);
assert.ok(authors.includes(TOOLS_ACCOUNT));
// Kept for now (review of issue 469): a pull request the admin account marked ready before the tools acted as
// their own keeps counting as ready after the delivery.
assert.ok(authors.includes(ADMIN), "marks made by the admin account before ADR 0365 keep counting");
// The Go forge bundle reads the token the tools' account holds, never the administrator's password.
const forgeBundle = manifest.build.artifacts.find((a: any) => a.name === "forge");
assert.ok(forgeBundle.env.MESH_GITEA_TOKEN_FILE);
assert.equal(forgeBundle.env.MESH_GITEA_ADMIN_PASSWORD_FILE, undefined);
});
// Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token.
test("a repository check said as a warning on a head without merge-check.sh reads the base's protection from the branch", async () => {
const client = fresh(await environment());
const forgeOf = { holdsFile: async () => false, appliedProtection: client.appliedProtection.bind(client) };
const c = { owner: "novox", repo: "hq", commit: "abc123def4567890abc123def4567890abc12345", "repo-check": { verdict: "warning" } } as any;
// main requires mesh/repo-check: required, so a head that defines no check fails it for a person to see.
assert.deepEqual(await repoCheckFacts(forgeOf, c, "main"), { defined: false, required: true });
// A base nothing guards: not required, so the head's repository check passes rather than fails.
assert.deepEqual(await repoCheckFacts(forgeOf, c, "feat/unguarded"), { defined: false, required: false });
});
test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => {
const env = await environment();
const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] };
forge.tokens.push(old);
forge.faults.mint = true;
try {
await assert.rejects(fresh(env).listRepos(), /500/);
} finally {
forge.faults.mint = false;
}
assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's token was deleted before a new one was kept");
forge.tokens.splice(forge.tokens.indexOf(old), 1);
});
test("an old token that cannot be deleted after the mint is said, the new token kept, and deleted at the next start", async () => {
const env = await environment();
const old = { id: 902, name: "mesh-tools", sha1: "sha-admin-stuck", user: ADMIN, scopes: ["read:user"] };
forge.tokens.push(old);
forge.faults.deleteToken = true;
try {
await fresh(env).listRepos();
} finally {
forge.faults.deleteToken = false;
}
const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT, "the account's token is kept and works");
assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1));
// The next process reuses the kept token and retires the old one it could not before.
const next = new MintedToken({ url: forge.url, admin: ADMIN, passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!,
file: join(env.MESH_GITEA_STATE_DIR, "token"), account: TOOLS_ACCOUNT, log: () => {} });
assert.equal(await next.current(), kept);
assert.ok(!forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's old token was deleted at the next start");
});
test("a team another process made between the look and the create is found, not a failure", async () => {
forge.teams.splice(0);
forge.faults.teamRace = true;
try {
await fresh(await environment()).listRepos();
} finally {
forge.faults.teamRace = false;
}
const team = forge.teams.find((t) => t.name === "mesh-tools");
assert.ok(team?.members.has(TOOLS_ACCOUNT));
});
-26
View File
@@ -173,29 +173,3 @@ test("a verdict that landed out of date keeps its account on the pull request",
const bare = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "pass" }, null, words);
assert.match(bare, /head dfb1415aaaaa merged into trunk a2c2c35bbbbb, pass/);
});
test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => {
// novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR
// 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release
// branch's move puts its open verdicts out of date, and leaves those into main alone.
const release = "feat/57-config-and-provisioning";
const heads: Record<string, string> = { main: TRUNK, [release]: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" };
const pulls = [{ number: 315, base: release, head: "3".repeat(40) }, { number: 1, base: "main", head: "1".repeat(40) }];
const { forge, statuses } = aForge(pulls, heads);
const gates = new GateWriter(forge);
const trunks: Record<string, string> = {};
await weighTrunks(forge, gates, trunks, "novox", "mesh-controller");
heads[release] = MOVED;
assert.equal(await weighTrunks(forge, gates, trunks, "novox", "mesh-controller"), true);
const s = statuses.get("3".repeat(40))!;
assert.equal(s["mesh/merge-gate"].state, "pending");
assert.match(s["mesh/merge-gate"].description!, /judged against trunk eeeeeeeeeeee, the trunk is now d28ca95ccccc/);
assert.equal(s["mesh/repo-check"].state, "pending");
assert.equal(statuses.get("1".repeat(40))!["mesh/merge-gate"].state, "success", "a pull request into main is left");
assert.equal(trunks[`novox/mesh-controller:${release}`], MOVED);
// The merge tool's rule reads the same: a verdict judged against the release branch's old head is not current.
assert.equal(verdictAtTrunk({ "mesh/merge-gate": { state: "success",
description: "head x merged into trunk eeeeeeeeeeee — pass" } }, MOVED).current, false);
});
-335
View File
@@ -1,335 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createHmac } from "node:crypto";
import type { AddressInfo } from "node:net";
// **The forge says every event on the bus** (novox/hq issue 729): a signed delivery is published once and acted on,
// an unsigned or unverified one is refused, and an event and the poll seeing the same head announce it once.
const secret = "s".repeat(64);
const sign = (body: string, key = secret) => createHmac("sha256", key).update(body).digest("hex");
const opened = JSON.stringify({ action: "opened", number: 7, pull_request: { number: 7, title: "t", user: { login: "jo" },
head: { ref: "feat/x", sha: "a".repeat(40) }, base: { ref: "main" }, merged: false },
repository: { name: "hq", full_name: "novox/hq", owner: { login: "novox" } }, sender: { login: "jo" } });
async function serve(published: { name: string }[], acted: { name: string }[]) {
const { receiver, HOOK_PATH } = await import("../webhook.ts");
const server = receiver({ secret, port: 0, log: () => {},
publish: async (e) => { published.push(e); }, act: async (e) => { acted.push(e); } });
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}${HOOK_PATH}`;
return { server, url };
}
test("a signed event is published on the bus and acted on once", async () => {
const published: { name: string }[] = [], acted: { name: string }[] = [];
const { server, url } = await serve(published, acted);
try {
const headers = { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened), "X-Gitea-Delivery": "d-1" };
const res = await fetch(url, { method: "POST", body: opened, headers });
assert.equal(res.status, 200);
// The same delivery sent again is answered and published nowhere.
const again = await fetch(url, { method: "POST", body: opened, headers });
assert.equal(again.status, 200);
await server.idle();
assert.deepEqual(published.map((e) => e.name), ["forge.pull-request.opened"]);
assert.deepEqual(acted.map((e) => e.name), ["forge.pull-request.opened"]);
} finally { server.close(); }
});
test("an unsigned event, or one whose signature does not verify, is refused and published nowhere", async () => {
const published: { name: string }[] = [], acted: { name: string }[] = [];
const { server, url } = await serve(published, acted);
try {
const unsigned = await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request" } });
assert.equal(unsigned.status, 401);
assert.match(await unsigned.text(), /unsigned/);
const forged = await fetch(url, { method: "POST", body: opened,
headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened, "another secret") } });
assert.equal(forged.status, 401);
assert.match(await forged.text(), /does not verify/);
const big = "x".repeat(5 * 1024 * 1024 + 1);
const large = await fetch(url, { method: "POST", body: big,
headers: { "X-Gitea-Event": "push", "X-Gitea-Signature": sign(big) } });
assert.equal(large.status, 413);
await server.idle();
assert.deepEqual(published, []);
assert.deepEqual(acted, []);
} finally { server.close(); }
});
test("an event and the poll seeing the same head announce it once", async () => {
const { claimHead } = await import("../webhook.ts");
const heads: Record<string, string> = {};
const sha = "a".repeat(40);
assert.equal(claimHead(heads, "novox/hq#7", sha), true, "the event claims the head and announces it");
assert.equal(claimHead(heads, "novox/hq#7", sha), false, "the poll finds it taken");
assert.equal(claimHead(heads, "novox/hq#7", "b".repeat(40)), true, "a new head is announced again");
});
test("each forge event is named in the module's stream and trimmed", async () => {
const { eventOf } = await import("../webhook.ts");
const merged = eventOf("pull_request", { action: "closed", pull_request: { number: 3, merged: true, merge_commit_sha: "c".repeat(40),
head: { sha: "a".repeat(40) }, base: { ref: "main" } }, repository: { name: "hq", owner: { login: "novox" } } });
assert.equal(merged.name, "forge.pull-request.merged");
assert.equal(merged.body.merge_commit_sha, "c".repeat(40));
assert.equal(eventOf("pull_request", { action: "label_updated", pull_request: { number: 3 }, label: { name: "mesh/ready" } }).name,
"forge.pull-request.labeled");
const comment = eventOf("issue_comment", { action: "created", is_pull: true, issue: { number: 3 },
comment: { id: 9, body: "ready at abc", user: { login: "jo" } } });
assert.equal(comment.name, "forge.pull-request.commented");
assert.equal(comment.body.comment, "ready at abc");
assert.equal(eventOf("push", { ref: "refs/heads/main", after: "d".repeat(40) }).name, "forge.push");
assert.equal(eventOf("pull_request_review_approved", {}).name, "forge.other");
});
// A forge's admin hooks as 1.27.3 keeps them: POST /admin/hooks makes a system webhook only when config.is_system_webhook
// is "true", and a default webhook otherwise; the listing names one kind at a time (type=system, the default, or default).
// Repositories hold their own webhooks (a default webhook's copies), listed a page at a time.
function fakeForge(start: { id: number; url: string; system: boolean }[] = [],
repoHooks: Record<string, { id: number; url: string }[]> = {}, repoCount = 0) {
const hooks = start.map((h) => ({ ...h, events: ["push"], active: true }));
const repos = [...Object.keys(repoHooks)];
for (let i = 0; repos.length < repoCount; i++) repos.push(`novox/r${i}`);
let next = 100;
const calls: string[] = [];
const made: any[] = [];
const page = (items: any[], q: URLSearchParams) => {
const p = Number(q.get("page") ?? 1), l = Number(q.get("limit") ?? 50);
return items.slice((p - 1) * l, p * l);
};
const admin = { call: async (method: string, path: string, body?: any) => {
calls.push(`${method} ${path}`);
const u = new URL(path, "http://f");
const repoHook = u.pathname.match(/^\/repos\/([^/]+\/[^/]+)\/hooks(?:\/(\d+))?$/);
if (method === "GET" && u.pathname === "/repos/search") {
return { status: 200, body: { ok: true, data: page(repos.map((full_name) => ({ full_name })), u.searchParams) } };
}
if (repoHook) {
const list = repoHooks[repoHook[1]] ?? [];
if (method === "GET") return { status: 200, body: page(list.map((h) => ({ id: h.id, config: { url: h.url } })), u.searchParams) };
const i = list.findIndex((h) => h.id === Number(repoHook[2]));
if (i < 0) return { status: 404, body: {} };
list.splice(i, 1);
return { status: 204, body: null };
}
if (method === "GET") {
const type = u.searchParams.get("type") ?? "system";
const listed = hooks.filter((h) => type === "all" || h.system === (type === "system"));
return { status: 200, body: listed.map((h) => ({ id: h.id, active: h.active, events: h.events,
config: { url: h.url, content_type: "json" } })) };
}
if (method === "POST") {
made.push(body);
hooks.push({ id: next++, url: body.config.url, system: body.config.is_system_webhook === "true", events: body.events,
active: body.active });
return { status: 201, body: {} };
}
if (method === "DELETE") {
const id = Number(path.split("/").pop());
const i = hooks.findIndex((h) => h.id === id);
if (i < 0) return { status: 404, body: {} };
hooks.splice(i, 1);
return { status: 204, body: null };
}
return { status: 405, body: {} };
} };
return { admin, hooks, calls, made, repoHooks };
}
test("the webhook is made a system webhook, which the forge sends every repository's events to", async () => {
const { ensureSystemHook } = await import("../webhook.ts");
const forge = fakeForge();
const url = "http://h:3047/forge-event";
const hook = await ensureSystemHook(forge.admin, url, secret);
assert.equal(forge.made.length, 1);
assert.equal(forge.made[0].config.is_system_webhook, "true", "without it the forge makes a default webhook, which fires for no repository");
assert.equal(forge.made[0].config.secret, secret);
assert.ok(forge.made[0].events.includes("pull_request") && forge.made[0].events.includes("push"));
assert.deepEqual(forge.hooks.map((h) => h.system), [true]);
assert.equal(hook.kind, "system");
assert.equal(hook.did, "created");
assert.equal(hook.url, url);
assert.ok(hook.events.includes("pull_request"));
});
test("default webhooks left at the receiver by an earlier start are replaced by one system webhook", async () => {
const { ensureSystemHook } = await import("../webhook.ts");
const url = "http://h:3047/forge-event";
// What the start at 23:05 left: a default webhook, invisible to the system listing, made again by every start.
const forge = fakeForge([{ id: 4, url, system: false }, { id: 5, url, system: false }, { id: 6, url: "http://other/x", system: true }]);
const hook = await ensureSystemHook(forge.admin, url, secret);
assert.deepEqual(hook.removed, { system: 0, default: 2, repository: 0, repositories: [] });
assert.equal(hook.did, "replaced");
assert.deepEqual(forge.hooks.map((h) => [h.url, h.system]), [["http://other/x", true], [url, true]], "another url is kept");
// A second start replaces the system webhook, so the secret kept now is the one the forge signs with (its edit never
// changes a secret).
const again = await ensureSystemHook(forge.admin, url, secret);
assert.deepEqual(again.removed, { system: 1, default: 0, repository: 0, repositories: [] });
assert.equal(forge.hooks.filter((h) => h.url === url).length, 1);
assert.ok(forge.calls.includes("DELETE /admin/hooks/4") && forge.calls.includes("DELETE /admin/hooks/5"));
// The new one is made before any old one is deleted, so no event falls in a gap.
const post = forge.calls.indexOf("POST /admin/hooks");
assert.ok(post >= 0 && forge.calls.findIndex((c) => c.startsWith("DELETE")) > post);
});
test("a repository's copy of a default webhook at the receiver is deleted, on every page, and said", async () => {
const { ensureSystemHook } = await import("../webhook.ts");
const url = "http://h:3047/forge-event";
// A repository made after the default webhook got its copy, which would send its events a second time; 120
// repositories, so the copies sit beyond the first page.
const repoHooks: Record<string, { id: number; url: string }[]> = {};
for (let i = 0; i < 118; i++) repoHooks[`novox/r${i}`] = [];
repoHooks["novox/late"] = [{ id: 31, url }, { id: 32, url: "http://ci/hook" }];
repoHooks["novox/later"] = [{ id: 33, url }];
const forge = fakeForge([{ id: 4, url, system: false }], repoHooks);
const hook = await ensureSystemHook(forge.admin, url, secret);
assert.deepEqual(hook.removed, { system: 0, default: 1, repository: 2, repositories: ["novox/late", "novox/later"] });
assert.deepEqual(forge.repoHooks["novox/late"].map((h) => h.id), [32], "a repository's other webhook is kept");
assert.deepEqual(forge.repoHooks["novox/later"], []);
assert.ok(forge.calls.some((c) => /\/repos\/search\?.*page=3/.test(c)), "every page of repositories is read");
});
test("a forge that makes no system webhook is said, never taken as heard", async () => {
const { ensureSystemHook } = await import("../webhook.ts");
const forge = fakeForge();
const admin = { call: async (method: string, path: string, body?: any) => {
// A forge that ignores the flag: what 23:05 met, had the module read back.
if (method === "POST") body = { ...body, config: { ...body.config, is_system_webhook: undefined } };
return forge.admin.call(method, path, body);
} };
await assert.rejects(ensureSystemHook(admin, "http://h:3047/forge-event", secret), /names 0 new system webhooks/);
});
test("the receiver tells what it verified and what it refused", async () => {
const { receiver, HOOK_PATH } = await import("../webhook.ts");
const heard: string[] = [], refused: string[] = [];
const server = receiver({ secret, port: 0, log: () => {}, publish: async () => {},
heard: (kind, delivery) => heard.push(`${kind} ${delivery}`), refused: (reason) => refused.push(reason) });
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}${HOOK_PATH}`;
try {
await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened), "X-Gitea-Delivery": "d-9" } });
await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened, "x") } });
await server.idle();
assert.deepEqual(heard, ["pull_request d-9"]);
assert.deepEqual(refused, ["its signature does not verify"]);
} finally { server.close(); }
});
test("a forge that never delivers is said loudly once, and unhealthy, when the poll finds what no event said", async () => {
const { Pace } = await import("../webhook.ts");
let now = 1_000_000;
const said: string[] = [];
const pace = new Pace(() => now, (l) => said.push(l));
const hook = { kind: "system" as const, id: 7, url: "http://h:3047/forge-event", events: ["push"], active: true,
did: "created" as const, removed: { system: 0, default: 0, repository: 0, repositories: [] } };
pace.listening("172.17.0.1", 3047, hook.url, hook);
now += 5 * 60_000;
pace.miss("novox/hq#7 at aaaaaaaa");
assert.equal(said.length, 0, "five minutes after the start is not yet silence");
assert.equal((pace.report() as any).healthy, true);
pace.refused("it is unsigned", "172.17.0.3");
assert.equal((pace.report() as any).healthy, false, "refused, and none verified");
now += 6 * 60_000;
pace.miss("the merge of novox/hq#7");
pace.miss("novox/hq#8 at bbbbbbbb");
assert.equal(said.filter((l) => /NOT HEARD: no delivery ever verified/.test(l)).length, 1, "said once");
let r = pace.report() as any;
assert.equal(r.healthy, false);
assert.equal(r.state, "unhealthy");
assert.match(r.why, /never delivered/);
assert.equal(r.polls.found_unsaid, 3);
assert.equal(r.refused.count, 1);
assert.equal(r.refused.last.reason, "it is unsigned");
assert.equal(r.hook.kind, "system");
assert.equal(r.receiver.port, 3047);
// The first delivery clears it.
now += 1000;
pace.heard("push", "d-1");
r = pace.report() as any;
assert.equal(r.healthy, true);
assert.equal(r.verified.count, 1);
assert.equal(r.verified.last.event, "push");
assert.equal(r.polls.every_seconds, 300);
assert.match(said.join("\n"), /heard again/);
});
test("a forge whose events stop after one delivery is said loudly once per silent spell, and refusals are unhealthy", async () => {
const { Pace } = await import("../webhook.ts");
let now = 1_000_000;
const said: string[] = [];
const pace = new Pace(() => now, (l) => said.push(l));
const hook = { kind: "system" as const, id: 7, url: "http://h:3047/forge-event", events: ["push"], active: true,
did: "created" as const, removed: { system: 0, default: 0, repository: 0, repositories: [] } };
pace.listening("172.17.0.1", 3047, hook.url, hook);
now += 60_000;
pace.heard("push", "d-1");
// The hook is deleted by hand: nothing more comes, and the poll finds what no event said.
now += 11 * 60_000;
pace.miss("novox/hq#7 at aaaaaaaa");
assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 0, "one find alone may be the poll beating an event");
pace.miss("the merge of novox/hq#7");
pace.miss("novox/hq#8 at bbbbbbbb");
assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 1, "said once");
assert.match(said.find((l) => /NOT HEARD/.test(l)) ?? "", /since the last at/);
assert.equal((pace.report() as any).healthy, false);
// A delivery ends the spell; the next spell is said again.
now += 1000;
pace.heard("push", "d-2");
assert.equal((pace.report() as any).healthy, true);
now += 11 * 60_000;
pace.miss("novox/hq#9 at cccccccc");
pace.miss("novox/hq#10 at dddddddd");
assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 2, "a second silent spell is said again");
// Every delivery refused after a verified one (a secret that no longer matches) is unhealthy at once.
now += 1000;
pace.heard("push", "d-3");
now += 1000;
pace.refused("its signature does not verify", "172.17.0.3");
pace.refused("its signature does not verify", "172.17.0.3");
const r = pace.report() as any;
assert.equal(r.healthy, false);
assert.equal(r.refused.since_verified, 2);
assert.match(r.why, /2 deliveries refused since the last verified one.*does not verify/);
now += 1000;
pace.heard("push", "d-4");
assert.equal((pace.report() as any).healthy, true, "a verified delivery clears it");
});
test("without a receiver by configuration the polls alone watch, healthy; a receiver that failed is unhealthy", async () => {
const { Pace } = await import("../webhook.ts");
let now = 1_000_000;
const said: string[] = [];
const quiet = new Pace(() => now, (l) => said.push(l));
quiet.unheard("MESH_GITEA_HOOK_URL unset", false);
now += 20 * 60_000;
quiet.miss("novox/hq#7 at aaaaaaaa");
assert.equal(said.length, 0, "no deliveries were expected");
assert.equal((quiet.report() as any).healthy, true);
const failed = new Pace(() => now, () => {});
failed.unheard("the forge's system webhook could not be made: 403");
const r = failed.report() as any;
assert.equal(r.healthy, false);
assert.match(r.why, /403/);
});
test("the polls look every 30 s until events are heard, every five minutes while they are, and 30 s again on a miss", async () => {
const { Pace } = await import("../webhook.ts");
let now = 1_000_000;
const said: string[] = [];
const pace = new Pace(() => now, (l) => said.push(l));
assert.equal(pace.every(), 30_000, "no delivery yet");
pace.heard();
assert.equal(pace.every(), 300_000, "a delivery heard");
now += 11 * 60_000;
assert.equal(pace.every(), 30_000, "ten minutes without a delivery");
pace.heard();
now += 1000;
pace.miss("novox/hq#7 at aaaaaaaa");
assert.equal(pace.every(), 30_000, "the poll found what no event said");
assert.match(said.join("\n"), /MISSED novox\/hq#7/);
now += 1000;
pace.heard();
assert.equal(pace.every(), 300_000, "the next delivery");
});
+30 -216
View File
@@ -12,20 +12,7 @@
// hands a token's value out exactly once, so a token not kept is a token lost;
// - re-minted when the forge rejects it (401) or the kept file is gone. The one case that is not
// a fault: the forge's data was restored from a predecessor and the token the file names never
// existed there;
// - minted by one process at a time. The runtime starts each of the bundle's entrypoints as a process
// of its own (novox/hq ADR 0193): the watcher (index.js) and the tools (tools/index.js) are two
// processes over one kept file. Two that minted at once left the forge two tokens by one name, which
// gitea then refuses to delete by name — for ever, and every forge tool with it (the 2026-10-14
// outage). So a mint holds a lock file beside the kept token, and reads the kept file again once it
// holds it: what another process minted meanwhile is reused, not minted over;
// - replaced by id: an old token by this name is found in the account's list and deleted by its id,
// every one of them, so a name held twice is cleared rather than refused.
//
// - minted for the tools' account, not the administrator (novox/hq ADR 0365 §2, issue 469): the administrator's
// login mints it for `mesh-tools` (tools-account.ts), after making that account and its team, and deletes any
// token by this name the administrator itself still holds — the token the tools used before. A token kept
// for another account than the one asked (the scopes line says whose) is minted again at the next start.
// existed there.
//
// An explicitly configured token still wins, and is never minted over: if it is rejected, that is
// reported, not repaired — somebody chose it.
@@ -33,10 +20,8 @@
// The token is never logged. Lines say that one was minted, reused or renewed, and where it is
// kept; never what it is.
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { setUpToolsAccount, TOOLS_ACCOUNT, type AdminCall } from "./tools-account.js";
/** The name the token carries in the forge's own list — one per mesh runtime, found by name. */
export const TOKEN_NAME = "mesh-tools";
@@ -54,7 +39,7 @@ export const TOKEN_NAME = "mesh-tools";
* `gitea_api` (novox/hq 229); whoever may call a tool includes agents, and the forge's administration is the
* operator's. The provisioner settles users with the admin account's own login (GiteaAdmin), never this token.
* A token kept with a different list is minted again with this one at the next start (the scopes are kept
* beside it), and the old one is deleted from the forge by its id — so the scope a kept token had does not outlive this.
* beside it), and the forge drops the old one by name — so the scope a kept token had does not outlive this.
*
* A token kept from before a scope was added lacks it: the forge answers such a call with
* `403 token does not have at least one of required scope(s)`, and the client treats that like a
@@ -110,14 +95,6 @@ export interface MintedTokenOptions {
readonly file: string;
readonly name?: string;
readonly scopes?: readonly string[];
/**
* The account the token is minted for: the tools' own (novox/hq ADR 0365 §2). The admin login mints it, after
* `setUp` made the account, and the admin's own tokens by this name are deleted. Left out, the token is the
* admin's own, as it was before.
*/
readonly account?: string;
/** Run with the admin's password before each mint for `account`: makes the account and its team (tools-account.ts). */
readonly setUp?: (password: string) => Promise<void>;
readonly log?: (line: string) => void;
readonly fetch?: typeof fetch;
}
@@ -129,14 +106,12 @@ export class MintedToken implements TokenSource {
private inflight: Promise<string> | null = null;
private readonly name: string;
private readonly scopes: readonly string[];
private readonly account: string;
private readonly log: (line: string) => void;
private readonly fetchImpl: typeof fetch;
constructor(private readonly opts: MintedTokenOptions) {
this.name = opts.name ?? TOKEN_NAME;
this.scopes = opts.scopes ?? TOKEN_SCOPES;
this.account = opts.account ?? opts.admin;
this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`));
this.fetchImpl = opts.fetch ?? fetch;
}
@@ -149,9 +124,9 @@ export class MintedToken implements TokenSource {
*/
static fromEnv(url: string, env: NodeJS.ProcessEnv = process.env): MintedToken {
const opts = MintedToken.optionsFromEnv(url, env);
// One source per kept file in a process: two sources in one process would each renew on a 401.
// This covers one process only. The watcher and the tools are two processes (the runtime starts
// each entrypoint as its own), and those are kept apart by the mint's lock file (mint, below).
// One source per kept file in a process. The watcher and the tools entrypoint both build a
// client in the same runtime; two sources over one file would each renew on a 401 and drop the
// other's token by name, forever. Shared, a renewal is one renewal.
const shared = MintedToken.shared.get(opts.file);
if (shared) return shared;
const source = new MintedToken(opts);
@@ -173,17 +148,7 @@ export class MintedToken implements TokenSource {
if (missing.length) {
throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`);
}
// The tools' own account, never the administrator (novox/hq ADR 0365 §2): MESH_GITEA_TOOLS_USER names it.
const account = env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT;
if (account === admin) throw new Error(`MESH_GITEA_TOOLS_USER names the admin account "${admin}"; the tools act as an account of their own (novox/hq ADR 0365)`);
return {
url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token"), account,
// GiteaAdmin is loaded when first used: client.ts imports this file.
setUp: async (password) => {
const { GiteaAdmin } = await import("./client.js");
await setUpToolsAccount(new GiteaAdmin(url, admin!, password), account, () => randomBytes(24).toString("base64url"));
},
};
return { url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token") };
}
async current(): Promise<string> {
@@ -194,13 +159,11 @@ export class MintedToken implements TokenSource {
if (kept !== null && this.keptScopes() === this.scopesLine()) {
this.held = kept;
this.log(`reusing the token kept at ${this.opts.file}`);
// A retirement of the admin's old token that failed after the mint is tried again, once a process.
if (existsSync(this.retireFile)) await this.locked(() => this.retireAdmin()).catch((err) => this.log(`${err}`));
return kept;
}
if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them", kept);
if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them");
}
return this.mint("no token kept — minting one", null);
return this.mint("no token kept — minting one");
}
async renew(rejected: string): Promise<string> {
@@ -214,7 +177,7 @@ export class MintedToken implements TokenSource {
return kept;
}
this.held = null;
return this.mint("the forge rejected the kept token — minting a fresh one", rejected);
return this.mint("the forge rejected the kept token — minting a fresh one");
}
/** What the forge's scoped tokens say when a kept token predates a scope the tools now need. */
@@ -222,95 +185,17 @@ export class MintedToken implements TokenSource {
return status === 403 && /required scope/i.test(body);
}
/**
* One mint at a time: concurrent first calls in this process share it, and a mint in another process
* on this machine is waited for (the lock file). Once the lock is held, the kept file is read again:
* a token there that is not `stale` (the one rejected, or kept with other scopes) and carries the
* scopes asked for was minted by the process that held the lock before — it is reused.
*/
private mint(why: string, stale: string | null): Promise<string> {
/** One mint at a time: concurrent first calls share it, rather than each minting its own. */
private mint(why: string): Promise<string> {
if (this.inflight === null) {
this.log(why);
this.inflight = this.locked(async () => {
const kept = this.read();
if (kept !== null && kept !== stale && this.keptScopes() === this.scopesLine()) {
this.held = kept;
this.log(`another process minted it meanwhile — reusing the token kept at ${this.opts.file}`);
return kept;
}
return this.doMint();
}).finally(() => {
this.inflight = this.doMint().finally(() => {
this.inflight = null;
});
}
return this.inflight;
}
/** How long a mint may hold the lock before another process takes it as left behind, and how long one waits. */
static readonly LOCK_STALE_MS = 120_000;
static readonly LOCK_WAIT_MS = 150_000;
/**
* Run `work` holding the lock file beside the kept token: created exclusively, holding this process's
* pid, the time and a nonce; removed after, only when it is still ours. A lock whose process is gone,
* or older than LOCK_STALE_MS, was left by a process that died mid-mint and is taken over — never
* waited on for ever, which would be the same lock-out by another road.
*/
private async locked<T>(work: () => Promise<T>): Promise<T> {
const lock = `${this.opts.file}.lock`;
mkdirSync(dirname(this.opts.file), { recursive: true, mode: 0o700 });
const mine = `${process.pid} ${Date.now()} ${randomBytes(8).toString("hex")}\n`;
const deadline = Date.now() + MintedToken.LOCK_WAIT_MS;
for (;;) {
try {
const fd = openSync(lock, "wx", 0o600);
try { writeSync(fd, mine); } finally { closeSync(fd); }
break;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
throw new Error(`cannot take the mint lock at ${lock}: ${(err as Error).message}`);
}
}
if (this.leftBehind(lock)) {
this.log(`the mint lock at ${lock} was left by a process that is gone — taking it over`);
try { unlinkSync(lock); } catch { /* another waiter took it over first */ }
continue;
}
if (Date.now() > deadline) {
throw new Error(`another process has held the mint lock at ${lock} for over ${MintedToken.LOCK_WAIT_MS / 1000}s — not minting beside it`);
}
await new Promise((r) => setTimeout(r, 50 + Math.floor(Math.random() * 50)));
}
try {
return await work();
} finally {
try {
if (readFileSync(lock, "utf8") === mine) unlinkSync(lock);
} catch { /* already gone */ }
}
}
/** Whether the lock's holder is gone: its pid answers no signal, or it is older than LOCK_STALE_MS. */
private leftBehind(lock: string): boolean {
let text: string;
let age: number;
try {
text = readFileSync(lock, "utf8");
age = Date.now() - statSync(lock).mtimeMs;
} catch {
return false; // released meanwhile: the next attempt takes it
}
if (age > MintedToken.LOCK_STALE_MS) return true;
const pid = Number(text.split(" ")[0]);
if (!Number.isInteger(pid) || pid <= 0) return age > 1_000; // half-written, or not ours: give it a moment
try {
process.kill(pid, 0);
return false;
} catch (err) {
return (err as NodeJS.ErrnoException).code === "ESRCH";
}
}
private read(): string | null {
try {
const token = readFileSync(this.opts.file, "utf8").replace(/\n$/, "");
@@ -321,10 +206,9 @@ export class MintedToken implements TokenSource {
}
}
/** The scopes as kept beside the token: sorted, one line, and whose it is when it is not the admin's. */
/** The scopes as kept beside the token: sorted, one line. */
private scopesLine(): string {
const scopes = [...this.scopes].sort().join(",");
return this.account === this.opts.admin ? scopes : `${scopes} for ${this.account}`;
return [...this.scopes].sort().join(",");
}
/** The scopes the kept token was minted with, "" when not kept (a token kept before they were). */
@@ -347,20 +231,17 @@ export class MintedToken implements TokenSource {
renameSync(`${this.opts.file}.scopes.tmp`, `${this.opts.file}.scopes`);
}
/** One call to the forge's API with the admin login, the password read now. */
private password(): string {
private async doMint(): Promise<string> {
let password: string;
try {
return readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, "");
password = readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, "");
} catch (err) {
throw new Error(`cannot read the admin password at ${this.opts.passwordFile}: ${(err as Error).message}`);
}
}
private adminCall(password = this.password()): AdminCall {
const authorization = "Basic " + Buffer.from(`${this.opts.admin}:${password}`).toString("base64");
const api = `${this.opts.url.replace(/\/+$/, "")}/api/v1`;
const admin: AdminCall = async (method, path, body) => {
const res = await this.fetchImpl(api + path, {
const tokens = `${this.opts.url.replace(/\/+$/, "")}/api/v1/users/${encodeURIComponent(this.opts.admin)}/tokens`;
const call = async (method: string, path = "", body?: unknown): Promise<{ status: number; body: any }> => {
const res = await this.fetchImpl(tokens + path, {
method,
headers: { "Content-Type": "application/json", Authorization: authorization },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
@@ -372,97 +253,30 @@ export class MintedToken implements TokenSource {
}
return { status: res.status, body: parsed };
};
return admin;
}
/** Where a retirement of the admin's old token that failed is remembered, to be tried again (retireAdmin). */
private get retireFile(): string {
return `${this.opts.file}.retire-admin`;
}
/**
* The admin's own token by this name goes: what the tools used before must not outlive the move (novox/hq ADR
* 0365 §2). Run after the account's token is minted and kept, so a failure here never leaves the tools without
* a token: it is said, remembered beside the kept token, and tried again when a process next reads that token.
*/
private async retireAdmin(admin: AdminCall = this.adminCall()): Promise<void> {
if (this.account === this.opts.admin) return;
try {
const retired = await this.dropNamed(admin, `/users/${encodeURIComponent(this.opts.admin)}/tokens`, this.opts.admin);
if (retired > 0) this.log(`deleted ${retired} token(s) named "${this.name}" the admin account "${this.opts.admin}" held: the tools act as "${this.account}" now`);
rmSync(this.retireFile, { force: true });
} catch (err) {
writeFileSync(this.retireFile, `${new Date().toISOString()}\n`, { mode: 0o600 });
this.log(`FAILED to delete the token(s) named "${this.name}" the admin account "${this.opts.admin}" holds — tried again at the next start: ${(err as Error).message}`);
}
}
private async doMint(): Promise<string> {
const password = this.password();
const admin = this.adminCall(password);
const tokensOf = (account: string) => `/users/${encodeURIComponent(account)}/tokens`;
const call = (method: string, path = "", body?: unknown) => admin(method, tokensOf(this.account) + path, body);
if (this.account !== this.opts.admin) {
// The tools' account and its team first (novox/hq ADR 0365 §2): a failure here fails the mint, tried again
// at the next call.
const me = await admin("GET", "/user");
if (me.status === 401 || me.status === 403) throw new AdminRefused(this.opts.admin, me.status);
await this.opts.setUp?.(password);
}
let res = await call("POST", "", { name: this.name, scopes: this.scopes });
if (res.status === 401 || res.status === 403) throw new AdminRefused(this.opts.admin, res.status);
if (res.status === 400 || res.status === 422) {
// The forge still holds a token by this name whose value we no longer have — the kept file
// went while the forge's data stayed, or two processes once minted at once and left two. It is
// ours to replace: find every one by this name in the account's list and delete each by its id
// (gitea refuses a delete by a name held twice: "multiple matches for token name"), then mint.
await this.dropNamed(admin, tokensOf(this.account), this.account, true);
// went while the forge's data stayed. It is ours to replace: drop it by name and mint again.
this.log(`the forge already holds a token named "${this.name}" — replacing it`);
const dropped = await call("DELETE", `/${encodeURIComponent(this.name)}`);
if (dropped.status !== 204 && dropped.status !== 404) {
throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${this.name}: ${dropped.status} ${detail(dropped.body)}`);
}
res = await call("POST", "", { name: this.name, scopes: this.scopes });
}
if (res.status !== 201 && res.status !== 200) {
throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} ${detail(res.body)}`);
throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} ${detail(res.body)}`);
}
const token = typeof res.body?.sha1 === "string" ? res.body.sha1 : null;
if (!token) throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} but no token in the reply`);
if (!token) throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} but no token in the reply`);
this.keep(token);
this.held = token;
this.log(`minted a token for "${this.account}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
// Only now, with the account's own token kept, does the admin's old one go.
await this.retireAdmin(admin);
this.log(`minted a token for "${this.opts.admin}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
return token;
}
/**
* Delete every token by this name an account holds, each by its id (gitea refuses a delete by a name held twice:
* "multiple matches for token name"); answers how many. `replacing` says the line for a mint that found its name
* taken.
*/
private async dropNamed(admin: AdminCall, tokens: string, account: string, replacing = false): Promise<number> {
const held: number[] = [];
for (let page = 1; page <= 100; page++) {
const listed = await admin("GET", `${tokens}?page=${page}&limit=50`);
if (listed.status !== 200 || !Array.isArray(listed.body)) {
throw new Error(`Gitea GET /users/${account}/tokens: ${listed.status} ${detail(listed.body)}`);
}
if (listed.body.length === 0) break;
for (const t of listed.body) if (t?.name === this.name && typeof t.id === "number") held.push(t.id);
}
if (replacing) {
this.log(held.length === 1
? `the forge already holds a token named "${this.name}" — replacing it`
: `the forge holds ${held.length} tokens named "${this.name}" — deleting each by its id and minting one`);
}
for (const id of held) {
const dropped = await admin("DELETE", `${tokens}/${id}`);
if (dropped.status !== 204 && dropped.status !== 404) {
throw new Error(`Gitea DELETE /users/${account}/tokens/${id} (named ${this.name}): ${dropped.status} ${detail(dropped.body)}`);
}
}
return held.length;
}
}
function detail(body: unknown): string {
-52
View File
@@ -1,52 +0,0 @@
// The forge's tools act as an account of their own, not as the site administrator (novox/hq ADR 0365 §2,
// issue 469).
//
// The administrator's token reached everything the forge holds, so the tools' boundary rested on their own guards
// (guard.ts): a protection edit, say, the forge itself would have taken. Now the tools' token is minted for a plain
// account, `mesh-tools`, which is no administrator and writes to the mesh's repositories through one team of the
// mesh's organisation:
//
// - **the account**: made by the module with the administrator's login (that login sets the forge up and does
// nothing else), never an administrator — one found made an administrator is made a plain account again. Its
// password is random, sent once and kept nowhere: the account is reached only by the tokens the administrator
// mints for it (gitea lets an administrator mint for another account: `reqSelfOrAdmin`).
// - **the team**: `mesh-tools` in the organisation `novox`, on every repository of it, writing code (pushes to a
// branch, commit statuses, merges), issues (notes, comments, labels) and pull requests. No administration of a
// repository, so the forge refuses the account a protection edit, a repository's settings and its deletion;
// no repository made in the organisation, since its maker would be that repository's administrator.
//
// Every step is idempotent (GiteaAdmin's, client.ts), and run with the administrator's login before each mint of the tools' token, so a team
// or a membership lost behind the module's back is made again at the next mint.
/** The account the tools act as. */
export const TOOLS_ACCOUNT = "mesh-tools";
/** The organisation whose repositories are the mesh's, and the team through which the tools' account writes there. */
export const TOOLS_ORG = "novox";
export const TOOLS_TEAM = "mesh-tools";
/** What the team grants: write on code, issues and pull requests of every repository of the organisation, and no more. */
export const TOOLS_TEAM_UNITS = {
permission: "write",
units_map: { "repo.code": "write", "repo.issues": "write", "repo.pulls": "write" },
includes_all_repositories: true,
can_create_org_repo: false,
} as const;
/** One call to the forge's API with the administrator's login: a path under /api/v1, the status and body answered. */
export type AdminCall = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: any }>;
/** What setting the account up needs of the forge's admin API: GiteaAdmin's own (client.ts), shared with the
* package provisioner, so a create race (422) and a user made by hand are handled once. */
export interface ToolsForge {
ensureUser(username: string, password: string, email: string, plain?: boolean): Promise<void>;
ensureTeamWith(org: string, team: string, units: Record<string, unknown>): Promise<number>;
addUserToTeam(teamId: number, username: string): Promise<void>;
}
/** Make the tools' account, plain, a member of its team on every repository of the organisation. Its password is
* set afresh each time and kept nowhere: the account is reached only by the tokens the administrator mints. */
export async function setUpToolsAccount(forge: ToolsForge, account = TOOLS_ACCOUNT, password: () => string): Promise<void> {
await forge.ensureUser(account, password(), `${account}@noreply.localhost`, true);
const team = await forge.ensureTeamWith(TOOLS_ORG, TOOLS_TEAM, { name: TOOLS_TEAM, ...TOOLS_TEAM_UNITS });
await forge.addUserToTeam(team, account);
}
+12 -19
View File
@@ -10,7 +10,7 @@
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { emit } from "@novox/mesh-sdk/events";
import { GiteaAdmin, GiteaClient, readyAuthors, type BranchProtection } from "../client.js";
import { GiteaClient, type BranchProtection } from "../client.js";
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
import {
@@ -59,13 +59,13 @@ function namesChecked(tools: ToolDefinition[]): ToolDefinition[] {
}));
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin));
export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea));
}
// 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): ToolDefinition[] {
return [
// ---- Repositories ----
{
@@ -401,11 +401,8 @@ 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.
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;
if (!args.branch) return { rules: (await gitea.branchProtections(owner, repo)).map(summarised) };
const rule = await gitea.branchProtection(owner, repo, String(args.branch));
return { branch: String(args.branch), rule: rule ? summarised(rule) : null };
},
},
@@ -507,7 +504,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
base = base || "main";
let rule: BranchProtection | null = null, unreadable: string | undefined;
try {
rule = await gitea.appliedProtection(owner, repo, base);
rule = await gitea.branchProtection(owner, repo, base);
} catch (err) {
unreadable = err instanceof Error ? err.message : String(err);
}
@@ -517,9 +514,9 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
name: "gitea_open_pulls",
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438), and each pull request's labels and ready mark — the label mesh/ready and the head named by the newest comment \"ready at <full head sha>\" that a listed account (the setting ready-authors) wrote after the label was added and after the last push — which mesh-delivery merges on (issue 722). Reads only.",
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438). Reads only.",
input: {},
run: async () => ({ pulls: await gitea.openPulls(readyAuthors()) }),
run: async () => ({ pulls: await gitea.openPulls() }),
},
{
name: "gitea_contains",
@@ -592,12 +589,8 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
const { owner, repo, branch, newBranch } = judged.contents;
const found = await gitea.api<{ default_branch?: string }>(`/repos/${checkName("owner", owner)}/${checkName("name", repo)}`);
const defaultBranch = String(found?.default_branch ?? "main");
// The protection the forge applies to the branch written, read from the branch: the rules' own list is a
// repository administrator's, which the tools' account is not (novox/hq ADR 0365). A branch not made yet is
// left to the forge, which refuses a write that would make a branch a rule guards.
const target = newBranch ?? branch ?? defaultBranch;
const applied = target === defaultBranch ? null : await gitea.appliedProtection(owner, repo, target);
const refused = judgeContentsWrite(target, defaultBranch, applied ? [applied] : []);
const refused = judgeContentsWrite(newBranch ?? branch ?? defaultBranch, defaultBranch,
await gitea.branchProtections(owner, repo));
if (refused) throw new Refused(refused);
}
// The path judged is the path sent: normalised, with only the allow-listed query keys (guard.ts).
@@ -618,7 +611,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
// way, gitea contributes none rather than failing the whole runtime, and says why.
registerModuleTools("gitea", (env) => {
try {
return getGiteaTools(GiteaClient.fromEnv(env), () => GiteaAdmin.fromEnv(env));
return getGiteaTools(GiteaClient.fromEnv(env));
} catch (err) {
console.log(`[gitea] no tools — ${err instanceof Error ? err.message : String(err)}`);
return [];
-519
View File
@@ -1,519 +0,0 @@
// **The forge says every event on the bus** (novox/hq issue 729; to-be 10: an event makes it fast, nothing makes it
// necessary). The module keeps one system webhook on its forge for every event, pointing at its own receiver, signed
// with a secret it mints and keeps beside its token; the receiver refuses whatever is unsigned or does not verify, and
// publishes each event, trimmed, on the module's events as forge.<kind>.<action> (forge.pull-request.opened, forge.push, …),
// on the shared EVENTS path its other events take; its own stream (ADR 0363) is a follow-up once issue 725 ships.
// The polls stay as the safety net, slowed; the announced-heads and announced-merges records keep anything from
// being announced twice, whichever path saw it first.
//
// The secret is never logged: lines say it was minted or reused and where it is kept, never what it is.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { createServer, type Server } from "node:http";
import { dirname } from "node:path";
/** The name the receiver answers on, and the port it listens on unless the manifest says otherwise. */
export const HOOK_PATH = "/forge-event";
export const HOOK_PORT = 3047;
/** The secret the forge signs with: read from its 0600 file, or minted and kept there. */
export function keptSecret(file: string, log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)): string {
try {
const kept = readFileSync(file, "utf8").trim();
if (kept.length >= 32) {
log(`reusing the webhook secret kept at ${file}`);
return kept;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
}
const secret = randomBytes(32).toString("hex");
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.tmp`;
writeFileSync(tmp, secret + "\n", { mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, file);
log(`minted a webhook secret, kept at ${file}`);
return secret;
}
/** Whether a body carries the forge's HMAC-SHA256 signature (X-Gitea-Signature, hex) under the secret. */
export function verified(secret: string, body: Buffer | string, signature: string | undefined): boolean {
if (!secret || !signature || !/^[0-9a-f]{64}$/i.test(signature.trim())) return false;
const want = createHmac("sha256", secret).update(body).digest();
const got = Buffer.from(signature.trim(), "hex");
return got.length === want.length && timingSafeEqual(got, want);
}
/** One forge event as the bus carries it: its name inside the stream, and what is needed of it — no secrets. */
export interface ForgeEvent {
name: string;
body: Record<string, unknown>;
}
/** Every event name the module emits for the forge, without its forge. prefix (module.json `emits`). */
export const EVENTS: readonly string[] = ["push", "create", "delete", "fork", "status", "pull-request.opened", "pull-request.reopened", "pull-request.synchronized", "pull-request.edited", "pull-request.closed", "pull-request.merged", "pull-request.labeled", "pull-request.unlabeled", "pull-request.assigned", "pull-request.unassigned", "pull-request.reviewed", "pull-request.review-requested", "pull-request.commented", "pull-request.changed", "issue-comment.created", "issue-comment.edited", "issue-comment.deleted", "issues.opened", "issues.reopened", "issues.closed", "issues.edited", "issues.labeled", "issues.unlabeled", "issues.assigned", "issues.unassigned", "issues.changed", "release.published", "release.updated", "release.deleted", "release.changed", "repository.created", "repository.deleted", "repository.changed", "wiki.changed", "other"];
/** The families the module's stream carries (module.json `streams`); any other kind is said as other.<kind>; underscores become hyphens. */
export const FAMILIES = ["push", "create", "delete", "fork", "status", "pull_request", "issue_comment", "issues",
"release", "repository", "wiki"] as const;
const ACTIONS: Record<string, string> = { label_updated: "labeled", label_cleared: "unlabeled", synchronized: "synchronized" };
function str(v: unknown): string | undefined {
return typeof v === "string" && v ? v : undefined;
}
function num(v: unknown): number | undefined {
return typeof v === "number" ? v : undefined;
}
function login(u: any): string | undefined {
return str(u?.login) ?? str(u?.username);
}
/**
* The event a forge delivery is: its kind (X-Gitea-Event) and the payload's action, trimmed to ids, repository, refs,
* shas, author, labels and a comment's body (a ready mark is one, ADR 0364). A merge is pull-request.merged, a comment on
* a pull request pull-request.commented.
*/
export function eventOf(kind: string, payload: any): ForgeEvent {
// The bus's event names take lower-case letters, digits and hyphens: the forge's underscores become hyphens.
const event = forgeEventOf(kind, payload);
const name = event.name.toLowerCase().replace(/_/g, "-").replace(/[^a-z0-9.-]/g, "-");
// Published on the module's events as it publishes its others, each name declared in its `emits`: one the manifest
// does not name is forge.other, saying what the forge called it. Its own stream (ADR 0363) follows issue 725.
if (EVENTS.includes(name)) return { name: `forge.${name}`, body: event.body };
return { name: "forge.other", body: { ...event.body, kind, said: name } };
}
function forgeEventOf(said: string, payload: any): ForgeEvent {
// The forge names some kinds finer than their family (pull_request_label, pull_request_sync): read by the payload.
let kind = said;
if (said.startsWith("pull_request") && payload?.pull_request) kind = "pull_request";
if ((said.startsWith("issue") || said === "pull_request_comment") && payload?.comment) kind = "issue_comment";
if (said.startsWith("issue") && !payload?.comment && payload?.issue) kind = "issues";
const repo = payload?.repository ?? {};
const base: Record<string, unknown> = {
owner: login(repo.owner), repo: str(repo.name), full_name: str(repo.full_name), clone_url: str(repo.clone_url),
sender: login(payload?.sender),
};
const family = (FAMILIES as readonly string[]).includes(kind) ? kind : `other`;
let action = str(payload?.action);
const pull = payload?.pull_request;
if (kind === "push") {
return { name: "push", body: { ...base, ref: str(payload.ref), before: str(payload.before), after: str(payload.after),
commits: Array.isArray(payload.commits) ? payload.commits.length : undefined, pusher: login(payload.pusher) } };
}
if (kind === "pull_request" && pull) {
if (action === "closed" && pull.merged) action = "merged";
action = ACTIONS[action ?? ""] ?? action ?? "changed";
return { name: `pull_request.${action}`, body: { ...base, number: num(pull.number ?? payload.number),
title: str(pull.title), author: login(pull.user), head: str(pull.head?.ref), head_sha: str(pull.head?.sha),
base: str(pull.base?.ref), merged: !!pull.merged, merge_commit_sha: str(pull.merge_commit_sha),
merged_at: str(pull.merged_at), html_url: str(pull.html_url),
labels: Array.isArray(pull.labels) ? pull.labels.map((l: any) => str(l?.name)).filter(Boolean) : undefined,
label: str(payload.label?.name) } };
}
if (kind === "issue_comment") {
const issue = payload.issue ?? {};
const onPull = !!payload.is_pull || !!issue.pull_request;
return { name: onPull ? "pull_request.commented" : `issue_comment.${action ?? "created"}`, body: { ...base,
number: num(issue.number), comment_id: num(payload.comment?.id), author: login(payload.comment?.user),
comment: str(payload.comment?.body), action } };
}
if (kind === "create" || kind === "delete") {
return { name: kind, body: { ...base, ref: str(payload.ref), ref_type: str(payload.ref_type), sha: str(payload.sha) } };
}
if (kind === "status") {
return { name: "status", body: { ...base, sha: str(payload.sha), context: str(payload.context), state: str(payload.state) } };
}
const name = family === "other" ? `other.${kind.replace(/[^a-z0-9_-]/gi, "_") || "unknown"}` :
["fork"].includes(family) ? family : `${family}.${action ?? "changed"}`;
return { name, body: { ...base, number: num(payload?.number ?? payload?.issue?.number), action } };
}
export interface ReceiverOptions {
secret: string;
port: number;
host?: string;
/** Publishes one event on the bus. */
publish: (event: ForgeEvent) => Promise<void>;
/** Acts on it at once, beside publishing it (the announcer). */
act?: (event: ForgeEvent) => Promise<void>;
/** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the polls know events are
* heard, and the module can say when it last heard one. */
heard?: (kind: string, delivery: string) => void;
/** Told of every refused delivery, with the reason and where it came from. */
refused?: (reason: string, from: string) => void;
log?: (line: string) => void;
}
/** The most a delivery may carry: the forge's push of many commits stays well under it. */
export const MAX_BODY = 5 * 1024 * 1024;
/** How many deliveries' ids are remembered, so a delivery sent again is published once. */
export const REMEMBERED = 2000;
/**
* The receiver. A request without a signature is refused before its body is read, a body over MAX_BODY is refused, and
* one whose signature does not verify is refused, each with 401 or 413 and a line saying so. A verified delivery is
* answered 200 at once and queued: it is published and acted on after the reply, one at a time, so the forge's
* delivery timeout is never reached. A delivery whose X-Gitea-Delivery id was already heard is answered and dropped.
*/
export function receiver(opts: ReceiverOptions): Server & { idle(): Promise<void> } {
const log = opts.log ?? ((l) => console.log(`[gitea] ${l}`));
const refuse = (from: string, reason: string): void => {
log(`refused a forge event from ${from}: ${reason}`);
opts.refused?.(reason, from);
};
const seen = new Set<string>();
let queue: Promise<void> = Promise.resolve();
const server = createServer((req, res) => {
if (req.method !== "POST" || (req.url ?? "").split("?")[0] !== HOOK_PATH) {
res.statusCode = 404;
return res.end();
}
const from = req.socket.remoteAddress ?? "?";
const signature = String(req.headers["x-gitea-signature"] ?? "").trim();
if (!signature) {
refuse(from, "it is unsigned");
res.writeHead(401, { Connection: "close" });
res.end("unsigned\n");
req.destroy();
return;
}
if (Number(req.headers["content-length"] ?? 0) > MAX_BODY) {
refuse(from, `its body is over ${MAX_BODY} bytes`);
res.writeHead(413, { Connection: "close" });
res.end("too large\n");
req.destroy();
return;
}
const chunks: Buffer[] = [];
let size = 0;
let over = false;
req.on("data", (c: Buffer) => {
size += c.length;
if (size > MAX_BODY) {
if (!over) {
over = true;
refuse(from, `its body is over ${MAX_BODY} bytes`);
res.writeHead(413, { Connection: "close" });
res.end("too large\n");
req.destroy();
}
} else chunks.push(c);
});
req.on("end", () => {
if (over) return;
const body = Buffer.concat(chunks);
if (!verified(opts.secret, body, signature)) {
refuse(from, "its signature does not verify");
res.statusCode = 401;
return res.end("signature does not verify\n");
}
let payload: unknown;
try {
payload = JSON.parse(body.toString("utf8"));
} catch {
refuse(from, "it is signed but not JSON");
res.statusCode = 400;
return res.end("not JSON\n");
}
const delivery = String(req.headers["x-gitea-delivery"] ?? "");
const kind = String(req.headers["x-gitea-event"] ?? "other");
opts.heard?.(kind, delivery);
if (delivery && seen.has(delivery)) {
res.statusCode = 200;
return res.end("already heard\n");
}
if (delivery) {
seen.add(delivery);
if (seen.size > REMEMBERED) seen.delete(seen.values().next().value as string);
}
const event = eventOf(kind, payload);
res.statusCode = 200;
res.end("queued\n");
queue = queue.then(async () => {
try {
await opts.publish(event);
if (opts.act) await opts.act(event);
} catch (err) {
log(`a forge event ${event.name} could not be published or acted on; the polls catch it — ${err instanceof Error ? err.message : err}`);
}
});
});
});
return Object.assign(server, { idle: () => queue });
}
/** What ensuring the system webhook needs of the forge's administration (GiteaAdmin's login). */
export interface HookAdmin {
call(method: string, path: string, body?: unknown): Promise<{ status: number; body: any }>;
}
/** The forge's one system webhook as ensured at start: read back from the forge after it was made. */
export interface HookState {
/** Always "system": a hook the forge's listing of system webhooks names, so it fires for every repository. */
kind: "system";
id: number;
url: string;
events: string[];
active: boolean;
content_type?: string;
/** Made where none was, or made in place of the ones there (system, default, or a repository's) at the receiver's url. */
did: "created" | "replaced";
/** The hooks at the receiver's url it took the place of, by kind: a repository's is a copy of a default webhook, which
* would have sent that repository's events a second time. */
removed: { system: number; default: number; repository: number; repositories: string[] };
}
/** How long without a verified delivery — since the start, or since the last one — a forge is said silent once the poll
* finds what no event said. */
export const SILENT_AFTER = 10 * 60_000;
/**
* **How fast the polls look, and whether the forge's events are heard** (issue 729): every five minutes only while the
* forge's events are heard — a verified delivery within the last ten minutes, and none missed since. Until the first
* delivery, after ten minutes without one, or once the poll found something no event said, every thirty seconds.
*
* It also keeps what `gitea_events` says: where the receiver listens, the hook as ensured, the verified and refused
* deliveries, and the polls' finds no event said. **A forge whose events stop is said** (issue 729): once the receiver
* listens and the hook is ensured, when nothing verified for ten minutes — since the start, or since the last delivery —
* and the poll has found at least two heads or merges no event said in that quiet (one could be the poll beating an
* event by a second), it is logged loudly once per silent spell, and the module's events are said unhealthy until a
* delivery verifies. Deliveries refused since the last verified one are said unhealthy too.
*/
export class Pace {
private last = 0;
private missed = 0;
private readonly started: number;
private readonly now: () => number;
private readonly log: (l: string) => void;
private where: { host: string; port: number; url: string } | null = null;
private hook: HookState | null = null;
private notHeard: string | null = null;
private notHeardFails = false;
private verifiedCount = 0;
private lastVerified: { at: number; event: string; delivery: string } | null = null;
private refusedCount = 0;
private refusedSpell = 0;
private lastRefused: { at: number; reason: string; from: string } | null = null;
private missCount = 0;
private lastMiss: { at: number; what: string } | null = null;
private silentSince = 0;
private quietMisses = 0;
constructor(now: () => number = Date.now, log: (l: string) => void = (l) => console.error(`[gitea] ${l}`)) {
this.now = now;
this.log = log;
this.started = now();
}
/** The receiver listens, and the hook at it was ensured: from now on, deliveries are expected. */
listening(host: string, port: number, url: string, hook: HookState): void {
this.where = { host, port, url };
this.hook = hook;
this.notHeard = null;
}
/** No events are listened for, and why: by configuration (`fails` false), or because the receiver could not listen
* or the hook could not be ensured (`fails` true, said unhealthy). The polls alone watch. */
unheard(why: string, fails = true): void {
this.notHeard = why;
this.notHeardFails = fails;
}
heard(event = "", delivery = ""): void {
this.last = this.now();
this.verifiedCount++;
this.lastVerified = { at: this.last, event, delivery };
this.refusedSpell = 0;
this.quietMisses = 0;
if (this.silentSince) this.log(`the forge's events are heard again: ${event || "a delivery"} verified`);
this.silentSince = 0;
}
refused(reason: string, from: string): void {
this.refusedCount++;
this.refusedSpell++;
this.lastRefused = { at: this.now(), reason, from };
}
/** The poll found a head or merge no forge event announced. */
miss(what: string): void {
const t = this.now();
if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the poll found it, so the polls look every 30 s again until the next event`);
this.missed = t;
this.missCount++;
this.lastMiss = { at: t, what };
// Silent: nothing verified for SILENT_AFTER — never since the start, or not since the last delivery (a hook deleted by
// hand, every delivery refused) — while the poll finds what no event said. Said once per silent spell.
const quietSince = this.last || this.started;
this.quietMisses++;
if (this.expecting() && t - quietSince >= SILENT_AFTER && this.quietMisses >= 2 && !this.silentSince) {
this.silentSince = t;
const minutes = Math.round((t - quietSince) / 60_000);
const refusedSince = this.refusedSinceHeard();
this.log(`THE FORGE'S EVENTS ARE NOT HEARD: ${this.last ? `no delivery verified since the last at ` +
`${new Date(this.last).toISOString()}` : "no delivery ever verified since the start"}, ${minutes} min, from the ` +
`system webhook at ${this.hook?.url}${refusedSince ? `; ${refusedSince} refused since, the last because ` +
`${this.lastRefused?.reason}` : ""}; the poll found ${what} — the polls alone watch the forge, every 30 s ` +
`(novox/hq issue 729)`);
}
}
/** Whether a delivery was refused after the last verified one (or with none verified). */
private refusedSinceHeard(): number {
return this.lastRefused && this.lastRefused.at >= this.last ? this.refusedSpell : 0;
}
hearing(): boolean {
const t = this.now();
return this.last > 0 && t - this.last < 10 * 60_000 && this.last > this.missed;
}
every(): number {
return this.hearing() ? 5 * 60_000 : 30_000;
}
private expecting(): boolean {
return this.where !== null && this.hook !== null;
}
/** What `gitea_events` answers: healthy, unhealthy or waits (ADR 0283's shape), with every fact behind it. */
report(): Record<string, unknown> {
const t = this.now();
const iso = (ms: number): string => new Date(ms).toISOString();
let state: "healthy" | "unhealthy" | "waits" = "healthy";
let why: string;
const refusedSince = this.refusedSinceHeard();
if (this.silentSince) {
state = "unhealthy";
why = `${this.last ? `no delivery verified since the last at ${iso(this.last)}` : `the forge has never delivered an ` +
`event since the start at ${iso(this.started)}`}, while the poll found ${this.missCount} head(s) or merge(s) no ` +
`event said, the last ${this.lastMiss?.what} at ${iso(this.lastMiss?.at ?? t)}`;
} else if (refusedSince) {
state = "unhealthy";
why = `${refusedSince} deliveries refused ${this.last ? `since the last verified one at ${iso(this.last)}` :
"and none verified"}, the last at ${iso(this.lastRefused?.at ?? t)} because ${this.lastRefused?.reason}`;
} else if (!this.expecting()) {
why = this.notHeard ? `no events are listened for, so the polls alone watch the forge: ${this.notHeard}` :
"the receiver is not listening yet";
if (this.notHeard && this.notHeardFails) state = "unhealthy";
} else if (this.hearing()) {
why = `the forge's events are heard: ${this.verifiedCount} verified, the last at ${iso(this.last)}`;
} else if (this.last === 0) {
why = `no delivery heard yet; the forge is said silent if the poll finds what no event said ${SILENT_AFTER / 60_000} min after the start`;
} else {
why = `the last delivery was at ${iso(this.last)}; the polls look every 30 s until the next`;
}
return {
healthy: state !== "unhealthy", state, why,
started: iso(this.started),
receiver: this.where ? { listening: true, ...this.where } : { listening: false, why: this.notHeard ?? "not started" },
hook: this.hook,
verified: { count: this.verifiedCount, last: this.lastVerified && { ...this.lastVerified, at: iso(this.lastVerified.at) } },
refused: { count: this.refusedCount, since_verified: refusedSince, last: this.lastRefused && { ...this.lastRefused, at: iso(this.lastRefused.at) } },
polls: { every_seconds: this.every() / 1000, hearing: this.hearing(), found_unsaid: this.missCount,
last_found: this.lastMiss && { ...this.lastMiss, at: iso(this.lastMiss.at) } },
silent: this.silentSince ? { since: iso(this.silentSince) } : null,
};
}
}
/** Every event family the forge (1.27) can send a webhook. */
export const HOOK_EVENTS = ["create", "delete", "fork", "push", "issues", "issue_comment", "pull_request",
"pull_request_review_request", "wiki", "repository", "release", "package", "status", "workflow_run", "workflow_job"];
async function hooksAt(admin: HookAdmin, type: "system" | "default", url: string): Promise<any[]> {
const listed = await admin.call("GET", `/admin/hooks?type=${type}&limit=50`);
if (listed.status !== 200) throw new Error(`the forge's ${type} webhooks could not be listed: ${listed.status}`);
return (Array.isArray(listed.body) ? listed.body : []).filter((h) => h?.config?.url === url);
}
/** Every page of a listing that answers an array (or `{ data: [] }`, the repository search), PAGE at a time. */
const PAGE = 50;
async function everyPage(admin: HookAdmin, path: string, what: string): Promise<any[]> {
const all: any[] = [];
for (let page = 1; ; page++) {
const r = await admin.call("GET", `${path}${path.includes("?") ? "&" : "?"}page=${page}&limit=${PAGE}`);
if (r.status !== 200) throw new Error(`${what} could not be listed: ${r.status}`);
const items: any[] = Array.isArray(r.body) ? r.body : Array.isArray(r.body?.data) ? r.body.data : [];
all.push(...items);
if (items.length < PAGE) return all;
}
}
/** Every repository's own webhooks at the receiver's url: copies of a default webhook made at it. */
async function repoHooksAt(admin: HookAdmin, url: string): Promise<{ repo: string; id: number }[]> {
const found: { repo: string; id: number }[] = [];
for (const repo of await everyPage(admin, "/repos/search?sort=id", "the forge's repositories")) {
const full = String(repo?.full_name ?? "");
if (!full.includes("/")) continue;
const hooks = await everyPage(admin, `/repos/${full}/hooks`, `${full}'s webhooks`);
for (const h of hooks) if (h?.config?.url === url) found.push({ repo: full, id: Number(h.id) });
}
return found;
}
/**
* **One system webhook, for every event, at the receiver** (issue 729), made each start and read back.
*
* The forge (1.27.3, routers/api/v1/utils/hook.go addHook) makes a *default* webhook from POST /admin/hooks unless
* `config.is_system_webhook` is "true": a default webhook fires for no repository and is only copied into repositories
* made after it, and the listing of system webhooks does not name it — so without the flag every start made another
* one and no event came. Its edit (editHook) never changes a webhook's secret nor its kind, so a hook already there is
* not corrected but replaced: the new system webhook is made first, with the secret kept now, so no event falls in a
* gap; then every other hook at the receiver's url is deleted — system, default, and each repository's copy of a
* default one, which would send that repository's events twice. The forge's listing of system webhooks must then name
* exactly one at the url, or this fails, saying so.
*/
export async function ensureSystemHook(admin: HookAdmin, url: string, secret: string): Promise<HookState> {
// The forge has no "every event" word: each family is named, and a family name (issues, pull_request) takes all of it.
const want = { type: "gitea", active: true, events: HOOK_EVENTS, branch_filter: "*",
config: { url, content_type: "json", secret, is_system_webhook: "true" } };
const before = new Set((await hooksAt(admin, "system", url)).map((h) => Number(h.id)));
const made = await admin.call("POST", "/admin/hooks", want);
if (made.status !== 201 && made.status !== 200) throw new Error(`the forge's system webhook could not be made: ${made.status}`);
const fresh = (await hooksAt(admin, "system", url)).filter((h) => !before.has(Number(h.id)));
if (fresh.length !== 1) {
throw new Error(`the forge names ${fresh.length} new system webhooks at ${url} after one was made, not one: its events ` +
`are not known to be sent`);
}
const ours = fresh[0];
const defaults = await hooksAt(admin, "default", url);
const gone = async (path: string): Promise<void> => {
const r = await admin.call("DELETE", path);
if (r.status !== 204 && r.status !== 200 && r.status !== 404) throw new Error(`the forge's webhook ${path} could not be deleted: ${r.status}`);
};
for (const id of before) await gone(`/admin/hooks/${id}`);
for (const h of defaults) await gone(`/admin/hooks/${h.id}`);
const copies = await repoHooksAt(admin, url);
for (const c of copies) await gone(`/repos/${c.repo}/hooks/${c.id}`);
const kept = await hooksAt(admin, "system", url);
if (kept.length !== 1 || Number(kept[0].id) !== Number(ours.id)) {
throw new Error(`the forge names ${kept.length} system webhooks at ${url} once the others were deleted, not the one ` +
`made (${ours.id})`);
}
const removed = { system: before.size, default: defaults.length, repository: copies.length,
repositories: [...new Set(copies.map((c) => c.repo))] };
return { kind: "system", id: Number(ours.id), url, events: Array.isArray(ours.events) ? ours.events : [],
active: !!ours.active, content_type: ours.config?.content_type,
did: removed.system + removed.default + removed.repository > 0 ? "replaced" : "created", removed };
}
/** The `gitea_events` tool: what the module knows of the forge's events, from its Pace. */
export function eventsTool(pace: Pace): { name: string; description: string; input: Record<string, unknown>;
run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> } {
return {
name: "gitea_events",
description: "Whether the forge's events reach the mesh (novox/hq issue 729): where the receiver listens, the " +
"system webhook as ensured at the start (its id, url, events, kind, and what it replaced), how many deliveries " +
"verified and the last one's time and event, how many were refused and the last reason, how often the polls look " +
"and whether events are heard, and how many heads or merges the poll found that no event said. Unhealthy when the " +
"forge has delivered nothing that verified for ten minutes (since the start or since the last delivery) while the " +
"poll found what no event said, when deliveries were refused since the last verified one, or when the receiver " +
"could not listen or the webhook could not be made.",
input: {},
run: async () => pace.report(),
};
}
/**
* **Claim a head for announcing, once** (issue 729): true when `sha` is not already the head recorded for `key`, which
* it then is. The poll and the forge's event both claim before they announce, so whichever comes second finds it taken.
*/
export function claimHead(heads: Record<string, string>, key: string, sha: string): boolean {
if (!sha || heads[key] === sha) return false;
heads[key] = sha;
return true;
}
-52
View File
@@ -1,52 +0,0 @@
# kernel
The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). Today it holds one:
**transparent huge pages**, set per machine. The rest of the row (the kernel package, microcode, boot
entries) is not built yet.
## Why (novox/hq issue 746)
With transparent huge pages `always`, the page cache of a file mapped by a program is collapsed into
2 MiB folios (khugepaged), and a small write into such a folio dirties all of it: writeback then writes
2 MiB. journald writes its journal files through mmap, and every journal reader (fail2ban's journal
jails among them) maps them too. Measured on the control node on 2026-10-14: journald's cgroup had
written 2.5 TB in 12 days while the journal grew by about 25-30 KB/s, fail2ban's cgroup 63 GB in 4 h
while its own files did not grow, both holding hundreds of MiB of file pages in huge folios; the bursts
saturated the mirrored spinning root disk that holds the bus store and the store, and the controller
lost its lease four times in 30 minutes. With `madvise`, only memory a program asks to have in huge pages
(`madvise(MADV_HUGEPAGE)`) gets them; a database or a JVM that wants them still asks.
## What it owns
| | what | notes |
|---|---|---|
| setting | `transparent-huge-pages` | `always`, `madvise` or `never`. Default `always`, what the distribution's kernel boots with, so assigning the module changes nothing until a machine sets another value |
| file | `${dir:state}/huge-pages` | the script, written from the setting: `apply` writes the mode to `/sys/kernel/mm/transparent_hugepage/enabled` and reads it back (a value that does not take fails the unit); `restore` gives back the mode found before the module first set it, kept in `${dir:state}/found` |
| file | `/etc/systemd/system/mesh-kernel-huge-pages.service` | oneshot, early in boot (`sysinit.target`, before the services that fill the page cache); reloading it runs `apply`, stopping it runs `restore`, which also removes `found`. Sandboxed: the file system read-only but for the module's state (`/sys` stays writable), no home, no network, no new privileges; the journal's rotation goes over its local socket, which a private network leaves reachable |
| service | `mesh-kernel-huge-pages.service` | running and enabled; **reloaded** when the script (so the setting) changes, so a new value is set directly and never passes back through the found one; restarted only when the unit changes; its health is the unit's |
The per-size controls (`hugepages-<n>kB/enabled`) are left alone: on the machines read, 2048 kB says
`inherit` (it follows the mode) and every other size `never`. `kernel_huge_pages` shows each one.
**A folio already in the page cache keeps its size.** A new mode applies to pages faulted or collapsed
from then on. The active journal files would keep their 2 MiB folios until archived, so `apply` rotates
the journal (`journalctl --rotate`) whenever it moves the mode away from `always`: new files are mapped
under the new mode. A failed rotation is said in the unit's journal and does not fail the setting. No
rotation is made while the machine is still starting (`systemctl is-system-running` says `initializing`
or `starting`): nothing is in huge pages yet, and the persistent journal opens after this unit.
## Tools
| tool | | answers |
|---|---|---|
| `kernel_huge_pages` | r | the mode, defrag and shared-memory mode; each per-size control and what `inherit` resolves to; huge pages by kind from `/proc/meminfo` (MiB); the units holding the most file pages in huge folios (`memory.stat` `file_thp`) with their dirty and writeback bytes, khugepaged's collapses and the bytes their cgroup wrote (`io.stat`, the most any one device saw: a mirror and its members each count the same write) |
## Tests
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); and the script as the mesh
writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once,
rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a
reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel
does not take before writing anything, fails when the write does not take, and says when the kernel has
no transparent huge pages.
@@ -1,241 +0,0 @@
package main
import (
"fmt"
"os"
"path"
"sort"
"strconv"
"strings"
)
// THPDir is where the kernel says and takes its transparent huge pages.
const THPDir = "sys/kernel/mm/transparent_hugepage"
// CgroupRoot is the unified cgroup hierarchy.
const CgroupRoot = "sys/fs/cgroup"
// HugePagesAnswer is what kernel_huge_pages answers.
type HugePagesAnswer struct {
// Mode is the word the kernel brackets in enabled: always, madvise or never.
Mode string `json:"mode"`
Defrag string `json:"defrag,omitempty"`
Shmem string `json:"shmem_enabled,omitempty"`
PerSize []Size `json:"per_size,omitempty"`
// Meminfo is the machine's huge pages by kind, in MiB.
Meminfo map[string]float64 `json:"meminfo_mib,omitempty"`
Units []Unit `json:"units"`
Said []string `json:"said"`
}
// Size is one per-size control (hugepages-<n>kB/enabled).
type Size struct {
Size string `json:"size"`
Enabled string `json:"enabled"`
// Means is what inherit resolves to: the top-level mode.
Means string `json:"means"`
}
// Unit is one cgroup holding file pages in huge folios.
type Unit struct {
Cgroup string `json:"cgroup"`
FileTHP int64 `json:"file_thp_bytes"`
FileDirty int64 `json:"file_dirty_bytes"`
FileWriteback int64 `json:"file_writeback_bytes"`
FileMapped int64 `json:"file_mapped_bytes"`
CollapsedByKhug int64 `json:"thp_collapse_alloc"`
Written int64 `json:"written_bytes"`
}
// bracketed is the word a sysfs choice file brackets: "always [madvise] never" is madvise.
func bracketed(text string) string {
open := strings.IndexByte(text, '[')
end := strings.IndexByte(text, ']')
if open < 0 || end < open {
return ""
}
return text[open+1 : end]
}
// HugePages reads the mode, the per-size controls, meminfo and the top units by file_thp.
func (m *Machine) HugePages(units int) (*HugePagesAnswer, error) {
enabled, err := m.read(THPDir + "/enabled")
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("this kernel has no /%s/enabled: it was built without transparent huge pages", THPDir)
}
return nil, fmt.Errorf("reading /%s/enabled: %w", THPDir, err)
}
a := &HugePagesAnswer{Mode: bracketed(enabled), Units: []Unit{}}
if s, err := m.read(THPDir + "/defrag"); err == nil {
a.Defrag = bracketed(s)
}
if s, err := m.read(THPDir + "/shmem_enabled"); err == nil {
a.Shmem = bracketed(s)
}
a.Said = append(a.Said, fmt.Sprintf("transparent huge pages: %s, defrag %s", a.Mode, orUnknown(a.Defrag)))
sizes, _ := m.dirs(THPDir)
for _, d := range sizes {
if !strings.HasPrefix(d, "hugepages-") {
continue
}
s, err := m.read(THPDir + "/" + d + "/enabled")
if err != nil {
continue
}
sz := Size{Size: strings.TrimPrefix(d, "hugepages-"), Enabled: bracketed(s)}
sz.Means = sz.Enabled
if sz.Enabled == "inherit" {
sz.Means = a.Mode
}
a.PerSize = append(a.PerSize, sz)
}
sort.Slice(a.PerSize, func(i, j int) bool { return kib(a.PerSize[i].Size) < kib(a.PerSize[j].Size) })
var on []string
for _, s := range a.PerSize {
if s.Means != "never" {
on = append(on, s.Size+" "+s.Means)
}
}
if len(a.PerSize) > 0 {
a.Said = append(a.Said, "sizes not never: "+orNone(on))
}
if mi, err := m.read("proc/meminfo"); err == nil {
a.Meminfo = meminfo(mi)
a.Said = append(a.Said, fmt.Sprintf("huge pages held: anonymous %.0f MiB, shared memory %.0f MiB, file %.0f MiB (mapped at 2 MiB %.0f MiB)",
a.Meminfo["AnonHugePages"], a.Meminfo["ShmemHugePages"], a.Meminfo["FileHugePages"], a.Meminfo["FilePmdMapped"]))
}
if units > 0 {
all := m.cgroups()
sort.Slice(all, func(i, j int) bool {
if all[i].FileTHP != all[j].FileTHP {
return all[i].FileTHP > all[j].FileTHP
}
return all[i].Cgroup < all[j].Cgroup
})
if len(all) > units {
all = all[:units]
}
a.Units = all
for _, u := range all {
a.Said = append(a.Said, fmt.Sprintf("%s: file in huge folios %s, dirty %s, writeback %s, written %s since it started",
u.Cgroup, mib(u.FileTHP), mib(u.FileDirty), mib(u.FileWriteback), mib(u.Written)))
}
if len(all) == 0 {
a.Said = append(a.Said, "no unit holds file pages in huge folios")
}
}
return a, nil
}
// cgroups walks the leaf services and scopes (to three levels below the root) that hold file_thp.
func (m *Machine) cgroups() []Unit {
var out []Unit
var walk func(rel string, depth int)
walk = func(rel string, depth int) {
children, _ := m.dirs(path.Join(CgroupRoot, rel))
for _, c := range children {
p := path.Join(rel, c)
if strings.HasSuffix(c, ".slice") {
if depth < 3 {
walk(p, depth+1)
}
continue
}
if !strings.HasSuffix(c, ".service") && !strings.HasSuffix(c, ".scope") {
continue
}
stat, err := m.read(path.Join(CgroupRoot, p, "memory.stat"))
if err != nil {
continue
}
kv := memoryStat(stat)
if kv["file_thp"] == 0 {
continue
}
u := Unit{Cgroup: p, FileTHP: kv["file_thp"], FileDirty: kv["file_dirty"], FileWriteback: kv["file_writeback"],
FileMapped: kv["file_mapped"], CollapsedByKhug: kv["thp_collapse_alloc"]}
if io, err := m.read(path.Join(CgroupRoot, p, "io.stat")); err == nil {
u.Written = mostWritten(io)
}
out = append(out, u)
}
}
walk("", 0)
return out
}
// memoryStat reads memory.stat's "key value" lines.
func memoryStat(text string) map[string]int64 {
kv := map[string]int64{}
for _, l := range strings.Split(text, "\n") {
f := strings.Fields(l)
if len(f) != 2 {
continue
}
if n, err := strconv.ParseInt(f[1], 10, 64); err == nil {
kv[f[0]] = n
}
}
return kv
}
// mostWritten is the most bytes any one device of io.stat saw written: a mirror's member disks and the
// mirror itself each count the same write, so the lines are never summed.
func mostWritten(text string) int64 {
var most int64
for _, l := range strings.Split(text, "\n") {
for _, f := range strings.Fields(l) {
if v, ok := strings.CutPrefix(f, "wbytes="); ok {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > most {
most = n
}
}
}
}
return most
}
// meminfo reads the huge page lines of /proc/meminfo, in MiB.
func meminfo(text string) map[string]float64 {
want := map[string]bool{"AnonHugePages": true, "ShmemHugePages": true, "ShmemPmdMapped": true, "FileHugePages": true, "FilePmdMapped": true}
out := map[string]float64{}
for _, l := range strings.Split(text, "\n") {
k, v, ok := strings.Cut(l, ":")
if !ok || !want[k] {
continue
}
f := strings.Fields(v)
if len(f) == 0 {
continue
}
if n, err := strconv.ParseFloat(f[0], 64); err == nil {
out[k] = n / 1024
}
}
return out
}
func kib(size string) int {
n, _ := strconv.Atoi(strings.TrimSuffix(size, "kB"))
return n
}
func mib(b int64) string { return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20)) }
func orUnknown(s string) string {
if s == "" {
return "unknown"
}
return s
}
func orNone(list []string) string {
if len(list) == 0 {
return "none"
}
return strings.Join(list, ", ")
}
@@ -1,113 +0,0 @@
package main
// Over a stand-in tree holding novox's own files of 2026-10-14 23:25 (read through node-reads for
// novox/hq issue 746): the mode, the per-size controls, meminfo's huge page lines, and fail2ban's and
// journald's memory.stat and io.stat, cut to the lines the tool reads.
import (
"os"
"path/filepath"
"strings"
"testing"
)
func tree(t *testing.T, files map[string]string) *Machine {
t.Helper()
root := t.TempDir()
for p, content := range files {
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
return &Machine{Root: root}
}
func novox(t *testing.T) *Machine {
thp := "sys/kernel/mm/transparent_hugepage/"
cg := "sys/fs/cgroup/system.slice/"
files := map[string]string{
thp + "enabled": "[always] madvise never\n",
thp + "defrag": "always defer defer+madvise [madvise] never\n",
thp + "shmem_enabled": "always within_size [advise] never deny force\n",
thp + "hugepages-2048kB/enabled": "always [inherit] madvise never\n",
thp + "hugepages-64kB/enabled": "always inherit madvise [never]\n",
thp + "hugepages-1024kB/enabled": "always inherit madvise [never]\n",
"proc/meminfo": "MemTotal: 65748808 kB\nAnonHugePages: 3579904 kB\nShmemHugePages: 4096 kB\n" +
"ShmemPmdMapped: 4096 kB\nFileHugePages: 1693696 kB\nFilePmdMapped: 882688 kB\nHugepagesize: 2048 kB\n",
cg + "fail2ban.service/memory.stat": "anon 21815296\nfile 204623872\nfile_mapped 130736128\nfile_dirty 27262976\n" +
"file_writeback 4096\nanon_thp 0\nfile_thp 197132288\nthp_fault_alloc 0\nthp_collapse_alloc 550\n",
cg + "fail2ban.service/io.stat": "9:0 \n8:16 rbytes=3223552 wbytes=67723415552 rios=240 wios=281159 dbytes=0 dios=0\n" +
"8:0 rbytes=6152192 wbytes=67723415552 rios=832 wios=281159 dbytes=0 dios=0\n" +
"9:2 rbytes=9375744 wbytes=67723415552 rios=1072 wios=281159 dbytes=0 dios=0\n",
cg + "systemd-journald.service/memory.stat": "anon 3227648\nfile 1032015872\nfile_mapped 131928064\nfile_dirty 622592\n" +
"file_writeback 159744\nanon_thp 2097152\nfile_thp 379584512\nthp_fault_alloc 0\nthp_collapse_alloc 11741\n",
cg + "systemd-journald.service/io.stat": "9:0 \n8:16 rbytes=55209984 wbytes=2498768035840 rios=1317 wios=24719004 dbytes=0 dios=0\n" +
"8:0 rbytes=72953856 wbytes=2498768039936 rios=2194 wios=24719005 dbytes=0 dios=0\n" +
"9:2 rbytes=128163840 wbytes=2498724843520 rios=3511 wios=24707873 dbytes=0 dios=0\n",
cg + "sshd.service/memory.stat": "anon 1000\nfile_thp 0\n",
}
return tree(t, files)
}
func TestNovoxBeforeTheChange(t *testing.T) {
a, err := novox(t).HugePages(10)
if err != nil {
t.Fatal(err)
}
if a.Mode != "always" || a.Defrag != "madvise" || a.Shmem != "advise" {
t.Fatalf("modes: %+v", a)
}
if len(a.PerSize) != 3 || a.PerSize[0].Size != "64kB" || a.PerSize[2].Size != "2048kB" ||
a.PerSize[2].Enabled != "inherit" || a.PerSize[2].Means != "always" || a.PerSize[0].Means != "never" {
t.Fatalf("per size: %+v", a.PerSize)
}
if a.Meminfo["FileHugePages"] != 1654 || a.Meminfo["FilePmdMapped"] != 862 {
t.Fatalf("meminfo: %v", a.Meminfo)
}
if len(a.Units) != 2 || a.Units[0].Cgroup != "system.slice/systemd-journald.service" ||
a.Units[1].Cgroup != "system.slice/fail2ban.service" {
t.Fatalf("units: %+v", a.Units)
}
// The mirror and its two members each count the write: the most any one saw, never their sum.
if a.Units[1].Written != 67723415552 || a.Units[1].FileTHP != 197132288 || a.Units[1].CollapsedByKhug != 550 {
t.Fatalf("fail2ban: %+v", a.Units[1])
}
said := strings.Join(a.Said, "\n")
for _, want := range []string{"transparent huge pages: always, defrag madvise", "sizes not never: 2048kB always",
"file 1654 MiB (mapped at 2 MiB 862 MiB)", "system.slice/fail2ban.service: file in huge folios 188.0 MiB"} {
if !strings.Contains(said, want) {
t.Errorf("said lacks %q:\n%s", want, said)
}
}
}
func TestUnitsAreBoundedAndNoneIsSaid(t *testing.T) {
a, err := novox(t).HugePages(1)
if err != nil || len(a.Units) != 1 {
t.Fatalf("%v %+v", err, a)
}
b, err := tree(t, map[string]string{"sys/kernel/mm/transparent_hugepage/enabled": "always [madvise] never\n"}).HugePages(10)
if err != nil || b.Mode != "madvise" || len(b.Units) != 0 || !strings.Contains(strings.Join(b.Said, "\n"), "no unit holds") {
t.Fatalf("%v %+v", err, b)
}
}
func TestAKernelWithoutHugePagesIsAnError(t *testing.T) {
if _, err := tree(t, map[string]string{"proc/meminfo": ""}).HugePages(10); err == nil ||
!strings.Contains(err.Error(), "built without transparent huge pages") {
t.Fatalf("%v", err)
}
}
func TestTheArgumentIsBounded(t *testing.T) {
if _, err := whole(map[string]any{"units": -1.0}, "units", 10, 0, 100); err == nil {
t.Error("a negative count was accepted")
}
if n, _ := whole(map[string]any{"units": 1000.0}, "units", 10, 0, 100); n != 100 {
t.Errorf("not bounded: %d", n)
}
}
@@ -1,65 +0,0 @@
package main
// The machine as the tools see it: the files they read, under a root that tests replace with a stand-in
// tree. Everything read here is readable by the runtime's account; nothing is escalated.
import (
"fmt"
"io/fs"
"os"
"path/filepath"
)
// Machine reads this machine's kernel files under Root ("/" on the machine).
type Machine struct {
Root string
}
// ThisMachine is the machine the runtime launched this bundle on.
func ThisMachine() *Machine { return &Machine{Root: "/"} }
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
func (m *Machine) read(p string) (string, error) {
b, err := os.ReadFile(m.path(p))
return string(b), err
}
func (m *Machine) dirs(p string) ([]string, error) {
entries, err := os.ReadDir(m.path(p))
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if e.IsDir() || e.Type()&fs.ModeSymlink != 0 {
out = append(out, e.Name())
}
}
return out, nil
}
// whole is a whole-number argument with a default, kept within bounds.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
raw, present := args[key]
if !present || raw == nil {
return def, nil
}
f, ok := raw.(float64)
if !ok || f != float64(int(f)) {
return 0, fmt.Errorf("%s must be a whole number", key)
}
n := int(f)
if n < least {
return 0, fmt.Errorf("%s must be at least %d", key, least)
}
if n > most {
n = most
}
return n, nil
}
// schema is a tool's input: its properties.
func schema(properties map[string]any) map[string]any {
return map[string]any{"type": "object", "properties": properties}
}
-50
View File
@@ -1,50 +0,0 @@
// The kernel module's tools bundle (novox/hq to-be 42 Phase 1 row 11, issue 746).
//
// Served by the node's runtime over MCP on stdio through the Go SDK (ADR 0188, ADR 0193). It only reads:
// the transparent huge pages the kernel runs with, and which units hold file pages in huge folios, which
// is what a small write to a mapped file costs a whole huge page of writeback for (issue 746).
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// binaryName is what the build names this bundle's executable: the manifest's `binary`.
const binaryName = "kernel-tools"
func main() {
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): kernel.
if err := stdio.Serve("", tools(ThisMachine())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func tools(m *Machine) []stdio.Tool {
return []stdio.Tool{
{
Name: "kernel_huge_pages",
Description: "Transparent huge pages on this machine: the mode in force (always, madvise or never) and defrag, " +
"each per-size control with what inherit resolves to, the machine's huge pages by kind (anonymous, " +
"shared memory, file, file mapped at 2 MiB) from /proc/meminfo, and the units holding the most file " +
"pages in huge folios (memory.stat file_thp) with their dirty and writeback bytes and the bytes their " +
"cgroup has written (io.stat, the most any one device saw). A unit with file_thp that writes far more " +
"than its files grow is paying a whole huge page of writeback per small write (novox/hq issue 746). " +
"Replaces cat /sys/kernel/mm/transparent_hugepage/enabled, grep Huge /proc/meminfo and reading " +
"memory.stat by hand. (r)",
Input: schema(map[string]any{
"units": map[string]any{"type": "integer", "description": "how many units to name, the most file_thp first (default 10, 0 for none, at most 100)"},
}),
Run: func(args map[string]any) (any, error) {
n, err := whole(args, "units", 10, 0, 100)
if err != nil {
return nil, err
}
return m.HugePages(n)
},
},
}
}
@@ -1,239 +0,0 @@
package main
// The module's shape (novox/hq issue 746): the setting's default is what the kernel boots with, so
// assigning the module changes nothing until a machine sets another value; the script is written into the
// module's own state and run by a sandboxed oneshot unit that is reloaded (applied again) when the script,
// and so the setting, changes and restarted only when the unit does; and the script itself sets, verifies,
// gives back and refuses as it says.
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestTheDefaultChangesNothingAndTheUnitFollowsTheSetting(t *testing.T) {
m := manifest(t)
s := m.Settings["transparent-huge-pages"]
if s["kind"] != "preference" || s["default"] != "always" || s["why"] == "" {
t.Fatalf("the setting: %v", s)
}
var ids []string
for _, r := range m.Resources {
ids = append(ids, r["id"].(string))
}
if strings.Join(ids, " ") != "state huge-pages unit apply" {
t.Fatalf("order: %v", ids)
}
script := m.resource(t, "huge-pages")
// The script runs as root, so the setting it is written from is set at the terminal alone (issue 339).
if script["path"] != "${dir:state}/huge-pages" || script["mode"] != "0755" || script["trusted"] != true ||
!strings.Contains(script["content"].(string), "want='${setting:transparent-huge-pages}'") {
t.Fatalf("the script: %v", script["path"])
}
unit := m.resource(t, "unit")["content"].(string)
for _, line := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=${dir:state}/huge-pages apply",
"ExecReload=${dir:state}/huge-pages apply", "ExecStop=${dir:state}/huge-pages restore", "WantedBy=sysinit.target",
"DefaultDependencies=no", "ProtectSystem=strict", "ReadWritePaths=${dir:state}", "ProtectHome=yes", "PrivateNetwork=yes",
"NoNewPrivileges=yes"} {
if !strings.Contains(unit, line+"\n") {
t.Errorf("the unit lacks %q", line)
}
}
apply := m.resource(t, "apply")
if apply["unit"] != "mesh-kernel-huge-pages.service" || apply["state"] != "running" || apply["boot"] != "enabled" {
t.Fatalf("apply: %v", apply)
}
// A new value reloads (apply again); only a new unit restarts, whose stop would pass back through the
// mode the machine was found with.
if on := apply["restart-on"].([]any); len(on) != 1 || on[0] != "unit" {
t.Fatalf("restart-on: %v", on)
}
if on := apply["reload-on"].([]any); len(on) != 1 || on[0] != "huge-pages" {
t.Fatalf("reload-on: %v", on)
}
if h := apply["health"].(map[string]any); h["kind"] != "unit" {
t.Fatalf("health: %v", h)
}
}
// bed is the script as the mesh writes it for one value of the setting, a stand-in transparent_hugepage
// directory, the module's state, and a PATH whose journalctl records that it was asked to rotate.
type bed struct {
script, sys, state, rotated, path, bin string
}
func newBed(t *testing.T, want, found string) bed {
return newBedIn(t, t.TempDir(), want, found)
}
// newBedIn writes the script for want into dir; a second bed in the same dir shares the machine and the
// state, as a reload with a new value does.
func newBedIn(t *testing.T, dir, want, found string) bed {
t.Helper()
b := bed{script: filepath.Join(dir, "huge-pages"), sys: filepath.Join(dir, "thp"), state: filepath.Join(dir, "state"),
rotated: filepath.Join(dir, "rotated")}
content := manifest(t).resource(t, "huge-pages")["content"].(string)
content = strings.ReplaceAll(content, "${setting:transparent-huge-pages}", want)
content = strings.ReplaceAll(content, "${dir:state}", b.state)
if strings.Contains(content, "${") {
t.Fatalf("a substitution the test does not make is left in the script")
}
bin := filepath.Join(dir, "bin")
for _, d := range []string{b.sys, b.state, bin} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
write(t, b.script, content, 0o755)
if found != "" {
write(t, filepath.Join(b.sys, "enabled"), found, 0o644)
}
write(t, filepath.Join(bin, "journalctl"), "#!/bin/sh\necho \"$@\" >>"+b.rotated+"\n", 0o755)
b.bin = bin
b.booted(t, "running")
b.path = bin + string(os.PathListSeparator) + os.Getenv("PATH")
return b
}
// booted makes the service manager say state for is-system-running.
func (b bed) booted(t *testing.T, state string) {
t.Helper()
write(t, filepath.Join(b.bin, "systemctl"), "#!/bin/sh\n[ \"$1\" = is-system-running ] && echo "+state+"\n", 0o755)
}
func write(t *testing.T, p, content string, mode os.FileMode) {
t.Helper()
if err := os.WriteFile(p, []byte(content), mode); err != nil {
t.Fatal(err)
}
}
func (b bed) run(t *testing.T, verb string) (string, error) {
t.Helper()
cmd := exec.Command("sh", b.script, verb, b.sys, b.state)
cmd.Env = append(os.Environ(), "PATH="+b.path)
out, err := cmd.CombinedOutput()
return string(out), err
}
func (b bed) file(t *testing.T, p string) string {
t.Helper()
raw, err := os.ReadFile(p)
if err != nil {
return ""
}
return string(raw)
}
func TestApplySetsTheModeRotatesTheJournalOnceAndRestoreGivesBackWhatWasFound(t *testing.T) {
// novox's file on 2026-10-14, read through node-reads.
b := newBed(t, "madvise", "[always] madvise never\n")
out, err := b.run(t, "apply")
if err != nil || !strings.Contains(out, "always -> madvise") {
t.Fatalf("apply: %v %s", err, out)
}
if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "madvise\n" {
t.Fatalf("written: %q", got)
}
if got := b.file(t, filepath.Join(b.state, "found")); got != "always\n" {
t.Fatalf("found: %q", got)
}
if got := b.file(t, b.rotated); got != "--rotate\n" {
t.Fatalf("rotation: %q", got)
}
// Applied again (a restart with the same value): nothing written, nothing rotated, found kept.
if out, err := b.run(t, "apply"); err != nil || !strings.Contains(out, "madvise, as set") {
t.Fatalf("again: %v %s", err, out)
}
if got := b.file(t, b.rotated); got != "--rotate\n" {
t.Fatalf("rotated again: %q", got)
}
if out, err := b.run(t, "restore"); err != nil || !strings.Contains(out, "back to always") {
t.Fatalf("restore: %v %s", err, out)
}
if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "always\n" {
t.Fatalf("restored: %q", got)
}
// Nothing of the module is kept once it is stopped.
if _, err := os.Stat(filepath.Join(b.state, "found")); !os.IsNotExist(err) {
t.Fatalf("found kept after restore: %v", err)
}
}
func TestAReloadWithANewValueGoesStraightThereAndNeverRewritesFound(t *testing.T) {
dir := t.TempDir()
b := newBedIn(t, dir, "madvise", "[always] madvise never\n")
if out, err := b.run(t, "apply"); err != nil {
t.Fatalf("apply: %v %s", err, out)
}
// The setting changes to never: the mesh rewrites the script and reloads the unit, which applies again.
n := newBedIn(t, dir, "never", "")
out, err := n.run(t, "apply")
if err != nil || !strings.Contains(out, "madvise -> never") {
t.Fatalf("reload: %v %s", err, out)
}
if got := n.file(t, filepath.Join(n.state, "found")); got != "always\n" {
t.Fatalf("found rewritten by a reload: %q", got)
}
}
func TestNoRotationWhileTheMachineIsStarting(t *testing.T) {
for _, state := range []string{"initializing", "starting"} {
b := newBed(t, "madvise", "[always] madvise never\n")
b.booted(t, state)
out, err := b.run(t, "apply")
if err != nil || !strings.Contains(out, "still starting") {
t.Fatalf("%s: %v %s", state, err, out)
}
if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "madvise\n" {
t.Fatalf("%s: not set: %q", state, got)
}
if got := b.file(t, b.rotated); got != "" {
t.Fatalf("%s: rotated at boot: %q", state, got)
}
}
}
func TestAlwaysIsSetWithoutRotating(t *testing.T) {
b := newBed(t, "always", "always [madvise] never\n")
if out, err := b.run(t, "apply"); err != nil || !strings.Contains(out, "madvise -> always") {
t.Fatalf("apply: %v %s", err, out)
}
if got := b.file(t, b.rotated); got != "" {
t.Fatalf("rotated for always: %q", got)
}
}
func TestAValueTheKernelDoesNotTakeIsRefusedBeforeAnythingIsWritten(t *testing.T) {
b := newBed(t, "sometimes", "[always] madvise never\n")
out, err := b.run(t, "apply")
if err == nil || !strings.Contains(out, "takes always, madvise or never") {
t.Fatalf("accepted: %v %s", err, out)
}
if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "[always] madvise never\n" {
t.Fatalf("written anyway: %q", got)
}
}
func TestAWriteThatDoesNotTakeFailsTheUnit(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root writes a read-only file")
}
b := newBed(t, "madvise", "[always] madvise never\n")
if err := os.Chmod(filepath.Join(b.sys, "enabled"), 0o444); err != nil {
t.Fatal(err)
}
if out, err := b.run(t, "apply"); err == nil {
t.Fatalf("a refused write passed: %s", out)
}
}
func TestAKernelWithoutHugePagesIsSaid(t *testing.T) {
b := newBed(t, "madvise", "")
if out, err := b.run(t, "apply"); err == nil || !strings.Contains(out, "built without transparent huge pages") {
t.Fatalf("%v %s", err, out)
}
}
@@ -1,79 +0,0 @@
package main
import (
"encoding/json"
"os"
"testing"
)
type resource map[string]any
type manifestShape struct {
Module string `json:"module"`
Capabilities []string `json:"capabilities"`
Settings map[string]map[string]any `json:"settings"`
Tools []string `json:"tools"`
Resources []resource `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func manifest(t *testing.T) manifestShape {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m manifestShape
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func (m manifestShape) resource(t *testing.T, id string) resource {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %s", id)
return nil
}
// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the
// bundle to the shape the builder compiles and the runtime loads.
func TestToolsAreTheManifests(t *testing.T) {
m := manifest(t)
names := map[string]bool{}
for _, tool := range tools(&Machine{Root: t.TempDir()}) {
if names[tool.Name] {
t.Errorf("%s is served twice", tool.Name)
}
names[tool.Name] = true
}
for _, want := range m.Tools {
if !names[want] {
t.Errorf("the manifest lists %s and the bundle does not serve it", want)
}
delete(names, want)
}
if len(names) != 0 {
t.Errorf("served and not listed: %v", names)
}
var tools map[string]any
for _, a := range m.Build.Artifacts {
if a["name"] == "tools" {
tools = a
}
}
if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" ||
tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName {
t.Fatalf("the tools artifact: %v", tools)
}
if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName {
t.Fatalf("loads: %v", tools["loads"])
}
}
-5
View File
@@ -1,5 +0,0 @@
module kernel
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
-2
View File
@@ -1,2 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
-71
View File
@@ -1,71 +0,0 @@
{
"module": "kernel",
"version": "1",
"capabilities": [
"service-manager"
],
"settings": {
"transparent-huge-pages": {
"kind": "preference",
"default": "always",
"why": "always is what the distribution's kernel boots with, so assigning the module changes nothing until a machine sets another value; madvise gives huge pages only to programs that ask for them, which stops a small write to a mapped file (the journal) dirtying and writing back a whole 2 MiB page on a machine whose disks are slow (novox/hq issue 746)"
}
},
"tools": [
"kernel_huge_pages"
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "huge-pages",
"type": "file",
"path": "${dir:state}/huge-pages",
"mode": "0755",
"trusted": true,
"content": "#!/bin/sh\n# Transparent huge pages as the mesh sets them on this machine (module kernel, novox/hq issue 746).\n# Written by the mesh from this machine's setting transparent-huge-pages and run by\n# mesh-kernel-huge-pages.service: `apply` at boot and whenever the setting changes, `restore` when the\n# unit stops (the module unassigned, or the machine shutting down). Replaced on every push; change the\n# setting, never this file. A change of the setting reloads the unit, which runs `apply` again: the mode\n# moves straight to the new value and never passes back through the one the machine was found with.\n#\n# huge-pages apply|restore [<the kernel's transparent_hugepage directory> [<this module's state>]]\n#\n# The two directories are arguments only so the module's tests can run this against a stand-in tree.\nset -eu\n\nwant='${setting:transparent-huge-pages}'\nsys=/sys/kernel/mm/transparent_hugepage\nstate='${dir:state}'\n[ $# -ge 2 ] && sys=$2\n[ $# -ge 3 ] && state=$3\n\ncase \"$want\" in\nalways | madvise | never) ;;\n*)\n\techo \"the setting transparent-huge-pages is '$want'; the kernel takes always, madvise or never\" >&2\n\texit 2\n\t;;\nesac\n[ -f \"$sys/enabled\" ] || {\n\techo \"this kernel has no $sys/enabled: it was built without transparent huge pages, so there is nothing to set\" >&2\n\texit 1\n}\n\n# The mode in force is the word the kernel brackets: \"always [madvise] never\". A file holding one bare\n# word (the stand-in tree of the tests) is read as that word.\nmode() { sed -n -e 's/.*\\[\\([a-z]*\\)\\].*/\\1/p' -e t -e 's/^ *\\([a-z]*\\) *$/\\1/p' \"$sys/enabled\"; }\n\n[ $# -ge 1 ] || set -- usage\ncase \"$1\" in\napply)\n\tnow=$(mode)\n\t# What the machine had before the mesh first set it, kept once, so `restore` gives it back.\n\t[ -s \"$state/found\" ] || printf '%s\\n' \"$now\" >\"$state/found\"\n\tif [ \"$now\" = \"$want\" ]; then\n\t\techo \"transparent huge pages: $want, as set\"\n\t\texit 0\n\tfi\n\tprintf '%s\\n' \"$want\" >\"$sys/enabled\"\n\tgot=$(mode)\n\t[ \"$got\" = \"$want\" ] || {\n\t\techo \"wrote $want to $sys/enabled and the kernel says $got\" >&2\n\t\texit 1\n\t}\n\techo \"transparent huge pages: $now -> $want\"\n\t# A file folio already in the page cache keeps its size: the active journal files, mapped by journald\n\t# and by every reader, stay in 2 MiB folios until they are archived (issue 746). Rotating starts new\n\t# files that are mapped under the new mode. A failed rotation is said and does not fail the setting.\n\t# At boot nothing is in huge folios yet, and the persistent journal opens only after this unit: no rotation\n\t# while the service manager is still bringing the machine up.\n\tbooting=$(systemctl is-system-running 2>/dev/null || true)\n\tcase \"$booting\" in\n\tinitializing | starting) echo \"the machine is still starting: nothing to rotate yet\" ;;\n\t*)\n\t\tif [ \"$want\" != always ] && command -v journalctl >/dev/null 2>&1; then\n\t\t\tjournalctl --rotate || echo \"the journal could not be rotated: its active files keep their huge pages until they are archived\" >&2\n\t\tfi\n\t\t;;\n\tesac\n\t;;\nrestore)\n\tfound=$(cat \"$state/found\" 2>/dev/null || true)\n\tcase \"$found\" in\n\talways | madvise | never)\n\t\tprintf '%s\\n' \"$found\" >\"$sys/enabled\"\n\t\techo \"transparent huge pages: back to $found, as the machine had them\"\n\t\t;;\n\t*) echo \"transparent huge pages: nothing recorded as found, left at $(mode)\" ;;\n\tesac\n\t# Unassigned or shut down: nothing of the module is kept; the next start records what it finds again.\n\trm -f \"$state/found\"\n\t;;\n*)\n\techo \"usage: huge-pages apply|restore [<transparent_hugepage directory> [<state directory>]]\" >&2\n\texit 2\n\t;;\nesac\n"
},
{
"id": "unit",
"type": "file",
"path": "/etc/systemd/system/mesh-kernel-huge-pages.service",
"mode": "0644",
"content": "[Unit]\nDescription=Transparent huge pages as the mesh sets them on this machine\n# Module kernel, novox/hq issue 746. Starting this unit sets the mode from the setting\n# transparent-huge-pages; reloading it (the setting changed) sets the new mode; stopping it gives back the\n# mode the machine had before. It runs early in boot, before the services that fill the page cache.\nDefaultDependencies=no\nAfter=local-fs.target\nBefore=sysinit.target shutdown.target\nConflicts=shutdown.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/huge-pages apply\nExecReload=${dir:state}/huge-pages apply\nExecStop=${dir:state}/huge-pages restore\n# It writes one file under /sys and its own state, and asks the journal daemon to rotate over its socket.\nProtectSystem=strict\nReadWritePaths=${dir:state}\nProtectHome=yes\nPrivateNetwork=yes\nNoNewPrivileges=yes\n\n[Install]\nWantedBy=sysinit.target\n"
},
{
"id": "apply",
"type": "service",
"unit": "mesh-kernel-huge-pages.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"unit"
],
"reload-on": [
"huge-pages"
],
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/kernel-tools",
"binary": "kernel-tools",
"loads": [
"kernel-tools"
]
}
]
}
}
-216
View File
@@ -1,216 +0,0 @@
// Whether mail flows through mailu, as the node-engine asks it every interval (novox/hq ADR 0240, kind
// `tool`; issue 690). Every container read healthy on 2026-10-14 while postfix could not resolve a name:
// inbound mail was refused with 450 and outbound mail sat in the deferred queue for days. The parts'
// checks judge parts; this judges the flow, from inside the postfix container, the way postfix sees it:
//
// 1. postfix runs (`postfix status`, standing in for the image's own check, `! /usr/libexec/postfix/master
// -t`, which this one replaces);
// 2. the deferred queue (`postqueue -j`) shows no fault of this side: a recipient deferred longer than the
// bound because a lookup failed here, or overdue mail to three or more domains. One or two far domains
// that do not answer are theirs, and can stay deferred for days: said, never unhealthy, so one dead
// recipient does not page the operator for days;
// 3. a public mail host's name resolves through the resolver postfix is given: a resolver that
// SERVFAILs refuses inbound mail before anything reaches the queue.
//
// The answer is the health answer every health tool gives (mesh-sdk go/health): `state`, `healthy` beside
// it for an older reader, and `why` in words — counts, the oldest message's age and its first reason —
// never a bare false.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
/** How long a message may wait in the deferred queue before mail is said not to flow. */
export const DEFERRED_BOUND_SECONDS = 30 * 60;
/** The public name resolved through postfix's resolver: gmail's mail exchanger, which mail needs. */
export const PUBLIC_NAME = "gmail-smtp-in.l.google.com";
// The lookup postfix's own would make, through the resolver the container is given; a failure is said on one
// line ("gaierror: [Errno -3] Try again" is what a SERVFAIL reads as), never as a traceback.
const RESOLVE = [
"import socket, sys",
"try:",
` socket.getaddrinfo(${JSON.stringify(PUBLIC_NAME)}, 25)`,
"except Exception as e:",
" sys.exit(type(e).__name__ + ': ' + str(e))",
].join("\n");
/** Overdue mail to this many recipient domains or more is our side's fault, not one far server's. */
export const SPREAD_DOMAINS = 3;
/**
* What postfix writes when its own lookups fail, as on 2026-10-14 (issue 687): "Host or domain name not
* found. Name service error for name=gmail.com type=MX: Host not found, try again", or "Temporary lookup
* failure". A far server that is down reads otherwise ("Connection timed out", "Connection refused").
*/
export const LOCAL_LOOKUP_FAILURE =
/Name service error|Temporary lookup failure|Host or domain name not found.*try again/is;
/** The domain of an address, lower-cased; "(no recipient)" when there is none. */
function domainOf(address?: string): string {
const at = address?.lastIndexOf("@") ?? -1;
return address && at >= 0 ? address.slice(at + 1).toLowerCase() : "(no recipient)";
}
/** Runs a command in a container: its stdout, or a rejection carrying stderr. */
export type Exec = (container: string, command: string[]) => Promise<string>;
/** The health answer (mesh-sdk go/health): healthy, or unhealthy with why. */
export interface HealthAnswer {
state: "healthy" | "unhealthy";
healthy: boolean;
why: string;
queue?: {
messages: number;
deferred: number;
overdue: number;
oldestDeferredSeconds?: number;
overdueDomains?: string[];
nameServiceFailures?: number;
};
}
/** One message as `postqueue -j` prints it, one JSON object a line (postfix 3.1 and later). */
interface Queued {
queue_name?: string;
queue_id?: string;
arrival_time?: number;
recipients?: Array<{ address?: string; delay_reason?: string }>;
}
/** docker exec, bounded: a hung container is an answer too. */
export function dockerExec(timeoutMs = 10_000): Exec {
return async (container, command) =>
(await run("docker", ["exec", container, ...command], { timeout: timeoutMs })).stdout;
}
/** What a failed exec said, on one line: its stderr when it has one, else its message. */
function said(e: unknown): string {
const err = e as { stderr?: string; message?: string; killed?: boolean; signal?: string };
if (err?.killed || err?.signal === "SIGTERM") return "it did not answer in time";
const text = (err?.stderr ?? "").trim() || (err?.message ?? String(e)).trim();
return text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "it failed and said nothing";
}
/** An age in words: "2 days 3 hours", "45 minutes". */
export function age(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
const unit = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
if (d > 0) return h > 0 ? `${unit(d, "day")} ${unit(h, "hour")}` : unit(d, "day");
if (h > 0) return m > 0 ? `${unit(h, "hour")} ${unit(m, "minute")}` : unit(h, "hour");
return unit(m, "minute");
}
function parseQueue(output: string): Queued[] {
const queued: Queued[] = [];
for (const line of output.split("\n")) {
const text = line.trim();
if (!text.startsWith("{")) continue; // "Mail queue is empty" and the like
queued.push(JSON.parse(text) as Queued);
}
return queued;
}
/**
* Judge whether mail flows. `now` is in seconds since the epoch. Every part is asked even when one
* fails, so the answer says everything that is wrong at once.
*/
export async function mailFlows(
exec: Exec,
container: string,
now: number = Date.now() / 1000,
boundSeconds: number = DEFERRED_BOUND_SECONDS,
): Promise<HealthAnswer> {
const faults: string[] = [];
const [status, queue, resolve] = await Promise.allSettled([
exec(container, ["postfix", "status"]),
exec(container, ["postqueue", "-j"]),
exec(container, ["python3", "-c", RESOLVE]),
]);
if (status.status === "rejected") {
faults.push(`postfix in ${container} does not run: ${said(status.reason)}.`);
}
let summary: HealthAnswer["queue"];
const notes: string[] = [];
if (queue.status === "rejected") {
faults.push(`postfix's queue in ${container} could not be read: ${said(queue.reason)}.`);
} else {
let queued: Queued[] = [];
try {
queued = parseQueue(queue.value);
} catch (e) {
faults.push(`postfix's queue in ${container} printed a line that is not JSON: ${said(e)}.`);
}
const deferred = queued.filter((q) => q.queue_name === "deferred");
const overdue = deferred
.filter((q) => typeof q.arrival_time === "number" && now - q.arrival_time > boundSeconds)
.sort((a, b) => (a.arrival_time ?? 0) - (b.arrival_time ?? 0));
const oldest = deferred.reduce<number | undefined>(
(o, q) => (typeof q.arrival_time === "number" && (o === undefined || q.arrival_time < o) ? q.arrival_time : o),
undefined,
);
// Each overdue recipient, oldest message first: what our side is judged by, not one far host.
const late = overdue.flatMap((q) =>
(q.recipients?.length ? q.recipients : [{}]).map((r) => ({
to: r.address,
domain: domainOf(r.address),
reason: r.delay_reason?.trim() || "postfix gave no reason",
waited: now - (q.arrival_time ?? now),
})),
);
const lookups = late.filter((r) => LOCAL_LOOKUP_FAILURE.test(r.reason));
const domains = [...new Set(late.map((r) => r.domain))];
summary = {
messages: queued.length,
deferred: deferred.length,
overdue: overdue.length,
...(oldest !== undefined ? { oldestDeferredSeconds: Math.max(0, Math.floor(now - oldest)) } : {}),
...(domains.length > 0 ? { overdueDomains: domains } : {}),
...(lookups.length > 0 ? { nameServiceFailures: lookups.length } : {}),
};
const one = (r: (typeof late)[number]) =>
`the oldest, ${age(r.waited)} old${r.to ? ` to ${r.to}` : ""}, waits because: ${r.reason}`;
const bound = age(boundSeconds);
if (lookups.length > 0) {
faults.push(
`${lookups.length} deferred ${lookups.length === 1 ? "recipient has" : "recipients have"} waited longer ` +
`than ${bound} on a name-service failure, which is this server's resolver; ${one(lookups[0])}.`,
);
} else if (domains.length >= SPREAD_DOMAINS) {
faults.push(
`${overdue.length} of ${deferred.length} deferred messages have waited longer than ${bound}, to ` +
`${domains.length} domains (${domains.join(", ")}): more than one far server's fault; ${one(late[0])}.`,
);
} else if (late.length > 0) {
notes.push(
`${overdue.length} deferred ${overdue.length === 1 ? "message has" : "messages have"} waited longer than ` +
`${bound}, only to ${domains.join(" and ")}, which reads as the far side's: ${one(late[0])}.`,
);
}
}
if (resolve.status === "rejected") {
faults.push(`${container} cannot resolve ${PUBLIC_NAME} through its resolver: ${said(resolve.reason)}.`);
}
if (faults.length > 0) {
const why = [`Mail does not flow.`, ...faults, ...notes].join(" ");
return { state: "unhealthy", healthy: false, why, queue: summary };
}
const waiting = !summary || summary.deferred === 0
? " The deferred queue is empty."
: notes.length > 0
? ` ${notes.join(" ")}`
: ` ${summary.deferred} deferred, the oldest ${age(summary.oldestDeferredSeconds ?? 0)} old, within ${age(boundSeconds)}.`;
return {
state: "healthy",
healthy: true,
why: `Mail flows: postfix runs, ${PUBLIC_NAME} resolves.${waiting}`,
queue: summary,
};
}
+4 -32
View File
@@ -5,10 +5,6 @@
"policy": "record",
"why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)"
},
"unhealthy": {
"severity": "urgent",
"why": "while mailu is broken, mail is refused or stuck for everyone with a mailbox here (hq ADR 0355, issue 687)"
},
"capabilities": [
"container-runtime"
],
@@ -60,19 +56,6 @@
"api-token": "${dir:state}/api-token.secret"
}
},
"tools": [
"mailu_list_users",
"mailu_create_user",
"mailu_change_password",
"mailu_delete_user",
"mailu_list_aliases",
"mailu_create_alias",
"mailu_delete_alias",
"mailu_list_domains",
"mailu_read_mail",
"mailu_search_mail",
"mailu_health"
],
"emits": [
"user.created",
"user.deleted",
@@ -388,7 +371,7 @@
"id": "resolver",
"type": "container",
"name": "mailu-resolver",
"image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e",
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
"health": {
"kind": "runtime"
},
@@ -460,12 +443,7 @@
"name": "mailu-smtp",
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
"health": {
"kind": "tool",
"tool": "mailu_health",
"interval": "60s",
"timeout": "30s",
"looks": 3,
"grace": "120s"
"kind": "runtime"
},
"network": "mailu",
"env-file": [
@@ -582,12 +560,7 @@
"name": "mailu-front",
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
"health": {
"kind": "exec",
"command": "curl -m3 -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 $(cat /run/dovecot/master.pid) && python3 -c \"import smtplib; s = smtplib.SMTP('127.0.0.1', 25, timeout=5); s.ehlo('health'); s.quit()\" && openssl x509 -checkend 1209600 -noout -in /certs/letsencrypt/live/mailu/nginx-chain.pem",
"interval": "60s",
"timeout": "15s",
"looks": 3,
"grace": "120s"
"kind": "runtime"
},
"network": "mailu",
"env-file": [
@@ -664,7 +637,6 @@
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_SMTP_CONTAINER": "mailu-smtp",
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
@@ -701,7 +673,7 @@
"jails": [
{
"name": "mailu-front",
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)\n ^.*client login failed: \"AUTH not supported\" while in http auth state, client: <HOST>, server: \\S+:25,",
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
}
]
-140
View File
@@ -1,140 +0,0 @@
// Whether mail flows (novox/hq issue 690): the health tool is unhealthy, in words, when a message waits in
// postfix's deferred queue longer than the bound, when postfix does not run, when a public name does not
// resolve, and when the container cannot be reached — and healthy otherwise. Run against the compiled
// module, the way the runtime loads it.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mailFlows, DEFERRED_BOUND_SECONDS } from "../dist/health.js";
const NOW = 1_800_000_000;
type Answers = { status?: string | Error; queue?: string | Error; resolve?: string | Error };
/** A container that answers each command as told; an Error is a failed exec, with its stderr. */
function container(a: Answers) {
const asked: string[][] = [];
const exec = async (_name: string, command: string[]) => {
asked.push(command);
const answer = command[0] === "postfix" ? a.status ?? "" : command[0] === "postqueue" ? a.queue ?? "" : a.resolve ?? "";
if (answer instanceof Error) throw answer;
return answer;
};
return { exec, asked };
}
function failed(stderr: string): Error {
return Object.assign(new Error("Command failed: docker exec"), { stderr });
}
function deferred(id: string, ageSeconds: number, reason: string, to = "friend@example.com"): string {
return JSON.stringify({
queue_name: "deferred",
queue_id: id,
arrival_time: NOW - ageSeconds,
message_size: 1234,
sender: "someone@example.org",
recipients: [{ address: to, delay_reason: reason }],
});
}
test("an empty queue is healthy, and says so", async () => {
const c = container({ queue: "" });
const a = await mailFlows(c.exec, "mailu-smtp", NOW);
assert.equal(a.state, "healthy");
assert.equal(a.healthy, true);
assert.match(a.why, /deferred queue is empty/);
assert.deepEqual(a.queue, { messages: 0, deferred: 0, overdue: 0 });
assert.equal(c.asked.length, 3, "postfix, its queue and a lookup were each asked");
});
test("a recipient overdue on a name-service failure is unhealthy, with counts, its age and its reason", async () => {
const reason = "Host or domain name not found. Name service error for name=gmail.com type=MX: Host not found, try again";
const queue = [
deferred("AAA111", 3 * 86400 + 2 * 3600, reason),
deferred("BBB222", 5 * 60, "connect to mx.example.net[192.0.2.1]:25: Connection timed out"),
JSON.stringify({ queue_name: "active", queue_id: "CCC333", arrival_time: NOW - 10, recipients: [] }),
].join("\n");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.equal(a.healthy, false);
assert.match(a.why, /^Mail does not flow\./);
assert.match(a.why, /1 deferred recipient has waited longer than 30 minutes on a name-service failure/);
assert.match(a.why, /3 days 2 hours old to friend@example\.com/);
assert.ok(a.why.includes(reason), "the reason is said");
assert.deepEqual(a.queue, {
messages: 3, deferred: 2, overdue: 1, oldestDeferredSeconds: 3 * 86400 + 2 * 3600,
overdueDomains: ["example.com"], nameServiceFailures: 1,
});
});
test("one dead far domain overdue is healthy, and said", async () => {
const queue = [
deferred("AAA111", 2 * 86400, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "a@dead.example"),
deferred("BBB222", 3600, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "b@dead.example"),
].join("\n");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "healthy");
assert.equal(a.healthy, true);
assert.match(a.why, /2 deferred messages have waited longer than 30 minutes, only to dead\.example, which reads as the far side's/);
assert.match(a.why, /2 days old to a@dead\.example, waits because: connect to mx\.dead\.example/);
assert.deepEqual(a.queue?.overdueDomains, ["dead.example"]);
});
test("overdue mail to three domains is unhealthy, whatever the reason", async () => {
const timedOut = "connect to mx[192.0.2.1]:25: Connection timed out";
const queue = [
deferred("AAA111", 7200, timedOut, "a@one.example"),
deferred("BBB222", 3600, timedOut, "b@two.example"),
deferred("CCC333", 3600, timedOut, "c@three.example"),
].join("\n");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.match(a.why, /3 of 3 deferred messages have waited longer than 30 minutes, to 3 domains \(one\.example, two\.example, three\.example\)/);
assert.match(a.why, /the oldest, 2 hours old to a@one\.example/);
});
test("a deferred message younger than the bound is healthy, and is said", async () => {
const queue = deferred("BBB222", DEFERRED_BOUND_SECONDS - 60, "connect to mx.example.net: Connection timed out");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "healthy");
assert.equal(a.healthy, true);
assert.match(a.why, /1 deferred, the oldest 29 minutes old, within 30 minutes/);
});
test("a container that cannot be reached is unhealthy, and says what docker said", async () => {
const gone = failed("Error response from daemon: No such container: mailu-smtp\n");
const a = await mailFlows(container({ status: gone, queue: gone, resolve: gone }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.equal(a.healthy, false);
assert.match(a.why, /postfix in mailu-smtp does not run: Error response from daemon: No such container: mailu-smtp\./);
assert.match(a.why, /postfix's queue in mailu-smtp could not be read: Error response from daemon: No such container/);
assert.equal(a.queue, undefined);
});
test("a name that does not resolve is unhealthy, though the queue is empty", async () => {
const a = await mailFlows(
container({ queue: "", resolve: failed("gaierror: [Errno -3] Try again\n") }).exec,
"mailu-smtp",
NOW,
);
assert.equal(a.state, "unhealthy");
assert.match(a.why, /mailu-smtp cannot resolve gmail-smtp-in\.l\.google\.com through its resolver: gaierror: \[Errno -3\] Try again\./);
});
test("a container that does not answer in time is said so", async () => {
const hung = Object.assign(new Error("Command failed"), { killed: true, signal: "SIGTERM" });
const a = await mailFlows(container({ queue: hung }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.match(a.why, /postfix's queue in mailu-smtp could not be read: it did not answer in time\./);
});
test("the health tool is served without the admin API, on the smtp container it is told", async () => {
const { collectTools } = await import("@novox/mesh-sdk/tools");
await import("../dist/tools/index.js");
const mailu = collectTools({ MESH_MAILU_SMTP_CONTAINER: "mailu-smtp" }).find((m) => m.module === "mailu");
const names = (mailu?.tools ?? []).map((t) => t.name);
assert.deepEqual(names, ["mailu_health"]);
assert.match(mailu!.tools[0].description, /inside mailu-smtp/);
});
+4 -27
View File
@@ -4,27 +4,6 @@
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { MailuClient } from "../client.js";
import { dockerExec, mailFlows, DEFERRED_BOUND_SECONDS, PUBLIC_NAME, SPREAD_DOMAINS } from "../health.js";
/**
* Whether mail flows (novox/hq ADR 0240, issue 690): the tool the node-engine asks as the smtp container's
* check. It needs no admin API, only the postfix container, so it is served even when the API is not
* configured — a check that vanished with the API would say unhealthy for the wrong reason.
*/
export function getHealthTool(env: NodeJS.ProcessEnv): ToolDefinition {
const container = env.MESH_MAILU_SMTP_CONTAINER || "mailu-smtp";
return {
name: "mailu_health",
description:
`Whether mail flows through mailu, asked inside ${container}: postfix runs; no recipient has waited ` +
`deferred longer than ${DEFERRED_BOUND_SECONDS / 60} minutes on a name-service failure here, and overdue ` +
`deferred mail does not reach ${SPREAD_DOMAINS} or more domains (one or two far domains are said, not ` +
`unhealthy); and ${PUBLIC_NAME} resolves through the resolver postfix uses. Says counts, domains, the ` +
"oldest message's age and its reason (novox/hq ADR 0240, issue 690).",
input: {},
run: async () => mailFlows(dockerExec(), container),
};
}
export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
return [
@@ -164,14 +143,12 @@ export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
];
}
// The admin tools exist only when the admin API is configured; without it, mailu contributes only its
// health tool rather than failing the whole runtime.
// The tools exist only when the admin API is configured; without it, mailu contributes none rather
// than failing the whole runtime.
registerModuleTools("mailu", (env) => {
let tools: ToolDefinition[] = [];
try {
tools = getMailuTools(MailuClient.fromEnv(env));
return getMailuTools(MailuClient.fromEnv(env));
} catch {
tools = [];
return [];
}
return [...tools, getHealthTool(env)];
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "health.ts", "index.ts", "tools/index.ts"]
"include": ["client.ts", "index.ts", "tools/index.ts"]
}
-96
View File
@@ -1,96 +0,0 @@
// What the catalogue makes of a build outcome it hears (novox/hq issue 712).
//
// Kept apart from index.ts, which subscribes at its top level, so the decision can be tested without a bus.
import type { Made } from "./store.js";
/** What the build seat says when it has built something, and what the control plane replays. */
export interface Built {
module?: string;
commit?: string;
repository?: string;
path?: string;
ref?: string;
manifest?: unknown;
/** What this build published, each pinned as anything else would name it. */
made?: Made[];
/**
* Every artifact this was built on top of, so the edge is derived rather than declared.
*
* References, not module names: the builder sees a pinned image and cannot see which module
* produced it. Turning that into an edge between module-versions is this module's job.
*/
against?: string[];
/**
* This is history, not news — the mesh re-announcing a build this catalogue was not there for.
*
* Registered exactly as any other, and announced as nothing. A module that moved months ago is
* not something anything should act on now: emitting `upgraded` would have the control plane
* decide about a rollout, and `rebuild-needed` would ask for builds of things that are already
* current.
*/
replay?: boolean;
/** Why the build failed; a failed build made no module-version. */
failed?: string;
/** A dry run is looked at, never taken in (novox/hq issue 240). */
"dry-run"?: boolean;
/** A pull request's merge check or a branch tried before one (to-be 45 §9, issue 686): nothing merged. */
"check-outcome"?: unknown;
check?: unknown;
/** The repository's default branch as the build seat read the forge at the build (ADR 0238); empty from a seat older than the rule. */
trunk?: string;
/** Whether the commit is on `trunk`. */
"on-trunk"?: boolean;
/** The branches the commit is on. */
branches?: string[];
}
/** The events a build outcome reaches this module on: the build seat's, as it happens, and the control plane's replay. */
export const BUILD_EVENTS = [
// **The build seat that builds now** (novox/hq ADR 0190). Until issue 712 the catalogue heard only the
// retired seat below, so every build after the handover reached the graph only through the replay this
// module asks for when it starts: a merge built and pushed stayed out of the catalogue until its next start.
"node-build-agent.built",
// The retired build role, while a machine still serves it (ADR 0190's handover). Goes with the seat row.
"mesh-build-machine.built",
// What the mesh already held, when this module asks what it missed (novox/hq ADR 0134).
"mesh-controller.built-before",
] as const;
/**
* Whether a build outcome is a module-version to place in the graph, and why not when it is not.
*
* It mirrors two of the rules the controller keeps before it registers a build (mesh-controller
* cmd/mesh-controller/main.go `builds.Built`, build.go `publishable`): a merge check, a try and a dry run are
* never published, and **only a commit on its repository's trunk is** (ADR 0238) — a branch built through
* the `build` verb is checked, never a module's version. Where the seat names no trunk (one older than the
* rule) it is placed, as the controller lets it through. A failure made nothing and is passed over too.
*
* Not mirrored, because the catalogue cannot see what they read: the branch a module follows when it is not
* the default one (the controller's `follows`; such a module's builds off the default branch are passed over
* here and reach the graph by the replay), ADR 0266's rule that a module registers only from the repository
* it is built from, a superseded request, a build in the scratch namespace, one that failed its gate, and a
* build whose source is unchanged. Each needs the controller's records. This is safe for what the mesh
* sends, which is chosen from the controller's own module table and never from this graph; that the graph
* can still hold a build the controller refused under those rules is a follow-up (hq issue 712).
*/
export function placeable(body: Built):
| { placed: "yes"; module: string; commit: string }
| { placed: "no"; why: string; fault: boolean } {
if (body["check-outcome"] != null || body.check != null) {
return { placed: "no", why: "a merge check or a try: nothing of it merged", fault: false };
}
if (body["dry-run"]) {
return { placed: "no", why: "a dry run is looked at, never taken in", fault: false };
}
if (body.failed) {
return { placed: "no", why: "a failed build made no module-version", fault: false };
}
if (body.trunk && !body["on-trunk"] && !(body.branches ?? []).includes(body.trunk)) {
return { placed: "no", why: `the commit is not on its repository's trunk ${body.trunk}: checked, never published (ADR 0238)`, fault: false };
}
if (!body.module || !body.commit) {
return { placed: "no", why: "it named no module or no commit", fault: true };
}
return { placed: "yes", module: body.module, commit: body.commit };
}
+41 -17
View File
@@ -10,8 +10,7 @@
// diamond need no special handling, and nothing holds a plan.
import { on, emit } from "@novox/mesh-sdk/events";
import { Graph } from "./store.js";
import { BUILD_EVENTS, placeable, type Built } from "./heard.js";
import { Graph, type Made } from "./store.js";
const graph = Graph.fromEnv();
@@ -22,29 +21,54 @@ const graph = Graph.fromEnv();
// would block the very apply that brings the overlay up — stopped being true when a step's failure
// became this module's business and not the machine's (ADR 0136).
/** What the builder says when it has built something. */
interface Built {
module?: string;
commit?: string;
repository?: string;
path?: string;
ref?: string;
manifest?: unknown;
/** What this build published, each pinned as anything else would name it. */
made?: Made[];
/**
* Every artifact this was built on top of, so the edge is derived rather than declared.
*
* References, not module names: the builder sees a pinned image and cannot see which module
* produced it. Turning that into an edge between module-versions is this module's job.
*/
against?: string[];
/**
* This is history, not news — the mesh re-announcing a build this catalogue was not there for.
*
* Registered exactly as any other, and announced as nothing. A module that moved months ago is
* not something anything should act on now: emitting `upgraded` would have the control plane
* decide about a rollout, and `rebuild-needed` would ask for builds of things that are already
* current.
*/
replay?: boolean;
}
/**
* What a build means for the graph, wherever it came from.
*
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
* and the control plane says what it already held when this module asks what it missed
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
* months ago — see `replay` in heard.ts.
* months ago — see `replay` above.
*/
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
const body = (event.body ?? {}) as Built;
const placing = placeable(body);
if (placing.placed === "no") {
// A check, a try, a dry run and a failure are the build seat's ordinary outcomes: passed over. One
// that names no module or commit is a fault in the builder, said rather than dropped, so it does
// not look like a missing event.
if (placing.fault) console.error(`mesh-catalog: a build event was not placed: ${placing.why}`, body);
if (!body.module || !body.commit) {
// Said rather than dropped: a build that announced itself without saying what it built is a
// fault in the builder, and a silent discard here would make it look like a missing event.
console.error("mesh-catalog: a build event named no module or no commit; ignored", body);
return;
}
const { module, commit } = placing;
const { upgraded, previous } = await graph.register({
module,
commit,
module: body.module,
commit: body.commit,
repository: body.repository ?? "",
path: body.path ?? "",
ref: body.ref ?? "",
@@ -56,7 +80,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
if (body.replay) return;
await emit("registered", {
module, commit, upgraded,
module: body.module, commit: body.commit, upgraded,
});
// **A rebuild that changed nothing is not an upgrade.** Announcing it would ripple outward
@@ -64,7 +88,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
if (!upgraded) return;
await emit("upgraded", {
module, commit, previous,
module: body.module, commit: body.commit, previous,
});
// What can be built now — stale, and waiting on nothing that is itself stale.
@@ -77,9 +101,9 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
}
};
// As it happens — from the build seat that builds now, and the retired one while it still answers — and
// what the mesh already held when this module asked what it missed (novox/hq issue 712).
for (const heard of BUILD_EVENTS) await on(heard, placeTheBuild);
// As it happens, and what the mesh already held when this module asked what it missed.
await on("mesh-build-machine.built", placeTheBuild);
await on("mesh-controller.built-before", placeTheBuild);
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
//
-1
View File
@@ -26,7 +26,6 @@
"postgres-database": "${dir:state}/database.secret"
},
"consumes": [
"node-build-agent.built",
"mesh-build-machine.built",
"mesh-controller.built-before"
],
-72
View File
@@ -1,72 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { BUILD_EVENTS, placeable } from "../heard.js";
// A walk's build as the build seat says it (mesh-controller's link.BuildResult), here mailu's of novox/hq issue 712.
const walkBuild = {
id: "build-1791997333800041716",
repository: "http://novox.internal:20000/novox/mesh-catalog.git",
path: "modules/mailu",
ref: "main",
on: "shanks",
trunk: "main",
"on-trunk": true,
branches: ["main"],
module: "mailu",
commit: "e1c8965ddbf1cf9af9cf1eed7a72ae9ffa6d5a87",
manifest: { module: "mailu", unhealthy: { severity: "urgent", why: "mail stops" } },
made: [{ name: "code", kind: "bundle", reference: "artifact-store://mailu/code/blobs/sha256:d041" }],
};
test("the catalogue hears the build seat that builds now, not only the retired one (issue 712)", async () => {
assert.ok(BUILD_EVENTS.includes("node-build-agent.built"));
assert.ok(BUILD_EVENTS.includes("mesh-controller.built-before"));
// Subscribing is not enough: the mesh grants and routes only what the manifest consumes.
const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8"));
for (const heard of BUILD_EVENTS) assert.ok(manifest.consumes.includes(heard), `${heard} is not consumed`);
});
test("a walk's build is placed, under its module and commit", () => {
assert.deepEqual(placeable(walkBuild), { placed: "yes", module: "mailu", commit: walkBuild.commit });
// A walk that took the check's build of the same key says so, and is placed all the same (ADR 0350 §2).
assert.equal(placeable({ ...walkBuild, reused: "the check's build" } as never).placed, "yes");
});
test("a merge check, a try, a dry run and a failure are passed over without a fault", () => {
for (const [body, why] of [
[{ ...walkBuild, "check-outcome": { verdict: "passed" } }, "merge check"],
[{ ...walkBuild, check: { try: true } }, "merge check or a try"],
[{ ...walkBuild, "dry-run": true }, "dry run"],
[{ ...walkBuild, failed: "exit status 1" }, "failed"],
] as const) {
const placing = placeable(body);
assert.equal(placing.placed, "no");
if (placing.placed === "no") {
assert.match(placing.why, new RegExp(why));
assert.equal(placing.fault, false);
}
}
});
test("an outcome that names no module or commit is said as the builder's fault", () => {
const placing = placeable({ repository: walkBuild.repository, commit: walkBuild.commit });
assert.equal(placing.placed, "no");
if (placing.placed === "no") assert.equal(placing.fault, true);
});
test("a branch built through the build verb is passed over; a commit on the trunk, or from a seat that names none, is placed (ADR 0238)", () => {
const branch = { ...walkBuild, ref: "feature", "on-trunk": false, branches: ["feature"] };
const placing = placeable(branch);
assert.equal(placing.placed, "no");
if (placing.placed === "no") {
assert.match(placing.why, /not on its repository's trunk main/);
assert.equal(placing.fault, false);
}
// On the trunk by its branches although the seat did not set on-trunk.
assert.equal(placeable({ ...branch, branches: ["feature", "main"] }).placed, "yes");
// A seat older than the rule says no trunk: placed, as the controller lets it through.
const { trunk, "on-trunk": _on, branches, ...older } = walkBuild;
assert.equal(placeable(older).placed, "yes");
});
-1
View File
@@ -11,7 +11,6 @@
"include": [
"pg.d.ts",
"store.ts",
"heard.ts",
"index.ts",
"tools/index.ts",
"prepare/index.ts"
@@ -144,9 +144,7 @@ func (h *Holder) accounted(w Walk) bool {
return false
}
// held is whether a live delivery follows the walk, or a live delivery it answers follows a newer one. A walk whose
// word only stops that lapsed give (lapsedOn, novox/hq issue 753) is not held: it stays under the orphans' timer, so
// past the grace without its word it is listed by `stalled`, saying why it was not given.
// held is whether a live delivery follows the walk, or a live delivery it answers follows a newer one.
func (h *Holder) heldWalk(w Walk) bool {
for _, d := range h.deliveries {
if d.State.Final() {
@@ -294,28 +292,12 @@ func (h *Holder) orphanLines(now time.Time) []StalledLine {
continue
}
w := s.walk
if lapsed, behind := h.lapsedOf(w); len(lapsed) > 0 || len(behind) > 0 {
// Owed its word by stops that lapsed, and not given it past the grace (review of issue 753): said, why.
says := fmt.Sprintf("the walk %s of %s at %s waits for this owner's word, which the stopped deliveries whose "+
"merge it answers owe it (novox/hq issue 753), and it has not started", w.ID, w.Repository, shortOf(w.Commit, 8))
if why := h.lapsedSaid(w); why != "" {
says += ": " + why
}
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: w.ID,
For: now.Sub(first).Round(time.Second).String(), Bound: matchGrace.String(),
H2: "none: its word is given at its turn — the operator's past the bound", Says: says})
continue
}
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Unheld, Walk: w.ID,
For: now.Sub(first).Round(time.Second).String(), Bound: matchGrace.String(),
H2: "none: no delivery holds the walk, so nothing gives it its word — the operator's",
Says: fmt.Sprintf("the walk %s of %s at %s waits for this owner's word, and no delivery that has not ended "+
"follows it or is answered by it: `mesh-delivery.show` for the deliveries of %s, and `plans go %s --why …` "+
"starts it by hand", w.ID, w.Repository, shortOf(w.Commit, 8), w.Repository, w.ID)})
out[len(out)-1].Says += h.endedSaid(w)
if hold := h.coverHeld(id); hold != "" {
out[len(out)-1].Says += "; it is held back meanwhile: " + hold
}
}
return out
}
@@ -362,7 +344,6 @@ func (h *Holder) forgetWalks(now time.Time) {
}
case !w.Open() && now.Sub(s.at) > keptWalkFor:
delete(h.walks, id)
delete(h.deliverErr, id)
}
}
}
@@ -375,168 +356,6 @@ type turnWant struct {
whys []string
}
// endedSaid names the ended deliveries a waiting walk answers or that follow it, with their states, so a walk no live
// delivery holds says what it carries instead of only that nothing holds it (novox/hq issue 753); empty for none.
func (h *Holder) endedSaid(w Walk) string {
var ended []string
for _, d := range h.deliveries {
if !d.State.Final() {
continue
}
_, answers := walkAnswers(w, d)
if answers || d.Walk != nil && d.Walk.ID == w.ID {
ended = append(ended, fmt.Sprintf("%s (%s)", d.ID, d.State))
}
}
if len(ended) == 0 {
return ""
}
sort.Strings(ended)
return "; every delivery it carries ended: " + strings.Join(ended, ", ")
}
// lapsedOn are the deliveries, by id, whose word an open walk waiting for this owner's word takes from a stop that
// lapsed (novox/hq issue 753): each was stopped by its group for a member delivered since, so its own change waits for
// a later walk of its repository that contains its merge (issue 747), and this walk answers that merge — it names it,
// or is made at it — and has not left it out. Such a walk is the one that delivery waits for: it gives it its word, so
// a walk whose every other delivery ended is never left without one. A walk at the merge of a delivery stopped for
// itself, or by a person, gets nothing from it. Under the lock.
//
// In its group's order (review of issue 753): a member gives its word only once every member ordered before it reached
// the machines, or is itself a stop that lapsed whose merge this same walk answers — the walk then delivers both, in
// the group's order among its tiers, as members sharing one walk do. A member behind one still undelivered gives
// nothing; lapsedBehind names that one.
func (h *Holder) lapsedOn(w Walk) []string {
out, _ := h.lapsedOf(w)
return out
}
// lapsedOf is lapsedOn, and, by delivery, the member before it in its group that holds back the word of a stop that
// lapsed whose merge the walk answers. Under the lock.
func (h *Holder) lapsedOf(w Walk) ([]string, map[string]string) {
if w.Release != nil || w.IsBatch() || !w.Open() || !w.Seen().Waits {
return nil, nil
}
var out []string
behind := map[string]string{}
for _, d := range h.deliveries {
if !h.lapsedAnswered(w, d) {
continue
}
if before := h.lapsedBehind(w, d); before != "" {
behind[d.ID] = before
continue
}
out = append(out, d.ID)
}
sort.Strings(out)
return out, behind
}
// lapsedAnswered is whether a delivery's stop lapsed and the walk answers its merge, not left out.
func (h *Holder) lapsedAnswered(w Walk, d *Delivery) bool {
if d.MergedAs == "" || !h.stopLapsed(d) {
return false
}
if _, ok := walkAnswers(w, d); !ok {
return false
}
_, left := w.leftOut(d.Repository, d.MergedAs)
return !left
}
// lapsedBehind is the member ordered before a delivery in its group that has not reached the machines and that this
// walk does not deliver with it, with its state; empty for none.
func (h *Holder) lapsedBehind(w Walk, d *Delivery) string {
g := h.groups[d.Group]
if d.Group == "" || g == nil {
return ""
}
members := h.membersOf(g)
byID := map[string]*Delivery{}
for _, m := range members {
byID[m.ID] = m
}
for _, m := range h.inOrder(g, members) {
if m.ID == d.ID {
return ""
}
o := byID[m.ID]
if o == nil || memberReached(o) || h.lapsedAnswered(w, o) {
continue
}
return fmt.Sprintf("%s (%s)", o.ID, o.State)
}
return ""
}
// lapsedSaid is why a walk whose word stops that lapsed give still waits: the members of their groups not yet
// delivered before them, what the last tick held it for, or the controller's refusal of the word. Under the lock.
func (h *Holder) lapsedSaid(w Walk) string {
var parts []string
_, behind := h.lapsedOf(w)
ids := make([]string, 0, len(behind))
for id := range behind {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
parts = append(parts, fmt.Sprintf("%s waits for %s, ordered before it in its group", id, behind[id]))
}
if why := h.waitsWhy[w.ID]; why != "" {
parts = append(parts, "not let go yet: "+why)
}
if err := h.deliverErr[w.ID]; err != "" {
parts = append(parts, "the controller refused its word: "+err)
}
lapsed, _ := h.lapsedOf(w)
for _, id := range lapsed {
if d := h.deliveries[id]; d != nil && strings.Contains(d.LetGoWhy, lapsedWord(w.ID)) {
parts = append(parts, fmt.Sprintf("its word was given for %s at %s, and the controller has not started it",
id, d.LetGo.Local().Format("15:04:05")))
}
}
return strings.Join(parts, "; ")
}
// lapsedOwed is whether a walk's word is owed by stops that lapsed: given now, or once a member before one is delivered.
func (h *Holder) lapsedOwed(w Walk) bool {
lapsed, behind := h.lapsedOf(w)
return len(lapsed) > 0 || len(behind) > 0
}
// lapsedTurns registers, for every open walk waiting for this owner's word, the turn of each delivery whose stop
// lapsed and whose merge it answers (lapsedOn, novox/hq issue 753). The word is then given as for any walk: once,
// when every live delivery it carries is at its turn too and it carries no stopped change still held back (words).
// Given once per walk: the walk's id is in the delivery's LetGoWhy once its word was given. Under the lock.
func (h *Holder) lapsedTurns() {
for id, s := range h.walks {
for _, did := range h.lapsedOn(s.walk) {
d := h.deliveries[did]
if strings.Contains(d.LetGoWhy, lapsedWord(id)) {
continue
}
if h.turning == nil {
h.turning = map[string]*turnWant{}
}
want := h.turning[id]
if want == nil {
want = &turnWant{}
h.turning[id] = want
}
if !containsString(want.ids, d.ID) {
want.ids = append(want.ids, d.ID)
want.whys = append(want.whys, fmt.Sprintf("%s: %s its merge %s, stopped by its group for %s, whose change "+
"was delivered since; it waits for a later walk of %s that contains its merge, and this is that "+
"walk (novox/hq issue 753)", d.ID, lapsedWord(id), shortOf(d.MergedAs, 8), d.stoppedFor(), d.Repository))
}
}
}
}
// lapsedWord is how the word a lapsed stop gives a walk names that walk.
func lapsedWord(walk string) string { return "the walk " + walk + " answers" }
// words are the asks of the word of every walk whose turn came for every live delivery it carries: one ask per
// walk, however many deliveries it carries, and none while one of them is not at its turn (ADR 0276 decision 5).
// Under the lock.
@@ -569,9 +388,6 @@ func (h *Holder) words() []func() {
if stopped, _ := h.carriesStopped(walk); len(stopped) > 0 {
h.waitsWhy[walk] = "it carries " + strings.Join(stopped, ", ") + ", whose delivery was stopped: it is " +
"asked to leave that merge out first"
if hold := h.coverHeld(walk); hold != "" {
h.waitsWhy[walk] = hold
}
h.Logf("[mesh-delivery] the walk %s is not let go: it carries %s, whose delivery was stopped", walk,
strings.Join(stopped, ", "))
continue
@@ -587,14 +403,9 @@ func (h *Holder) words() []func() {
defer h.mu.Unlock()
delete(h.asking, walk)
if err != nil {
if h.deliverErr == nil {
h.deliverErr = map[string]string{}
}
h.deliverErr[walk] = err.Error() // said by `stalled` past the grace (review of novox/hq issue 753)
h.Logf("[mesh-delivery] the walk %s could not be let go yet: %v", walk, err)
return
}
delete(h.deliverErr, walk)
for _, id := range ids {
d := h.deliveries[id]
if d == nil {
@@ -668,28 +479,9 @@ func (h *Holder) WalksWaiting() []StalledLine {
switch {
case len(held) > 0:
out = append(out, held...)
case h.coverHeld(id) != "":
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
Bound: coverAskBound.String(), H2: "none: it is asked to leave the merge out past the bound",
Says: "held back: " + h.coverHeld(id)})
case h.waitsWhy[id] != "":
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
Bound: "0s", H2: "none: it goes at its turn", Says: "not let go yet: " + h.waitsWhy[id]})
case !live && h.lapsedOwed(w):
// Its word comes from the stopped deliveries whose merge it answers (novox/hq issue 753): said, never
// unheld, while the controller has not taken it, with what holds it.
lapsed, _ := h.lapsedOf(w)
says := "its word is owed by the stopped deliveries whose merge it answers, each stopped for a member " +
"delivered since (novox/hq issue 753)"
if len(lapsed) > 0 {
says = "its word is given for " + strings.Join(lapsed, ", ") + ", stopped for a member delivered since, " +
"whose merge it answers (novox/hq issue 753)"
}
if why := h.lapsedSaid(w); why != "" {
says += "; " + why
}
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
Bound: "0s", H2: "none: it goes at its turn", Says: says})
case !live:
says := "no delivery that has not ended follows it or is answered by it, so nothing gives it its word: " +
"`mesh-delivery.show` for the deliveries of " + w.Repository
@@ -700,7 +492,7 @@ func (h *Holder) WalksWaiting() []StalledLine {
"), yet the walk moves " + strings.Join(walkModules(w), ", ") + " — nothing gives it its word"
}
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Unheld, Walk: id, For: waited,
Bound: "0s", H2: "none: no delivery holds the walk — the operator's", Says: says + h.endedSaid(w)})
Bound: "0s", H2: "none: no delivery holds the walk — the operator's", Says: says})
}
}
sort.Slice(out, func(i, j int) bool {
@@ -150,9 +150,6 @@ func statusOf(d *Delivery) (string, string) {
case Delivered:
return "success", "delivered"
case Failed:
if d.coveredStop() {
return "success", clip(d.coveredStopSaid(), 140) // its change reached the machines (novox/hq issue 747)
}
return "failure", clip("failed: "+why, 140)
case Superseded:
return "warning", clip("superseded: "+why, 140)
@@ -181,7 +178,7 @@ func ViewBody(d *Delivery, g *Group) string {
var b strings.Builder
fmt.Fprintf(&b, "%s\n**Delivery** `%s` — **%s** since %s\n\n", viewMarker, d.ID, d.State, d.Since.UTC().Format(time.RFC3339))
if d.coveredStop() {
fmt.Fprintf(&b, "**Covered**: %s\n\n", d.coveredStopSaid())
fmt.Fprintf(&b, "**Covered** after its stop: a later merge that contains it was delivered: %s\n\n", d.CoveredBy)
}
if d.HeldWhy != "" && d.State == Held {
fmt.Fprintf(&b, "Held for a person: %s — `mesh-delivery.release` with why lets it go on.\n\n", d.HeldWhy)
@@ -106,8 +106,6 @@ type fakeController struct {
moves map[string][]string
plans int
down bool
// refuseDeliver refuses every word, as a controller that will not take it does (review of novox/hq issue 753).
refuseDeliver bool
// onCheck runs as a check is asked, before it is answered: what the forge held at that moment.
onCheck func()
// planned is what Plan answers instead of "builds app", when set.
@@ -184,9 +182,6 @@ func (c *fakeController) Deliver(walk, why string) error {
if c.down {
return errors.New("down")
}
if c.refuseDeliver {
return errors.New("the controller refuses the word")
}
w, known := c.walks[walk]
if !known || w.Delivery == nil || w.Delivery.Go != nil {
return fmt.Errorf("%s waits for nobody", walk)
@@ -401,9 +396,6 @@ type fakeForge struct {
// open is what OpenPulls answers; openDown a forge that cannot say.
open []OpenPull
openDown bool
// merges, comments and unlabels are what was asked of the forge for ready marks (novox/hq issue 722).
merges, comments, unlabels []string
refuse error
}
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
@@ -657,25 +649,3 @@ func aWalk(id, repo, commit string, waits bool) Walk {
}
return w
}
// Merge, Comment and Unlabel record what was asked (novox/hq issue 722); Merge answers refuse when set.
func (f *fakeForge) Merge(owner, repo string, number int, head string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.merges = append(f.merges, fmt.Sprintf("%s/%s#%d@%s", owner, repo, number, head))
return f.refuse
}
func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.comments = append(f.comments, fmt.Sprintf("%s/%s#%d: %s", owner, repo, number, body))
return nil
}
func (f *fakeForge) Unlabel(owner, repo string, number, label int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.unlabels = append(f.unlabels, fmt.Sprintf("%s/%s#%d-%d", owner, repo, number, label))
return nil
}
@@ -60,8 +60,6 @@ type Holder struct {
waitsWhy map[string]string
// asking are the walks whose word is being asked right now, outside the lock: not asked twice meanwhile.
asking map[string]bool
// deliverErr is, by walk, the controller's last refusal of its word, until it is given (review of issue 753).
deliverErr map[string]string
// leaving are, by walk, the merges a stop gathered to leave out of it, asked once (novox/hq ADR 0299).
leaving map[string][]string
// leaveAsked is when a walk was last asked to leave out the stopped merges it carries (leaveStopped).
@@ -69,17 +67,8 @@ type Holder struct {
// leaveRefused is, by walk, what the controller answered when it refused both to leave a stopped merge out and
// to stop the walk whole.
leaveRefused map[string]string
// coverAsked are the deliveries that failed or were stopped whose cover by a later finished walk waits for the
// forge's word on containment, worked out each pass (novox/hq issue 747): a walk carrying a merge stopped for one of
// them is held back meanwhile, never asked to leave it out before the answer.
coverAsked map[string]bool
// coverAskedSince is, by delivery, when the forge was first asked about its cover and has not answered: past
// coverAskBound the walk is no longer held back for it, and is asked to leave the merge out (review of issue 747).
coverAskedSince map[string]time.Time
// tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407).
tried owedTries
// marks are the open pull requests read with a ready mark, by owner/repo#number (novox/hq issue 722, ready.go).
marks map[string]*marked
}
// owedTries is how many owed effects were tried since this owner started, and when the last was: three numbers
@@ -131,7 +120,6 @@ func (h *Holder) init() {
h.askTried = map[string]time.Time{}
h.walkAsked = map[string]time.Time{}
h.unchecked = map[string]unchecked{}
h.marks = map[string]*marked{}
h.walks = map[string]seenWalk{}
h.orphans = map[string]time.Time{}
h.asking = map[string]bool{}
@@ -1008,12 +996,6 @@ func (h *Holder) Tick() {
for _, ask := range h.leaveStopped() {
ask()
}
h.mu.Lock()
asks := h.readyAsks(h.Now())
h.mu.Unlock()
for _, ask := range asks {
ask()
}
}
// plan works out, under the lock, what to ask; each ask applies its own answer.
@@ -1121,48 +1103,6 @@ func (h *Holder) plan() []func() {
// A published delivery whose walk ended done before its word is read the same way (novox/hq issue 474): what
// that walk did not build, a later delivered merge may have; and when the cover test says none did, past the
// grace, it is stopped saying so — never left waiting for a start that cannot come.
// A delivery that failed or was stopped is read for a later finished walk of its repository that delivered its
// change after all: a walk that failed in an outage and was resumed, or the walk of a later merge that contains it
// (novox/hq issue 747). Read each tick, from what the walks and the kept deliveries say, so one stuck before this
// was built clears by itself after a restart.
h.coverAsked = map[string]bool{}
if h.coverAskedSince == nil {
h.coverAskedSince = map[string]time.Time{}
}
pending := map[string]bool{}
for _, d := range h.deliveries {
if d.State != Failed && d.State != Stopped || d.coveredStop() {
continue
}
by, ask := h.walkCover(d)
if len(ask) > 0 && by == "" {
pending[d.ID] = true
since, asked := h.coverAskedSince[d.ID]
if !asked {
since = now
h.coverAskedSince[d.ID] = now
}
// Held back only within the bound: a forge that keeps erroring never holds a walk for good.
h.coverAsked[d.ID] = now.Sub(since) < coverAskBound
}
if by != "" {
d.DeliveredBy = by
h.tellCovered(d, now)
continue
}
for _, in := range ask {
if a := h.askContains(d, in, now); a != nil {
asks = append(asks, a)
}
}
}
for id := range h.coverAskedSince {
if !pending[id] {
delete(h.coverAskedSince, id)
}
}
for _, d := range h.deliveries {
ended := d.endedUnstarted()
if d.coveredStop() && !d.CoverTold {
@@ -1246,8 +1186,6 @@ func (h *Holder) plan() []func() {
}
h.turn(d, "its turn: a delivery in no group")
}
// A walk that answers the merge of a delivery whose stop lapsed: that delivery's turn (novox/hq issue 753).
h.lapsedTurns()
// The word, once per walk, when every live delivery it carries is at its turn.
asks = append(asks, h.words()...)
@@ -1326,7 +1264,7 @@ func (h *Holder) laterOnItsWay(d *Delivery) string {
// stays final, and the bus's transition event is not said again.
func (h *Holder) tellCovered(d *Delivery, now time.Time) {
d.CoverTold = true
line := oneLine(fmt.Sprintf("%s delivery %s: %s, %s", now.UTC().Format(time.RFC3339), d.ID, d.State, d.coveredStopSaid()))
line := oneLine(fmt.Sprintf("%s delivery %s: stopped, %s", now.UTC().Format(time.RFC3339), d.ID, d.coveredStopSaid()))
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Line: line, Since: now})
if d.MergedAs != "" && !sameCommit(d.MergedAs, d.Commit) {
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.MergedAs, Line: line, Since: now})
@@ -1336,7 +1274,7 @@ func (h *Holder) tellCovered(d *Delivery, now time.Time) {
state, desc := statusOf(d)
d.owe(Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/delivery", State: state, Line: desc, Since: now})
}
h.Logf("[mesh-delivery] %s: %s, %s", d.ID, d.State, d.coveredStopSaid())
h.Logf("[mesh-delivery] %s: stopped, %s", d.ID, d.coveredStopSaid())
h.keep(d)
}
@@ -1347,104 +1285,6 @@ func strandedUncovered(d *Delivery) bool {
return d.State == Stopped && d.Stranded != "" && d.CoveredBy == ""
}
// walkCover says which later finished walk delivered the change of a delivery that failed or was stopped, or
// nothing, and which walk commits the forge must still be asked about (novox/hq issue 747). A walk covers it when it
// is of its repository and trunk, done, made after its merge, its commit containing its merge — the merge itself, or
// a later one the forge says contains it — and it built every module the delivery's plan moves, each it did not
// build being one the delivery's own walk said unchanged (issue 611). This is the containment "one walk answers every
// merge it contains" uses (issue 362, ADR 0276), read after the fact: a failed walk resumed to done answers its own
// merge, and a later merge's walk answers every merge it contains. A walk that only said a module unchanged for its
// own merges says nothing of this one's, so it covers no module that way. Walks are read from the controller's records
// this owner holds and from every delivery's kept walk, so a walk long ended still counts after a restart.
func (h *Holder) walkCover(d *Delivery) (string, []string) {
if d.State != Failed && d.State != Stopped || d.MergedAs == "" || d.Plan == nil || len(d.Plan.Moved) == 0 {
return "", nil
}
var ask []string
for _, c := range h.doneWalksOf(d.Repository) {
w := c.walk
if !sameBranch(w.Branch, d.Base) || !w.Created.IsZero() && w.Created.Before(d.MergedAt.Add(-time.Minute)) {
continue
}
var missing []string
for _, m := range d.Plan.Moved {
unchanged := false
if d.Walk != nil {
_, unchanged = d.Walk.Unchanged[m]
}
if !containsString(w.Moved, m) && !unchanged {
missing = append(missing, m)
}
}
if len(missing) > 0 {
continue
}
if !sameCommit(c.commit, d.MergedAs) {
yes, known := d.ContainedIn[c.commit]
if !known && !containsString(ask, c.commit) {
ask = append(ask, c.commit)
}
if !yes {
continue
}
}
return fmt.Sprintf("%s (%s at %s into %s, done, contains its merge %s)", w.ID, d.Repository, shortOf(c.commit, 8),
orNone(d.Base), shortOf(d.MergedAs, 8)), nil
}
return "", ask
}
// doneWalk is a finished walk and the commit of its repository it carried.
type doneWalk struct {
walk *WalkSeen
commit string
}
// doneWalksOf are the finished walks of a repository this owner knows, oldest first: the controller's records it
// holds, and each delivery's kept walk with the commit of its repository that walk carried for it.
func (h *Holder) doneWalksOf(repository string) []doneWalk {
byID := map[string]doneWalk{}
for id, s := range h.walks {
w := s.walk
if w.Release != nil || w.IsBatch() || w.State != walkDone || !strings.EqualFold(w.Repository, repository) {
continue
}
byID[id] = doneWalk{walk: w.Seen(), commit: w.Commit}
}
for _, o := range h.deliveries {
if o.Walk == nil || o.Walk.State != walkDone || !strings.EqualFold(o.Repository, repository) {
continue
}
if _, known := byID[o.Walk.ID]; known {
continue
}
commit := o.AnsweredBy
if commit == "" && o.MergedAs != "" && sameCommit(o.Walk.Commit, o.MergedAs) {
commit = o.MergedAs
}
if commit == "" {
continue
}
byID[o.Walk.ID] = doneWalk{walk: o.Walk, commit: commit}
}
out := make([]doneWalk, 0, len(byID))
for _, c := range byID {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool {
if !out[i].walk.Created.Equal(out[j].walk.Created) {
return out[i].walk.Created.Before(out[j].walk.Created)
}
return out[i].walk.ID < out[j].walk.ID
})
return out
}
// stopLapsed is whether a member its group stopped for another is stopped for nothing any more (see stopLapsed).
func (h *Holder) stopLapsed(d *Delivery) bool {
return stopLapsed(d, func(id string) *Delivery { return h.deliveries[id] })
}
// unwalkedPast is whether a merged delivery has gone without a walk past the bound one opens within, read
// again after it: what holds it for a person, unless something already ends it.
func (h *Holder) unwalkedPast(d *Delivery, now time.Time) bool {
@@ -1887,8 +1727,7 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa
// A superseded member a later delivered walk carried counts as reached, as in GroupState: a group counts a
// covered member as delivered, so the member after it gets its turn (ADR 0270 decision 2, novox/hq
// issues 442 and 445).
// A stop or failure a later walk delivered since counts as reached too (novox/hq issues 480 and 747).
if memberReached(d) {
if d.State.Reached() || d.State == Superseded && d.CoveredBy != "" {
continue
}
if sharing != "" && (d.Walk == nil || d.Walk.ID != sharing) {
@@ -1913,7 +1752,7 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa
}
if w.why != "" {
for _, later := range ordered[i+1:] {
if a := h.stopInGroup(byID[later.ID], d.ID, fmt.Sprintf("its group %s stopped: %s superseded, and %s", g.ID,
if a := h.stopInGroup(byID[later.ID], fmt.Sprintf("its group %s stopped: %s superseded, and %s", g.ID,
d.ID, w.why)); a != nil {
asks = append(asks, a)
}
@@ -1940,13 +1779,9 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa
continue
}
return asks
case h.stopLapsed(d):
// Stopped for a member whose change reached the machines since: it stops nothing more. Its own change is
// delivered by a later walk of its repository that contains it, which then covers it (novox/hq issue 747).
continue
case d.State == Failed || d.State == Stopped:
for _, later := range ordered[i+1:] {
if a := h.stopInGroup(byID[later.ID], d.ID, fmt.Sprintf("its group %s stopped: %s %s", g.ID, d.ID, d.State)); a != nil {
if a := h.stopInGroup(byID[later.ID], fmt.Sprintf("its group %s stopped: %s %s", g.ID, d.ID, d.State)); a != nil {
asks = append(asks, a)
}
}
@@ -2184,7 +2019,7 @@ func mayCarry(c, d *Delivery, module string) bool {
// controller ends the walk and walks the other merges again without it, and without each later merge of its
// repository, which contains it. Every member a pass stops on one walk is gathered first, and the walk is asked
// once, by the first member's ask, after the pass: a member is never walked again because its stop came second.
func (h *Holder) stopInGroup(d *Delivery, cause, why string) func() {
func (h *Holder) stopInGroup(d *Delivery, why string) func() {
if d == nil || d.State.Final() {
return nil
}
@@ -2193,7 +2028,6 @@ func (h *Holder) stopInGroup(d *Delivery, cause, why string) func() {
h.refuse(err)
return nil
}
d.StoppedFor = cause // the member it was stopped for: once that one is delivered, the stop lapses (novox/hq issue 747)
h.moved(d, []Transition{t})
// Its open walk; or, its walk stopped for another merge, the batch or walk that walks its merge again (review
// of #221): a merge walked again and then stopped by its group is left out there too.
@@ -2275,12 +2109,6 @@ func (h *Holder) carriesStopped(walk string) ([]string, string) {
if last := d.Transitions[len(d.Transitions)-1]; last.Event != EvStop || last.By == "" {
continue
}
// A stop whose change a later walk delivered since, or one its group made for a member delivered since,
// holds nothing back: the group stops a walk only for a member whose change is still undelivered (novox/hq
// issue 747).
if d.coveredStop() || h.stopLapsed(d) {
continue
}
// By the merges it names alone: its own commit may be another's merge it no longer answers.
if !slices.ContainsFunc(s.walk.Delivery.Merges, func(m WalkMerge) bool {
return strings.EqualFold(m.Repository, d.Repository) && sameCommit(m.Commit, d.MergedAs)
@@ -2298,51 +2126,6 @@ func (h *Holder) carriesStopped(walk string) ([]string, string) {
return out, why
}
// coverAwaited is whether a stopped merge a walk carries, given as `<repository>@<commit>`, waits for the forge's
// word on whether a later finished walk delivered it or the member its group stopped it for (novox/hq issue 747).
// It says since when the forge was first asked, the earliest of them. Held only within coverAskBound of the first
// ask: past it the forge's silence or error lets the walk be asked to leave the merge out. Under the lock.
func (h *Holder) coverAwaited(merges []string) (time.Time, bool) {
var since time.Time
awaited := false
for _, d := range h.deliveries {
if d.State != Stopped || d.MergedAs == "" || !containsString(merges, d.Repository+"@"+d.MergedAs) {
continue
}
for _, id := range []string{d.ID, d.stoppedFor()} {
if id == "" || !h.coverAsked[id] {
continue
}
if at := h.coverAskedSince[id]; !awaited || at.Before(since) {
since = at
}
awaited = true
}
}
return since, awaited
}
// coverHeld says why a walk is held back while the forge is asked whether a later finished walk delivered a stopped
// merge it carries (novox/hq issue 747), or nothing when it is not. Said by `waits`, `stalled` and `stalled walks`, so
// a forge that cannot answer is loud. Under the lock.
func (h *Holder) coverHeld(walk string) string {
without, _ := h.carriesStopped(walk)
if len(without) == 0 {
return ""
}
since, awaited := h.coverAwaited(without)
if !awaited {
return ""
}
return fmt.Sprintf("it carries %s, whose delivery was stopped, and the forge is asked since %s whether a later "+
"finished walk delivered it; it is asked to leave the merge out if no answer comes by %s (novox/hq issue 747)",
strings.Join(without, ", "), since.Local().Format("15:04:05"), since.Add(coverAskBound).Local().Format("15:04:05"))
}
// coverAskBound is how long a walk carrying a stopped merge is held back while the forge is asked whether a later
// finished walk delivered it (novox/hq issue 747); past it, the walk is asked to leave the merge out.
const coverAskBound = 10 * time.Minute
// lastWhyOf is the reason of a delivery's last transition.
func lastWhyOf(d *Delivery) string {
if len(d.Transitions) == 0 {
@@ -2378,9 +2161,6 @@ func (h *Holder) leaveStopped() []func() {
if without, _ := h.carriesStopped(id); len(without) > 0 {
said := fmt.Sprintf("its walk %s is not let go: it carries %s, whose delivery was stopped, and is asked to "+
"leave it out (novox/hq ADR 0299)", id, strings.Join(without, ", "))
if hold := h.coverHeld(id); hold != "" {
said = "its walk " + id + " is held back: " + hold
}
if r := h.leaveRefused[id]; r != "" {
said += "; the controller refused: " + r
}
@@ -2404,9 +2184,6 @@ func (h *Holder) leaveStopped() []func() {
delete(h.leaveRefused, id)
continue
}
if _, awaited := h.coverAwaited(without); awaited {
continue // held back, not stopped, until the forge says whether a later walk delivered it (issue 747)
}
// Once per record of it: the record that answers an ask is newer. Asked again after five minutes, should
// the ask have gone nowhere.
if a, asked := h.leaveAsked[id]; asked && seen.walk.Revision <= a.revision && now.Sub(a.at) < 5*time.Minute {
@@ -2528,28 +2305,18 @@ func againWords(a StopAnswer) string {
func GroupState(g *Group, members []*Delivery) (string, string) {
count := map[State]int{}
reached, ended := 0, 0
var uncovered, lapsed []string
byID := map[string]*Delivery{}
for _, d := range members {
byID[d.ID] = d
}
var uncovered []string
for _, d := range members {
count[d.State]++
if d.State.Final() {
ended++
}
switch {
case memberReached(d):
// Reached, whatever its final state: a failure or stop a later walk delivered since is no failure of the
// group (novox/hq issue 747).
case d.State.Reached(), d.State == Superseded && d.CoveredBy != "":
reached++
continue
case stopLapsed(d, func(id string) *Delivery { return byID[id] }):
lapsed = append(lapsed, d.ID)
continue
case d.State == Superseded:
uncovered = append(uncovered, d.ID)
}
count[d.State]++
}
n := len(members)
switch {
@@ -2559,11 +2326,6 @@ func GroupState(g *Group, members []*Delivery) (string, string) {
return "stopped", "a member was stopped"
case n > 0 && reached == n:
return "delivered", "every member is delivered"
case g.Closed && len(lapsed) > 0 && reached+len(lapsed) == n:
// Stopped for a member delivered since: its own change waits for a later walk of its repository, which then
// covers it (novox/hq issue 747).
return "delivering", fmt.Sprintf("%d of %d delivered; %s, stopped for a member delivered since, waits for a "+
"later walk of its repository", reached, n, strings.Join(lapsed, ", "))
case g.Closed && n > 0 && ended == n:
return "stopped", supersededWhy + strings.Join(uncovered, ", ")
case g.Closed && g.Check != nil && (g.Check.OutOfDate != "" || g.Check.Verdict == "" ||
@@ -1,258 +0,0 @@
package main
import (
"strings"
"testing"
"time"
)
// novox/hq issue 747: in the group fix/a-module-moves-only-when-its-program-changes, the controller's member #336
// (merged as 5d22d7df) failed at 22:32 during an outage of the forge's tools. The walk of the controller's next merge,
// #339 at 60cc9ec3, which contains 5d22d7df, failed with it and was resumed to done by a rebuild. mesh-delivery still
// held #336 failed, so its group stopped every later walk of the catalogue that carried the catalogue member's merge
// 2edef5a0: the catalogue could deliver nothing. A failed or stopped delivery is covered once a later finished walk of
// its repository that contains its merge built its modules, and a group stops a walk only for a member whose change is
// still undelivered.
//
// The shape is the live state of 2026-10-14 23:07, as mesh-delivery.show gave it, read back after a restart: the
// controller holds none of the walks of the night any more, only the walk of the catalogue's next merge.
const (
g747 = "fix/a-module-moves-only-when-its-program-changes"
ctl747, cat747 = "novox/mesh-controller", "novox/mesh-catalog"
head336, merge336 = "b34055f580d4e2a4", "5d22d7df86c73452"
head339, merge339 = "e4acab32d6a20ade", "60cc9ec363dfa7c6"
head301, merge301 = "d94764f00168471f", "2edef5a00ae05605"
merge307 = "0f76b6e2aaaaaaaa"
)
var (
at747 = time.Date(2026, 10, 14, 20, 21, 6, 0, time.UTC)
id336, id339, id301 = IDOf(ctl747, head336), IDOf(ctl747, head339), IDOf(cat747, head301)
unchangedRouteProxy747 = map[string]string{"route-proxy": "its build source holds none of the changed files"}
)
// night747 is the stuck state, with the forge saying, or not, that 60cc9ec3 contains 5d22d7df.
func night747(t *testing.T, contained bool) *world { return night747With(t, contained, nil) }
// night747With is night747 with #339's walk having built only the modules given, saying the others unchanged.
func night747With(t *testing.T, contained bool, built []string) *world {
t.Helper()
return night747Over(t, func(f *fakeForge) { f.contains[merge336+" in "+merge339] = contained }, built)
}
// night747Over is night747With with the forge set as given: one that is not told the pair cannot say.
func night747Over(t *testing.T, set func(*fakeForge), built []string) *world {
t.Helper()
at := at747
failedAt := at.Add(11 * time.Minute)
store := newMemStore()
moved339, unchanged339 := []string{"build-agent", "mesh-controller"}, unchangedRouteProxy747
if built != nil {
moved339, unchanged339 = built, map[string]string{}
for _, m := range []string{"build-agent", "mesh-controller", "route-proxy"} {
if !containsString(built, m) {
unchanged339[m] = "its build source holds none of the files #339 changed"
}
}
}
m336 := &Delivery{ID: id336, Repository: ctl747, Commit: head336, Number: 336, Base: "main", Branch: g747, Group: g747,
State: Failed, Since: failedAt, Created: at.Add(-time.Hour), MergedAs: merge336, MergedAt: at,
AnsweredBy: merge336, Check: &Verdict{Gate: "pass"},
Plan: planMoving(ctl747, "build-agent", "mesh-controller", "route-proxy"),
Walk: &WalkSeen{ID: "plan-336", Commit: merge336, State: walkFailed, Branch: "main", MovedSaid: true,
Note: "mesh-controller failed to build in tier 0", Created: at.Add(7 * time.Minute), Tiers: 2,
Unchanged: unchangedRouteProxy747},
Transitions: []Transition{{At: failedAt, From: Delivering, To: Failed, Event: EvFailed,
Why: "its walk failed, carrying its own commit"}}}
m339 := &Delivery{ID: id339, Repository: ctl747, Commit: head339, Number: 339, Base: "main", Branch: "fix/726",
State: Failed, Since: failedAt, Created: at.Add(-10 * time.Minute), MergedAs: merge339, MergedAt: at.Add(7 * time.Minute),
AnsweredBy: merge339, Check: &Verdict{Gate: "pass"}, Plan: planMoving(ctl747, "mesh-controller"),
// Its walk failed with #336's, and a rebuild resumed it to done: the record it keeps says so.
Walk: &WalkSeen{ID: "plan-339", Commit: merge339, State: walkDone, Branch: "main", MovedSaid: true,
Moved: moved339, Unchanged: unchanged339,
Created: at.Add(11 * time.Minute), Tiers: 2, Tier: 2},
Transitions: []Transition{{At: failedAt, From: Delivering, To: Failed, Event: EvFailed,
Why: "its walk failed, carrying its own commit"}}}
m301 := &Delivery{ID: id301, Repository: cat747, Commit: head301, Number: 301, Base: "main", Branch: g747, Group: g747,
State: Stopped, Since: failedAt, Created: at.Add(-2 * time.Hour), MergedAs: merge301, MergedAt: at.Add(7 * time.Second),
Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "build-agent", "route-proxy"),
Transitions: []Transition{{At: failedAt, From: Ready, To: Stopped, Event: EvStop, By: byOwner,
Why: "its group " + g747 + " stopped: " + id336 + " failed"}}}
for _, d := range []*Delivery{m336, m339, m301} {
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
g := &Group{ID: g747, Members: []string{id301, id336}, Closed: true, Order: []string{id336, id301}, Said: "failed",
OrderOf: commitsOf([]*Delivery{m301, m336}), Check: &GroupCheck{Asked: "check-1", At: at.Add(-time.Minute),
Verdict: "pass", Summary: "both compose", Commits: commitsOf([]*Delivery{m301, m336})}}
if err := store.PutGroup(g); err != nil {
t.Fatal(err)
}
ctlr := newFakeController()
// The catalogue's next merge: its walk carries 2edef5a0, merged before it and never walked, and waits for its word.
ctlr.put(Walk{ID: "plan-cat", Repository: cat747, Branch: "main", Commit: merge307, Created: at.Add(46 * time.Minute),
State: walkBuilding, Tiers: [][]string{{"gitea"}}, Modules: map[string]*WalkModule{"gitea": {}},
Delivery: &WalkDelivery{Awaits: "mesh-delivery", Merges: []WalkMerge{
{Repository: cat747, Commit: merge301, Carried: merge307}, {Repository: cat747, Commit: merge307}}}})
ctlr.mergedAt[merge301], ctlr.mergedAt[merge307] = m301.MergedAt, at.Add(46*time.Minute)
forge := newFakeForge()
set(forge)
w := newWorldOver(t, store, ctlr, forge, at.Add(47*time.Minute))
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
return w
}
func groupState747(w *world) (string, string) {
g := w.h.groups[g747]
return GroupState(g, w.h.membersOf(g))
}
// **A failed member is covered by a later finished walk that contains it**: #339's walk, done at 60cc9ec3, which the
// forge says contains 5d22d7df, built build-agent and mesh-controller, and #336's own walk said route-proxy unchanged.
// #336 stays failed, reads covered by that walk on its pull request, and its group no longer reads failed. #339, whose
// own walk was resumed to done, is covered by it too.
func TestAFailedMemberIsCoveredByALaterDoneWalkThatContainsIt(t *testing.T) {
w := night747(t, true)
d := w.delivery(id336)
if state, desc := statusOf(d); d.State != Failed || state != "success" || !strings.Contains(desc, "covered after its failure") ||
!strings.Contains(desc, "plan-339") {
t.Fatalf("#336's status reads %s %q", state, desc)
}
if s := statusOn(w, ctl747, head336); !strings.HasPrefix(s, "success") {
t.Fatalf("the forge was told %q of #336", s)
}
if state, desc := statusOf(w.delivery(id339)); state != "success" || !strings.Contains(desc, "plan-339") {
t.Fatalf("#339 reads %s %q; want it covered by its own walk, resumed to done", state, desc)
}
if state, why := groupState747(w); state == "failed" || state == "stopped" {
t.Fatalf("the group reads %s: %s", state, why)
}
// Told once, and kept: a restart tells nothing again.
notes := len(w.forge.notesOn(merge336))
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Minute))
again.settleAll()
if state, _ := statusOf(again.delivery(id336)); len(w.forge.notesOn(merge336)) != notes || state != "success" {
t.Fatalf("a restart told it again or forgot it: %d notes, was %d", len(w.forge.notesOn(merge336)), notes)
}
}
// **The group then no longer stops the other repository's walk**: the catalogue member was stopped for #336 alone,
// so the walk of the catalogue's next merge, which carries its merge, is neither stopped nor held back, and the
// group waits for that walk to deliver the catalogue member's change.
func TestACoveredMemberNoLongerStopsTheOtherRepositorysWalk(t *testing.T) {
w := night747(t, true)
if len(w.ctl.withouts) > 0 || containsString(w.ctl.stops, "plan-cat") {
t.Fatalf("the catalogue's walk was stopped: without %v, stops %v", w.ctl.withouts, w.ctl.stops)
}
if stopped, _ := w.h.carriesStopped("plan-cat"); len(stopped) > 0 {
t.Fatalf("the catalogue's walk is held back for %v", stopped)
}
if state, why := groupState747(w); state != "delivering" || !strings.Contains(why, id301) {
t.Fatalf("the group reads %s: %s; want delivering, the catalogue member waiting for a later walk", state, why)
}
// Once a later walk of the catalogue that contains its merge built its modules, it is covered, and the group is
// delivered.
w.forge.contains[merge301+" in "+merge307] = true
r := w.ctl.walk("plan-cat")
sent := w.now
r.Modules = map[string]*WalkModule{}
for _, m := range []string{"build-agent", "route-proxy", "gitea"} {
r.Modules[m] = &WalkModule{State: "built", SentAt: &sent}
}
r.State = walkDone
w.h.WalkMoved(w.ctl.put(r))
w.later(time.Minute)
w.settleAll()
if state, desc := statusOf(w.delivery(id301)); state != "success" || !strings.Contains(desc, "plan-cat") {
t.Fatalf("the catalogue member reads %s %q", state, desc)
}
if state, why := groupState747(w); state != "delivered" {
t.Fatalf("the group reads %s: %s", state, why)
}
}
// **A failed member not contained still stops it**: the forge says 60cc9ec3 does not contain 5d22d7df, so #336's change
// never reached the machines; it stays failed and uncovered, its group failed, and the catalogue's walk that carries
// the stopped member's merge is asked to leave it out.
func TestAFailedMemberNotContainedStillStopsTheOtherRepositorysWalk(t *testing.T) {
w := night747(t, false)
d := w.delivery(id336)
if state, desc := statusOf(d); d.State != Failed || state != "failure" {
t.Fatalf("#336 is %s and reads %s %q", d.State, state, desc)
}
if state, _ := groupState747(w); state != "failed" {
t.Fatalf("the group reads %s", state)
}
if len(w.ctl.withouts) == 0 || !strings.HasPrefix(w.ctl.withouts[0], "plan-cat without "+cat747+"@"+merge301) {
t.Fatalf("the catalogue's walk was not asked to leave the stopped merge out: %v", w.ctl.withouts)
}
}
// **A later walk that only said a module unchanged covers nothing of an earlier merge that moves it**: #339's walk
// built mesh-controller alone and said build-agent unchanged for #339's own files, which says nothing of #336's; only
// #336's own walk says that of its change (route-proxy). #336 stays failed and its group still stops the catalogue.
func TestALaterWalksUnchangedCoversNoModuleOfAnEarlierMerge(t *testing.T) {
w := night747With(t, true, []string{"mesh-controller"})
if state, desc := statusOf(w.delivery(id336)); state != "failure" {
t.Fatalf("#336 reads %s %q, though no walk built build-agent for it", state, desc)
}
if state, _ := groupState747(w); state != "failed" {
t.Fatalf("the group reads %s", state)
}
if len(w.ctl.withouts) == 0 {
t.Fatal("the catalogue's walk was not asked to leave the stopped merge out")
}
}
// **A forge that cannot say holds the walk only within the bound** (review of issue 747): while the forge errors on
// whether 60cc9ec3 contains 5d22d7df, the catalogue's walk is held back, loudly, neither stopped nor asked to leave the
// merge out, and #336 still reads failed. Past the bound the forge's silence holds nothing back any more: the walk
// is asked to leave the stopped merge out, as with no later walk at all.
func TestAForgeThatCannotSayHoldsTheWalkOnlyWithinTheBound(t *testing.T) {
for _, c := range []struct {
name string
set func(*fakeForge)
}{
{"erroring", func(f *fakeForge) { f.containsDown = true }},
{"not answering for the pair", func(*fakeForge) {}},
} {
t.Run(c.name, func(t *testing.T) {
w := night747Over(t, c.set, nil)
if len(w.ctl.withouts) > 0 || len(w.ctl.stops) > 0 {
t.Fatalf("within the bound the walk was not held: without %v, stops %v", w.ctl.withouts, w.ctl.stops)
}
if state, desc := statusOf(w.delivery(id336)); state != "failure" {
t.Fatalf("#336 reads %s %q while the forge cannot say", state, desc)
}
said := ""
for _, l := range w.h.WalksWaiting() {
if l.Walk == "plan-cat" {
said += l.Says
}
}
w.later(3 * time.Minute) // past the grace an unheld walk is given, so `stalled` lists it
w.settleAll()
for _, l := range w.h.Stalled() {
if l.Walk == "plan-cat" {
said += " | " + l.Says
}
}
if strings.Count(said, "the forge is asked since") != 2 {
t.Fatalf("the hold is not said by `stalled walks` and `stalled`: %q", said)
}
if len(w.ctl.withouts) > 0 || len(w.ctl.stops) > 0 {
t.Fatalf("within the bound the walk was not held: without %v, stops %v", w.ctl.withouts, w.ctl.stops)
}
w.later(coverAskBound + time.Minute)
w.settleAll()
if len(w.ctl.withouts) == 0 || !strings.HasPrefix(w.ctl.withouts[0], "plan-cat without "+cat747+"@"+merge301) {
t.Fatalf("past the bound the walk was not asked to leave the stopped merge out: %v", w.ctl.withouts)
}
})
}
}
@@ -1,292 +0,0 @@
package main
import (
"strings"
"testing"
"time"
)
// novox/hq issue 753: once issue 747's fix was live (2026-10-14 23:35), the catalogue's walk plan-1792012015539062661 at
// 0f76b6e2 (#307's merge; build-agent, gitea, route-proxy to all four machines) was resumed and said "published; its
// walk waits for mesh-delivery's word: no delivery that has not ended follows it or is answered by it". Every delivery
// it carried had ended: #301 stopped by its group for the controller's #336, delivered since, and waiting for "a later
// walk of its repository that contains its merge"; #304 stopped; #307 delivered. The only walk that contained #301's
// merge waited for #301's word: a deadlock, released by a hand `plans go`. A delivery whose stop lapsed gives its word
// to the walk that answers its merge, and a walk with no living delivery behind it says what it carries.
//
// The shape is the live state as `mesh-delivery.show` and `mesh-controller.plans` gave it, read back after a restart.
const (
merge304, head304, head307 = "4c0ffee0bbbbbbbb", "a304a304a304a304", "b307b307b307b307"
plan753 = "plan-1792012015539062661"
)
var id304, id307 = IDOf(cat747, head304), IDOf(cat747, head307)
// night753 is the stuck walk: #336 delivered (its change reached the machines), #301 stopped by the given act, #304
// stopped by its group, #307 delivered, and the catalogue's walk at #307's merge naming all three merges, waiting.
func night753(t *testing.T, stop Transition) *world { return night753With(t, stop) }
// night753With is night753 with more deliveries kept: one in #301's group is ordered after #336 and before #301.
func night753With(t *testing.T, stop Transition, more ...*Delivery) *world {
t.Helper()
return night753Over(t, stop, nil, nil, more...)
}
// night753Over is night753With with more merges named by the walk and the controller set as given before the first
// tick.
func night753Over(t *testing.T, stop Transition, merges []WalkMerge, set func(*fakeController), more ...*Delivery) *world {
t.Helper()
at := at747
store := newMemStore()
m336 := &Delivery{ID: id336, Repository: ctl747, Commit: head336, Number: 336, Base: "main", Branch: g747, Group: g747,
State: Delivered, Since: at.Add(70 * time.Minute), Created: at.Add(-time.Hour), MergedAs: merge336, MergedAt: at,
AnsweredBy: merge336, Check: &Verdict{Gate: "pass"}, Plan: planMoving(ctl747, "build-agent", "mesh-controller"),
Walk: &WalkSeen{ID: "plan-336", Commit: merge336, State: walkDone, Branch: "main", MovedSaid: true,
Moved: []string{"build-agent", "mesh-controller"}, Created: at.Add(7 * time.Minute), Tiers: 2, Tier: 2}}
m301 := &Delivery{ID: id301, Repository: cat747, Commit: head301, Number: 301, Base: "main", Branch: g747, Group: g747,
State: Stopped, Since: stop.At, Created: at.Add(-2 * time.Hour), MergedAs: merge301, MergedAt: at.Add(7 * time.Second),
Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "build-agent", "route-proxy"), Transitions: []Transition{stop}}
if stop.By == byOwner {
m301.StoppedFor = id336
}
m304 := &Delivery{ID: id304, Repository: cat747, Commit: head304, Number: 304, Base: "main", Branch: "fix/forge-events",
State: Stopped, Since: at.Add(80 * time.Minute), Created: at.Add(-time.Hour), MergedAs: merge304,
MergedAt: at.Add(30 * time.Minute), Check: &Verdict{Gate: "pass"},
Plan: planMoving(cat747, "build-agent", "gitea", "route-proxy"),
// Stopped with its walk, which the walk of #307 took over, and covered since by the walk of #306 (issue 747).
DeliveredBy: "plan-1792011830935225963 (novox/mesh-catalog at 0361c44a into main, done, contains its merge 4c0ffee0)",
CoverTold: true,
Walk: &WalkSeen{ID: "plan-1792009301770286799", Commit: merge304, State: walkSuperseded, Branch: "main",
TakenOverBy: plan753, Waits: true, WaitedFor: "mesh-delivery", Created: at.Add(31 * time.Minute)},
Transitions: []Transition{{At: at.Add(80 * time.Minute), From: Published, To: Stopped, Event: EvStop,
Why: "its walk plan-1792009301770286799 was stopped by mesh-delivery"}}}
m307 := &Delivery{ID: id307, Repository: cat747, Commit: head307, Number: 307, Base: "main", Branch: "fix/ts-check",
State: Delivered, Since: at.Add(90 * time.Minute), Created: at.Add(-time.Hour), MergedAs: merge307,
MergedAt: at.Add(46 * time.Minute), Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "build-agent")}
for _, d := range append([]*Delivery{m336, m301, m304, m307}, more...) {
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
inGroup, order := []*Delivery{m301, m336}, []string{id336}
for _, d := range more {
if d.Group == g747 {
inGroup, order = append(inGroup, d), append(order, d.ID)
}
}
order = append(order, id301)
var ids []string
for _, d := range inGroup {
ids = append(ids, d.ID)
}
g := &Group{ID: g747, Members: ids, Closed: true, Order: order, Said: "delivering",
OrderOf: commitsOf(inGroup), Check: &GroupCheck{Asked: "check-1", At: at.Add(-time.Minute),
Verdict: "pass", Summary: "all compose", Commits: commitsOf(inGroup)}}
if err := store.PutGroup(g); err != nil {
t.Fatal(err)
}
ctlr := newFakeController()
modules := map[string]*WalkModule{"build-agent": {}, "gitea": {}, "route-proxy": {}}
ctlr.put(Walk{ID: plan753, Repository: cat747, Branch: "main", Commit: merge307, Created: at.Add(46 * time.Minute),
State: walkBuilding, Tiers: [][]string{{"build-agent", "gitea", "route-proxy"}}, Modules: modules,
Delivery: &WalkDelivery{Awaits: "mesh-delivery", Merges: []WalkMerge{
{Repository: cat747, Commit: merge301, Carried: merge307}, {Repository: cat747, Commit: merge304, Carried: merge307},
{Repository: cat747, Commit: merge307}}}})
if len(merges) > 0 {
r := ctlr.walk(plan753)
r.Delivery.Merges = append(merges, r.Delivery.Merges...)
ctlr.put(r)
}
if set != nil {
set(ctlr)
}
ctlr.mergedAt[merge301], ctlr.mergedAt[merge304], ctlr.mergedAt[merge307] = m301.MergedAt, m304.MergedAt, m307.MergedAt
w := newWorldOver(t, store, ctlr, newFakeForge(), at.Add(100*time.Minute))
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
return w
}
// groupStop753 is #301's stop as its group made it: for #336, whose change was delivered since.
func groupStop753() Transition {
return Transition{At: at747.Add(11 * time.Minute), From: Ready, To: Stopped, Event: EvStop, By: byOwner,
Why: "its group " + g747 + " stopped: " + id336 + " failed"}
}
func saidOf(lines []StalledLine, walk string) (string, State) {
said, state := "", State("")
for _, l := range lines {
if l.Walk == walk {
said, state = said+l.Says, l.State
}
}
return said, state
}
// **A stopped delivery gives its word to the later walk it waits for**: #301, stopped for #336, delivered since, waits for
// a later walk of the catalogue that contains its merge; plan-1792012015539062661 names that merge and waits for this
// owner's word. #301 gives it, once, saying why, and `stalled walks` never calls the walk unheld.
func TestAStoppedDeliveryGivesItsWordToTheWalkItWaitsFor(t *testing.T) {
w := night753(t, groupStop753())
if n := strings.Count(strings.Join(w.ctl.delivers, " "), plan753); n != 1 {
t.Fatalf("the walk was let go %d times (%v); want once", n, w.ctl.delivers)
}
d := w.delivery(id301)
if d.State != Stopped || !strings.Contains(d.LetGoWhy, lapsedWord(plan753)) || !strings.Contains(d.LetGoWhy, "issue 753") {
t.Fatalf("#301 is %s, its word given for %q", d.State, d.LetGoWhy)
}
if said, state := saidOf(w.h.WalksWaiting(), plan753); state == Unheld {
t.Fatalf("the walk reads unheld: %q", said)
}
if len(w.ctl.withouts) > 0 || len(w.ctl.stops) > 0 {
t.Fatalf("the walk was stopped: without %v, stops %v", w.ctl.withouts, w.ctl.stops)
}
// Once that walk built build-agent and route-proxy and ended done, #301 is covered by it (issue 747).
w.forge.contains[merge301+" in "+merge307] = true
r := w.ctl.walk(plan753)
sent := w.now
for _, m := range []string{"build-agent", "gitea", "route-proxy"} {
r.Modules[m] = &WalkModule{State: "built", SentAt: &sent}
}
r.State = walkDone
w.h.WalkMoved(w.ctl.put(r))
w.later(time.Minute)
w.settleAll()
if state, desc := statusOf(w.delivery(id301)); state != "success" || !strings.Contains(desc, plan753) {
t.Fatalf("#301 reads %s %q", state, desc)
}
}
// **A walk still holding a live delivery not at its turn waits for it**: the stopped delivery's word is one turn among
// the walk's, never a way past a live delivery it carries.
func TestALapsedStopDoesNotLetGoPastALiveDelivery(t *testing.T) {
at := at747
held := &Delivery{ID: IDOf(cat747, "c308c308c308c308"), Repository: cat747, Commit: "c308c308c308c308", Number: 308,
Base: "main", Branch: "fix/held", State: Held, HeldWhy: "a person's", Since: at, Created: at.Add(-time.Hour),
MergedAs: "c308aaaaaaaaaaaa", MergedAt: at.Add(40 * time.Minute), Check: &Verdict{Gate: "pass"},
Plan: planMoving(cat747, "gitea"), Walk: &WalkSeen{ID: plan753, Commit: merge307, State: walkBuilding, Branch: "main",
Waits: true, Created: at.Add(46 * time.Minute)}}
w := night753With(t, groupStop753(), held)
if containsString(w.ctl.delivers, plan753) {
t.Fatalf("the walk was let go past a held delivery it carries: %v", w.ctl.delivers)
}
if said, state := saidOf(w.h.WalksWaiting(), plan753); state != Held {
t.Fatalf("`stalled walks` reads %s %q; want the held delivery", state, said)
}
}
// **A stop by a person gives no word**: #301 stopped by hand holds its change back; the walk is asked to leave it out,
// never let go for it.
func TestAPersonsStopGivesNoWord(t *testing.T) {
w := night753(t, Transition{At: at747.Add(11 * time.Minute), From: Ready, To: Stopped, Event: EvStop, By: "jochen",
Why: "stopped by hand"})
if containsString(w.ctl.delivers, plan753) {
t.Fatalf("the walk was let go for a person's stop: %v", w.ctl.delivers)
}
if len(w.ctl.withouts) == 0 && len(w.ctl.stops) == 0 {
t.Fatal("the walk carrying a person's stop was neither left without it nor stopped")
}
}
// **A walk no living delivery holds says what it carries** (the issue's third expectation): with nothing to give it
// its word, `stalled walks` and, past the grace, `stalled` name every ended delivery it carries and its state, never
// only that nothing holds it.
func TestAWalkWithNoLivingDeliverySaysWhatItCarries(t *testing.T) {
w := night753(t, groupStop753())
// The walk read again as waiting, naming only #307's merge, which ended delivered: no delivery whose stop lapsed
// gives it its word, and no live one holds it.
r := w.ctl.walk(plan753)
r.Delivery.Go = nil
r.Delivery.Merges = []WalkMerge{{Repository: cat747, Commit: merge307}}
w.h.WalkMoved(w.ctl.put(r))
said, state := saidOf(w.h.WalksWaiting(), plan753)
if state != Unheld || !strings.Contains(said, id307+" (delivered)") {
t.Fatalf("`stalled walks` reads %s %q; want unheld, naming %s (delivered)", state, said, id307)
}
w.settleAll()
w.later(matchGrace + time.Minute)
w.settleAll()
said, _ = saidOf(w.h.Stalled(), plan753)
if !strings.Contains(said, "every delivery it carries ended: ") || !strings.Contains(said, id307+" (delivered)") {
t.Fatalf("`stalled` reads %q", said)
}
}
// m299 is a member of #301's group ordered before it, stopped for #336 as #301 was, its merge on another walk.
func m299() *Delivery {
at := at747
return &Delivery{ID: IDOf(cat747, "e299e299e299e299"), Repository: cat747, Commit: "e299e299e299e299", Number: 299,
Base: "main", Branch: g747, Group: g747, State: Stopped, StoppedFor: id336, Since: at.Add(11 * time.Minute),
Created: at.Add(-3 * time.Hour), MergedAs: "e299aaaaaaaaaaaa", MergedAt: at.Add(5 * time.Second),
Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "gitea"),
Transitions: []Transition{{At: at.Add(11 * time.Minute), From: Ready, To: Stopped, Event: EvStop, By: byOwner,
Why: "its group " + g747 + " stopped: " + id336 + " failed"}}}
}
// **A stopped member gives its word only in its group's order** (review of issue 753): #299, ordered before #301 and
// stopped for #336 too, is not delivered and this walk does not carry its merge, so #301 gives no word — it would land
// before #299 — and `stalled walks` names #299. When the walk names #299's merge too, it delivers both, in order, and
// is let go.
func TestAStoppedMemberGivesItsWordOnlyInItsGroupsOrder(t *testing.T) {
w := night753With(t, groupStop753(), m299())
if containsString(w.ctl.delivers, plan753) {
t.Fatalf("the walk was let go for #301 before #299, ordered before it: %v", w.ctl.delivers)
}
said, state := saidOf(w.h.WalksWaiting(), plan753)
if state == Unheld || !strings.Contains(said, m299().ID+" (stopped)") {
t.Fatalf("`stalled walks` reads %s %q; want #299 named", state, said)
}
w = night753Over(t, groupStop753(), []WalkMerge{{Repository: cat747, Commit: "e299aaaaaaaaaaaa", Carried: merge307}}, nil,
m299())
if !containsString(w.ctl.delivers, plan753) {
t.Fatalf("the walk carrying both members was not let go: %v", w.ctl.delivers)
}
}
// **A word the controller refuses is never silent** (review of issue 753): the controller keeps refusing it, and past
// the grace `stalled` lists the walk, with the refusal.
func TestARefusedWordIsListedByStalled(t *testing.T) {
w := night753Over(t, groupStop753(), nil, func(c *fakeController) { c.refuseDeliver = true })
if containsString(w.ctl.delivers, plan753) {
t.Fatalf("the walk was let go: %v", w.ctl.delivers)
}
w.later(matchGrace + time.Minute)
w.settleAll()
said, state := saidOf(w.h.Stalled(), plan753)
if state != Published || !strings.Contains(said, "the controller refused its word: the controller refuses the word") {
t.Fatalf("`stalled` reads %s %q", state, said)
}
// Once the controller takes it, the walk is let go.
w.ctl.refuseDeliver = false
w.settleAll()
if !containsString(w.ctl.delivers, plan753) {
t.Fatalf("the walk was not let go once the controller took the word: %v", w.ctl.delivers)
}
}
// **A walk held back for another stopped change is never silent** (review of issue 753): #304 was stopped by a person,
// and the controller refuses to leave its merge out or to stop the walk, so the word #301 owes is never sent; past the
// grace `stalled` lists the walk, saying why.
func TestAWalkHeldForAPersonsStopIsListedByStalled(t *testing.T) {
at := at747
m305 := &Delivery{ID: IDOf(cat747, "f305f305f305f305"), Repository: cat747, Commit: "f305f305f305f305", Number: 305,
Base: "main", Branch: "fix/other", State: Stopped, Since: at.Add(80 * time.Minute), Created: at.Add(-time.Hour),
MergedAs: "f305aaaaaaaaaaaa", MergedAt: at.Add(35 * time.Minute), Check: &Verdict{Gate: "pass"},
Plan: planMoving(cat747, "gitea"), Transitions: []Transition{{At: at.Add(80 * time.Minute), From: Ready,
To: Stopped, Event: EvStop, By: "jochen", Why: "stopped by hand"}}}
w := night753Over(t, groupStop753(), []WalkMerge{{Repository: cat747, Commit: m305.MergedAs, Carried: merge307}},
func(c *fakeController) { c.refuseWithout, c.refuseStop = true, true }, m305)
if containsString(w.ctl.delivers, plan753) {
t.Fatalf("the walk carrying a person's stop was let go: %v", w.ctl.delivers)
}
w.later(matchGrace + time.Minute)
w.settleAll()
said, _ := saidOf(w.h.Stalled(), plan753)
if !strings.Contains(said, "owe it (novox/hq issue 753)") || !strings.Contains(said, "f305aaaaaaaaaaaa") ||
!strings.Contains(said, "whose delivery was stopped") {
t.Fatalf("`stalled` reads %q", said)
}
}
@@ -76,9 +76,8 @@ func run(h *Holder, l *listening) {
logf("[mesh-delivery] the controller's walks cannot be read: %v", err)
}
}
// Every open pull request, from the forge itself, every minute (novox/hq issues 347, 722): what says a
// head nobody checks, and the ready marks a merge is asked on.
if now.Sub(looked) >= time.Minute {
// Every open pull request, from the forge itself, every five minutes (novox/hq issue 347).
if now.Sub(looked) >= 5*time.Minute {
looked = now
h.SeeOpenPulls()
}
@@ -65,9 +65,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
want = append(want, "seat:"+ControllerSeat+"."+v)
}
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls",
// The merge on a ready mark (novox/hq issue 722): the merge tool, a comment, a label taken off.
"gitea.gitea_merge_pull_request", "gitea.gitea_add_comment", "gitea.gitea_api")
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls")
if !reflect.DeepEqual(m.Invokes, want) {
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
}
@@ -83,14 +83,6 @@ type Delivery struct {
// CoverTold is whether the forge was told that such a stop was covered since: its status, its view and its note
// (novox/hq issue 480). Kept, so a restart tells it once, and a stop covered before this was built is told too.
CoverTold bool `json:"cover_told,omitempty"`
// DeliveredBy says, for a delivery that failed or was stopped, the later finished walk of its repository whose
// commit contains its merge and which built every module it moves — each it did not build, its own walk said
// unchanged: its change reached the machines after all (novox/hq issue 747). Its state stays final, as a covered
// stop's does (issue 480), and it reads covered: its group counts it as delivered.
DeliveredBy string `json:"delivered_by,omitempty"`
// StoppedFor is, for a member its group stopped, the member whose end stopped it (novox/hq issue 747): once that
// member's change reached the machines, the stop has lapsed, and holds no walk of its repository back.
StoppedFor string `json:"stopped_for,omitempty"`
// TurnAwaits says, for a group member whose turn waits for a later merge to carry a superseded member before it,
// which merges and until when; TurnAwaitsUntil is that moment, which bounds the wait (ADR 0276 decision 9,
// novox/hq issue 445). `show` shows both, and `stalled` says them past that moment.
@@ -118,9 +110,6 @@ type Delivery struct {
LetGo time.Time `json:"let_go"`
LetGoWhy string `json:"let_go_why,omitempty"`
// Ready is what this owner did with its pull request's ready mark (novox/hq issue 722, ready.go).
Ready *ReadyRun `json:"ready,omitempty"`
Transitions []Transition `json:"transitions,omitempty"`
// Owed are the forge's and the bus's effects of its transitions not yet done: said only after the
// delivery is kept, retried until done.
@@ -311,77 +300,18 @@ func (d *Delivery) FromForgesWord() bool {
// shownOnItsPullRequest is whether its view and status are kept on its pull request: not for history, whose
// pull request the forge may no longer hold — or holds under its number another one.
// coveredStop is whether a delivery that ended stopped or failed was covered since, and reads covered. A stop
// because its walk ended done before its word without building its modules (novox/hq issue 474) is covered when a
// later merge into its trunk that contains it was delivered, carrying every module it moves to every machine running
// it (ADR 0270's cover test, kept as CoveredBy; issue 480). Any stop or failure is covered when a later finished walk
// of its repository whose commit contains its merge built its modules (DeliveredBy, novox/hq issue 747). Its state
// stays final, as a covered superseded member stays superseded (issue 442), and it reads covered, by that merge or
// walk. A person's stop with a cover found only while it was held keeps its error.
// coveredStop is whether a delivery stopped because its walk ended done before its word without building its modules
// (novox/hq issue 474) was covered since: a later merge into its trunk that contains it was delivered, carrying every
// module it moves to every machine running it (ADR 0270's cover test, kept as CoveredBy). Its state stays stopped —
// final, as a covered superseded member stays superseded (issue 442) — and it reads covered, by that merge (novox/hq
// issue 480). A stop for any other reason never reads covered.
func (d *Delivery) coveredStop() bool {
switch d.State {
case Stopped:
return d.Stranded != "" && d.CoveredBy != "" || d.DeliveredBy != ""
case Failed:
return d.DeliveredBy != ""
}
return false
return d.State == Stopped && d.Stranded != "" && d.CoveredBy != ""
}
// coveredStopSaid is how a covered stop or failure reads: on the forge's status, in its view and note, and in `show`.
// coveredStopSaid is how a covered stop reads: on the forge's status, in its view and note, and in `show`.
func (d *Delivery) coveredStopSaid() string {
ended := "its stop"
if d.State == Failed {
ended = "its failure"
}
if d.Stranded != "" && d.CoveredBy != "" {
return "covered after " + ended + ": a later merge that contains it was delivered: " + d.CoveredBy
}
return "covered after " + ended + ": a later walk that contains it was delivered: " + d.DeliveredBy
}
// memberReached is whether a group counts a member as delivered: its change reached the machines, or had nothing to
// reach them with — delivered, history or covered, a superseded member a later delivered walk carried (issue 442),
// or a stop or failure covered since (issues 480 and 747).
func memberReached(d *Delivery) bool {
return d.State.Reached() || d.State == Superseded && d.CoveredBy != "" || d.coveredStop()
}
// stoppedFor is the member whose end stopped this one in its group: kept since novox/hq issue 747, and read from the
// stop's reason for a stop made before ("its group <id> stopped: <member> …").
func (d *Delivery) stoppedFor() string {
if d.StoppedFor != "" || d.State != Stopped || d.Group == "" {
return d.StoppedFor
}
prefix := "its group " + d.Group + " stopped: "
for i := len(d.Transitions) - 1; i >= 0; i-- {
t := d.Transitions[i]
if t.Event != EvStop {
continue
}
if t.By != byOwner || !strings.HasPrefix(t.Why, prefix) {
return ""
}
member, _, _ := strings.Cut(strings.TrimPrefix(t.Why, prefix), " ")
return strings.TrimRight(member, ",;:")
}
return ""
}
// stopLapsed is whether a member its group stopped for another member is stopped for nothing any more: that member's
// change reached the machines since — delivered, or its failure covered by a later walk (novox/hq issue 747). Its own
// change is still to deliver, by a later walk of its repository that contains it; its stop holds none back. find
// gives a delivery by its id.
func stopLapsed(d *Delivery, find func(string) *Delivery) bool {
if d.State != Stopped || d.coveredStop() {
return false
}
by := d.stoppedFor()
if by == "" || by == d.ID {
return false
}
o := find(by)
return o != nil && memberReached(o)
return "covered after its stop: a later merge that contains it was delivered: " + d.CoveredBy
}
func (d *Delivery) shownOnItsPullRequest() bool {
@@ -91,13 +91,6 @@ type Forge interface {
// OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue
// 347). Reads only.
OpenPulls() ([]OpenPull, error)
// Merge asks the forge's merge tool to merge a pull request at the head named (gitea_merge_pull_request, novox/hq
// issue 722): the one path every merge takes. Its refusal is the error, in its words.
Merge(owner, repo string, number int, head string) error
// Comment adds a comment to a pull request.
Comment(owner, repo string, number int, body string) error
// Unlabel takes a label off a pull request, by the label's id.
Unlabel(owner, repo string, number, label int) error
}
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
@@ -399,19 +392,6 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
return &c, nil
}
func (f toolForge) Merge(owner, repo string, number int, head string) error {
return f.tool("gitea_merge_pull_request", map[string]any{"owner": owner, "repo": repo, "number": number, "head": head})
}
func (f toolForge) Comment(owner, repo string, number int, body string) error {
return f.tool("gitea_add_comment", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body})
}
func (f toolForge) Unlabel(owner, repo string, number, label int) error {
return f.tool("gitea_api", map[string]any{"method": "DELETE",
"path": fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%d", owner, repo, number, label)})
}
func (f toolForge) OpenPulls() ([]OpenPull, error) {
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
if err != nil {
@@ -1,323 +0,0 @@
package main
import (
"fmt"
"strings"
"time"
)
// Merge on ready (novox/hq issue 722).
//
// The operator, 2026-10-14: "the reviewer task should merge the request or mark the MR as ready to merge in any case".
// Before, a reviewed pull request with green checks waited until a session called the forge's merge tool, and again
// after every check the tool asked because the trunk had moved. Now the independent reviewer marks the pull request
// ready at the head it approved — the forge label `mesh/ready` and a comment "ready at <full head sha>" (approving
// through the forge is the operator's) — and this owner merges it, through the forge's own merge tool
// (gitea_merge_pull_request), the one path every merge takes:
//
// - only at the head the mark names, and only while that is the pull request's head: a push after the mark clears
// it — the label taken off, and said on the pull request — and the new head needs a mark of its own;
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is judged
// again by the merge tool, which asks the check against the trunk's head itself (novox/hq ADR 0323 §5); the merge
// is asked again once a minute, and lands on the passing verdict;
// - a group member only at its turn in the group's order, and only once the group's composed check passed for its
// members' heads as they are;
// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the
// controller raises as the condition `delivery.<id>.stalled`. So is a mark the merge tool refused for an hour.
//
// What this owner does with a mark is kept on the delivery (Ready), said in its log, and the merge's transitions name
// it as their author.
// ReadyMark is a pull request's ready mark as the forge reads it (the gitea module's gitea_open_pulls): the head the
// newest "ready at" comment names, who wrote it and when, and the label's id. Head is empty when the label is on and
// no comment names a head.
type ReadyMark struct {
Head string `json:"head"`
By string `json:"by,omitempty"`
At string `json:"at,omitempty"`
LabelID int `json:"label_id"`
}
// ReadyLabel is the label that marks a pull request ready.
const ReadyLabel = "mesh/ready"
// readyTryEvery is how often a marked head's merge is asked while the merge tool refuses it: a check it asked takes
// minutes, and a refusal costs the forge a few reads.
const readyTryEvery = time.Minute
// readyRefusedFor is how long the merge tool may refuse a marked head before it is said as stalled: longer than a
// check takes (the controller's checkTimeout is 45 minutes), so a check on its way is never said.
const readyRefusedFor = time.Hour
// ReadyRun is what this owner did with a delivery's ready mark: kept on the delivery and shown whole by `show`.
type ReadyRun struct {
// Head, By and MarkedAt are the mark: the head it names, who marked it, when.
Head string `json:"head"`
By string `json:"by,omitempty"`
MarkedAt string `json:"marked_at,omitempty"`
// Seen is when this owner first read the mark.
Seen time.Time `json:"seen"`
// Waits is what the merge waits on, when it waits: a verdict, the group's turn or its composed check.
Waits string `json:"waits,omitempty"`
// Tries is how many times the merge was asked; LastTry when, and Answer what the merge tool last answered.
Tries int `json:"tries,omitempty"`
LastTry time.Time `json:"last_try"`
Answer string `json:"answer,omitempty"`
// RefusedSince is when the merge tool began refusing it without a break.
RefusedSince *time.Time `json:"refused_since,omitempty"`
// Merged is when the merge tool merged it.
Merged *time.Time `json:"merged,omitempty"`
// Failed is why it is not merged and will not be on this head: its check failed. Said once on the pull request.
Failed string `json:"failed,omitempty"`
}
// mergedBy is who a merge this owner made on a ready mark is said to be by, in its transitions.
func (r *ReadyRun) mergedBy() string {
if r == nil || r.Merged == nil {
return ""
}
by := byOwner + ", on the ready mark at " + shortOf(r.Head, 12)
if r.By != "" {
by += " by " + r.By
}
return by
}
// marked is a pull request read with a ready mark.
type marked struct {
pull OpenPull
// cleared is when the mark was cleared, the label taken off: not cleared again within readyTryEvery.
cleared time.Time
}
// pullKey is a pull request as a key: owner/repo#number.
func pullKey(p OpenPull) string {
return strings.ToLower(p.Owner+"/"+p.Repo) + fmt.Sprintf("#%d", p.Number)
}
// seeMarks keeps the open pull requests that carry a ready mark; under the lock. A pull request no longer open or
// no longer marked drops out.
func (h *Holder) seeMarks(open []OpenPull) {
seen := map[string]*marked{}
for _, p := range open {
if p.Ready == nil || p.HeadSHA == "" {
continue
}
k := pullKey(p)
m := &marked{pull: p}
if was := h.marks[k]; was != nil && was.pull.Ready != nil && was.pull.Ready.Head == p.Ready.Head &&
was.pull.HeadSHA == p.HeadSHA {
m.cleared = was.cleared
}
seen[k] = m
}
h.marks = seen
}
// sameFull is whether a mark names a head: the whole sha, or — read from an older forge module — at least twelve
// characters of it.
func sameFull(mark, head string) bool {
mark, head = strings.ToLower(mark), strings.ToLower(head)
return len(mark) >= 12 && strings.HasPrefix(head, mark)
}
// readyAsks works out, under the lock, what each ready mark needs: a mark cleared, a merge asked, a failure said.
// The asking happens outside the lock.
func (h *Holder) readyAsks(now time.Time) []func() {
var asks []func()
for k, m := range h.marks {
p, mark := m.pull, *m.pull.Ready
repository := strings.ToLower(p.Owner + "/" + p.Repo)
if !sameFull(mark.Head, p.HeadSHA) {
if !m.cleared.IsZero() && now.Sub(m.cleared) < readyTryEvery {
continue
}
m.cleared = now
asks = append(asks, h.clearMark(k, p, mark))
continue
}
d := h.deliveries[IDOf(repository, p.HeadSHA)]
if d == nil || d.State.Final() || d.MergedAs != "" {
continue // not announced yet, or past its merge
}
if d.Ready == nil || d.Ready.Head != mark.Head {
d.Ready = &ReadyRun{Head: mark.Head, By: mark.By, MarkedAt: mark.At, Seen: now}
h.Logf("[mesh-delivery] %s is marked ready at %s by %s: merged once its check passes against its trunk's head",
d.ID, shortOf(mark.Head, 12), orNone(mark.By))
h.keep(d)
}
r := d.Ready
if r.Merged != nil {
continue
}
if d.State == Rejected {
if r.Failed == "" {
r.Failed = "its check failed"
if d.Check != nil {
r.Failed += ": " + oneLine(d.Check.Summary)
if j := d.Check.Judged(); j != "" {
r.Failed += " (" + j + ")"
}
}
r.Waits = ""
h.Logf("[mesh-delivery] %s is marked ready and not merged: %s", d.ID, r.Failed)
h.keep(d)
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request is marked "+
"ready at %s, and %s. A new head, marked ready once reviewed, or a recheck that passes, merges it.",
mark.Head, r.Failed)))
}
continue
}
r.Failed = ""
waits := h.readyWaits(d)
if waits != r.Waits {
r.Waits = waits
if waits != "" {
h.Logf("[mesh-delivery] %s is marked ready and waits: %s", d.ID, waits)
}
h.keep(d)
}
if waits != "" || (!r.LastTry.IsZero() && now.Sub(r.LastTry) < readyTryEvery) {
continue
}
r.Tries++
r.LastTry = now
h.keep(d)
asks = append(asks, h.askMerge(d.ID, p, mark.Head))
}
return asks
}
// readyWaits is what a marked delivery's merge waits on; empty when it is asked now. A ready delivery is asked; so is
// a proposed one whose verdict went out of date and whose check nobody asked again — the merge tool asks it.
func (h *Holder) readyWaits(d *Delivery) string {
switch {
case d.State == Ready:
case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil:
case d.State == Proposed && d.AskedAgain != nil:
return "its check against the trunk's head is asked and has not answered"
default:
return "its check has not passed yet (" + string(d.State) + ")"
}
if d.Group == "" {
return ""
}
return h.groupTurnToMerge(d)
}
// groupTurnToMerge is why a group member may not merge yet; empty at its turn: the group's composed check passed for
// its members' heads as they are and is not out of date, and every member before it in the group's order merged.
func (h *Holder) groupTurnToMerge(d *Delivery) string {
g := h.groups[d.Group]
if g == nil {
return ""
}
members := h.membersOf(g)
if state, why := GroupState(g, members); state != "ready" && state != "delivering" {
return fmt.Sprintf("its group %s is %s: %s", g.ID, state, why)
}
if c := g.Check; c == nil || (c.Verdict != "pass" && c.Verdict != "warning") || c.OutOfDate != "" ||
!sameMembers(c.Commits, commitsOf(members)) {
return "its group " + g.ID + "'s composed check has not passed for its members' heads"
}
if !containsString(g.Order, d.ID) {
return "its group " + g.ID + "'s order is not worked out for it yet"
}
for _, id := range g.Order {
if id == d.ID {
return ""
}
if o := h.deliveries[id]; o != nil && o.MergedAs == "" && !o.State.Final() {
return "its group " + g.ID + "'s order merges " + id + " first"
}
}
return ""
}
// askMerge asks the forge's merge tool to merge a marked head, and keeps its answer.
func (h *Holder) askMerge(id string, p OpenPull, head string) func() {
return func() {
err := h.Forge.Merge(p.Owner, p.Repo, p.Number, head)
h.mu.Lock()
defer h.mu.Unlock()
d := h.deliveries[id]
if d == nil || d.Ready == nil {
return
}
r, now := d.Ready, h.Now()
if err == nil {
r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil
h.Logf("[mesh-delivery] %s merged on its ready mark at %s, its check passed against its trunk's head", id,
shortOf(head, 12))
h.keep(d)
return
}
answer := oneLine(err.Error())
if r.RefusedSince == nil {
r.RefusedSince = &now
}
if answer != r.Answer {
h.Logf("[mesh-delivery] %s is marked ready, and the merge tool did not merge it (asked again in %s): %s", id,
readyTryEvery, answer)
}
r.Answer = answer
h.keep(d)
}
}
// clearMark takes a mark off a pull request whose head moved after it, and says so on it.
func (h *Holder) clearMark(k string, p OpenPull, mark ReadyMark) func() {
return func() {
err := h.Forge.Unlabel(p.Owner, p.Repo, p.Number, mark.LabelID)
if err == nil {
err = h.Forge.Comment(p.Owner, p.Repo, p.Number, fmt.Sprintf("mesh-delivery: the ready mark at %s is cleared: "+
"the head is now %s, pushed after it. Mark the new head ready once it is reviewed.", mark.Head, p.HeadSHA))
}
if err != nil {
h.Logf("[mesh-delivery] %s's ready mark at %s names an older head than %s, and could not be cleared yet: %v",
k, shortOf(mark.Head, 12), shortOf(p.HeadSHA, 12), err)
return
}
h.Logf("[mesh-delivery] %s's ready mark at %s cleared: its head is now %s", k, shortOf(mark.Head, 12),
shortOf(p.HeadSHA, 12))
h.mu.Lock()
delete(h.marks, k)
h.mu.Unlock()
}
}
// sayOn comments on a delivery's pull request.
func (h *Holder) sayOn(d *Delivery, body string) func() {
owner, repo, number, id := d.Owner(), d.Repo(), d.Number, d.ID
return func() {
if err := h.Forge.Comment(owner, repo, number, body); err != nil {
h.Logf("[mesh-delivery] %s: could not say on its pull request: %v", id, err)
}
}
}
// readyLines are the marked heads that will not merge: a check that failed at once, a merge refused past its bound;
// as stalled lines, which the controller raises as conditions. Under the lock.
func (h *Holder) readyLines(now time.Time) []StalledLine {
var out []StalledLine
for _, d := range h.deliveries {
r := d.Ready
if r == nil || r.Merged != nil || d.MergedAs != "" || d.State.Final() {
continue
}
switch {
case r.Failed != "" && d.State == Rejected:
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
Bound: "0s", H2: "none: a new head marked ready, or a recheck that passes — the operator's",
Says: fmt.Sprintf("%s#%d is marked ready at %s and not merged: %s", d.Repository, d.Number,
shortOf(r.Head, 12), r.Failed)})
case r.RefusedSince != nil && now.Sub(*r.RefusedSince) >= readyRefusedFor:
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: readyRefusedFor.String(),
H2: "none: the merge is asked again each minute — the operator's",
Says: fmt.Sprintf("%s#%d is marked ready at %s, and the merge tool has refused it since %s: %s", d.Repository,
d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
}
}
return out
}
@@ -1,199 +0,0 @@
package main
import (
"errors"
"strings"
"testing"
"time"
)
// **A pull request marked ready is merged by this owner** (novox/hq issue 722): before, a reviewed pull request with
// green checks waited until a session called the merge tool, and again after every check the tool asked because the
// trunk had moved. Each test below fails on a holder without the ready mark: nothing merged it.
const (
readyHead = "1111111111111111111111111111111111111111"
pushed = "2222222222222222222222222222222222222222"
)
// markedPull is an open pull request as gitea_open_pulls reads it, labelled mesh/ready at the head named.
func markedPull(repo string, number int, head, markedAt string) OpenPull {
owner, name, _ := strings.Cut(repo, "/")
return OpenPull{Owner: owner, Repo: name, Number: number, Base: "main", HeadSHA: head, Labels: []string{ReadyLabel},
Ready: &ReadyMark{Head: markedAt, By: "reviewer", At: "2026-10-14T19:00:00Z", LabelID: 31}}
}
func judgedAt(repo string, number int, sha, gate, trunk string) CheckedEvent {
c := verdict(repo, number, sha, gate)
c.Trunk = trunk
return c
}
func TestAReadyPullRequestMarkedAtItsHeadIsMergedThroughTheMergeTool(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", readyHead)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("an unmarked pull request was merged: %v", w.forge.merges)
}
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
t.Fatalf("a ready, marked pull request was merged %v; wanted once, at the marked head", w.forge.merges)
}
d := w.delivery(id)
if d.Ready == nil || d.Ready.Merged == nil || d.Ready.By != "reviewer" || d.Ready.Tries != 1 {
t.Fatalf("what was done with the mark is not kept: %+v", d.Ready)
}
// Its merge's transition names who merged it.
w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
w.h.WalkMoved(w.ctl.put(aWalk("plan-7", "novox/app", "m7m7m7m7m7m7", true)))
w.settleAll()
d = w.delivery(id)
var last Transition
for _, tr := range d.Transitions {
if tr.Event == EvMerged {
last = tr
}
}
if !strings.Contains(last.By, "ready mark at "+readyHead[:12]+" by reviewer") {
t.Fatalf("the merge's transition does not name the mark: %+v", last)
}
if len(w.forge.merges) != 1 {
t.Fatalf("merged again: %v", w.forge.merges)
}
}
func TestAPushAfterTheMarkClearsItAndNothingIsMerged(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, pushed, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("a head pushed after the mark was merged: %v", w.forge.merges)
}
if len(w.forge.unlabels) != 1 || w.forge.unlabels[0] != "novox/app#7-31" {
t.Fatalf("the stale mark's label was not taken off: %v", w.forge.unlabels)
}
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "cleared") ||
!strings.Contains(w.forge.comments[0], pushed) {
t.Fatalf("the cleared mark is not said on the pull request: %v", w.forge.comments)
}
}
func TestAMovedTrunkIsJudgedAgainByTheMergeToolAndMergedOnItsVerdict(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", readyHead)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
// Another merge moves the trunk: its verdict is out of date, and it goes back to proposed.
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
if w.state(id) != Proposed {
t.Fatalf("out of date, it is %s", w.state(id))
}
// The merge tool asks the check against the trunk's head and refuses: nothing merged.
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: The check of head merged into trunk was asked")
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 1 {
t.Fatalf("the merge tool was not asked once to judge it again: %v", w.forge.merges)
}
// The tool's ask is the head announced again; it waits for that verdict, asking nothing.
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.later(5 * time.Minute)
w.settleAll()
if len(w.forge.merges) != 1 || !strings.Contains(w.delivery(id).Ready.Waits, "asked") {
t.Fatalf("asked again while its check was on its way: %v (%+v)", w.forge.merges, w.delivery(id).Ready)
}
// It passes against the trunk's head: merged, without anyone asking.
w.forge.refuse = nil
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
w.settleAll()
if w.state(id) != Ready || len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil {
t.Fatalf("on its passing verdict it was not merged: %s %v %+v", w.state(id), w.forge.merges, w.delivery(id).Ready)
}
}
func TestAMarkedHeadWhoseCheckFailedIsNotMergedAndIsSaid(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", readyHead)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
w.later(10 * time.Minute)
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("a head whose check failed was merged: %v", w.forge.merges)
}
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "not merged") {
t.Fatalf("the failure is not said once on the pull request: %v", w.forge.comments)
}
lines := w.h.Stalled()
if len(lines) != 1 || lines[0].ID != id || lines[0].State != Rejected || !strings.Contains(lines[0].Says, "marked ready") {
t.Fatalf("the failure is not a stalled line, which the controller raises as a condition: %+v", lines)
}
}
func TestAMarkRefusedForAnHourIsSaid(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check is pending")
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
w.later(30 * time.Second)
w.settleAll()
if len(w.forge.merges) != 1 {
t.Fatalf("asked again within the minute: %v", w.forge.merges)
}
w.later(readyRefusedFor)
w.settleAll()
if len(w.forge.merges) != 2 {
t.Fatalf("not asked again after a minute: %v", w.forge.merges)
}
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "refused it since") {
t.Fatalf("a mark refused for an hour is not said: %+v", lines)
}
}
func TestAGroupMergesItsMarkedMembersInOrderOnlyOnceComposed(t *testing.T) {
w := newWorld(t)
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctl, cat}} }
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
w.forge.open = []OpenPull{markedPull("novox/mesh-controller", 1, ctlHead, ctlHead),
markedPull("novox/mesh-catalog", 2, catHead, catHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("a member merged before its group's composed check passed: %v", w.forge.merges)
}
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
w.settleAll()
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/mesh-controller#1@"+ctlHead {
t.Fatalf("composed, the first member in the group's order was not merged alone: %v", w.forge.merges)
}
if waits := w.delivery(cat).Ready.Waits; !strings.Contains(waits, "merges "+ctl+" first") {
t.Fatalf("the second member does not say it waits its turn: %q", waits)
}
w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1"))
w.settleAll()
if len(w.forge.merges) != 2 || w.forge.merges[1] != "novox/mesh-catalog#2@"+catHead {
t.Fatalf("after the first member merged, the next was not: %v", w.forge.merges)
}
}
@@ -498,12 +498,7 @@ func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) {
if why == "" {
why = r.Guard
}
by := f.By
if by == "" && (ev == EvMerged || ev == EvDone) && d.MergedAs != "" {
// A merge this owner made on a ready mark names it as its author (novox/hq issue 722).
by = d.Ready.mergedBy()
}
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: by}
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: f.By}
d.State, d.Since = r.To, f.Now
d.Transitions = append(d.Transitions, t)
if len(d.Transitions) > keptTransitions {
@@ -72,9 +72,6 @@ type OpenPull struct {
// than that says none: the head is then read by Statuses alone, as before, and a pending check is not told
// from an answered one.
Checks map[string]HeadCheck `json:"checks,omitempty"`
// Labels are its labels by name, and Ready its ready mark, when it carries one (novox/hq issue 722).
Labels []string `json:"labels,omitempty"`
Ready *ReadyMark `json:"ready,omitempty"`
}
// HeadCheck is one status on a head as the forge keeps it: pending, success, failure, error or warning.
@@ -204,7 +201,6 @@ func (h *Holder) SeeOpenPulls() {
seen[id] = u
}
h.unchecked = seen
h.seeMarks(open)
}
// started is whether the head's check started, rather than was never asked: it has any merge check, or a delivery
@@ -72,10 +72,6 @@ func (h *Holder) waitsFor(d *Delivery) string {
return "its walk: " + clip(d.Walk.Note, 120)
}
case Stopped:
if h.stopLapsed(d) {
return "a later walk of its repository that contains its merge: its group stopped it for " + d.stoppedFor() +
", whose change was delivered since (novox/hq issue 747)"
}
if strandedUncovered(d) {
return "a later delivered merge to carry " + strings.Join(d.notMovedBy(), ", ") + ": it is stopped, and " +
"listed by stalled until one does (novox/hq issue 480)"
@@ -253,7 +249,6 @@ func (h *Holder) Stalled() []StalledLine {
}
out = append(out, h.withChecks(h.uncheckedLines(now))...)
out = append(out, h.orphanLines(now)...)
out = append(out, h.readyLines(now)...)
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
+1 -4
View File
@@ -47,10 +47,7 @@
"gitea.gitea_commit_status",
"gitea.gitea_commit_statuses",
"gitea.gitea_contains",
"gitea.gitea_open_pulls",
"gitea.gitea_merge_pull_request",
"gitea.gitea_add_comment",
"gitea.gitea_api"
"gitea.gitea_open_pulls"
],
"state": [
"deliveries",
-58
View File
@@ -1,58 +0,0 @@
# mesh-mail — the operator's mail accounts, watched and said on the bus
One module holds the operator's mail accounts, watches them, and says each change of a message on the bus for the
modules the operator grants it to (novox/hq ADR 0359, research 049 document 02). One instance, on the control-node.
This is the first stage: accounts of the kind `imap`, read-only. Nothing here flags, moves, deletes, drafts or sends.
## What it does
- **Watches.** For each account it keeps one connection: IDLE on the inbox, ended and begun again every two minutes
(which proves the server still answers), and a look at every other folder every five minutes. Folders are opened
read-only and bodies are peeked, so watching never marks a message seen.
- **Indexes.** One row per message and folder in its own database (`postgres-database`): metadata, flags, the text
for search, and the attachments' references. Each attachment is kept once, by its SHA-256, in its bucket
(`s3-bucket`, from `minio`).
- **Says each change.** A message added, its flags changed, or removed is said as the event
`mail.<account>.message.<added|changed|removed>`: account, folder, uid, message id, date, sender, recipients,
subject, size, flags and attachment references; never the body, never an attachment's bytes. The events go to the
module's own stream `MAIL`, kept seven days, never to the shared EVENTS stream (novox/hq ADR 0363); a module hears an
account's part only when its `consumes` names mesh-mail and the setting `grants` names it for that account. The first look at a
folder indexes what is there and says nothing: the bus hears what changed since the watch began. The keeper writes
each event in the same step as the change it says; the bundle says it through the runtime and removes it once the
bus took it.
## Its settings, both trusted
`accounts` — per account name (a lower-case token, which names the account on the bus and its secret):
```json
{"mesh": {"kind": "imap", "address": "<the address>",
"incoming": {"host": "<the IMAP server>", "port": 993, "security": "tls"},
"outgoing": {"host": "<the SMTP server>", "port": 465, "security": "tls"},
"folders": ["optional: the folders watched besides the inbox; every folder when left out"]}}
```
`outgoing` is read and never used in this stage. A second `imap` account on another server is one more entry and one
more password; no code changes. `user` signs in under another name than the address.
`grants` — per account, who may read its mail: a module's name (on every machine), `<machine>.<module>`, or
`person.<name>`. Nobody by default. It restricts the stream `MAIL` on the bus (a person's entry grants nothing there)
and the tools `mail_search` and `mail_read`.
The password of an account is the own secret `mail-credential-<account>`, given at the desk; the mesh never makes one.
## Its tools
| Tool | Answers |
|---|---|
| `mail_accounts` | each account's kind, state and why, when its server last answered, its folders; the address only to a caller granted it |
| `mail_search` | messages of the accounts the caller is granted, by every word of a query, newest first, no body |
| `mail_read` | one message's metadata and text, or one attachment's bytes, to a caller granted its account |
| `mail_health` | healthy; unhealthy naming the account and why (connection down, or no answer for five minutes); waits naming `mail-credential-<account>` while its password is not given |
The caller is the runtime's word, never an argument.
## Its processes
`prepare` brings the index's schema to this version's (numbered migrations, and `schema.sql` for a fresh database).
`keep` watches the accounts and is judged by its unit. `watch` judges every minute and is judged by `mail_health`.
-260
View File
@@ -1,260 +0,0 @@
package main
// The operator's mail accounts and who may read them (novox/hq ADR 0359 §1, §4, §5): two trusted settings, each a
// file the mesh writes from the module's settings and changes only on the operator's approval. `accounts` says which
// server each account is on and how to sign in (never a credential); `grants` says, per account, which callers may
// read its mail. The password of an account is the module's own secret mail-credential-<account>, given at the desk
// (ADR 0283 §6), at the path the secrets directory and that name make.
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// The kinds of account an adapter exists for. Only `imap` is built (ADR 0359 §5); `imap-oauth` and `graph` are later
// decisions (§7), and an account of a kind not listed here is refused by name.
const KindIMAP = "imap"
// Kinds is every kind this version has an adapter for.
var Kinds = []string{KindIMAP}
// SecretFamily is the prefix of every account's own secret: mail-credential-<account> (ADR 0283 §6).
const SecretFamily = "mail-credential-"
// accountName is what an account may be called: a member name of the secret family (ADR 0283 §6) and one token of a
// bus subject, so never a dot, a slash or an upper-case letter.
var accountName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,39}$`)
// Server is where an account's mail is reached.
type Server struct {
Host string `json:"host"`
Port int `json:"port"`
// Security is tls (TLS from the first byte) or starttls (plain, then upgraded before signing in). Nothing
// signs in over a connection that is not encrypted.
Security string `json:"security"`
}
// Address is host:port.
func (s Server) Address() string { return fmt.Sprintf("%s:%d", s.Host, s.Port) }
// Account is one of the operator's mail accounts, as the setting `accounts` declares it.
type Account struct {
// Name is the key the account has in the setting; set when the setting is read.
Name string `json:"-"`
Kind string `json:"kind"`
// Address is the account's mail address, said only to a caller granted the account.
Address string `json:"address"`
// User is the name it signs in with; the address when empty.
User string `json:"user,omitempty"`
Incoming Server `json:"incoming"`
Outgoing *Server `json:"outgoing,omitempty"`
// Folders are the folders watched besides the inbox; every folder the server lists when empty.
Folders []string `json:"folders,omitempty"`
}
// SignInAs is the name the account signs in with.
func (a Account) SignInAs() string {
if a.User != "" {
return a.User
}
return a.Address
}
// Secret is the name of the account's own secret.
func (a Account) Secret() string { return SecretFamily + a.Name }
// AccountsFile is the shape of the trusted setting `accounts`, as the mesh writes it.
type AccountsFile struct {
Accounts map[string]Account `json:"accounts"`
}
// ReadAccounts reads the accounts setting, refusing it whole when any account in it cannot be watched: an account
// half-understood would be watched wrongly, and the operator approved the setting as one.
func ReadAccounts(path string) ([]Account, error) {
if path == "" {
return nil, errors.New("the module was started without its accounts setting (MESH_MAIL_ACCOUNTS)")
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("the accounts setting cannot be read: %w", err)
}
return ParseAccounts(raw)
}
// ParseAccounts reads the accounts setting's content.
func ParseAccounts(raw []byte) ([]Account, error) {
var f AccountsFile
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&f); err != nil {
return nil, fmt.Errorf("the accounts setting is not the module's: %v", err)
}
var out []Account
var problems []string
for name, a := range f.Accounts {
a.Name = name
if err := a.Check(); err != nil {
problems = append(problems, err.Error())
continue
}
out = append(out, a)
}
if len(problems) > 0 {
sort.Strings(problems)
return nil, errors.New("the accounts setting is refused: " + strings.Join(problems, "; "))
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
// Check says why an account cannot be watched, or nil.
func (a Account) Check() error {
say := func(format string, args ...any) error {
return fmt.Errorf("the account %q: %s", a.Name, fmt.Sprintf(format, args...))
}
if !accountName.MatchString(a.Name) {
return say("is not an account's name: a lower-case letter or digit, then lower-case letters, digits and hyphens, at most 40")
}
known := false
for _, k := range Kinds {
known = known || a.Kind == k
}
if !known {
return say("is of the kind %q, and this version watches only %s", a.Kind, strings.Join(Kinds, ", "))
}
if !strings.Contains(a.Address, "@") {
return say("has no mail address")
}
if err := a.Incoming.check("incoming"); err != nil {
return say("%v", err)
}
if a.Outgoing != nil {
if err := a.Outgoing.check("outgoing"); err != nil {
return say("%v", err)
}
}
for _, f := range a.Folders {
if strings.TrimSpace(f) == "" || strings.EqualFold(f, "INBOX") {
return say("lists the folder %q, and the inbox is always watched", f)
}
}
return nil
}
func (s Server) check(which string) error {
switch {
case strings.TrimSpace(s.Host) == "" || strings.ContainsAny(s.Host, " /:"):
return fmt.Errorf("its %s server has no host name", which)
case s.Port <= 0 || s.Port > 65535:
return fmt.Errorf("its %s server's port %d is not a port", which, s.Port)
case s.Security != "tls" && s.Security != "starttls":
return fmt.Errorf("its %s server's security is %q, and only tls or starttls signs in", which, s.Security)
}
return nil
}
// SecretPath is where the account's own secret is placed in the secrets directory, as the manifest declares the
// family: <dir>/mail-credential-<account>.secret.
func SecretPath(dir string, a Account) string { return filepath.Join(dir, a.Secret()+".secret") }
// ReadSecret is the account's password, or given false when nobody gave it yet: the mesh never makes a member of the
// family, so a member not given is no file (ADR 0283 §6).
func ReadSecret(dir string, a Account) (string, bool, error) {
raw, err := os.ReadFile(SecretPath(dir, a))
if errors.Is(err, os.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, err
}
pw := strings.TrimRight(string(raw), "\r\n")
if pw == "" {
return "", false, nil
}
return pw, true, nil
}
// SecretGiven says whether the account's own secret was given.
//
// It looks at the file and never reads it: whoever judges whether a password was given has no need of the password.
// A file that is there and not empty was given; whether it signs in is the keeper's to say.
func SecretGiven(dir string, a Account) bool {
info, err := os.Stat(SecretPath(dir, a))
return err == nil && info.Mode().IsRegular() && info.Size() > 0
}
// ---- grants ------------------------------------------------------------------------------------------------------
// Grants is the trusted setting `grants`: per account, the callers that may read its mail (ADR 0359 §4). An entry is a
// module's name (granted wherever it runs), a module on one machine (`<machine>.<module>`) or a person
// (`person.<name>`), as the runtime names callers.
type Grants map[string][]string
// GrantsFile is the shape of the setting as the mesh writes it.
type GrantsFile struct {
Grants Grants `json:"grants"`
}
// ReadGrants reads the grants setting. A setting that cannot be read grants nothing, and says so.
func ReadGrants(path string) (Grants, error) {
if path == "" {
return Grants{}, errors.New("the module was started without its grants setting (MESH_MAIL_GRANTS)")
}
raw, err := os.ReadFile(path)
if err != nil {
return Grants{}, fmt.Errorf("the grants setting cannot be read: %w", err)
}
var f GrantsFile
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&f); err != nil {
return Grants{}, fmt.Errorf("the grants setting is not the module's: %v", err)
}
if f.Grants == nil {
f.Grants = Grants{}
}
return f.Grants, nil
}
// Unproven is the caller the runtime proved nobody to be; it is granted nothing.
const Unproven = "unproven"
// Allows says whether the caller may read the account's mail. Nobody is granted an account by default, an unproven
// caller never, and a wildcard is no entry.
func (g Grants) Allows(caller, account string) bool {
if caller == "" || caller == Unproven {
return false
}
for _, entry := range g[account] {
if entry == "" || strings.ContainsAny(entry, "*>") {
continue
}
if entry == caller {
return true
}
// A module's name alone grants it on every machine: the caller is <machine>.<module>.
if !strings.Contains(entry, ".") {
if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") {
return true
}
}
}
return false
}
// Granted is the accounts, of those given, the caller may read.
func (g Grants) Granted(caller string, accounts []Account) []string {
var out []string
for _, a := range accounts {
if g.Allows(caller, a.Name) {
out = append(out, a.Name)
}
}
return out
}
@@ -1,124 +0,0 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// The first stage's account, as the operator sets the setting `accounts` (ADR 0359 §5): its address and its server,
// never a credential; its password is the own secret mail-credential-mesh.
const stageOne = `{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be",
"incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"}}}}`
func TestTheFirstStagesAccountIsReadAndItsSecretNamed(t *testing.T) {
got, err := ParseAccounts([]byte(stageOne))
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Name != "mesh" || got[0].Kind != KindIMAP || got[0].SignInAs() != "jschoubben@novox.be" ||
got[0].Incoming.Address() != "mail.novox.be:993" || got[0].Secret() != "mail-credential-mesh" || got[0].Outgoing != nil {
t.Fatalf("%+v", got)
}
if p := SecretPath("/state", got[0]); p != "/state/mail-credential-mesh.secret" {
t.Fatalf("the secret is placed at %s", p)
}
}
// A second account of the kind imap on another server — Gmail with an app password, another member of the family —
// is configuration only: no code changes for it. Each account may also say its outgoing server, which this stage
// reads and never uses (it sends nothing).
func TestASecondIMAPAccountIsConfigurationOnly(t *testing.T) {
got, err := ParseAccounts([]byte(`{"accounts": {
"mesh": {"kind": "imap", "address": "jschoubben@novox.be", "incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"}},
"gmail": {"kind": "imap", "address": "someone@gmail.com", "incoming": {"host": "imap.gmail.com", "port": 993, "security": "tls"},
"outgoing": {"host": "smtp.gmail.com", "port": 465, "security": "tls"}, "folders": ["[Gmail]/Sent Mail"]}}}`))
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Name != "gmail" || got[0].Secret() != "mail-credential-gmail" || got[0].Outgoing.Address() != "smtp.gmail.com:465" ||
!reflect.DeepEqual(got[0].Folders, []string{"[Gmail]/Sent Mail"}) {
t.Fatalf("%+v", got)
}
}
func TestAnAccountThatCannotBeWatchedRefusesTheSetting(t *testing.T) {
for name, raw := range map[string]string{
"a later kind": `{"accounts": {"work": {"kind": "graph", "address": "a@b.c", "incoming": {"host": "h", "port": 1, "security": "tls"}}}}`,
"no encryption": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "incoming": {"host": "h", "port": 143, "security": "none"}}}}`,
"a name with a dot": `{"accounts": {"my.box": {"kind": "imap", "address": "a@b.c", "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`,
"a password in it": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "password": "p", "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`,
"no address": `{"accounts": {"x": {"kind": "imap", "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`,
"the inbox listed": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "folders": ["INBOX"], "incoming": {"host": "h", "port": 993, "security": "tls"}}}}`,
"a bad outgoing one": `{"accounts": {"x": {"kind": "imap", "address": "a@b.c", "incoming": {"host": "h", "port": 993, "security": "tls"}, "outgoing": {"host": "", "port": 465, "security": "tls"}}}}`,
} {
if _, err := ParseAccounts([]byte(raw)); err == nil {
t.Errorf("%s: accepted", name)
}
}
if got, err := ParseAccounts([]byte(`{"accounts": {}}`)); err != nil || len(got) != 0 {
t.Fatalf("the manifest's default: %v %v", got, err)
}
}
// Grants (ADR 0359 §4): nobody by default; a module's name grants it wherever it runs; a module on one machine, or a
// person, by its full name; never the unproven, never by a wildcard, never one account for another.
func TestGrantsAreByAccountAndByProvenCaller(t *testing.T) {
g := Grants{"mesh": {"triage", "novox.desk", "person.jochen", "*"}}
for caller, want := range map[string]bool{
"novox.triage": true, "shanks.triage": true, "novox.desk": true, "g14.desk": false, "person.jochen": true,
"person.triage": false, "novox.other": false, Unproven: false, "": false, "novox.*": false,
} {
if got := g.Allows(caller, "mesh"); got != want {
t.Errorf("%s: %v", caller, got)
}
}
if g.Allows("novox.triage", "gmail") || (Grants{}).Allows("novox.triage", "mesh") {
t.Fatal("a grant reached an account it does not name")
}
dir := t.TempDir()
path := filepath.Join(dir, "grants.json")
_ = os.WriteFile(path, []byte(`{"grants": {"mesh": ["triage"]}}`), 0o600)
read, err := ReadGrants(path)
if err != nil || !read.Allows("novox.triage", "mesh") {
t.Fatalf("%v %v", read, err)
}
_ = os.WriteFile(path, []byte(`{"grants": {"mesh": "triage"}}`), 0o600)
if read, err := ReadGrants(path); err == nil || len(read) != 0 {
t.Fatal("a grants setting it cannot read granted something")
}
}
// Whether a password was given is read from the file's presence and size alone (issue 733): an empty file is not
// given, a file the judge cannot read is still given, and the password is never read to say so.
func TestASecretIsGivenByItsFileNotByReadingIt(t *testing.T) {
dir := t.TempDir()
a := testAccount()
if SecretGiven(dir, a) {
t.Fatal("given before anything was written")
}
_ = os.WriteFile(SecretPath(dir, a), nil, 0o600)
if SecretGiven(dir, a) {
t.Fatal("an empty file is a password")
}
_ = os.WriteFile(SecretPath(dir, a), []byte("pw\n"), 0o600)
if pw, given, _ := ReadSecret(dir, a); !given || pw != "pw" || strings.Contains(pw, "\n") {
t.Fatalf("%q %v", pw, given)
}
// Unreadable to the judge, as a secret owned by root is to the watch: still given.
if err := os.Chmod(SecretPath(dir, a), 0); err != nil {
t.Fatal(err)
}
if !SecretGiven(dir, a) {
t.Fatal("a password the judge cannot read is said not given")
}
src, _ := os.ReadFile("config.go")
body := string(src)
body = body[strings.Index(body, "func SecretGiven("):]
body = body[:strings.Index(body, "\n}\n")]
if strings.Contains(body, "ReadSecret") || strings.Contains(body, "ReadFile") || strings.Contains(body, "os.Open") {
t.Fatalf("SecretGiven reads the password:\n%s", body)
}
}
-170
View File
@@ -1,170 +0,0 @@
package main
// How mesh-mail is (novox/hq ADR 0359 §6). Its keeper's liveness is its unit's; its watching is judged here, by the
// watch process, once a minute, and left in a status file the bundle's tool `mail_health` reads:
//
// - waits, naming the own secret mail-credential-<account>, for an account whose password nobody gave yet (ADR 0283),
// so the condition is needs-operator and the gate passes;
// - unhealthy, naming the account and why, when its connection is down or its server has not answered for
// SilentAfter (five minutes) — an IDLE that went silent;
// - healthy otherwise.
//
// A fault beside a wait is unhealthy and says both.
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// HealthTool is the module's tool the node-engine asks how it is.
const HealthTool = "mail_health"
// WatchEvery is how often the watch judges; StatusStale how old its verdict may be before the tool says it stopped.
const (
WatchEvery = time.Minute
StatusStale = 3 * WatchEvery
// SignInGrace is how long a password just given may wait for its keeper to sign in before that is a fault.
SignInGrace = 3 * time.Minute
)
// Judge is the module's health from what the watch reads: the accounts setting (or why it cannot be read), whether the
// index serves, which passwords were given, and each account's state as its keeper said it.
func Judge(accounts []Account, accountsErr, readyErr error, given func(Account) bool, states map[string]AccountState, now time.Time) health.Answer {
if accountsErr != nil {
return health.Unhealthy(accountsErr.Error())
}
var problems, fine []string
var waits []health.Wait
if readyErr != nil {
problems = append(problems, "the index cannot serve: "+readyErr.Error())
}
for _, a := range accounts {
if !given(a) {
waits = append(waits, health.Wait{
Part: "the mail account " + a.Name,
Secret: a.Secret(),
What: "the password of the mail account " + a.Name,
})
continue
}
if readyErr != nil {
continue
}
st, said := states[a.Name]
switch {
case !said:
problems = append(problems, fmt.Sprintf("the account %s: its keeper has said nothing of it", a.Name))
case st.State == AccountWaits:
if now.Sub(st.Said) > SignInGrace {
problems = append(problems, fmt.Sprintf("the account %s: its password was given, and its keeper has not signed in for %s",
a.Name, now.Sub(st.Said).Round(time.Second)))
} else {
fine = append(fine, a.Name+" is signing in")
}
case st.State == AccountFailing:
problems = append(problems, fmt.Sprintf("the account %s is not watched: %s", a.Name, st.Why))
case st.Heard.IsZero():
problems = append(problems, fmt.Sprintf("the account %s: its server has not answered since its keeper started", a.Name))
case now.Sub(st.Heard) > SilentAfter:
problems = append(problems, fmt.Sprintf("the account %s: its server has not answered for %s (its IDLE on the inbox is silent)",
a.Name, now.Sub(st.Heard).Round(time.Second)))
default:
fine = append(fine, fmt.Sprintf("%s is %s (%s)", a.Name, st.State, st.Why))
}
}
waitSaid := ""
if len(waits) > 0 {
var names []string
for _, w := range waits {
names = append(names, w.Secret)
}
waitSaid = "waits for its password: " + strings.Join(names, ", ")
}
switch {
case len(problems) > 0:
why := strings.Join(problems, "; ")
if waitSaid != "" {
why += "; and it " + waitSaid
}
return health.Unhealthy(why, waits...)
case len(waits) > 0:
why := "nothing is wrong, and it " + waitSaid
if len(fine) > 0 {
why += "; " + strings.Join(fine, "; ")
}
return health.Waiting(why, waits...)
case len(accounts) == 0:
return health.Healthy("no mail account is declared, so nothing is watched")
}
return health.Healthy(fmt.Sprintf("%d account(s) watched: %s", len(accounts), strings.Join(fine, "; ")))
}
// Status is the watch's verdict as it leaves it: the answer, and when it looked.
type Status struct {
health.Answer
At time.Time `json:"at"`
}
func writeStatus(path string, s Status) error {
raw, err := json.Marshal(s)
if err != nil {
return err
}
tmp := path + ".next"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// ReadStatus is the watch's last verdict, as the tool answers it: unhealthy when there is none, when it cannot be read,
// or when it is older than StatusStale.
func ReadStatus(path string, now time.Time) health.Answer {
raw, err := os.ReadFile(path)
if err != nil {
return health.Unhealthy("the watch has left no verdict yet: " + err.Error())
}
var s Status
if err := json.Unmarshal(raw, &s); err != nil {
return health.Unhealthy("the watch's verdict cannot be read")
}
if age := now.Sub(s.At); age > StatusStale {
return health.Unhealthy(fmt.Sprintf("the watch has not looked for %s (its last verdict: %s)", age.Round(time.Second), s.Why))
}
// Read through the SDK's reader, so a wait it cannot check is never excused.
a, err := health.Read(mustJSON(s.Answer))
if err != nil {
return health.Unhealthy("the watch's verdict cannot be read: " + err.Error())
}
return a
}
func mustJSON(v any) []byte {
raw, _ := json.Marshal(v)
return raw
}
// watchOnce is one look: what the watch reads, judged.
func watchOnce(ctx context.Context, env Env, open func(context.Context) (Store, func(), error), now time.Time) health.Answer {
accounts, accountsErr := ReadAccounts(env.Accounts)
var readyErr error
states := map[string]AccountState{}
st, done, err := open(ctx)
if err != nil {
readyErr = err
} else {
defer done()
if readyErr = st.Ready(ctx); readyErr == nil {
if states, err = st.States(ctx); err != nil {
readyErr = err
}
}
}
return Judge(accounts, accountsErr, readyErr, func(a Account) bool { return SecretGiven(env.Secrets, a) }, states, now)
}
@@ -1,116 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
var judgedAt = time.Date(2026, 10, 14, 18, 0, 0, 0, time.UTC)
func given(names ...string) func(Account) bool {
return func(a Account) bool { return contains(names, a.Name) }
}
// Before the password is given, mail_health answers waits, naming the secret, which the node-engine reads as a wait
// the controller can excuse (ADR 0283 §1, §3): the family mail-credential-* is declared issued outside the mesh.
func TestItWaitsNamingTheSecretUntilThePasswordIsGiven(t *testing.T) {
a := Judge([]Account{testAccount()}, nil, nil, given(), nil, judgedAt)
if a.State != health.StateWaits || len(a.Waits) != 1 || a.Waits[0].Secret != "mail-credential-mesh" || a.Healthy {
t.Fatalf("%+v", a)
}
read, err := health.Read(mustJSON(a))
if err != nil || read.State != health.StateWaits {
t.Fatalf("the node-engine reads it as %+v (%v)", read, err)
}
if strings.Contains(a.Waits[0].What, "@") || strings.Contains(a.Why, "@") {
t.Fatalf("the wait says an address: %+v", a)
}
}
// Unhealthy, naming the account and why, when its connection is down or its server silent for five minutes.
func TestItIsUnhealthyWhenAnAccountIsDownOrSilent(t *testing.T) {
acc := []Account{testAccount()}
down := map[string]AccountState{"mesh": {Account: "mesh", State: AccountFailing, Why: "its server mail.example cannot be reached", Said: judgedAt}}
if a := Judge(acc, nil, nil, given("mesh"), down, judgedAt); a.State != health.StateUnhealthy || !strings.Contains(a.Why, "the account mesh is not watched: its server") {
t.Fatalf("down: %+v", a)
}
silent := map[string]AccountState{"mesh": {Account: "mesh", State: AccountCurrent, Heard: judgedAt.Add(-SilentAfter - time.Second), Said: judgedAt}}
if a := Judge(acc, nil, nil, given("mesh"), silent, judgedAt); a.State != health.StateUnhealthy || !strings.Contains(a.Why, "IDLE on the inbox is silent") {
t.Fatalf("silent: %+v", a)
}
fine := map[string]AccountState{"mesh": {Account: "mesh", State: AccountCurrent, Why: "watching", Heard: judgedAt.Add(-time.Minute), Said: judgedAt}}
if a := Judge(acc, nil, nil, given("mesh"), fine, judgedAt); a.State != health.StateHealthy || !a.Healthy {
t.Fatalf("fine: %+v", a)
}
if a := Judge(acc, nil, errors.New("the schema is behind"), given("mesh"), fine, judgedAt); a.State != health.StateUnhealthy {
t.Fatalf("an index that cannot serve: %+v", a)
}
if a := Judge(nil, errors.New("the accounts setting is refused"), nil, given(), nil, judgedAt); a.State != health.StateUnhealthy {
t.Fatalf("a setting it cannot read: %+v", a)
}
// A fault beside a wait is unhealthy and says both (ADR 0283 §1).
two := []Account{testAccount(), {Name: "gmail", Kind: KindIMAP, Address: "x@example.com", Incoming: Server{Host: "h", Port: 993, Security: "tls"}}}
a := Judge(two, nil, nil, given("mesh"), down, judgedAt)
if a.State != health.StateUnhealthy || len(a.Waits) != 1 || a.Waits[0].Secret != "mail-credential-gmail" || !strings.Contains(a.Why, "mail-credential-gmail") {
t.Fatalf("both: %+v", a)
}
// A password just given is signing in for a while; not for ever.
signing := map[string]AccountState{"mesh": {Account: "mesh", State: AccountWaits, Said: judgedAt.Add(-time.Minute)}}
if a := Judge(acc, nil, nil, given("mesh"), signing, judgedAt); a.State != health.StateHealthy {
t.Fatalf("signing in: %+v", a)
}
signing["mesh"] = AccountState{Account: "mesh", State: AccountWaits, Said: judgedAt.Add(-SignInGrace - time.Minute)}
if a := Judge(acc, nil, nil, given("mesh"), signing, judgedAt); a.State != health.StateUnhealthy {
t.Fatalf("never signed in: %+v", a)
}
if a := Judge(nil, nil, nil, given(), nil, judgedAt); a.State != health.StateHealthy || !strings.Contains(a.Why, "nothing is watched") {
t.Fatalf("no account: %+v", a)
}
}
// The tool reads the watch's verdict, and says when the watch stopped looking.
func TestTheHealthToolReadsTheWatchsVerdictAndSaysWhenItIsStale(t *testing.T) {
path := filepath.Join(t.TempDir(), "health.json")
if a := ReadStatus(path, judgedAt); a.State != health.StateUnhealthy {
t.Fatalf("no verdict: %+v", a)
}
waits := Judge([]Account{testAccount()}, nil, nil, given(), nil, judgedAt)
if err := writeStatus(path, Status{Answer: waits, At: judgedAt}); err != nil {
t.Fatal(err)
}
if a := ReadStatus(path, judgedAt.Add(time.Minute)); a.State != health.StateWaits || a.Waits[0].Secret != "mail-credential-mesh" {
t.Fatalf("%+v", a)
}
if a := ReadStatus(path, judgedAt.Add(StatusStale+time.Second)); a.State != health.StateUnhealthy || !strings.Contains(a.Why, "has not looked") {
t.Fatalf("stale: %+v", a)
}
}
// One look of the watch, end to end over the files the mesh writes: the accounts setting, the secrets directory and
// the index.
func TestTheWatchJudgesFromTheFilesAndTheIndex(t *testing.T) {
dir := t.TempDir()
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Secrets: dir}
writeFile(t, env.Accounts, stageOne)
st := newMemStore()
open := func(context.Context) (Store, func(), error) { return st, func() {}, nil }
if a := watchOnce(context.Background(), env, open, judgedAt); a.State != health.StateWaits {
t.Fatalf("%+v", a)
}
giveSecret(t, dir, Account{Name: "mesh"}, "pw")
_ = st.SetState(context.Background(), AccountState{Account: "mesh", State: AccountCurrent, Heard: judgedAt, Said: judgedAt})
if a := watchOnce(context.Background(), env, open, judgedAt); a.State != health.StateHealthy {
t.Fatalf("%+v", a)
}
raw, _ := json.Marshal(Status{Answer: health.Healthy("x"), At: judgedAt})
if !strings.Contains(string(raw), `"state":"healthy"`) || !strings.Contains(string(raw), `"at":`) {
t.Fatalf("the status file's shape: %s", raw)
}
}
-576
View File
@@ -1,576 +0,0 @@
package main
// The adapter for an account of the kind `imap` (novox/hq ADR 0359 §1, §2, §5). For each account it keeps one
// connection: IDLE on the inbox, and every PollEvery a look at every other folder. Each look compares what the server
// holds with the index and says each difference as an event: a message added, its flags changed, or removed. The first
// look at a folder indexes what is there without saying it — what the bus is told is what changed since the mesh
// started watching, never the history of a mailbox.
//
// **It only reads.** Every folder is opened read-only (EXAMINE), and every message is fetched with BODY.PEEK, so
// watching never marks a message seen. The client is used through reader alone, which has no command that flags,
// moves, copies, deletes, appends or sends (§5); a test holds the code to that.
import (
"context"
"crypto/tls"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
)
// How the keeper paces itself. IdleCycle is how long one IDLE lasts before it is ended and begun again, which proves
// the server still answers; SilentAfter is how long without an answer makes the account unhealthy (§6).
const (
IdleCycle = 2 * time.Minute
PollEvery = 5 * time.Minute
SilentAfter = 5 * time.Minute
RetryFirst = 30 * time.Second
RetryMost = 5 * time.Minute
FetchBatch = 25
// BatchBytes bounds the mail one fetch of bodies holds; SizeBatch is how many sizes one fetch asks.
BatchBytes = 64 << 20
SizeBatch = 500
)
// Batches cuts the uids, in order, into batches of at most most messages whose sizes add up to at most limit bytes. A
// message larger than limit is a batch of its own. A size not known counts as nothing: the server listed the message,
// and the fetch says what it holds.
func Batches(uids []uint32, sizes map[uint32]int64, limit int64, most int) [][]uint32 {
var out [][]uint32
var cur []uint32
var bytes int64
for _, u := range uids {
size := sizes[u]
if len(cur) > 0 && (len(cur) >= most || bytes+size > limit) {
out = append(out, cur)
cur, bytes = nil, 0
}
cur = append(cur, u)
bytes += size
}
if len(cur) > 0 {
out = append(out, cur)
}
return out
}
func uidSet(uids []uint32) imap.UIDSet {
set := make([]imap.UID, len(uids))
for i, u := range uids {
set[i] = imap.UID(u)
}
return imap.UIDSetNum(set...)
}
func orBytes(n, def int64) int64 {
if n > 0 {
return n
}
return def
}
// reader is the part of an IMAP client the keeper uses: nothing in it changes a mailbox.
type reader interface {
Login(username, password string) *imapclient.Command
List(ref, pattern string, options *imap.ListOptions) *imapclient.ListCommand
Select(mailbox string, options *imap.SelectOptions) *imapclient.SelectCommand
Fetch(numSet imap.NumSet, options *imap.FetchOptions) *imapclient.FetchCommand
Idle() (*imapclient.IdleCommand, error)
Caps() imap.CapSet
Closed() <-chan struct{}
Logout() *imapclient.Command
Close() error
}
// Dialer opens a connection to an account's server, handing what the server says unasked to the handler.
type Dialer func(a Account, handler *imapclient.UnilateralDataHandler) (reader, error)
// DialServer is the dialer for the mesh: TLS from the first byte, or STARTTLS before signing in, as the account says.
func DialServer(a Account, handler *imapclient.UnilateralDataHandler) (reader, error) {
opts := &imapclient.Options{UnilateralDataHandler: handler, TLSConfig: &tls.Config{ServerName: a.Incoming.Host, MinVersion: tls.VersionTLS12}}
var c *imapclient.Client
var err error
switch a.Incoming.Security {
case "tls":
c, err = imapclient.DialTLS(a.Incoming.Address(), opts)
case "starttls":
c, err = imapclient.DialStartTLS(a.Incoming.Address(), opts)
default:
err = fmt.Errorf("the account %s's security %q signs in over nothing this version speaks", a.Name, a.Incoming.Security)
}
if err != nil {
return nil, err
}
return c, nil
}
// Keeper watches the accounts and keeps the index.
type Keeper struct {
Store Store
Objects Objects
Dial Dialer
SecretsDir string
Logf func(format string, a ...any)
Now func() time.Time
// Paces, for the tests; the constants above when zero.
IdleCycle, PollEvery, RetryFirst time.Duration
// BatchBytes, for the tests; the constant when zero.
BatchBytes int64
}
func (k *Keeper) now() time.Time {
if k.Now != nil {
return k.Now()
}
return time.Now()
}
func (k *Keeper) logf(format string, a ...any) {
if k.Logf != nil {
k.Logf(format, a...)
}
}
func orDefault(d, def time.Duration) time.Duration {
if d > 0 {
return d
}
return def
}
// Run watches every account until ctx ends.
func (k *Keeper) Run(ctx context.Context, accounts []Account) {
var wg sync.WaitGroup
for _, a := range accounts {
wg.Add(1)
go func(a Account) {
defer wg.Done()
k.Watch(ctx, a)
}(a)
}
wg.Wait()
}
func (k *Keeper) say(ctx context.Context, a Account, state, why string, heard, synced time.Time) {
if err := k.Store.SetState(ctx, AccountState{Account: a.Name, State: state, Why: why, Heard: heard, Synced: synced, Said: k.now()}); err != nil {
k.logf("[mesh-mail] the account %s's state could not be kept: %v", a.Name, err)
}
}
// Watch keeps one account until ctx ends: waits while its secret is not given, signs in, watches, and on any fault
// says it, waits a little longer each time, and begins again.
func (k *Keeper) Watch(ctx context.Context, a Account) {
retry := orDefault(k.RetryFirst, RetryFirst)
var heard, synced time.Time
for ctx.Err() == nil {
pw, given, err := ReadSecret(k.SecretsDir, a)
switch {
case err != nil:
k.say(ctx, a, AccountFailing, "its password was given and cannot be read: "+err.Error(), heard, synced)
case !given:
k.say(ctx, a, AccountWaits, "its password ("+a.Secret()+") has not been given", heard, synced)
default:
s := &session{k: k, a: a, wake: make(chan struct{}, 1), heard: heard, synced: synced}
err = s.run(ctx, pw)
heard, synced = s.heard, s.synced
if ctx.Err() != nil {
return
}
k.say(ctx, a, AccountFailing, err.Error(), heard, synced)
k.logf("[mesh-mail] the account %s: %v; trying again in %s", a.Name, err, retry)
}
select {
case <-ctx.Done():
return
case <-time.After(retry):
}
if given && err != nil {
retry = min(retry*2, RetryMost)
} else {
retry = orDefault(k.RetryFirst, RetryFirst)
}
}
}
// session is one connection to one account's server.
type session struct {
k *Keeper
a Account
c reader
wake chan struct{}
heard time.Time
synced time.Time
// state and why are what the session last said of the account, and told when it said it.
state, why string
told time.Time
}
// answered is the server having answered: kept, and told at least once a minute, so a long first look at a large
// mailbox still shows the account alive to the health watch.
func (s *session) answered() {
s.heard = s.k.now()
if s.state != "" && s.heard.Sub(s.told) >= time.Minute {
s.tell(s.state, s.why)
}
}
// tell says the account's state.
func (s *session) tell(state, why string) {
s.state, s.why, s.told = state, why, s.k.now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.k.say(ctx, s.a, state, why, s.heard, s.synced)
}
func (s *session) poke() {
select {
case s.wake <- struct{}{}:
default:
}
}
func (s *session) run(ctx context.Context, password string) error {
handler := &imapclient.UnilateralDataHandler{
Expunge: func(uint32) { s.poke() },
Mailbox: func(*imapclient.UnilateralDataMailbox) { s.poke() },
Fetch: func(*imapclient.FetchMessageData) { s.poke() },
}
c, err := s.k.Dial(s.a, handler)
if err != nil {
return fmt.Errorf("its server %s cannot be reached: %v", s.a.Incoming.Host, err)
}
s.c = c
defer c.Close()
if err := c.Login(s.a.SignInAs(), password).Wait(); err != nil {
var said *imap.Error
if errors.As(err, &said) {
return fmt.Errorf("its server refused the sign-in (%s): its password may be wrong, and is given again at the desk", strings.TrimSpace(said.Text))
}
return fmt.Errorf("signing in failed: %v", err)
}
s.answered()
s.tell(AccountSyncing, "signed in; looking at its folders")
folders, err := s.folders()
if err != nil {
return err
}
idles := c.Caps().Has(imap.CapIdle) || c.Caps().Has(imap.CapIMAP4rev2)
var polled time.Time
for ctx.Err() == nil {
if polled.IsZero() || s.k.now().Sub(polled) >= orDefault(s.k.PollEvery, PollEvery) {
for _, f := range folders {
if err := s.k.syncFolder(ctx, c, s.a, f, s.answered); err != nil {
return err
}
}
polled = s.k.now()
}
// The inbox last, so it is the folder selected while the session idles.
if err := s.k.syncFolder(ctx, c, s.a, "INBOX", s.answered); err != nil {
return err
}
s.synced = s.k.now()
s.tell(AccountCurrent, fmt.Sprintf("watching the inbox and %d other folder(s)", len(folders)))
if err := s.wait(ctx, idles); err != nil {
return err
}
}
_ = c.Logout().Wait()
return ctx.Err()
}
// wait is one IDLE on the inbox, ended by what the server says unasked, by the cycle's end or by ctx. Ending it waits
// for the server's answer, which is what proves the connection alive.
func (s *session) wait(ctx context.Context, idles bool) error {
cycle := time.NewTimer(orDefault(s.k.IdleCycle, IdleCycle))
defer cycle.Stop()
if !idles {
select {
case <-ctx.Done():
case <-cycle.C:
case <-s.c.Closed():
return errors.New("its server closed the connection")
}
return nil
}
idle, err := s.c.Idle()
if err != nil {
return fmt.Errorf("its server refused to IDLE on the inbox: %v", err)
}
select {
case <-ctx.Done():
case <-s.wake:
case <-cycle.C:
case <-s.c.Closed():
}
if err := idle.Close(); err != nil {
return fmt.Errorf("its IDLE on the inbox ended badly: %v", err)
}
if err := idle.Wait(); err != nil {
return fmt.Errorf("its IDLE on the inbox ended badly: %v", err)
}
s.answered()
return nil
}
// folders is what is watched besides the inbox: the account's list, or every folder the server can open.
func (s *session) folders() ([]string, error) {
if len(s.a.Folders) > 0 {
return append([]string(nil), s.a.Folders...), nil
}
listed, err := s.c.List("", "*", nil).Collect()
if err != nil {
return nil, fmt.Errorf("its folders cannot be listed: %v", err)
}
s.answered()
var out []string
for _, l := range listed {
if strings.EqualFold(l.Mailbox, "INBOX") || hasAttr(l.Attrs, imap.MailboxAttrNoSelect) || hasAttr(l.Attrs, imap.MailboxAttrNonExistent) {
continue
}
out = append(out, l.Mailbox)
}
sort.Strings(out)
return out, nil
}
func hasAttr(attrs []imap.MailboxAttr, want imap.MailboxAttr) bool {
for _, a := range attrs {
if strings.EqualFold(string(a), string(want)) {
return true
}
}
return false
}
// syncFolder brings the index of one folder to what the server holds, and says each difference, unless this is the
// first look at the folder.
func (k *Keeper) syncFolder(ctx context.Context, c reader, a Account, folder string, answered func()) error {
sel, err := c.Select(folder, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return fmt.Errorf("the folder %s cannot be opened: %v", folder, err)
}
answered()
known, seen, err := k.Store.Folder(ctx, a.Name, folder)
if err != nil {
return err
}
if seen && known.UIDValidity != sel.UIDValidity {
k.logf("[mesh-mail] the account %s's folder %s was renumbered by its server; it is indexed afresh", a.Name, folder)
if err := k.Store.DropFolder(ctx, a.Name, folder); err != nil {
return err
}
seen = false
}
server := map[uint32][]string{}
if sel.NumMessages > 0 {
all, err := c.Fetch(imap.UIDSet{{Start: 1, Stop: 0}}, &imap.FetchOptions{UID: true, Flags: true}).Collect()
if err != nil {
return fmt.Errorf("the folder %s's messages cannot be listed: %v", folder, err)
}
answered()
for _, m := range all {
server[uint32(m.UID)] = flagStrings(m.Flags)
}
}
index, err := k.Store.Flags(ctx, a.Name, folder)
if err != nil {
return err
}
say := func(e *Event) *Event {
if seen {
return e
}
return nil
}
// Removed: in the index, no longer on the server.
for uid := range index {
if _, still := server[uid]; still {
continue
}
m, _, err := k.Store.Get(ctx, a.Name, folder, uid)
if err != nil {
return err
}
if m.UID == 0 {
m = Message{Account: a.Name, Folder: folder, UID: uid, UIDValidity: sel.UIDValidity}
}
if err := k.Store.Remove(ctx, a.Name, folder, uid, say(removedEvent(m))); err != nil {
return err
}
}
// Changed: on both, with other flags.
var added []uint32
for uid, flags := range server {
before, indexed := index[uid]
if !indexed {
added = append(added, uid)
continue
}
if sameStrings(before, flags) {
continue
}
m, _, err := k.Store.Get(ctx, a.Name, folder, uid)
if err != nil {
return err
}
m.Flags = flags
if err := k.Store.SetFlags(ctx, a.Name, folder, uid, flags, say(changedEvent(m, before, flags))); err != nil {
return err
}
}
// Added: on the server, not yet in the index. Their sizes first, then their bodies in batches bounded by bytes as
// well as by count, so one batch never holds more than BatchBytes of mail in memory; a message larger than that
// is fetched alone, and said. Fetched without marking them seen.
sort.Slice(added, func(i, j int) bool { return added[i] < added[j] })
sizes := map[uint32]int64{}
for start := 0; start < len(added); start += SizeBatch {
part := added[start:min(start+SizeBatch, len(added))]
got, err := c.Fetch(uidSet(part), &imap.FetchOptions{UID: true, RFC822Size: true}).Collect()
if err != nil {
return fmt.Errorf("the folder %s's new messages cannot be measured: %v", folder, err)
}
answered()
for _, m := range got {
sizes[uint32(m.UID)] = m.RFC822Size
}
}
cap := orBytes(k.BatchBytes, BatchBytes)
for _, batch := range Batches(added, sizes, cap, FetchBatch) {
if len(batch) == 1 && sizes[batch[0]] > cap {
k.logf("[mesh-mail] the account %s's message %d in %s is %d bytes, more than a batch holds; it is fetched alone",
a.Name, batch[0], folder, sizes[batch[0]])
}
msgs, err := c.Fetch(uidSet(batch), &imap.FetchOptions{
UID: true, Flags: true, Envelope: true, RFC822Size: true, InternalDate: true,
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
}).Collect()
if err != nil {
return fmt.Errorf("the folder %s's new messages cannot be fetched: %v", folder, err)
}
answered()
for _, fm := range msgs {
m, err := k.index(ctx, a, folder, sel.UIDValidity, fm)
if err != nil {
return err
}
if err := k.Store.Add(ctx, m, say(addedEvent(m))); err != nil {
return err
}
}
}
return k.Store.SetFolder(ctx, Folder{Account: a.Name, Name: folder, UIDValidity: sel.UIDValidity, Messages: len(server), Synced: k.now()})
}
// index is a fetched message as the index keeps it: its metadata, its text, and its attachments kept in the store.
func (k *Keeper) index(ctx context.Context, a Account, folder string, validity uint32, fm *imapclient.FetchMessageBuffer) (Message, error) {
m := Message{Account: a.Name, Folder: folder, UIDValidity: validity, UID: uint32(fm.UID), Size: fm.RFC822Size,
Flags: flagStrings(fm.Flags), Date: fm.InternalDate.UTC(), From: []Person{}, To: []Person{}, Attachments: []AttachmentRef{}}
if env := fm.Envelope; env != nil {
m.MessageID, m.Subject = env.MessageID, env.Subject
if len(env.InReplyTo) > 0 {
m.InReplyTo = env.InReplyTo[0]
}
if !env.Date.IsZero() {
m.Date = env.Date.UTC()
}
m.From, m.To, m.Cc = people(env.From), people(env.To), people(env.Cc)
}
var raw []byte
if len(fm.BodySection) > 0 {
raw = fm.BodySection[0].Bytes
}
parsed, err := ParseMessage(raw)
if err != nil {
k.logf("[mesh-mail] the account %s's message %d in %s is read in part: %v", a.Name, m.UID, folder, err)
}
m.Body = parsed.Text
for _, p := range parsed.Attachments {
ref, err := Keep(ctx, k.Objects, p.Data, p.Type, p.Name)
if err != nil {
return m, err
}
m.Attachments = append(m.Attachments, ref)
}
return m, nil
}
func people(as []imap.Address) []Person {
out := []Person{}
for _, a := range as {
if addr := a.Addr(); addr != "" {
out = append(out, Person{Name: a.Name, Address: addr})
}
}
return out
}
func flagStrings(fs []imap.Flag) []string {
out := make([]string, 0, len(fs))
for _, f := range fs {
if strings.EqualFold(string(f), `\Recent`) {
continue
}
out = append(out, string(f))
}
sort.Strings(out)
return out
}
func sameStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// ---- the events ---------------------------------------------------------------------------------------------------
// EventKey is where an account's change is said: mail.<account>.message.<added|changed|removed> (research 049
// document 02 §5), the account by its name in the setting, never its address.
func EventKey(account, change string) string { return "mail." + account + ".message." + change }
// Changes are the three things said of a message.
var Changes = []string{"added", "changed", "removed"}
// metadata is what an event says of a message: never its body, never an attachment's bytes (ADR 0359 §2).
func metadata(m Message) map[string]any {
atts := make([]map[string]any, 0, len(m.Attachments))
for _, r := range m.Attachments {
atts = append(atts, map[string]any{"bucket": r.Bucket, "key": r.Key, "size": r.Size, "type": r.Type, "name": r.Name})
}
return map[string]any{
"account": m.Account, "folder": m.Folder, "uid": m.UID, "uidvalidity": m.UIDValidity,
"message-id": m.MessageID, "in-reply-to": m.InReplyTo, "date": m.Date.UTC().Format(time.RFC3339),
"from": m.From, "to": m.To, "cc": m.Cc, "subject": m.Subject, "size": m.Size, "flags": m.Flags,
"attachments": atts,
}
}
func addedEvent(m Message) *Event {
return &Event{Key: EventKey(m.Account, "added"), Body: metadata(m)}
}
func changedEvent(m Message, before, after []string) *Event {
body := metadata(m)
body["changes"] = map[string]any{"flags": map[string]any{"before": before, "after": after}}
return &Event{Key: EventKey(m.Account, "changed"), Body: body}
}
func removedEvent(m Message) *Event {
return &Event{Key: EventKey(m.Account, "removed"), Body: map[string]any{
"account": m.Account, "folder": m.Folder, "uid": m.UID, "uidvalidity": m.UIDValidity, "message-id": m.MessageID,
}}
}
@@ -1,292 +0,0 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/emersion/go-imap/v2"
)
// keeping starts a keeper on one account of the test server, its paces short, and stops it when the test ends.
func keeping(t *testing.T, s *mailServer, st Store, objects Objects, secrets string) {
t.Helper()
k := &Keeper{Store: st, Objects: objects, Dial: s.dialer(), SecretsDir: secrets, Logf: t.Logf,
IdleCycle: 300 * time.Millisecond, PollEvery: 400 * time.Millisecond, RetryFirst: 100 * time.Millisecond}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { k.Watch(ctx, testAccount()); close(done) }()
t.Cleanup(func() { cancel(); <-done })
}
func stateOf(st Store, account string) AccountState {
states, _ := st.States(context.Background())
return states[account]
}
// float64OrUint reads a uid from an event body whether it came from memory (uint32) or from postgres (float64).
type float64OrUint struct{ v any }
func (f float64OrUint) value() uint32 {
switch n := f.v.(type) {
case uint32:
return n
case float64:
return uint32(n)
}
return 0
}
func uidOf(e Event) uint32 { return float64OrUint{e.Body["uid"]}.value() }
func eventsNamed(st Store, key string) []Event {
evs, _ := st.Pending(context.Background(), 1000)
var out []Event
for _, e := range evs {
if e.Key == key {
out = append(out, e)
}
}
return out
}
func describe(st Store) string {
evs, _ := st.Pending(context.Background(), 1000)
var keys []string
for _, e := range evs {
keys = append(keys, e.Key)
}
s := stateOf(st, "mesh")
return "state " + s.State + " (" + s.Why + "); events " + strings.Join(keys, ", ")
}
// The whole watch, over a real IMAP conversation (ADR 0359 §2, §3, §5): the first look indexes what is there and says
// nothing; then a new mail in the inbox is said within moments of its delivery, through IDLE, with its metadata and
// its attachment by reference, never its body; its flags changed are said; its removal is said; a mail in another
// folder is said at the next poll; and watching never marks a message seen.
func TestTheKeeperSaysEachChangeOnTheBusAndOnlyReads(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
s := newMailServer(t)
op := s.operator(t)
deliver(t, op, "INBOX", plainMail("before the watch", "an old letter"))
deliver(t, op, "Archive", plainMail("archived long ago", "an older letter"))
secrets := t.TempDir()
giveSecret(t, secrets, testAccount(), testPassword)
objects := newMemObjects()
keeping(t, s, st, objects, secrets)
eventually(t, 10*time.Second, "the first look", func() (bool, string) {
return stateOf(st, "mesh").State == AccountCurrent, describe(st)
})
ctx := context.Background()
if hits, _ := st.Search(ctx, Query{Text: "letter", Accounts: []string{"mesh"}}); len(hits) != 2 {
t.Fatalf("the first look indexed %d messages, not 2", len(hits))
}
if evs, _ := st.Pending(ctx, 100); len(evs) != 0 {
t.Fatalf("the first look said %d event(s): the history of a mailbox is not news", len(evs))
}
// A new mail with an attachment, delivered while the keeper idles on the inbox.
deliver(t, op, "INBOX", attachedMail("the invoice"))
var added Event
eventually(t, 5*time.Second, "the new mail said", func() (bool, string) {
evs := eventsNamed(st, "mail.mesh.message.added")
if len(evs) == 1 {
added = evs[0]
}
return len(evs) == 1, describe(st)
})
body := added.Body
if body["subject"] != "the invoice" || body["folder"] != "INBOX" || body["account"] != "mesh" || body["message-id"] != "att@example.org" {
t.Fatalf("the event says %v", body)
}
raw, _ := json.Marshal(body)
for _, never := range []string{"Please find", "dGhlIGludm9pY2Ug", attachmentData} {
if strings.Contains(string(raw), never) {
t.Fatalf("the event carries the body or an attachment's bytes (%q): %s", never, raw)
}
}
sum := sha256.Sum256([]byte(attachmentData))
key := hex.EncodeToString(sum[:])
if !strings.Contains(string(raw), `"key":"`+key+`"`) || !strings.Contains(string(raw), `"name":"invoice.pdf"`) {
t.Fatalf("the event does not name the attachment by its SHA-256 %s: %s", key, raw)
}
if kept, _ := objects.Get(ctx, key); string(kept) != attachmentData {
t.Fatalf("the attachment store keeps %q under %s", kept, key)
}
uid := uidOf(added)
m, ok, _ := st.Get(ctx, "mesh", "INBOX", uid)
if !ok || !strings.Contains(m.Body, "Please find the invoice attached.") {
t.Fatalf("the index keeps the text for mail_read: %+v", m)
}
// Flagged by the operator elsewhere: said as changed, with the flags before and after.
if _, err := op.Select("INBOX", nil).Wait(); err != nil {
t.Fatal(err)
}
if err := op.Store(imap.UIDSetNum(imap.UID(uid)), &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagFlagged}, Silent: true}, nil).Close(); err != nil {
t.Fatal(err)
}
eventually(t, 5*time.Second, "the flag said", func() (bool, string) {
evs := eventsNamed(st, "mail.mesh.message.changed")
if len(evs) != 1 {
return false, describe(st)
}
raw, _ := json.Marshal(evs[0].Body["changes"])
return strings.Contains(string(raw), `"after":["\\Flagged"]`), string(raw)
})
// Never marked seen by the watching: the message is still unseen on the server.
fetched, err := op.Fetch(imap.UIDSetNum(imap.UID(uid)), &imap.FetchOptions{Flags: true, UID: true}).Collect()
if err != nil || len(fetched) != 1 {
t.Fatalf("%v %v", fetched, err)
}
for _, f := range fetched[0].Flags {
if f == imap.FlagSeen {
t.Fatal("watching marked the message seen")
}
}
// Deleted by the operator elsewhere: said as removed.
if err := op.Store(imap.UIDSetNum(imap.UID(uid)), &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagDeleted}, Silent: true}, nil).Close(); err != nil {
t.Fatal(err)
}
if err := op.Expunge().Close(); err != nil {
t.Fatal(err)
}
eventually(t, 5*time.Second, "the removal said", func() (bool, string) {
evs := eventsNamed(st, "mail.mesh.message.removed")
return len(evs) == 1 && uidOf(evs[0]) == uid && evs[0].Body["message-id"] == "att@example.org", describe(st)
})
if _, ok, _ := st.Get(ctx, "mesh", "INBOX", uid); ok {
t.Fatal("the removed message is still indexed")
}
// A mail in another folder is said at the next poll.
deliver(t, op, "Archive", plainMail("filed away", "a letter filed"))
eventually(t, 5*time.Second, "the archived mail said", func() (bool, string) {
for _, e := range eventsNamed(st, "mail.mesh.message.added") {
if e.Body["folder"] == "Archive" && e.Body["subject"] == "filed away" {
return true, ""
}
}
return false, describe(st)
})
if s := stateOf(st, "mesh"); s.State != AccountCurrent || time.Since(s.Heard) > time.Minute {
t.Fatalf("the account is %+v", s)
}
})
}
// No password given: the account waits, and nothing is dialled. A wrong one: the account fails, saying the server
// refused the sign-in. Given later: it is picked up without a restart.
func TestTheKeeperWaitsForThePasswordAndSaysARefusal(t *testing.T) {
s := newMailServer(t)
st := newMemStore()
secrets := t.TempDir()
keeping(t, s, st, newMemObjects(), secrets)
eventually(t, 5*time.Second, "the wait", func() (bool, string) {
got := stateOf(st, "mesh")
return got.State == AccountWaits && strings.Contains(got.Why, "mail-credential-mesh"), describe(st)
})
giveSecret(t, secrets, testAccount(), "not-the-password")
eventually(t, 5*time.Second, "the refusal", func() (bool, string) {
got := stateOf(st, "mesh")
return got.State == AccountFailing && strings.Contains(got.Why, "refused the sign-in"), describe(st)
})
giveSecret(t, secrets, testAccount(), testPassword)
eventually(t, 10*time.Second, "the sign-in", func() (bool, string) {
return stateOf(st, "mesh").State == AccountCurrent, describe(st)
})
}
// It only reads (ADR 0359 §5): no code of the module, but its tests, issues a command that flags, moves, copies,
// deletes, appends, creates, renames or sends, and every folder is opened read-only and every body peeked.
func TestNothingButTheTestsWritesToAMailbox(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
writes := regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)\.(Store|Move|Copy|Expunge|UIDExpunge|Append|Create|Delete|Rename|Subscribe|Unsubscribe|SetMetadata|SetQuota)\(`)
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
raw, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, m := range writes.FindAllSubmatch(raw, -1) {
if string(m[1]) != "os" { // a file of the module's own, never a mailbox
t.Errorf("%s calls %s, which writes to a mailbox", f, m[0])
}
}
if strings.Contains(string(raw), "net/smtp") {
t.Errorf("%s speaks SMTP: this stage sends nothing", f)
}
}
src, _ := os.ReadFile("imap.go")
if !strings.Contains(string(src), "&imap.SelectOptions{ReadOnly: true}") || !strings.Contains(string(src), "{{Peek: true}}") {
t.Fatal("folders are not opened read-only, or bodies not peeked")
}
}
// A fetch of bodies is bounded by bytes (issue 733): batches cut by size and by count, and a message larger than a
// batch holds alone.
func TestBatchesAreBoundedByBytesAndALargerMessageGoesAlone(t *testing.T) {
sizes := map[uint32]int64{1: 40, 2: 40, 3: 500, 4: 10, 5: 10, 6: 10}
got := Batches([]uint32{1, 2, 3, 4, 5, 6}, sizes, 100, 2)
want := [][]uint32{{1, 2}, {3}, {4, 5}, {6}}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("%v, not %v", got, want)
}
if got := Batches(nil, nil, 100, 25); len(got) != 0 {
t.Fatalf("%v", got)
}
}
// Over a real conversation: with a cap of a few hundred bytes, a large mail is fetched alone and said so, and every
// mail is still indexed and said.
func TestTheKeeperFetchesUnderAByteCap(t *testing.T) {
s := newMailServer(t)
op := s.operator(t)
st := newMemStore()
secrets := t.TempDir()
giveSecret(t, secrets, testAccount(), testPassword)
var mu sync.Mutex
var said []string
k := &Keeper{Store: st, Objects: newMemObjects(), Dial: s.dialer(), SecretsDir: secrets,
Logf: func(f string, a ...any) { mu.Lock(); said = append(said, fmt.Sprintf(f, a...)); mu.Unlock() },
IdleCycle: 300 * time.Millisecond, PollEvery: 400 * time.Millisecond, RetryFirst: 100 * time.Millisecond, BatchBytes: 600}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { k.Watch(ctx, testAccount()); close(done) }()
t.Cleanup(func() { cancel(); <-done })
eventually(t, 10*time.Second, "the first look", func() (bool, string) { return stateOf(st, "mesh").State == AccountCurrent, describe(st) })
deliver(t, op, "INBOX", plainMail("small one", "short"))
deliver(t, op, "INBOX", plainMail("large one", strings.Repeat("a long letter ", 200)))
deliver(t, op, "INBOX", plainMail("small two", "short"))
eventually(t, 5*time.Second, "three mails said", func() (bool, string) {
return len(eventsNamed(st, "mail.mesh.message.added")) == 3, describe(st)
})
mu.Lock()
defer mu.Unlock()
alone := 0
for _, line := range said {
if strings.Contains(line, "fetched alone") {
alone++
}
}
if alone != 1 {
t.Fatalf("said %d message(s) fetched alone: %v", alone, said)
}
}
-252
View File
@@ -1,252 +0,0 @@
package main
// What the tests stand on: an IMAP server in this process (go-imap's own in-memory server, speaking the protocol over
// a real socket), the index in memory always and on a real postgres when one can be had (MESH_TEST_POSTGRES, or a
// private server started here from the machine's initdb on a socket of its own, listening on no port), and the
// attachment store in memory.
import (
"context"
"fmt"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-imap/v2/imapserver"
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
)
func TestMain(m *testing.M) {
code := m.Run()
stopPostgres()
os.Exit(code)
}
// ---- the IMAP server --------------------------------------------------------------------------------------------
const (
testUser = "operator@example.org"
testPassword = "the-password"
)
type mailServer struct {
addr string
srv *imapserver.Server
}
// newMailServer is a server holding one user with an inbox and an Archive folder.
func newMailServer(t *testing.T) *mailServer {
t.Helper()
mem := imapmemserver.New()
user := imapmemserver.NewUser(testUser, testPassword)
for _, f := range []string{"INBOX", "Archive"} {
if err := user.Create(f, nil); err != nil {
t.Fatal(err)
}
}
mem.AddUser(user)
srv := imapserver.New(&imapserver.Options{
NewSession: func(*imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return mem.NewSession(), nil, nil
},
InsecureAuth: true,
Caps: imap.CapSet{imap.CapIMAP4rev1: {}, imap.CapIMAP4rev2: {}},
})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go func() { _ = srv.Serve(ln) }()
t.Cleanup(func() { _ = srv.Close() })
return &mailServer{addr: ln.Addr().String(), srv: srv}
}
// dialer reaches the test server without TLS, which only a test does: the mesh's dialer signs in over TLS alone.
func (s *mailServer) dialer() Dialer {
return func(_ Account, h *imapclient.UnilateralDataHandler) (reader, error) {
c, err := imapclient.DialInsecure(s.addr, &imapclient.Options{UnilateralDataHandler: h})
if err != nil {
return nil, err
}
return c, nil
}
}
// operator is a second client, as the operator's mail program or another sender is: it appends, flags and deletes.
func (s *mailServer) operator(t *testing.T) *imapclient.Client {
t.Helper()
c, err := imapclient.DialInsecure(s.addr, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = c.Close() })
if err := c.Login(testUser, testPassword).Wait(); err != nil {
t.Fatal(err)
}
return c
}
func deliver(t *testing.T, c *imapclient.Client, folder, raw string) {
t.Helper()
raw = strings.ReplaceAll(raw, "\n", "\r\n")
cmd := c.Append(folder, int64(len(raw)), nil)
if _, err := cmd.Write([]byte(raw)); err != nil {
t.Fatal(err)
}
if err := cmd.Close(); err != nil {
t.Fatal(err)
}
if _, err := cmd.Wait(); err != nil {
t.Fatal(err)
}
}
func plainMail(subject, body string) string {
return "From: Alice <alice@example.org>\nTo: Operator <operator@example.org>\nSubject: " + subject +
"\nMessage-Id: <" + strings.ReplaceAll(subject, " ", "-") + "@example.org>\nDate: Wed, 14 Oct 2026 10:00:00 +0000\n" +
"MIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n" + body + "\n"
}
// attachmentData is an attachment's content, and attachedMail a message carrying it.
const attachmentData = "the invoice, in full"
func attachedMail(subject string) string {
return "From: Bob <bob@example.org>\nTo: operator@example.org\nSubject: " + subject + "\nMessage-Id: <att@example.org>\n" +
"Date: Wed, 14 Oct 2026 11:00:00 +0000\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=XYZ\n\n" +
"--XYZ\nContent-Type: text/plain; charset=utf-8\n\nPlease find the invoice attached.\n" +
"--XYZ\nContent-Type: application/pdf; name=invoice.pdf\nContent-Disposition: attachment; filename=invoice.pdf\n" +
"Content-Transfer-Encoding: base64\n\ndGhlIGludm9pY2UsIGluIGZ1bGw=\n--XYZ--\n"
}
// ---- the account ------------------------------------------------------------------------------------------------
func testAccount() Account {
return Account{Name: "mesh", Kind: KindIMAP, Address: testUser, Incoming: Server{Host: "localhost", Port: 993, Security: "tls"}}
}
func giveSecret(t *testing.T, dir string, a Account, pw string) {
t.Helper()
if err := os.WriteFile(SecretPath(dir, a), []byte(pw+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
// eventually waits for a condition, failing with what it last saw.
func eventually(t *testing.T, within time.Duration, what string, ok func() (bool, string)) {
t.Helper()
deadline := time.Now().Add(within)
last := ""
for time.Now().Before(deadline) {
var done bool
if done, last = ok(); done {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("%s did not happen within %s; last seen: %s", what, within, last)
}
// ---- postgres ---------------------------------------------------------------------------------------------------
var (
pgOnce sync.Once
pgBase string
pgDir string
pgWhy string
pgCount int
pgMu sync.Mutex
)
func startPostgres() {
if u := os.Getenv("MESH_TEST_POSTGRES"); u != "" {
pgBase = u
return
}
initdb, err := exec.LookPath("initdb")
if err != nil {
pgWhy = "no postgres: MESH_TEST_POSTGRES is unset and the machine has no initdb"
return
}
pgctl := filepath.Join(filepath.Dir(initdb), "pg_ctl")
dir, err := os.MkdirTemp("", "mmpg")
if err != nil {
pgWhy = err.Error()
return
}
pgDir = dir
if out, err := exec.Command(initdb, "-D", dir+"/data", "-U", "postgres", "-A", "trust", "-E", "UTF8", "--no-locale").CombinedOutput(); err != nil {
pgWhy = fmt.Sprintf("initdb: %v: %s", err, out)
return
}
if out, err := exec.Command(pgctl, "-D", dir+"/data", "-l", dir+"/log", "-w", "-o",
"-k "+dir+" -c listen_addresses='' -F", "start").CombinedOutput(); err != nil {
pgWhy = fmt.Sprintf("pg_ctl start: %v: %s", err, out)
return
}
pgBase = "postgres://postgres@/postgres?host=" + dir
}
func stopPostgres() {
if pgDir == "" {
return
}
if initdb, err := exec.LookPath("initdb"); err == nil {
_ = exec.Command(filepath.Join(filepath.Dir(initdb), "pg_ctl"), "-D", pgDir+"/data", "-m", "immediate", "stop").Run()
}
_ = os.RemoveAll(pgDir)
}
// freshDatabase is a database of its own for one test, or a skip saying why there is none.
func freshDatabase(t *testing.T) string {
t.Helper()
pgOnce.Do(startPostgres)
if pgBase == "" {
t.Skipf("NOT TESTED ON POSTGRES: %s", pgWhy)
}
pgMu.Lock()
pgCount++
name := fmt.Sprintf("mail_%d_%d", os.Getpid(), pgCount)
pgMu.Unlock()
ctx := context.Background()
admin, err := OpenPgStore(ctx, pgBase)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
if _, err := admin.pool.Exec(ctx, "create database "+name); err != nil {
t.Fatal(err)
}
u, err := url.Parse(pgBase)
if err != nil {
t.Fatal(err)
}
u.Path = "/" + name
return u.String()
}
func pgStore(t *testing.T) *PgStore {
t.Helper()
s, err := OpenPgStore(context.Background(), freshDatabase(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(s.Close)
if _, err := s.Prepare(context.Background()); err != nil {
t.Fatal(err)
}
return s
}
// eachStore runs a test over the index in memory and on postgres.
func eachStore(t *testing.T, run func(t *testing.T, s Store)) {
t.Run("memory", func(t *testing.T) { run(t, newMemStore()) })
t.Run("postgres", func(t *testing.T) { run(t, pgStore(t)) })
}
-250
View File
@@ -1,250 +0,0 @@
// mesh-mail: the operator's mail accounts, watched, and each mail said on the bus (novox/hq ADR 0359). One instance,
// on the control-node. The first stage holds the mesh's own server, read-only: no flag, move, delete, draft or send.
//
// mesh-mail the bundle the node's runtime launches: the tools mail_accounts, mail_search, mail_read and
// mail_health, and the events the keeper wrote, said on the bus as the module
// mesh-mail prepare the preparation step: the index's schema brought to this version's
// mesh-mail keep acts: watches every account (IDLE on the inbox, a look at the other folders every five
// minutes), keeps the index and the attachment store, and writes each change's event beside it
// mesh-mail watch judges: reads every minute how each account is, and leaves its verdict for mail_health
//
// The two long-running processes are the act and the judge, as mesh-knowledge's are: keep is judged by its unit, watch
// by the tool mail_health (ADR 0359 §6, ADR 0240 §3 and §7). Only the bundle reaches the bus, through the runtime that
// launched it, so the keeper hands its events over in the index, written in the same step as the change they say,
// and the bundle says them and removes each once the bus took it. stdout is the bundle's MCP channel; what this module
// says, it says on stderr.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Module is this module's name.
const Module = "mesh-mail"
// Env is where the mesh puts what the module reads, as the manifest's environment names it.
type Env struct {
Accounts string // the trusted setting accounts
Grants string // the trusted setting grants
Secrets string // the directory the own secrets mail-credential-<account> are placed in
Store string // the attachment store's facts
Status string // the watch's verdict
}
func envFromOS() Env {
return Env{
Accounts: os.Getenv("MESH_MAIL_ACCOUNTS"),
Grants: os.Getenv("MESH_MAIL_GRANTS"),
Secrets: os.Getenv("MESH_MAIL_SECRETS"),
Store: os.Getenv("MESH_MAIL_STORE"),
Status: os.Getenv("MESH_MAIL_STATUS"),
}
}
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "prepare":
os.Exit(prepare())
case "keep":
os.Exit(keep(envFromOS()))
case "watch":
os.Exit(watch(envFromOS()))
}
}
serve(envFromOS())
}
// ---- prepare ------------------------------------------------------------------------------------------------------
func prepare() int {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
s, err := PgStoreFromEnv(ctx)
if err != nil {
logf("[mesh-mail] preparing the index: %v", err)
return 1
}
defer s.Close()
said, err := s.Prepare(ctx)
for _, line := range said {
logf("[mesh-mail] %s", line)
}
if err != nil {
logf("[mesh-mail] preparing the index failed: %v", err)
return 1
}
return 0
}
// ---- keep ---------------------------------------------------------------------------------------------------------
// keep watches every account until it is stopped. A setting it cannot read, an index that is behind or a store it
// cannot reach ends it with a fault, which its unit says; the mesh starts it again.
func keep(env Env) int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
accounts, err := ReadAccounts(env.Accounts)
if err != nil {
logf("[mesh-mail] %v", err)
return 1
}
st, err := PgStoreFromEnv(ctx)
if err != nil {
logf("[mesh-mail] the index cannot be opened: %v", err)
return 1
}
defer st.Close()
if err := st.Ready(ctx); err != nil {
logf("[mesh-mail] %v", err)
return 1
}
facts, err := ReadStoreFacts(env.Store)
if err != nil {
logf("[mesh-mail] %v", err)
return 1
}
k := &Keeper{Store: st, Objects: NewS3(facts), Dial: DialServer, SecretsDir: env.Secrets, Logf: logf}
logf("[mesh-mail] keeping %d account(s)", len(accounts))
k.Run(ctx, accounts)
return 0
}
// ---- watch --------------------------------------------------------------------------------------------------------
func watch(env Env) int {
if env.Status == "" {
logf("[mesh-mail] watch was started without MESH_MAIL_STATUS")
return 2
}
open := func(ctx context.Context) (Store, func(), error) {
s, err := PgStoreFromEnv(ctx)
if err != nil {
return nil, nil, err
}
return s, s.Close, nil
}
for {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
now := time.Now().UTC()
answer := watchOnce(ctx, env, open, now)
cancel()
if err := writeStatus(env.Status, Status{Answer: answer, At: now}); err != nil {
logf("[mesh-mail] the verdict could not be written: %v", err)
}
time.Sleep(WatchEvery)
}
}
// ---- the bundle ---------------------------------------------------------------------------------------------------
func serve(env Env) {
var mu sync.Mutex
var st *PgStore
b := &Bundle{
Env: env,
Index: func(ctx context.Context) (Store, error) {
mu.Lock()
defer mu.Unlock()
if st == nil {
s, err := PgStoreFromEnv(ctx)
if err != nil {
return nil, err
}
st = s
}
if err := st.Ready(ctx); err != nil {
return nil, err
}
return st, nil
},
Objects: func() (Objects, error) {
facts, err := ReadStoreFacts(env.Store)
if err != nil {
return nil, err
}
return NewS3(facts), nil
},
}
p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: logf}
go p.Run(context.Background())
if err := stdio.Serve("", b.Tools()); err != nil {
logf("%v", err)
os.Exit(1)
}
}
// Publisher says the events the keeper wrote, oldest first, and removes each once the bus took it. One the bus did not
// take stays, and is said again on the next round: delivery is at least once, and a consumer reads the uid and the
// folder to know a repeat.
type Publisher struct {
Index func(ctx context.Context) (Store, error)
Emit func(key string, body any) error
Logf func(format string, a ...any)
Every time.Duration
}
// PublishBatch is how many events one round says at most.
const PublishBatch = 100
// Once says what is pending, and answers how many it said.
func (p *Publisher) Once(ctx context.Context) (int, error) {
st, err := p.Index(ctx)
if err != nil {
return 0, err
}
pending, err := st.Pending(ctx, PublishBatch)
if err != nil {
return 0, err
}
var said []int64
var failed error
for _, e := range pending {
if err := p.Emit(e.Key, e.Body); err != nil {
failed = fmt.Errorf("the bus did not take %s: %v", e.Key, err)
break
}
said = append(said, e.ID)
}
if len(said) > 0 {
if err := st.Said(ctx, said); err != nil {
return 0, err
}
}
return len(said), failed
}
// Run says pending events until ctx ends: every few seconds, at once again while a round was full.
func (p *Publisher) Run(ctx context.Context) {
every := p.Every
if every <= 0 {
every = 3 * time.Second
}
// The runtime takes the bundle's first calls only once Serve runs.
time.Sleep(every)
for ctx.Err() == nil {
c, cancel := context.WithTimeout(ctx, time.Minute)
n, err := p.Once(c)
cancel()
if err != nil && p.Logf != nil {
p.Logf("[mesh-mail] events wait to be said: %v", err)
}
if n == PublishBatch {
continue
}
select {
case <-ctx.Done():
case <-time.After(every):
}
}
}
@@ -1,151 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// The manifest says what the code does (novox/hq ADR 0359): the database and the bucket it requires (§3), the own
// secret family issued outside the mesh (§5, ADR 0283 §6), the two trusted settings (§4), the tools (§5), the events,
// and its two long-running processes with their health (§6) — and it is assigned to no machine by itself.
type manifest struct {
Module string `json:"module"`
Slug string `json:"slug"`
Requires []string `json:"requires"`
Contributes map[string]map[string]any `json:"contributes"`
OwnSecrets map[string]map[string]string `json:"own-secrets"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Streams []struct {
Name string `json:"name"`
Events []string `json:"events"`
MaxAgeDays int `json:"max-age-days"`
Grant struct {
Config string `json:"config"`
Events string `json:"events"`
} `json:"grant"`
} `json:"streams"`
Consumes []string `json:"consumes"`
Invokes []string `json:"invokes"`
Claims []json.RawMessage `json:"claims"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []struct {
From string `json:"from"`
Binary string `json:"binary"`
Loads []string `json:"loads"`
Env map[string]string `json:"env"`
} `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, map[string]map[string]any) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
byID := map[string]map[string]any{}
for _, r := range m.Resources {
byID[r["id"].(string)] = r
}
return m, byID
}
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
m, byID := readManifest(t)
if m.Module != Module || !reflect.DeepEqual(m.Requires, []string{"postgres-database", "s3-bucket"}) || m.Contributes["postgres-database"]["name"] != "mail" {
t.Fatalf("module %s, requires %v, contributes %v", m.Module, m.Requires, m.Contributes)
}
// mesh_<node>_<slug> fits the S3 access key's 20 characters on every node.
if len("mesh_shanks_"+m.Slug) > 20 {
t.Fatalf("the slug %q overflows an S3 access key", m.Slug)
}
fam := m.OwnSecrets[SecretFamily+"*"]
if fam["issued-by"] != "outside" || fam["path"] != "${dir:state}/"+SecretFamily+"*.secret" || len(m.OwnSecrets) != 1 {
t.Fatalf("own secrets %v", m.OwnSecrets)
}
var names []string
for _, tool := range (&Bundle{}).Tools() {
names = append(names, tool.Name)
}
if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, []string{"mail_accounts", "mail_search", "mail_read", "mail_health"}) {
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
}
// Its events go to its own stream MAIL, kept a week, each account's part heard only by the consumers the trusted
// config value grants names for it (ADR 0359 §2, §4; ADR 0363): none on the shared EVENTS stream.
var events []string
for _, c := range Changes {
events = append(events, EventKey("*", c))
}
if len(m.Emits) != 0 || len(m.Streams) != 1 || m.Streams[0].Name != "mail" || !reflect.DeepEqual(m.Streams[0].Events, events) ||
m.Streams[0].MaxAgeDays != 7 || m.Streams[0].Grant.Config != "grants" || m.Streams[0].Grant.Events != "mail.{key}.**" {
t.Fatalf("emits %v, streams %+v; the code says %v", m.Emits, m.Streams, events)
}
// It reads its own mail and calls nothing, consumes nothing and claims no seat.
if len(m.Consumes)+len(m.Invokes)+len(m.Claims) != 0 {
t.Fatalf("consumes %v, invokes %v, claims %v", m.Consumes, m.Invokes, m.Claims)
}
// The two settings are trusted, and their defaults are an empty account list and no grant.
for id, want := range map[string]string{"accounts": `{"accounts":{}}`, "grants": `{"grants":{}}`} {
r := byID[id]
if r == nil || r["trusted"] != true || r["merge"] != "json" || !reflect.DeepEqual(r["takes-settings"], []any{id}) {
t.Fatalf("%s: %v", id, r)
}
if strings.Join(strings.Fields(r["content"].(string)), "") != want {
t.Fatalf("%s's default is %q", id, r["content"])
}
if _, err := ParseAccounts([]byte(byID["accounts"]["content"].(string))); err != nil {
t.Fatal(err)
}
}
a := m.Build.Artifacts
if len(a) != 1 || a[0].From != "cmd/mesh-mail" || a[0].Binary != Module || !reflect.DeepEqual(a[0].Loads, []string{Module}) {
t.Fatalf("build %+v", m.Build)
}
for k, v := range map[string]string{"MESH_MAIL_ACCOUNTS": "accounts", "MESH_MAIL_GRANTS": "grants", "MESH_MAIL_STORE": "store", "DATABASE_URL_FILE": "database-url"} {
if a[0].Env[k] != byID[v]["path"] {
t.Errorf("the bundle reads %s at %s, and the manifest writes %s at %v", k, a[0].Env[k], v, byID[v]["path"])
}
}
if a[0].Env["MESH_MAIL_SECRETS"] != "${dir:state}" {
t.Errorf("the secrets directory is %s", a[0].Env["MESH_MAIL_SECRETS"])
}
}
// Its processes: the preparation once, keep judged by its unit, watch by mail_health (ADR 0359 §6), the watch writing
// the verdict the bundle's tool reads, as the account the bundle runs as.
func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
m, byID := readManifest(t)
prep, keep, watch := byID["prepare"], byID["keep"], byID["watch"]
if prep == nil || prep["run-once"] != true || prep["run"].([]any)[1] != "prepare" {
t.Fatalf("prepare %v", prep)
}
if keep == nil || keep["run"].([]any)[1] != "keep" || keep["health"].(map[string]any)["kind"] != "unit" {
t.Fatalf("keep %v", keep)
}
h, _ := watch["health"].(map[string]any)
if watch == nil || watch["run"].([]any)[1] != "watch" || h["kind"] != "tool" || h["tool"] != HealthTool || h["needs"] != "postgres-database" {
t.Fatalf("watch %v", watch)
}
if watch["user"] != "${machine:account}" || watch["env"].(map[string]any)["MESH_MAIL_STATUS"] != m.Build.Artifacts[0].Env["MESH_MAIL_STATUS"] {
t.Fatalf("watch writes another status than the tool reads, or as another account: %v", watch)
}
// keep runs as the machine's root, which the own secrets' files belong to; it starts again when its settings or
// its store change.
if _, as := keep["user"]; as {
t.Fatalf("keep runs as %v", keep["user"])
}
if !reflect.DeepEqual(keep["restart-on"], []any{"database-url", "store", "accounts"}) {
t.Fatalf("keep restarts on %v", keep["restart-on"])
}
}
@@ -1,46 +0,0 @@
-- mesh-mail's index (novox/hq ADR 0359 §3): one row per message and folder, what is known of each folder, each
-- account's state as its keeper said it, and the events written with the change they say, until they are said.
create table folder (
account text not null,
name text not null,
uidvalidity bigint not null,
messages integer not null default 0,
synced timestamptz not null default now(),
primary key (account, name));
create table message (
account text not null,
folder text not null,
uid bigint not null,
uidvalidity bigint not null,
message_id text not null default '',
in_reply_to text not null default '',
date timestamptz not null,
sender jsonb not null default '[]',
recipients jsonb not null default '[]',
copied jsonb not null default '[]',
people text not null default '',
subject text not null default '',
size bigint not null default 0,
flags text[] not null default '{}',
attachments jsonb not null default '[]',
body text not null default '',
search tsvector generated always as (
to_tsvector('simple'::regconfig, subject || ' ' || people || ' ' || body)) stored,
primary key (account, folder, uid));
create index message_search on message using gin (search);
create index message_date on message (account, date desc);
create table account_state (
account text primary key,
state text not null,
why text not null default '',
heard timestamptz,
synced timestamptz,
said timestamptz not null default now());
create table outbox (
id bigserial primary key,
key text not null,
body jsonb not null,
made timestamptz not null default now());
-125
View File
@@ -1,125 +0,0 @@
package main
// A message's text and its attachments, read from its MIME source. The text is kept in the index for search and served
// through mail_read; each attachment goes to the attachment store once, by its SHA-256 (novox/hq ADR 0359 §3).
import (
"bytes"
"errors"
"html"
"io"
"mime"
"regexp"
"strings"
_ "github.com/emersion/go-message/charset" // the charsets mail is written in, beyond UTF-8 and ASCII
"github.com/emersion/go-message/mail"
)
// Part is one attachment as read from a message.
type Part struct {
Name string
Type string
Data []byte
}
// MostBodyText bounds the text kept per message: a longer body is cut, and says so.
const MostBodyText = 256 << 10
// Parsed is a message's text and its attachments.
type Parsed struct {
Text string
Attachments []Part
}
// ParseMessage reads a message's text (its plain part; else its HTML part as text) and its attachments. A message the
// reader cannot follow keeps what was read before the fault, and the error says what.
func ParseMessage(raw []byte) (Parsed, error) {
var p Parsed
r, err := mail.CreateReader(bytes.NewReader(raw))
if r == nil {
return p, err
}
var plain, htmlText string
for {
part, err := r.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return finish(p, plain, htmlText), err
}
switch h := part.Header.(type) {
case *mail.InlineHeader:
ct, params, _ := h.ContentType()
_, disp, _ := h.ContentDisposition()
body, readErr := io.ReadAll(io.LimitReader(part.Body, 64<<20))
if readErr != nil {
return finish(p, plain, htmlText), readErr
}
name := params["name"]
if name == "" {
name = disp["filename"]
}
switch {
case ct == "text/plain" && plain == "" && name == "":
plain = string(body)
case ct == "text/html" && htmlText == "" && name == "":
htmlText = HTMLText(string(body))
case !strings.HasPrefix(ct, "text/") || name != "":
// An inline image or a named inline file is an attachment too.
p.Attachments = append(p.Attachments, Part{Name: name, Type: orOctets(ct), Data: body})
}
case *mail.AttachmentHeader:
ct, _, _ := h.ContentType()
name, _ := h.Filename()
body, readErr := io.ReadAll(io.LimitReader(part.Body, 64<<20))
if readErr != nil {
return finish(p, plain, htmlText), readErr
}
p.Attachments = append(p.Attachments, Part{Name: name, Type: orOctets(ct), Data: body})
}
}
return finish(p, plain, htmlText), nil
}
func finish(p Parsed, plain, htmlText string) Parsed {
p.Text = plain
if strings.TrimSpace(p.Text) == "" {
p.Text = htmlText
}
p.Text = strings.ToValidUTF8(p.Text, "�")
if len(p.Text) > MostBodyText {
p.Text = strings.ToValidUTF8(p.Text[:MostBodyText], "") + "\n[the text goes on; it was cut at 256 KiB]"
}
return p
}
func orOctets(ct string) string {
if ct == "" {
return "application/octet-stream"
}
if t, _, err := mime.ParseMediaType(ct); err == nil {
return t
}
return ct
}
var (
scriptsAndStyles = regexp.MustCompile(`(?is)<(script|style|head)\b.*?</(script|style|head)>`)
breaks = regexp.MustCompile(`(?i)<(br|/p|/div|/tr|/li|/h[1-6])\b[^>]*>`)
tags = regexp.MustCompile(`(?s)<[^>]*>`)
blankLines = regexp.MustCompile(`\n[ \t]*\n([ \t]*\n)+`)
)
// HTMLText is an HTML body as plain text: no script, style or markup, the entities read, paragraphs kept as lines.
// Text only, so nothing of the sender's markup ever reaches whoever reads it.
func HTMLText(s string) string {
s = scriptsAndStyles.ReplaceAllString(s, "")
s = breaks.ReplaceAllString(s, "\n")
s = tags.ReplaceAllString(s, "")
s = html.UnescapeString(s)
s = strings.ReplaceAll(s, "\r", "")
s = blankLines.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
-275
View File
@@ -1,275 +0,0 @@
package main
// The attachment store (novox/hq ADR 0359 §3): each attachment kept once, by its SHA-256, in the bucket the `minio`
// module provides this module through the `s3-bucket` provision. The mesh writes the bucket's facts and its key into
// one file (MESH_MAIL_STORE); this speaks S3 to it with the standard library alone: a HEAD, a PUT and a GET, each
// signed with AWS Signature Version 4, path-style, as an S3-compatible object store answers them.
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
"sync"
"time"
)
// Objects is the attachment store.
type Objects interface {
// Bucket is the bucket's name, as an event names it.
Bucket() string
// Has says whether the key is kept.
Has(ctx context.Context, key string) (bool, error)
// Put keeps the bytes under the key.
Put(ctx context.Context, key, mediaType string, data []byte) error
// Get is the bytes kept under the key.
Get(ctx context.Context, key string) ([]byte, error)
}
// Keep stores an attachment once by its SHA-256 and answers its reference: the same file mailed ten times is one
// object.
func Keep(ctx context.Context, o Objects, data []byte, mediaType, name string) (AttachmentRef, error) {
sum := sha256.Sum256(data)
key := hex.EncodeToString(sum[:])
has, err := o.Has(ctx, key)
if err != nil {
return AttachmentRef{}, err
}
if !has {
if err := o.Put(ctx, key, mediaType, data); err != nil {
return AttachmentRef{}, err
}
}
return AttachmentRef{Bucket: o.Bucket(), Key: key, Size: int64(len(data)), Type: mediaType, Name: name}, nil
}
// StoreFacts is the file the mesh writes from the s3-bucket binding and its secret.
type StoreFacts struct {
Scheme string `json:"scheme"`
At string `json:"at"`
Port string `json:"port"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Key string `json:"key"`
Secret string `json:"secret"`
}
// ReadStoreFacts reads the store's facts.
func ReadStoreFacts(path string) (StoreFacts, error) {
var f StoreFacts
if path == "" {
return f, errors.New("the module was started without its store's facts (MESH_MAIL_STORE)")
}
raw, err := os.ReadFile(path)
if err != nil {
return f, fmt.Errorf("the store's facts cannot be read: %w", err)
}
if err := json.Unmarshal(raw, &f); err != nil {
return f, fmt.Errorf("the store's facts are not the module's: %v", err)
}
if f.At == "" || f.Bucket == "" || f.Key == "" || f.Secret == "" {
return f, errors.New("the store's facts name no address, bucket or key")
}
if f.Scheme == "" {
f.Scheme = "http"
}
if f.Region == "" {
f.Region = "us-east-1"
}
return f, nil
}
// S3 is the attachment store over S3.
type S3 struct {
Endpoint string // scheme://host[:port]
Name string
Region string
Access string
Secret string
Client *http.Client
Now func() time.Time
}
// NewS3 is the store the facts name.
func NewS3(f StoreFacts) *S3 {
host := f.At
if f.Port != "" {
host += ":" + f.Port
}
return &S3{Endpoint: f.Scheme + "://" + host, Name: f.Bucket, Region: f.Region, Access: f.Key, Secret: f.Secret,
Client: &http.Client{Timeout: 2 * time.Minute}, Now: time.Now}
}
func (s *S3) Bucket() string { return s.Name }
func (s *S3) do(ctx context.Context, method, key, mediaType string, body []byte) (*http.Response, error) {
u, err := url.Parse(s.Endpoint + "/" + s.Name + "/" + key)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
if mediaType != "" {
req.Header.Set("Content-Type", mediaType)
}
req.ContentLength = int64(len(body))
Sign(req, body, s.Access, s.Secret, s.Region, s.Now().UTC())
return s.Client.Do(req)
}
func (s *S3) Has(ctx context.Context, key string) (bool, error) {
resp, err := s.do(ctx, http.MethodHead, key, "", nil)
if err != nil {
return false, fmt.Errorf("the attachment store cannot be reached: %w", err)
}
resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
}
return false, fmt.Errorf("the attachment store answered %s to asking for %s", resp.Status, key)
}
func (s *S3) Put(ctx context.Context, key, mediaType string, data []byte) error {
resp, err := s.do(ctx, http.MethodPut, key, mediaType, data)
if err != nil {
return fmt.Errorf("the attachment store cannot be reached: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
said, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("the attachment store refused %s: %s %s", key, resp.Status, strings.TrimSpace(string(said)))
}
return nil
}
func (s *S3) Get(ctx context.Context, key string) ([]byte, error) {
resp, err := s.do(ctx, http.MethodGet, key, "", nil)
if err != nil {
return nil, fmt.Errorf("the attachment store cannot be reached: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("the attachment store answered %s for %s", resp.Status, key)
}
return io.ReadAll(resp.Body)
}
// Sign signs a request with AWS Signature Version 4 for S3: every header it carries but the agent and the length is
// signed, with the payload's hash.
func Sign(req *http.Request, body []byte, access, secret, region string, now time.Time) {
payload := sha256.Sum256(body)
payloadHex := hex.EncodeToString(payload[:])
amzDate := now.Format("20060102T150405Z")
day := now.Format("20060102")
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", payloadHex)
headers := map[string]string{"host": req.URL.Host}
for k, v := range req.Header {
lk := strings.ToLower(k)
if lk == "user-agent" || lk == "content-length" || lk == "authorization" {
continue
}
headers[lk] = strings.TrimSpace(strings.Join(v, ","))
}
names := make([]string, 0, len(headers))
for k := range headers {
names = append(names, k)
}
sort.Strings(names)
var canonHeaders strings.Builder
for _, k := range names {
canonHeaders.WriteString(k + ":" + headers[k] + "\n")
}
signed := strings.Join(names, ";")
path := req.URL.EscapedPath()
if path == "" {
path = "/"
}
canonical := strings.Join([]string{req.Method, path, canonicalQuery(req.URL.Query()), canonHeaders.String(), signed, payloadHex}, "\n")
scope := day + "/" + region + "/s3/aws4_request"
sum := sha256.Sum256([]byte(canonical))
toSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sum[:])
key := hmacOf([]byte("AWS4"+secret), day)
key = hmacOf(key, region)
key = hmacOf(key, "s3")
key = hmacOf(key, "aws4_request")
sig := hex.EncodeToString(hmacOf(key, toSign))
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+access+"/"+scope+", SignedHeaders="+signed+", Signature="+sig)
}
func canonicalQuery(q url.Values) string {
keys := make([]string, 0, len(q))
for k := range q {
keys = append(keys, k)
}
sort.Strings(keys)
var parts []string
for _, k := range keys {
vs := append([]string(nil), q[k]...)
sort.Strings(vs)
for _, v := range vs {
parts = append(parts, url.QueryEscape(k)+"="+strings.ReplaceAll(url.QueryEscape(v), "+", "%20"))
}
}
return strings.Join(parts, "&")
}
func hmacOf(key []byte, data string) []byte {
h := hmac.New(sha256.New, key)
h.Write([]byte(data))
return h.Sum(nil)
}
// ---- in memory ----------------------------------------------------------------------------------------------------
type memObjects struct {
mu sync.Mutex
name string
kept map[string][]byte
puts int
}
func newMemObjects() *memObjects { return &memObjects{name: "mail-test", kept: map[string][]byte{}} }
func (m *memObjects) Bucket() string { return m.name }
func (m *memObjects) Has(_ context.Context, key string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.kept[key]
return ok, nil
}
func (m *memObjects) Put(_ context.Context, key, _ string, data []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
m.kept[key] = append([]byte(nil), data...)
m.puts++
return nil
}
func (m *memObjects) Get(_ context.Context, key string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
b, ok := m.kept[key]
if !ok {
return nil, fmt.Errorf("%s is not kept", key)
}
return b, nil
}
@@ -1,104 +0,0 @@
package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// The signature is AWS Signature Version 4 as S3 documents it: its own worked example, "GET Object", signs to the
// signature AWS publishes.
func TestTheSignatureIsTheOneS3Documents(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/test.txt", nil)
req.Header.Set("Range", "bytes=0-9")
Sign(req, nil, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "us-east-1", time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC))
want := "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, " +
"SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
if got := req.Header.Get("Authorization"); got != want {
t.Fatalf("signed\n%s\nnot\n%s", got, want)
}
}
// An attachment is kept once by its SHA-256: the same file twice is one PUT, and it reads back.
func TestAnAttachmentIsKeptOnceByItsHash(t *testing.T) {
var mu sync.Mutex
kept := map[string][]byte{}
puts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.Header.Get("Authorization"), "AWS4-HMAC-SHA256 Credential=mesh_novox_mail/") {
w.WriteHeader(http.StatusForbidden)
return
}
mu.Lock()
defer mu.Unlock()
switch r.Method {
case http.MethodHead:
if _, ok := kept[r.URL.Path]; !ok {
w.WriteHeader(http.StatusNotFound)
}
case http.MethodPut:
body, _ := io.ReadAll(r.Body)
kept[r.URL.Path] = body
puts++
case http.MethodGet:
b, ok := kept[r.URL.Path]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write(b)
}
}))
defer srv.Close()
dir := t.TempDir()
path := filepath.Join(dir, "store.json")
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
writeFile(t, path, `{"scheme": "http", "at": "`+host+`", "port": "`+port+`", "bucket": "mesh-novox-mail", "region": "eu-west",
"key": "mesh_novox_mail", "secret": "s3cr3t"}`)
facts, err := ReadStoreFacts(path)
if err != nil {
t.Fatal(err)
}
s3 := NewS3(facts)
ctx := context.Background()
a, err := Keep(ctx, s3, []byte("same file"), "text/plain", "a.txt")
if err != nil {
t.Fatal(err)
}
b, err := Keep(ctx, s3, []byte("same file"), "text/plain", "b.txt")
if err != nil {
t.Fatal(err)
}
if a.Key != b.Key || a.Bucket != "mesh-novox-mail" || puts != 1 || len(a.Key) != 64 {
t.Fatalf("%+v %+v, %d put(s)", a, b, puts)
}
if _, ok := kept["/mesh-novox-mail/"+a.Key]; !ok {
t.Fatalf("kept %v", kept)
}
got, err := s3.Get(ctx, a.Key)
if err != nil || string(got) != "same file" {
t.Fatalf("%q %v", got, err)
}
}
func TestAMessagesTextAndAttachmentsAreRead(t *testing.T) {
p, err := ParseMessage([]byte(strings.ReplaceAll(attachedMail("x"), "\n", "\r\n")))
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(p.Text) != "Please find the invoice attached." || len(p.Attachments) != 1 ||
p.Attachments[0].Name != "invoice.pdf" || p.Attachments[0].Type != "application/pdf" || string(p.Attachments[0].Data) != attachmentData {
t.Fatalf("%+v", p)
}
html := "From: a@b.c\r\nSubject: s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<html><head><style>p{}</style></head><body><p>Hello &amp; welcome</p><script>alert(1)</script><p>Bye</p></body></html>"
p, err = ParseMessage([]byte(html))
if err != nil || p.Text != "Hello & welcome\nBye" {
t.Fatalf("%q %v", p.Text, err)
}
}
-504
View File
@@ -1,504 +0,0 @@
package main
// The index on the mesh's postgres (novox/hq ADR 0359 §3): the database the mesh provisions for this module through
// the postgres-database provision, its address in the file DATABASE_URL_FILE names. Its schema is numbered migrations
// in migrations/, applied by the preparation step (`mesh-mail prepare`, ADR 0135) and recorded in the database itself;
// a fresh database is given the consolidated schema, schema.sql, at once, with every migration recorded as applied.
// Nothing else changes the schema: the keeper, the watch and the tools refuse a schema that is behind, and say so.
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
//go:embed schema.sql
var consolidated string
// Migration is one numbered change of the schema.
type Migration struct {
Number int
Name string
SQL string
Checksum string
}
var migrationFile = regexp.MustCompile(`^(\d{4})-([a-z0-9-]+)\.sql$`)
// Migrations are the migrations this binary carries, in order, numbered from 0001 without a gap.
func Migrations() ([]Migration, error) {
entries, err := fs.ReadDir(migrationFiles, "migrations")
if err != nil {
return nil, err
}
var out []Migration
for _, e := range entries {
m := migrationFile.FindStringSubmatch(e.Name())
if m == nil {
return nil, fmt.Errorf("%s is not a migration's name (NNNN-what-it-does.sql)", e.Name())
}
n, _ := strconv.Atoi(m[1])
body, err := fs.ReadFile(migrationFiles, path.Join("migrations", e.Name()))
if err != nil {
return nil, err
}
if strings.TrimSpace(string(body)) == "" {
return nil, fmt.Errorf("%s is empty", e.Name())
}
sum := sha256.Sum256(body)
out = append(out, Migration{Number: n, Name: m[2], SQL: string(body), Checksum: hex.EncodeToString(sum[:])})
}
sort.Slice(out, func(i, j int) bool { return out[i].Number < out[j].Number })
for i, m := range out {
if m.Number != i+1 {
return nil, fmt.Errorf("the migrations are not numbered 0001 on without a gap: %04d is the %d. one", m.Number, i+1)
}
}
return out, nil
}
const ledger = `create table if not exists migration (
number integer primary key,
name text not null,
checksum text not null,
applied timestamptz not null default now())`
// lockKey is the advisory lock every migration run takes, so two preparations never interleave.
const lockKey int64 = 4_250_359_001
// PgStore is the Store on postgres.
type PgStore struct{ pool *pgxpool.Pool }
// PgStoreFromEnv opens the index the mesh provisioned.
func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
file := os.Getenv("DATABASE_URL_FILE")
if file == "" {
return nil, errors.New("DATABASE_URL_FILE is not set: the index's database is a requirement the mesh resolves")
}
raw, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("the database's address cannot be read: %w", err)
}
return OpenPgStore(ctx, strings.TrimSpace(string(raw)))
}
// OpenPgStore opens the index at an address.
func OpenPgStore(ctx context.Context, url string) (*PgStore, error) {
pool, err := pgxpool.New(ctx, url)
if err != nil {
return nil, err
}
return &PgStore{pool: pool}, nil
}
// Close closes the store.
func (s *PgStore) Close() { s.pool.Close() }
// Prepare brings the schema to what this version needs, and says what it did.
func (s *PgStore) Prepare(ctx context.Context) ([]string, error) {
migrations, err := Migrations()
if err != nil {
return nil, err
}
conn, err := s.pool.Acquire(ctx)
if err != nil {
return nil, err
}
defer conn.Release()
if _, err := conn.Exec(ctx, `select pg_advisory_lock($1)`, lockKey); err != nil {
return nil, fmt.Errorf("the migration lock cannot be taken: %w", err)
}
defer func() { _, _ = conn.Exec(context.WithoutCancel(ctx), `select pg_advisory_unlock($1)`, lockKey) }()
if _, err := conn.Exec(ctx, ledger); err != nil {
return nil, err
}
applied := map[int]string{}
rows, err := conn.Query(ctx, `select number, checksum from migration`)
if err != nil {
return nil, err
}
highest := 0
for rows.Next() {
var n int
var sum string
if err := rows.Scan(&n, &sum); err != nil {
rows.Close()
return nil, err
}
applied[n] = sum
highest = max(highest, n)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
record := func(tx pgx.Tx, m Migration) error {
_, err := tx.Exec(ctx, `insert into migration (number, name, checksum) values ($1, $2, $3)`, m.Number, m.Name, m.Checksum)
return err
}
if len(applied) == 0 {
var exists bool
if err := conn.QueryRow(ctx, `select to_regclass('message') is not null`).Scan(&exists); err != nil {
return nil, err
}
if exists {
return nil, errors.New("the database holds the index's tables and no record of a migration: its contents are not known, so nothing is changed")
}
tx, err := conn.Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if _, err := tx.Exec(ctx, consolidated); err != nil {
return nil, fmt.Errorf("the consolidated schema failed, and nothing of it was kept: %w", err)
}
for _, m := range migrations {
if err := record(tx, m); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return []string{fmt.Sprintf("a fresh database: the consolidated schema, through migration %04d", len(migrations))}, nil
}
var problems []string
var pending []Migration
for _, m := range migrations {
sum, ran := applied[m.Number]
switch {
case ran && sum != m.Checksum:
problems = append(problems, fmt.Sprintf("migration %04d-%s ran, and its file has changed since: write a new one", m.Number, m.Name))
case !ran && m.Number < highest:
problems = append(problems, fmt.Sprintf("migration %04d-%s never ran, and %04d did: renumber it above %04d", m.Number, m.Name, highest, highest))
case !ran:
pending = append(pending, m)
}
}
if len(problems) > 0 {
return nil, errors.New("this database and these migrations disagree: " + strings.Join(problems, "; "))
}
var said []string
for _, m := range pending {
err := func() error {
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if _, err := tx.Exec(ctx, m.SQL); err != nil {
return fmt.Errorf("migration %04d-%s failed, and nothing it did was kept: %w", m.Number, m.Name, err)
}
if err := record(tx, m); err != nil {
return err
}
return tx.Commit(ctx)
}()
if err != nil {
return said, err
}
said = append(said, fmt.Sprintf("migration %04d-%s applied", m.Number, m.Name))
}
if len(said) == 0 {
said = []string{fmt.Sprintf("the schema is at migration %04d, as this version needs", len(migrations))}
}
return said, nil
}
// Ready says why the index cannot serve this version, or nil.
func (s *PgStore) Ready(ctx context.Context) error {
migrations, err := Migrations()
if err != nil {
return err
}
var at int
err = s.pool.QueryRow(ctx, `select coalesce(max(number), 0) from migration`).Scan(&at)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "42P01" {
at = 0
} else if err != nil {
return fmt.Errorf("the database cannot be reached: %w", err)
}
if at != len(migrations) {
return fmt.Errorf("the database's schema is at migration %04d and this version needs %04d: the preparation step "+
"(mesh-mail prepare) has not run", at, len(migrations))
}
return nil
}
func (s *PgStore) Folder(ctx context.Context, account, folder string) (Folder, bool, error) {
f := Folder{Account: account, Name: folder}
var validity int64
err := s.pool.QueryRow(ctx, `select uidvalidity, messages, synced from folder where account = $1 and name = $2`, account, folder).
Scan(&validity, &f.Messages, &f.Synced)
if errors.Is(err, pgx.ErrNoRows) {
return f, false, nil
}
f.UIDValidity = uint32(validity)
return f, err == nil, err
}
func (s *PgStore) SetFolder(ctx context.Context, f Folder) error {
_, err := s.pool.Exec(ctx, `insert into folder (account, name, uidvalidity, messages, synced) values ($1, $2, $3, $4, $5)
on conflict (account, name) do update set uidvalidity = excluded.uidvalidity, messages = excluded.messages, synced = excluded.synced`,
f.Account, f.Name, int64(f.UIDValidity), f.Messages, f.Synced)
return err
}
func (s *PgStore) DropFolder(ctx context.Context, account, folder string) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `delete from message where account = $1 and folder = $2`, account, folder); err != nil {
return err
}
_, err := tx.Exec(ctx, `delete from folder where account = $1 and name = $2`, account, folder)
return err
})
}
func (s *PgStore) Flags(ctx context.Context, account, folder string) (map[uint32][]string, error) {
rows, err := s.pool.Query(ctx, `select uid, flags from message where account = $1 and folder = $2`, account, folder)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[uint32][]string{}
for rows.Next() {
var uid int64
var flags []string
if err := rows.Scan(&uid, &flags); err != nil {
return nil, err
}
out[uint32(uid)] = flags
}
return out, rows.Err()
}
func keepEvent(ctx context.Context, tx pgx.Tx, e *Event) error {
if e == nil {
return nil
}
body, err := json.Marshal(e.Body)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `insert into outbox (key, body) values ($1, $2)`, e.Key, body)
return err
}
func (s *PgStore) Add(ctx context.Context, m Message, e *Event) error {
from, _ := json.Marshal(m.From)
to, _ := json.Marshal(m.To)
cc, _ := json.Marshal(orNone(m.Cc))
atts, _ := json.Marshal(m.Attachments)
flags := m.Flags
if flags == nil {
flags = []string{}
}
people := strings.Join([]string{peopleText(m.From), peopleText(m.To), peopleText(m.Cc)}, " ")
// An address is one word to the search, so its parts are kept beside it: "bob" finds bob@example.org.
people += " " + strings.NewReplacer("@", " ", ".", " ", ",", " ").Replace(people)
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `insert into message (account, folder, uid, uidvalidity, message_id, in_reply_to, date, sender,
recipients, copied, people, subject, size, flags, attachments, body)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
on conflict (account, folder, uid) do update set uidvalidity = excluded.uidvalidity, message_id = excluded.message_id,
in_reply_to = excluded.in_reply_to, date = excluded.date, sender = excluded.sender, recipients = excluded.recipients,
copied = excluded.copied, people = excluded.people, subject = excluded.subject, size = excluded.size,
flags = excluded.flags, attachments = excluded.attachments, body = excluded.body`,
m.Account, m.Folder, int64(m.UID), int64(m.UIDValidity), m.MessageID, m.InReplyTo, m.Date, from, to, cc, people,
strings.ToValidUTF8(strings.ReplaceAll(m.Subject, "\x00", ""), ""), m.Size, flags, atts,
strings.ReplaceAll(m.Body, "\x00", "")); err != nil {
return err
}
return keepEvent(ctx, tx, e)
})
}
func orNone(ps []Person) []Person {
if ps == nil {
return []Person{}
}
return ps
}
func (s *PgStore) SetFlags(ctx context.Context, account, folder string, uid uint32, flags []string, e *Event) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `update message set flags = $4 where account = $1 and folder = $2 and uid = $3`,
account, folder, int64(uid), flags); err != nil {
return err
}
return keepEvent(ctx, tx, e)
})
}
func (s *PgStore) Remove(ctx context.Context, account, folder string, uid uint32, e *Event) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `delete from message where account = $1 and folder = $2 and uid = $3`, account, folder, int64(uid)); err != nil {
return err
}
return keepEvent(ctx, tx, e)
})
}
const messageColumns = `account, folder, uid, uidvalidity, message_id, in_reply_to, date, sender, recipients, copied, subject,
size, flags, attachments`
func scanMessage(row pgx.Row, withBody bool) (Message, error) {
var m Message
var uid, validity int64
var from, to, cc, atts []byte
dest := []any{&m.Account, &m.Folder, &uid, &validity, &m.MessageID, &m.InReplyTo, &m.Date, &from, &to, &cc, &m.Subject,
&m.Size, &m.Flags, &atts}
if withBody {
dest = append(dest, &m.Body)
}
if err := row.Scan(dest...); err != nil {
return m, err
}
m.UID, m.UIDValidity, m.Date = uint32(uid), uint32(validity), m.Date.UTC()
for _, f := range []struct {
raw []byte
into any
}{{from, &m.From}, {to, &m.To}, {cc, &m.Cc}, {atts, &m.Attachments}} {
if err := json.Unmarshal(f.raw, f.into); err != nil {
return m, err
}
}
return m, nil
}
func (s *PgStore) Get(ctx context.Context, account, folder string, uid uint32) (Message, bool, error) {
m, err := scanMessage(s.pool.QueryRow(ctx, `select `+messageColumns+`, body from message where account = $1 and folder = $2 and uid = $3`,
account, folder, int64(uid)), true)
if errors.Is(err, pgx.ErrNoRows) {
return Message{}, false, nil
}
return m, err == nil, err
}
func (s *PgStore) Search(ctx context.Context, q Query) ([]Message, error) {
limit := q.Limit
if limit <= 0 {
limit = 20
}
rows, err := s.pool.Query(ctx, `select `+messageColumns+` from message
where account = any($1) and ($2 = '' or folder = $2) and search @@ plainto_tsquery('simple'::regconfig, $3)
order by date desc limit $4`, q.Accounts, q.Folder, q.Text, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Message
for rows.Next() {
m, err := scanMessage(rows, false)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *PgStore) Folders(ctx context.Context, account string) ([]Folder, error) {
rows, err := s.pool.Query(ctx, `select name, uidvalidity, messages, synced from folder where account = $1 order by name`, account)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Folder
for rows.Next() {
f := Folder{Account: account}
var validity int64
if err := rows.Scan(&f.Name, &validity, &f.Messages, &f.Synced); err != nil {
return nil, err
}
f.UIDValidity, f.Synced = uint32(validity), f.Synced.UTC()
out = append(out, f)
}
return out, rows.Err()
}
func (s *PgStore) Pending(ctx context.Context, limit int) ([]Event, error) {
rows, err := s.pool.Query(ctx, `select id, key, body from outbox order by id limit $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Event
for rows.Next() {
var e Event
var body []byte
if err := rows.Scan(&e.ID, &e.Key, &body); err != nil {
return nil, err
}
if err := json.Unmarshal(body, &e.Body); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func (s *PgStore) Said(ctx context.Context, ids []int64) error {
_, err := s.pool.Exec(ctx, `delete from outbox where id = any($1)`, ids)
return err
}
func (s *PgStore) SetState(ctx context.Context, st AccountState) error {
_, err := s.pool.Exec(ctx, `insert into account_state (account, state, why, heard, synced, said) values ($1, $2, $3, $4, $5, $6)
on conflict (account) do update set state = excluded.state, why = excluded.why, heard = excluded.heard,
synced = excluded.synced, said = excluded.said`,
st.Account, st.State, st.Why, nullTime(st.Heard), nullTime(st.Synced), st.Said)
return err
}
func nullTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
func (s *PgStore) States(ctx context.Context) (map[string]AccountState, error) {
rows, err := s.pool.Query(ctx, `select account, state, why, heard, synced, said from account_state`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]AccountState{}
for rows.Next() {
var st AccountState
var heard, synced *time.Time
if err := rows.Scan(&st.Account, &st.State, &st.Why, &heard, &synced, &st.Said); err != nil {
return nil, err
}
if heard != nil {
st.Heard = heard.UTC()
}
if synced != nil {
st.Synced = synced.UTC()
}
st.Said = st.Said.UTC()
out[st.Account] = st
}
return out, rows.Err()
}
@@ -1,99 +0,0 @@
package main
import (
"context"
"reflect"
"strings"
"testing"
)
// The schema is numbered migrations and a consolidated schema for a fresh database (the mesh's rule: both). A
// database given the consolidated schema and one given every migration in order hold the same tables and columns.
func TestTheConsolidatedSchemaIsTheMigrationsApplied(t *testing.T) {
ctx := context.Background()
fresh := pgStore(t)
stepped, err := OpenPgStore(ctx, freshDatabase(t))
if err != nil {
t.Fatal(err)
}
defer stepped.Close()
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
for _, m := range migrations {
if _, err := stepped.pool.Exec(ctx, m.SQL); err != nil {
t.Fatalf("migration %04d: %v", m.Number, err)
}
}
a, b := shape(t, fresh), shape(t, stepped)
if !reflect.DeepEqual(a, b) || len(a) < 25 {
t.Fatalf("the consolidated schema and the migrations differ:\nconsolidated %v\nmigrations %v", a, b)
}
if !strings.Contains(consolidated, "through migration 0001") || len(migrations) != 1 {
t.Fatal("the consolidated schema does not say how far it is consolidated")
}
// Prepared twice, it changes nothing; and it is ready.
if said, err := fresh.Prepare(ctx); err != nil || !strings.Contains(said[0], "as this version needs") {
t.Fatalf("%v %v", said, err)
}
if err := fresh.Ready(ctx); err != nil {
t.Fatal(err)
}
if err := stepped.Ready(ctx); err == nil {
t.Fatal("a database whose migrations are not recorded is ready")
}
}
func shape(t *testing.T, s *PgStore) []string {
t.Helper()
rows, err := s.pool.Query(context.Background(), `select table_name || '.' || column_name || ' ' || data_type
from information_schema.columns where table_schema = 'public' and table_name <> 'migration' order by 1`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var c string
_ = rows.Scan(&c)
out = append(out, c)
}
return out
}
// The search finds by every word, across the accounts asked only, newest first, and answers no body.
func TestTheIndexSearchesByWordAndAccount(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
for i, subj := range []string{"the invoice of october", "lunch", "another invoice"} {
m := Message{Account: "mesh", Folder: "INBOX", UID: uint32(i + 1), Subject: subj, Date: judgedAt.AddDate(0, 0, i),
From: []Person{{Address: "bob@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "text " + subj}
if err := st.Add(ctx, m, nil); err != nil {
t.Fatal(err)
}
}
other := Message{Account: "gmail", Folder: "INBOX", UID: 1, Subject: "invoice elsewhere", Date: judgedAt, From: []Person{}, To: []Person{},
Flags: []string{}, Attachments: []AttachmentRef{}}
if err := st.Add(ctx, other, nil); err != nil {
t.Fatal(err)
}
hits, err := st.Search(ctx, Query{Text: "invoice", Accounts: []string{"mesh"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(hits) != 2 || hits[0].Subject != "another invoice" || hits[0].Body != "" {
t.Fatalf("%+v", hits)
}
if hits, _ := st.Search(ctx, Query{Text: "invoice october", Accounts: []string{"mesh"}}); len(hits) != 1 {
t.Fatalf("%+v", hits)
}
if hits, _ := st.Search(ctx, Query{Text: "bob", Accounts: []string{"mesh"}}); len(hits) != 3 {
t.Fatalf("by sender: %+v", hits)
}
got, ok, err := st.Get(ctx, "mesh", "INBOX", 2)
if err != nil || !ok || got.Body != "text lunch" {
t.Fatalf("%+v %v %v", got, ok, err)
}
})
}
@@ -1,46 +0,0 @@
-- The consolidated schema, through migration 0001: what a fresh database is given at once (the mesh's rule: a
-- numbered migration for the running store, and this for a fresh install).
create table folder (
account text not null,
name text not null,
uidvalidity bigint not null,
messages integer not null default 0,
synced timestamptz not null default now(),
primary key (account, name));
create table message (
account text not null,
folder text not null,
uid bigint not null,
uidvalidity bigint not null,
message_id text not null default '',
in_reply_to text not null default '',
date timestamptz not null,
sender jsonb not null default '[]',
recipients jsonb not null default '[]',
copied jsonb not null default '[]',
people text not null default '',
subject text not null default '',
size bigint not null default 0,
flags text[] not null default '{}',
attachments jsonb not null default '[]',
body text not null default '',
search tsvector generated always as (
to_tsvector('simple'::regconfig, subject || ' ' || people || ' ' || body)) stored,
primary key (account, folder, uid));
create index message_search on message using gin (search);
create index message_date on message (account, date desc);
create table account_state (
account text primary key,
state text not null,
why text not null default '',
heard timestamptz,
synced timestamptz,
said timestamptz not null default now());
create table outbox (
id bigserial primary key,
key text not null,
body jsonb not null,
made timestamptz not null default now());
-332
View File
@@ -1,332 +0,0 @@
package main
// The index (novox/hq ADR 0359 §3): one row per message and folder, in the module's own database; the events still to
// be said on the bus, written in the same step as the change they say; and each account's state as its keeper last
// saw it, which the health watch reads. The Store is that, over postgres (pgstore.go) or in memory for the tests.
import (
"context"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Person is a sender or a recipient.
type Person struct {
Name string `json:"name,omitempty"`
Address string `json:"address"`
}
// AttachmentRef names an attachment by its content (§3): the bucket and key it is kept under, the key being its
// SHA-256 in hex, with its size, media type and file name.
type AttachmentRef struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
Size int64 `json:"size"`
Type string `json:"type"`
Name string `json:"name,omitempty"`
}
// Message is one message in one folder of one account, as indexed.
type Message struct {
Account string `json:"account"`
Folder string `json:"folder"`
UIDValidity uint32 `json:"uidvalidity"`
UID uint32 `json:"uid"`
MessageID string `json:"message-id,omitempty"`
InReplyTo string `json:"in-reply-to,omitempty"`
Date time.Time `json:"date"`
From []Person `json:"from"`
To []Person `json:"to"`
Cc []Person `json:"cc,omitempty"`
Subject string `json:"subject"`
Size int64 `json:"size"`
Flags []string `json:"flags"`
Attachments []AttachmentRef `json:"attachments"`
// Body is the message's text, kept for search and served only through mail_read; never on the bus.
Body string `json:"-"`
}
// Event is one change said on the bus: its key (`mail.<account>.message.<added|changed|removed>`) and its body,
// metadata only.
type Event struct {
ID int64
Key string
Body map[string]any
}
// Folder is what the index knows of one folder.
type Folder struct {
Account string `json:"-"`
Name string `json:"name"`
UIDValidity uint32 `json:"-"`
Messages int `json:"messages"`
Synced time.Time `json:"synced"`
}
// The states an account's keeper says it is in.
const (
AccountSyncing = "syncing"
AccountCurrent = "current"
AccountWaits = "waits-for-sign-in"
AccountFailing = "failing"
)
// AccountState is an account as its keeper last said it: its state, why, and when the server last answered.
type AccountState struct {
Account string `json:"account"`
State string `json:"state"`
Why string `json:"why"`
Heard time.Time `json:"heard,omitempty"`
Synced time.Time `json:"synced,omitempty"`
Said time.Time `json:"said"`
}
// Query is a search of the index.
type Query struct {
Text string
Accounts []string
Folder string
Limit int
}
// Store is the index.
type Store interface {
Ready(ctx context.Context) error
// Folder is what the index knows of a folder; false when it never synced it.
Folder(ctx context.Context, account, folder string) (Folder, bool, error)
// SetFolder records a folder synced.
SetFolder(ctx context.Context, f Folder) error
// DropFolder forgets a folder's rows, without an event: the server renumbered it (a new UIDVALIDITY) and the
// next sync indexes it afresh.
DropFolder(ctx context.Context, account, folder string) error
// Flags is every indexed message of a folder, by UID, with its flags.
Flags(ctx context.Context, account, folder string) (map[uint32][]string, error)
// Add indexes a message, and keeps the event that says it when there is one, in one step.
Add(ctx context.Context, m Message, e *Event) error
// SetFlags changes a message's flags, and keeps the event, in one step.
SetFlags(ctx context.Context, account, folder string, uid uint32, flags []string, e *Event) error
// Remove removes a message, and keeps the event, in one step.
Remove(ctx context.Context, account, folder string, uid uint32, e *Event) error
// Get is one message, with its body.
Get(ctx context.Context, account, folder string, uid uint32) (Message, bool, error)
// Search finds messages, newest first, without their bodies.
Search(ctx context.Context, q Query) ([]Message, error)
// Folders is every folder the index knows of an account.
Folders(ctx context.Context, account string) ([]Folder, error)
// Pending is the events not yet said, oldest first; Said removes those said.
Pending(ctx context.Context, limit int) ([]Event, error)
Said(ctx context.Context, ids []int64) error
// SetState and States are each account's state, as its keeper says it.
SetState(ctx context.Context, s AccountState) error
States(ctx context.Context) (map[string]AccountState, error)
}
// ---- in memory ----------------------------------------------------------------------------------------------------
type memStore struct {
mu sync.Mutex
messages map[string]Message
folders map[string]Folder
events []Event
next int64
states map[string]AccountState
}
func newMemStore() *memStore {
return &memStore{messages: map[string]Message{}, folders: map[string]Folder{}, states: map[string]AccountState{}}
}
func msgKey(account, folder string, uid uint32) string {
return account + "\x00" + folder + "\x00" + itoa(int64(uid))
}
func folderKey(account, folder string) string { return account + "\x00" + folder }
func (s *memStore) Ready(context.Context) error { return nil }
func (s *memStore) Folder(_ context.Context, account, folder string) (Folder, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
f, ok := s.folders[folderKey(account, folder)]
return f, ok, nil
}
func (s *memStore) SetFolder(_ context.Context, f Folder) error {
s.mu.Lock()
defer s.mu.Unlock()
s.folders[folderKey(f.Account, f.Name)] = f
return nil
}
func (s *memStore) DropFolder(_ context.Context, account, folder string) error {
s.mu.Lock()
defer s.mu.Unlock()
for k, m := range s.messages {
if m.Account == account && m.Folder == folder {
delete(s.messages, k)
}
}
delete(s.folders, folderKey(account, folder))
return nil
}
func (s *memStore) Flags(_ context.Context, account, folder string) (map[uint32][]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := map[uint32][]string{}
for _, m := range s.messages {
if m.Account == account && m.Folder == folder {
out[m.UID] = append([]string(nil), m.Flags...)
}
}
return out, nil
}
func (s *memStore) keep(e *Event) {
if e == nil {
return
}
s.next++
ev := *e
ev.ID = s.next
s.events = append(s.events, ev)
}
func (s *memStore) Add(_ context.Context, m Message, e *Event) error {
s.mu.Lock()
defer s.mu.Unlock()
s.messages[msgKey(m.Account, m.Folder, m.UID)] = m
s.keep(e)
return nil
}
func (s *memStore) SetFlags(_ context.Context, account, folder string, uid uint32, flags []string, e *Event) error {
s.mu.Lock()
defer s.mu.Unlock()
k := msgKey(account, folder, uid)
if m, ok := s.messages[k]; ok {
m.Flags = flags
s.messages[k] = m
}
s.keep(e)
return nil
}
func (s *memStore) Remove(_ context.Context, account, folder string, uid uint32, e *Event) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.messages, msgKey(account, folder, uid))
s.keep(e)
return nil
}
func (s *memStore) Get(_ context.Context, account, folder string, uid uint32) (Message, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
m, ok := s.messages[msgKey(account, folder, uid)]
return m, ok, nil
}
func (s *memStore) Search(_ context.Context, q Query) ([]Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
words := strings.Fields(strings.ToLower(q.Text))
var out []Message
for _, m := range s.messages {
if !contains(q.Accounts, m.Account) || (q.Folder != "" && q.Folder != m.Folder) {
continue
}
hay := strings.ToLower(m.Subject + " " + m.Body + " " + peopleText(m.From) + " " + peopleText(m.To) + " " + peopleText(m.Cc))
all := true
for _, w := range words {
all = all && strings.Contains(hay, w)
}
if all {
m.Body = ""
out = append(out, m)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Date.After(out[j].Date) })
if q.Limit > 0 && len(out) > q.Limit {
out = out[:q.Limit]
}
return out, nil
}
func (s *memStore) Folders(_ context.Context, account string) ([]Folder, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []Folder
for _, f := range s.folders {
if f.Account == account {
out = append(out, f)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (s *memStore) Pending(_ context.Context, limit int) ([]Event, error) {
s.mu.Lock()
defer s.mu.Unlock()
n := min(limit, len(s.events))
return append([]Event(nil), s.events[:n]...), nil
}
func (s *memStore) Said(_ context.Context, ids []int64) error {
s.mu.Lock()
defer s.mu.Unlock()
said := map[int64]bool{}
for _, id := range ids {
said[id] = true
}
kept := s.events[:0]
for _, e := range s.events {
if !said[e.ID] {
kept = append(kept, e)
}
}
s.events = kept
return nil
}
func (s *memStore) SetState(_ context.Context, st AccountState) error {
s.mu.Lock()
defer s.mu.Unlock()
s.states[st.Account] = st
return nil
}
func (s *memStore) States(context.Context) (map[string]AccountState, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := map[string]AccountState{}
for k, v := range s.states {
out[k] = v
}
return out, nil
}
// ---- small helpers ------------------------------------------------------------------------------------------------
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
func peopleText(ps []Person) string {
var parts []string
for _, p := range ps {
parts = append(parts, strings.TrimSpace(p.Name+" "+p.Address))
}
return strings.Join(parts, ", ")
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
-340
View File
@@ -1,340 +0,0 @@
package main
// The module's tools (novox/hq ADR 0359 §5): mail_accounts, mail_search, mail_read and mail_health. Who calls is the
// runtime's word (stdio.CallerOf), never an argument; a caller reads an account's mail — its search, a body, an
// attachment — only when the trusted setting `grants` names it for that account (§4). Nothing here writes to a mailbox.
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tool names, as the manifest declares them.
const (
AccountsTool = "mail_accounts"
SearchTool = "mail_search"
ReadTool = "mail_read"
)
// Bounds of what a tool answers.
const (
MostResults = 50
MostQuery = 200
MostBodyAnswered = 64 << 10
MostAttachment = 8 << 20
)
// Bundle is what the tools reach: the settings, the index and the attachment store.
type Bundle struct {
Env Env
Index func(ctx context.Context) (Store, error)
Objects func() (Objects, error)
Caller func(args map[string]any) string
Now func() time.Time
}
func (b *Bundle) now() time.Time {
if b.Now != nil {
return b.Now()
}
return time.Now()
}
// settings are the accounts and the grants. A grants setting that cannot be read grants nothing, and its reason is
// kept, so a refusal says why rather than only that the caller is not granted.
func (b *Bundle) settings() ([]Account, access, error) {
accounts, err := ReadAccounts(b.Env.Accounts)
if err != nil {
return nil, access{}, err
}
grants, err := ReadGrants(b.Env.Grants)
if err != nil {
return accounts, access{grants: Grants{}, unread: err}, nil
}
return accounts, access{grants: grants}, nil
}
// access is the grants as read, and why they could not be, when they could not.
type access struct {
grants Grants
unread error
}
func (a access) Allows(caller, account string) bool { return a.grants.Allows(caller, account) }
func (a access) Granted(caller string, accounts []Account) []string {
return a.grants.Granted(caller, accounts)
}
// refuse is the refusal of a caller for an account, saying why: the grants unread, or the caller not in them.
func (a access) refuse(caller, account string) error {
if a.unread != nil {
return fmt.Errorf("refused: the setting grants cannot be read, so it grants nobody: %v", a.unread)
}
return refused(caller, account)
}
// Tools are the module's tools.
func (b *Bundle) Tools() []stdio.Tool {
return []stdio.Tool{
{Name: AccountsTool,
Description: "The operator's mail accounts you are granted, each with its kind, its address, its state (syncing, " +
"current, waits-for-sign-in or failing, and why), when its server last answered, when it was last brought up to " +
"date, and its folders with how many messages each holds. An account you are not granted is not listed, and " +
"nothing of it is said. How the module as a whole is, is mail_health's to say. Read-only.",
Input: schema(nil, map[string]any{}),
Run: b.accountsTool},
{Name: SearchTool,
Description: "Search the mail of the accounts you are granted: every word of the query must be in the subject, the " +
"text, a sender or a recipient. Answers the newest first, at most 50: account, folder, uid, message id, date, " +
"sender, recipients, subject, size, flags and attachment references, never a body. Refused for an account you " +
"are not granted, and when you are granted none. Read-only.",
Input: schema([]string{"query"}, map[string]any{
"query": text("the words to find, at most 200 characters"),
"account": text("one account by its name in mail_accounts; every account you are granted when left out"),
"folder": text("one folder, such as INBOX; every folder when left out"),
"limit": integer("how many to answer, at most 50 (20 when left out)"),
}),
Run: b.searchTool},
{Name: ReadTool,
Description: "Read one message of an account you are granted, as an event or mail_search names it (account, folder, " +
"uid): its metadata and its text (the plain part, or the HTML part as text, at most 64 KiB here). With " +
"attachment (its key, the SHA-256 the message's references name), that one attachment's bytes, base64, at most " +
"8 MiB. Refused for an account you are not granted. Never marks a message seen. Read-only.",
Input: schema([]string{"account", "folder", "uid"}, map[string]any{
"account": text("the account, by its name in mail_accounts"),
"folder": text("the folder, such as INBOX"),
"uid": integer("the message's uid in that folder"),
"attachment": text("an attachment's key, the SHA-256 the message's references name, to answer its bytes"),
}),
Run: b.readTool},
{Name: HealthTool,
Description: "How mesh-mail is, as its watch last saw it: healthy when every account's server answered within five " +
"minutes; unhealthy, naming the account and why, when a connection is down or its IDLE on the inbox went silent; " +
"waits, naming the account's password, while the operator has not given it at the desk.",
Input: schema(nil, map[string]any{}),
Run: func(map[string]any) (any, error) { return ReadStatus(b.Env.Status, b.now()), nil }},
}
}
func (b *Bundle) caller(args map[string]any) string {
if b.Caller != nil {
return b.Caller(args)
}
return stdio.CallerOf(args)
}
func (b *Bundle) accountsTool(args map[string]any) (any, error) {
if err := only(args); err != nil {
return nil, err
}
accounts, grants, err := b.settings()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
st, err := b.Index(ctx)
if err != nil {
return nil, fmt.Errorf("the index cannot be reached: %v", err)
}
states, err := st.States(ctx)
if err != nil {
return nil, err
}
caller := b.caller(args)
out := []map[string]any{}
for _, a := range accounts {
// Only the accounts the caller is granted: of any other it learns nothing, not even that it exists.
if !grants.Allows(caller, a.Name) {
continue
}
entry := map[string]any{"account": a.Name, "kind": a.Kind, "address": a.Address}
if s, ok := states[a.Name]; ok {
entry["state"], entry["why"] = s.State, s.Why
if !s.Heard.IsZero() {
entry["heard"] = s.Heard.UTC().Format(time.RFC3339)
}
if !s.Synced.IsZero() {
entry["synced"] = s.Synced.UTC().Format(time.RFC3339)
}
} else {
entry["state"], entry["why"] = "unknown", "its keeper has said nothing of it yet"
}
folders, err := st.Folders(ctx, a.Name)
if err != nil {
return nil, err
}
entry["folders"] = folders
out = append(out, entry)
}
answer := map[string]any{"accounts": out, "caller": caller}
if grants.unread != nil {
answer["why"] = "the setting grants cannot be read, so it grants nobody: " + grants.unread.Error()
}
return answer, nil
}
func (b *Bundle) searchTool(args map[string]any) (any, error) {
if err := only(args, "query", "account", "folder", "limit"); err != nil {
return nil, err
}
query, _ := args["query"].(string)
if strings.TrimSpace(query) == "" || len(query) > MostQuery {
return nil, fmt.Errorf("%s needs a query of 1 to %d characters", SearchTool, MostQuery)
}
account, _ := args["account"].(string)
folder, _ := args["folder"].(string)
limit, err := number(args, "limit", 20)
if err != nil {
return nil, err
}
if limit < 1 || limit > MostResults {
return nil, fmt.Errorf("limit is 1 to %d", MostResults)
}
accounts, grants, err := b.settings()
if err != nil {
return nil, err
}
caller := b.caller(args)
granted := grants.Granted(caller, accounts)
if account != "" {
if !grants.Allows(caller, account) {
return nil, grants.refuse(caller, account)
}
granted = []string{account}
}
if len(granted) == 0 {
if grants.unread != nil {
return nil, grants.refuse(caller, "")
}
return nil, fmt.Errorf("refused: %s is granted no mail account; the operator grants one through the setting grants", caller)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
st, err := b.Index(ctx)
if err != nil {
return nil, fmt.Errorf("the index cannot be reached: %v", err)
}
found, err := st.Search(ctx, Query{Text: query, Accounts: granted, Folder: folder, Limit: limit})
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(found))
for _, m := range found {
out = append(out, metadata(m))
}
return map[string]any{"searched": granted, "found": out}, nil
}
func (b *Bundle) readTool(args map[string]any) (any, error) {
if err := only(args, "account", "folder", "uid", "attachment"); err != nil {
return nil, err
}
account, _ := args["account"].(string)
folder, _ := args["folder"].(string)
attachment, _ := args["attachment"].(string)
uid, err := number(args, "uid", 0)
if err != nil {
return nil, err
}
if account == "" || folder == "" || uid <= 0 {
return nil, fmt.Errorf("%s needs an account, a folder and a uid", ReadTool)
}
_, grants, err := b.settings()
if err != nil {
return nil, err
}
caller := b.caller(args)
if !grants.Allows(caller, account) {
return nil, grants.refuse(caller, account)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
st, err := b.Index(ctx)
if err != nil {
return nil, fmt.Errorf("the index cannot be reached: %v", err)
}
m, ok, err := st.Get(ctx, account, folder, uint32(uid))
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("the account %s holds no message %d in %s", account, uid, folder)
}
if attachment != "" {
var ref *AttachmentRef
for i := range m.Attachments {
if m.Attachments[i].Key == attachment {
ref = &m.Attachments[i]
}
}
if ref == nil {
return nil, fmt.Errorf("the message names no attachment %s", attachment)
}
if ref.Size > MostAttachment {
return nil, fmt.Errorf("the attachment %s is %d bytes, more than the %d this tool answers", ref.Name, ref.Size, MostAttachment)
}
o, err := b.Objects()
if err != nil {
return nil, err
}
data, err := o.Get(ctx, ref.Key)
if err != nil {
return nil, err
}
return map[string]any{"attachment": ref, "base64": base64.StdEncoding.EncodeToString(data)}, nil
}
out := metadata(m)
body := m.Body
if len(body) > MostBodyAnswered {
body = strings.ToValidUTF8(body[:MostBodyAnswered], "") + "\n[cut at 64 KiB]"
}
out["text"] = body
return out, nil
}
func refused(caller, account string) error {
return fmt.Errorf("refused: %s is not granted the mail account %s; the operator grants it through the setting grants", caller, account)
}
// ---- arguments ----------------------------------------------------------------------------------------------------
// only refuses an argument the tool does not take: never ignored, and none can say who calls.
func only(args map[string]any, takes ...string) error {
for k := range args {
if !contains(takes, k) {
return fmt.Errorf("the argument %q is not taken", k)
}
}
return nil
}
func number(args map[string]any, key string, otherwise int) (int, error) {
v, ok := args[key]
if !ok || v == nil {
return otherwise, nil
}
f, ok := v.(float64)
if !ok || f != float64(int(f)) {
return 0, errors.New(key + " is a whole number")
}
return int(f), nil
}
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
func integer(d string) map[string]any { return map[string]any{"type": "integer", "description": d} }
func schema(required []string, props map[string]any) map[string]any {
s := map[string]any{"type": "object", "properties": props, "additionalProperties": false}
if len(required) > 0 {
s["required"] = required
}
return s
}
@@ -1,275 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
// runtime plays the node's runtime against the bundle over its MCP channel, as the bundle is launched: it calls tools
// naming who called in `_meta`, as the runtime does from the subject a call arrived on, and takes what the bundle
// publishes.
type runtime struct {
t *testing.T
in *io.PipeWriter
mu sync.Mutex
next int
answers map[string]chan map[string]any
published []map[string]any
refuse bool
}
func startBundle(t *testing.T, tools []stdio.Tool) *runtime {
t.Helper()
inR, inW := io.Pipe()
outR, outW := io.Pipe()
r := &runtime{t: t, in: inW, answers: map[string]chan map[string]any{}}
go func() { _ = stdio.ServeOn(Module, tools, inR, outW) }()
go func() {
sc := bufio.NewScanner(outR)
sc.Buffer(make([]byte, 1<<20), 1<<24)
for sc.Scan() {
var m map[string]any
if json.Unmarshal(sc.Bytes(), &m) != nil {
continue
}
id, _ := json.Marshal(m["id"])
if m["method"] == "mesh/publish" {
r.mu.Lock()
refuse := r.refuse
if !refuse {
r.published = append(r.published, m["params"].(map[string]any))
}
r.mu.Unlock()
reply := map[string]any{"id": m["id"], "result": map[string]any{}}
if refuse {
reply = map[string]any{"id": m["id"], "error": map[string]any{"code": -32000, "message": "the bus is away"}}
}
r.send(reply)
continue
}
r.mu.Lock()
ch := r.answers[string(id)]
r.mu.Unlock()
if ch != nil {
ch <- m
}
}
}()
t.Cleanup(func() { _ = inW.Close(); _ = outW.Close() })
// Answered once the bundle serves: from then on, what it emits goes through this runtime and no other.
r.mu.Lock()
ready := make(chan map[string]any, 1)
r.answers[`"init"`] = ready
r.mu.Unlock()
r.send(map[string]any{"jsonrpc": "2.0", "id": "init", "method": "initialize", "params": map[string]any{}})
select {
case <-ready:
case <-time.After(10 * time.Second):
t.Fatal("the bundle did not start")
}
return r
}
func (r *runtime) send(m map[string]any) {
raw, _ := json.Marshal(m)
r.mu.Lock()
defer r.mu.Unlock()
_, _ = r.in.Write(append(raw, '\n'))
}
// call calls a tool as the caller, answering its text and whether it was an error.
func (r *runtime) call(tool, caller string, args map[string]any) (string, bool) {
r.mu.Lock()
r.next++
id := r.next
ch := make(chan map[string]any, 1)
r.answers[fmt.Sprint(id)] = ch
r.mu.Unlock()
r.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "tools/call",
"params": map[string]any{"name": tool, "arguments": args, "_meta": map[string]any{stdio.MetaCaller: caller}}})
select {
case m := <-ch:
res := m["result"].(map[string]any)
text := res["content"].([]any)[0].(map[string]any)["text"].(string)
isErr, _ := res["isError"].(bool)
return text, isErr
case <-time.After(10 * time.Second):
r.t.Fatalf("%s did not answer", tool)
}
return "", true
}
// aBundle is the bundle over an index holding one message with an attachment, granted to the module triage alone.
func aBundle(t *testing.T) (*Bundle, *memStore, *memObjects) {
t.Helper()
dir := t.TempDir()
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir,
Status: filepath.Join(dir, "health.json")}
writeFile(t, env.Accounts, stageOne)
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`)
st, objects := newMemStore(), newMemObjects()
ctx := context.Background()
ref, err := Keep(ctx, objects, []byte(attachmentData), "application/pdf", "invoice.pdf")
if err != nil {
t.Fatal(err)
}
m := Message{Account: "mesh", Folder: "INBOX", UID: 7, UIDValidity: 1, MessageID: "att@example.org", Subject: "the invoice",
Date: judgedAt, From: []Person{{Name: "Bob", Address: "bob@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{}, Attachments: []AttachmentRef{ref}, Body: "Please find the invoice attached."}
if err := st.Add(ctx, m, addedEvent(m)); err != nil {
t.Fatal(err)
}
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent, Why: "watching", Heard: judgedAt, Said: judgedAt})
b := &Bundle{Env: env, Index: func(context.Context) (Store, error) { return st, nil },
Objects: func() (Objects, error) { return objects, nil }, Now: func() time.Time { return judgedAt }}
return b, st, objects
}
// The body tools answer a granted caller and refuse every other (ADR 0359 §4, "How it is checked"): the caller is the
// runtime's word, never an argument.
func TestTheBodyToolsAnswerAGrantedCallerAndRefuseAnyOther(t *testing.T) {
b, _, _ := aBundle(t)
rt := startBundle(t, b.Tools())
read := map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7}
text, isErr := rt.call(ReadTool, "novox.triage", read)
if isErr || !strings.Contains(text, "Please find the invoice attached.") || !strings.Contains(text, `"subject":"the invoice"`) {
t.Fatalf("granted: %v %s", isErr, text)
}
for _, caller := range []string{"novox.other", "person.someone", Unproven} {
text, isErr := rt.call(ReadTool, caller, read)
if !isErr || !strings.Contains(text, "refused") || strings.Contains(text, "Please find") {
t.Fatalf("%s: %v %s", caller, isErr, text)
}
text, isErr = rt.call(SearchTool, caller, map[string]any{"query": "invoice"})
if !isErr || !strings.Contains(text, "refused") {
t.Fatalf("%s searched: %v %s", caller, isErr, text)
}
}
// An argument cannot name the caller.
if text, isErr := rt.call(ReadTool, "novox.other", map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7, "caller": "novox.triage"}); !isErr || strings.Contains(text, "Please find") {
t.Fatalf("an argument named the caller: %s", text)
}
text, isErr = rt.call(SearchTool, "shanks.triage", map[string]any{"query": "invoice"})
if isErr || !strings.Contains(text, `"message-id":"att@example.org"`) || strings.Contains(text, "Please find") {
t.Fatalf("search: %v %s", isErr, text)
}
sum := ""
if m, _ := b.Index(context.Background()); m != nil {
got, _, _ := m.Get(context.Background(), "mesh", "INBOX", 7)
sum = got.Attachments[0].Key
}
text, isErr = rt.call(ReadTool, "novox.triage", map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7, "attachment": sum})
var att struct {
Base64 string `json:"base64"`
}
if isErr || json.Unmarshal([]byte(text), &att) != nil {
t.Fatalf("attachment: %v %s", isErr, text)
}
if data, _ := base64.StdEncoding.DecodeString(att.Base64); string(data) != attachmentData {
t.Fatalf("the attachment read is %q", data)
}
}
// mail_accounts says each account's state to anyone, and its address only to a caller granted it.
func TestTheAccountsToolListsOnlyTheAccountsTheCallerIsGranted(t *testing.T) {
b, _, _ := aBundle(t)
rt := startBundle(t, b.Tools())
_ = b.Env // the index holds a folder of the account, so an ungranted answer has something to leak
st, _ := b.Index(context.Background())
_ = st.SetFolder(context.Background(), Folder{Account: "mesh", Name: "Secret-Project", Messages: 41, Synced: judgedAt})
_ = st.SetState(context.Background(), AccountState{Account: "mesh", State: AccountFailing, Why: "the server said NO [AUTHENTICATIONFAILED]", Said: judgedAt})
text, isErr := rt.call(AccountsTool, "novox.other", map[string]any{})
if isErr || !strings.Contains(text, `"accounts":[]`) {
t.Fatalf("ungranted: %s", text)
}
for _, leak := range []string{"mesh\"", "jschoubben@", "Secret-Project", "41", "AUTHENTICATIONFAILED", "failing"} {
if strings.Contains(text, leak) {
t.Fatalf("an ungranted caller learns %q: %s", leak, text)
}
}
text, _ = rt.call(AccountsTool, "novox.triage", map[string]any{})
if !strings.Contains(text, `"address":"jschoubben@novox.be"`) || !strings.Contains(text, "Secret-Project") || !strings.Contains(text, "AUTHENTICATIONFAILED") {
t.Fatalf("granted: %s", text)
}
}
// The events the keeper wrote are said on the bus as the module, through the runtime, with their metadata and never a
// body; each is removed once the bus took it, and kept when it did not.
func TestTheEventsAreSaidOnTheBusThroughTheRuntime(t *testing.T) {
b, st, _ := aBundle(t)
rt := startBundle(t, b.Tools())
p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: t.Logf}
ctx := context.Background()
rt.mu.Lock()
rt.refuse = true
rt.mu.Unlock()
if n, err := p.Once(ctx); n != 0 || err == nil {
t.Fatalf("said %d with the bus away (%v)", n, err)
}
if left, _ := st.Pending(ctx, 10); len(left) != 1 {
t.Fatalf("an event the bus did not take was dropped: %d left", len(left))
}
rt.mu.Lock()
rt.refuse = false
rt.mu.Unlock()
if n, err := p.Once(ctx); n != 1 || err != nil {
t.Fatalf("said %d (%v)", n, err)
}
if left, _ := st.Pending(ctx, 10); len(left) != 0 {
t.Fatalf("%d event(s) left after the bus took them", len(left))
}
rt.mu.Lock()
defer rt.mu.Unlock()
if len(rt.published) != 1 {
t.Fatalf("published %d", len(rt.published))
}
pub := rt.published[0]
raw, _ := json.Marshal(pub)
if pub["key"] != "mail.mesh.message.added" || !strings.Contains(string(raw), `"subject":"the invoice"`) ||
strings.Contains(string(raw), "Please find") || strings.Contains(string(raw), "dGhlIGludm9pY2Ug") {
t.Fatalf("published %s", raw)
}
}
// A grants setting that cannot be read grants nobody, and every refusal says why (issue 733).
func TestARefusalSaysWhyTheGrantsCouldNotBeRead(t *testing.T) {
b, _, _ := aBundle(t)
writeFile(t, b.Env.Grants, `{"grants": {"mesh": "triage"}}`)
rt := startBundle(t, b.Tools())
text, isErr := rt.call(ReadTool, "novox.triage", map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7})
if !isErr || !strings.Contains(text, "the setting grants cannot be read") || !strings.Contains(text, "not the module's") || strings.Contains(text, "Please find") {
t.Fatalf("read: %v %s", isErr, text)
}
text, isErr = rt.call(SearchTool, "novox.triage", map[string]any{"query": "invoice"})
if !isErr || !strings.Contains(text, "the setting grants cannot be read") {
t.Fatalf("search: %v %s", isErr, text)
}
text, _ = rt.call(AccountsTool, "novox.triage", map[string]any{})
if !strings.Contains(text, "the setting grants cannot be read") || !strings.Contains(text, `"accounts":[]`) {
t.Fatalf("accounts: %s", text)
}
}
-19
View File
@@ -1,19 +0,0 @@
module mesh-mail
go 1.25.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
github.com/jackc/pgx/v5 v5.11.0
)
require (
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
-65
View File
@@ -1,65 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-189
View File
@@ -1,189 +0,0 @@
{
"module": "mesh-mail",
"version": "1",
"slug": "mail",
"requires": [
"postgres-database",
"s3-bucket"
],
"contributes": {
"postgres-database": {
"name": "mail"
}
},
"binds": {
"postgres-database": "${dir:state}/database.json",
"s3-bucket": "${dir:state}/store-binding.json"
},
"secrets": {
"postgres-database": "${dir:state}/database.secret",
"s3-bucket": "${dir:state}/store.secret"
},
"own-secrets": {
"mail-credential-*": {
"path": "${dir:state}/mail-credential-*.secret",
"issued-by": "outside"
}
},
"tools": [
"mail_accounts",
"mail_search",
"mail_read",
"mail_health"
],
"streams": [
{
"name": "mail",
"events": [
"mail.*.message.added",
"mail.*.message.changed",
"mail.*.message.removed"
],
"max-age-days": 7,
"grant": {
"config": "grants",
"events": "mail.{key}.**"
}
}
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "database-url",
"type": "file",
"path": "${dir:state}/database.url",
"mode": "0600",
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
},
{
"id": "store",
"type": "file",
"path": "${dir:state}/store.json",
"mode": "0600",
"content": "{\n \"scheme\": \"${bound:s3-bucket:scheme}\",\n \"at\": \"${bound:s3-bucket:at}\",\n \"port\": \"${bound:s3-bucket:port}\",\n \"bucket\": \"${bound:s3-bucket:bucket}\",\n \"region\": \"${bound:s3-bucket:region}\",\n \"key\": \"${bound:s3-bucket:as}\",\n \"secret\": \"${secret:s3-bucket}\"\n}\n"
},
{
"id": "accounts",
"trusted": true,
"type": "file",
"path": "${dir:state}/accounts.json",
"mode": "0600",
"merge": "json",
"takes-settings": [
"accounts"
],
"content": "{\n \"accounts\": {}\n}\n"
},
{
"id": "grants",
"trusted": true,
"type": "file",
"path": "${dir:state}/grants.json",
"mode": "0600",
"merge": "json",
"takes-settings": [
"grants"
],
"content": "{\n \"grants\": {}\n}\n"
},
{
"id": "prepare",
"type": "process",
"name": "mesh-mail-prepare",
"artifact": "tools",
"run": [
"./mesh-mail",
"prepare"
],
"run-once": true,
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url"
},
"restart-on": [
"database-url"
]
},
{
"id": "keep",
"type": "process",
"name": "mesh-mail-keep",
"artifact": "tools",
"run": [
"./mesh-mail",
"keep"
],
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url",
"MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json",
"MESH_MAIL_SECRETS": "${dir:state}",
"MESH_MAIL_STORE": "${dir:state}/store.json"
},
"restart-on": [
"database-url",
"store",
"accounts"
],
"health": {
"kind": "unit"
}
},
{
"id": "watch",
"type": "process",
"name": "mesh-mail-watch",
"artifact": "tools",
"run": [
"./mesh-mail",
"watch"
],
"user": "${machine:account}",
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url",
"MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json",
"MESH_MAIL_SECRETS": "${dir:state}",
"MESH_MAIL_STATUS": "${dir:state}/health.json"
},
"restart-on": [
"database-url",
"accounts"
],
"health": {
"kind": "tool",
"tool": "mail_health",
"needs": "postgres-database",
"interval": "60s",
"timeout": "10s",
"looks": 2,
"grace": "180s"
}
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-mail",
"binary": "mesh-mail",
"loads": [
"mesh-mail"
],
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url",
"MESH_MAIL_ACCOUNTS": "${dir:state}/accounts.json",
"MESH_MAIL_GRANTS": "${dir:state}/grants.json",
"MESH_MAIL_SECRETS": "${dir:state}",
"MESH_MAIL_STORE": "${dir:state}/store.json",
"MESH_MAIL_STATUS": "${dir:state}/health.json"
}
}
]
}
}
+4 -6
View File
@@ -49,9 +49,8 @@ channel's client and no channel's secret.
warrant published to the asker alone. A warrant the bus did not take is published again every minute.
- **What the operator sees when they answer** (issue 372): first, in reply to the answer where it was given,
that it was recorded and whether its asker was told ("You chose Approve: recorded, and claude-code was
told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited with no
answers left on it: its question whole (headline, summary, points, link, Details), and below it how it ended
("You chose Approve at 13:30 on Telegram.") and who acts on it (issue 714).
told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited to how
it ended, as its title ("You chose Approve at 13:30 on Telegram · <headline>"), with no answers left on it.
What the asker then did is not said: the asks protocol carries no result back to the router, so the
message says only that the asker was told and acts on it itself.
- **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would.
@@ -139,9 +138,8 @@ and said in the log). Everything else is the message's note (`asks.Message.Note`
smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered;
and the asker's Details (who proposed it, a fingerprint, how to read it whole) — on a kind that proves who answers
and was given the ask's answers, its Details whole (`asks.Ask.WholeDetails`): every value an approval sets, held to
the secret rule alone, as the whole words are. Answered, the message keeps its headline, body and Details, so the
operator can read back what they answered (issue 714); its note becomes "You chose Approve at 16:06 on Telegram."
with the router's word on the asker.
the secret rule alone, as the whole words are. Answered, the message is one line —
"You chose Approve at 16:06 on Telegram · <headline>" — and the router's word on the asker is its note.
**An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation
twice: as it is held to the whole content rule, and whole (`asks.Ask.Whole`), its paths and addresses as they
@@ -365,12 +365,10 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
for _, m := range ch.edits {
edited = m
}
// The question stays whole (issue 714); below it, how it ended and what the router knows of the asker
// (issue 372).
if len(edited.Actions) != 0 || edited.Title != "Delivery of hq held for 36 hours" ||
edited.Body != heldAsk(c, "d-1").Explanation ||
edited.Note != "You chose Release at 12:00 on Telegram.\nTold to mesh-delivery, which acts on it itself." {
t.Errorf("%s still offers %q, or does not keep the question and say how it ended (issue 714): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note)
// The question's title is how it ended, read at a glance; then what the router knows of the asker (issue 372).
if len(edited.Actions) != 0 || edited.Title != "You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours" ||
edited.Body != "" || edited.Note != "Told to mesh-delivery, which acts on it itself." {
t.Errorf("%s still offers %q, or does not say how it ended in one line (issue 639): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note)
}
}
// The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question
@@ -626,8 +624,8 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) {
for _, m := range tg.edits {
edited = m
}
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "Delivery of hq") ||
edited.Body == "" || edited.Note != "No longer answerable: nobody answered in time.\nThe delivery stays held." {
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "No longer answerable: nobody answered in time · Delivery of hq") ||
edited.Body != "" || edited.Note != "The delivery stays held." {
t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited)
}
}
@@ -51,7 +51,7 @@ func TestAnApprovalIsConfirmedWhereItWasGivenAndThenTheQuestionSaysHowItEnded(t
}
want := []string{
"send You chose Release: recorded, and mesh-delivery was told.",
"edit Delivery of hq held for 36 hours",
"edit You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours",
}
if strings.Join(log, "\n") != strings.Join(want, "\n") {
t.Fatalf("the operator saw, in order:\n%s\nwant:\n%s", strings.Join(log, "\n"), strings.Join(want, "\n"))
@@ -122,7 +122,7 @@ func TestTheConfirmationNeverClaimsWhatDidNotHappen(t *testing.T) {
for _, m := range tg.edits {
edited = m
}
if !strings.Contains(edited.Note, "\nTold to mesh-delivery as soon as the bus takes it") {
if !strings.HasPrefix(edited.Note, "Told to mesh-delivery as soon as the bus takes it") {
t.Errorf("the question claims its asker was told: %q", edited.Note)
}
})
@@ -9,8 +9,8 @@ import (
)
// novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points
// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note. Once answered
// it keeps the question whole, so the operator can read back what they answered (issue 714).
// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note; once answered
// it is one line with the headline.
// decisionAsk is the ask for ADR 0343 as the knowledge base composes it now (its glance form).
func decisionAsk(c *clock) asks.Ask {
@@ -59,8 +59,7 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
if n := len(strings.Split(m.Text(), "\n")); n > 9 || len(m.Actions) != 2 {
t.Errorf("the message is %d lines offering %s:\n%s", n, labelsOf(m), m.Text())
}
// Answered, it keeps its headline, summary, points and Details; below them, how it ended and who acts on it;
// its buttons are gone (issue 714: the one line issue 639 made left nothing to read back).
// Answered, it is one line with the headline; the body is not said again.
if out, err := h.Chosen("telegram", choiceOf(ticketFor(m, "Approve"), "42", c.now())); err != nil || out != "chosen" {
t.Fatalf("Approve: %q %v", out, err)
}
@@ -68,9 +67,8 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
for _, e := range tg.edits {
edited = e
}
if edited.Title != a.Headline || edited.Body != a.Explanation+"\n"+want[2]+"\n"+want[3] ||
edited.Note != "You chose Approve at "+asked+" on Telegram.\nTold to mesh-knowledge, which acts on it itself." ||
len(edited.Actions) != 0 {
if edited.Title != "You chose Approve at "+asked+" on Telegram · "+a.Headline || edited.Body != "" ||
strings.Contains(edited.Note, "What an agent learns") || len(edited.Actions) != 0 {
t.Errorf("the answered copy: %q\n%q\n%q", edited.Title, edited.Body, edited.Note)
}
}
+10 -43
View File
@@ -856,7 +856,7 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
}
}
if r.Answered != "" {
m = h.answeredWords(r, m, kind)
m = h.answeredWords(r, m)
}
if kind == "" {
return m
@@ -910,24 +910,18 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
return m
}
// answeredWords is a message that says what came of its question. An ask's own message keeps the question
// whole — its headline as the title, the asker's summary, points and link as the body, its Details after them —
// so the operator can read back what they answered (issue 714, which undid issue 639's one line). How it ended
// ("You chose Approve at 13:30 on Telegram.") and what the router knows of the asker after it are the note,
// below the question, and its answers are gone (issue 372). A channel that must shorten it cuts the body from
// its end, the Details first; never the headline or the note. A condition's message keeps its own title, and
// says how it ended first.
func (h *Holder) answeredWords(r *Record, m Message, kind string) Message {
// answeredWords is a message that says what came of its question. An ask's own message is the question
// itself, so it becomes how it ended and what it was about — "You chose Approve at 13:30 on Telegram ·
// <headline>" — one line, read at a glance on the phone, where the edit rings nothing (issue 372); the body
// is not said again (issue 639), and what the router knows of the asker after it is the note. A condition's
// message keeps its own title, and says it first.
func (h *Holder) answeredWords(r *Record, m Message) Message {
if r.Origin == "ask" {
first, rest, _ := strings.Cut(r.Answered, "\n")
headline, _ := wordsOf(r)
body := []string{}
if b := strings.TrimSpace(m.Body); b != "" {
body = append(body, b)
}
body = append(body, h.answeredDetails(r, kind)...)
out := Message{Title: headline, Body: strings.Join(body, "\n"), Note: strings.TrimSpace(r.Answered),
out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Note: strings.TrimSpace(rest),
Urgent: m.Urgent, Quiet: m.Quiet}
if _, ok := Check(out.Title + "\n" + out.Note); ok {
if _, ok := Check(out.Text()); ok {
return out
}
// Its headline refused (it is the asker's): the outcome is still said, before what may leave.
@@ -936,33 +930,6 @@ func (h *Holder) answeredWords(r *Record, m Message, kind string) Message {
return m
}
// answeredDetails are an answered ask's Details, line by line, as its open message noted them (issue 714): whole
// on a kind that proves who answers, a line that may not leave withheld and said so.
func (h *Holder) answeredDetails(r *Record, kind string) []string {
if r.Ask == "" {
return nil
}
e := h.askOf(r.Ask)
if e == nil {
return nil
}
details, passes := e.rec.Ask.Details, Check
if wd := h.wholeDetailsOf(r, kind); wd != "" {
details, passes = wd, CheckSecret
}
var lines []string
for _, line := range strings.Split(strings.TrimSpace(details), "\n") {
if line = strings.TrimSpace(line); line == "" {
continue
}
if refusal, ok := passes(line); !ok {
line = withheldLine(line, refusal, passes)
}
lines = append(lines, line)
}
return lines
}
// wholeWords is a record's message with the ask's whole words in place of its explanation, when they carry no
// secret's shape once composed; else false, and the message under the whole content rule stands.
func (h *Holder) wholeWords(r *Record, what, whole string) (Message, bool) {
+1 -1
View File
@@ -91,7 +91,7 @@ func (h *Holder) Preview(key, what string) (map[string]any, error) {
asking := h.openAsk(&r) != nil // askOf takes the same lock, so this is asked outside it
m, line, refusal, ownWords := h.allowedWords(&r, what, now)
if r.Answered != "" {
m = h.answeredWords(&r, m, "")
m = h.answeredWords(&r, m)
}
out := map[string]any{
"key": key,
@@ -50,7 +50,7 @@ func TestAReplacedAsksMessageEndsWithIt(t *testing.T) {
m, edited = r.m, true
}
}
if !edited || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") {
if !edited || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m))
}
}
@@ -86,7 +86,7 @@ func TestAnAsksMessageLeftOpenAfterItsAskEndedIsEndedOnTheNextTick(t *testing.T)
if got := openAskRecords(h); len(got) != 0 {
t.Fatalf("the message of an ended ask is still open: %v", got)
}
if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") {
if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
t.Errorf("the leftover's copy was not ended: %v %q %v", ok, m.Text(), labelsOf(m))
}
}
@@ -61,7 +61,8 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes
if got := w.of("mesh-controller"); len(got) != 0 || store.asks["mesh-controller.s-1"].State != StateOpen {
t.Errorf("the ask was answered: %+v / %s", got, store.asks["mesh-controller.s-1"].State)
}
// Approved, the message keeps its headline and words, and says below them how it ended (issue 714).
// Approved, the message is edited to one line that says so, with the headline; the body is not said again
// (issue 639).
if out, err := h.Chosen("telegram", choiceOf(ticketFor(phone, "Release"), "42", c.now())); err != nil || out != "chosen" {
t.Fatalf("Release: %q %v", out, err)
}
@@ -69,8 +70,8 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes
if !ok {
t.Fatalf("the phone's message was not edited: %+v", tg.edits)
}
if edited.Title != "Set mounts on shanks?" || edited.Body == "" ||
!strings.HasPrefix(edited.Note, "You chose Release at ") || len(edited.Actions) != 0 {
if !strings.HasPrefix(edited.Title, "You chose Release at ") || !strings.HasSuffix(edited.Title, " · Set mounts on shanks?") ||
edited.Body != "" || len(edited.Actions) != 0 {
t.Errorf("the edited message: %q\n%s\nactions %+v", edited.Title, edited.Body, edited.Actions)
}
}
+15 -3
View File
@@ -1,6 +1,8 @@
// nats-tools: the mesh bus's own tools (novox/hq design 25), a Go bundle the node's runtime launches on the
// machine that runs the bus. Everything here reads — the server's monitoring API and the user list the
// controller composed — and nothing changes the bus. stdout is the MCP channel; this says nothing else.
// controller composed — and nothing changes the bus. Beside the tools runs the watch (watch.go, novox/hq
// ADR 0357), which says on the bus every user's slow consumer the server closed. stdout is the MCP
// channel; this says nothing else.
package main
import (
@@ -29,7 +31,7 @@ func limitOf(a map[string]any, fallback int) int {
return fallback
}
func tools(monitor Monitor, users func(context.Context) ([]byte, error), written func(context.Context) (time.Time, error)) []stdio.Tool {
func tools(monitor Monitor, users func(context.Context) ([]byte, error), written func(context.Context) (time.Time, error), watch *Watch) []stdio.Tool {
get := func(path string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
@@ -112,6 +114,9 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
}
return Buckets(doc)
}},
{Name: "nats_bus_watch",
Description: "The watch that says every bus user's slow consumer and every connection the bus closed for breaking its rules, as the events bus.slow-consumer and bus.connection-closed the controller raises as S9 (novox/hq ADR 0357): when it last read the server's record of closed connections, what it said, the newest closures said, the reasons it leaves to D15 and any it could not publish.",
Run: func(map[string]any) (any, error) { return watch.Report(), nil }},
{Name: "nats_users",
Description: "The bus's users as the controller composed them — never a password hash. Without a user, each with how many grants it holds; with part of a user's name, its publish and subscribe grants in full.",
Input: map[string]any{"user": str("part of a user name, e.g. g14.node-tools or controller")},
@@ -144,7 +149,14 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
}
func main() {
if err := stdio.Serve("", tools(LiveMonitor(), ReadUsers, UsersWritten)); err != nil {
monitor := LiveMonitor()
// The watch publishes through the runtime that launched this bundle, under the module's name: the
// events the manifest declares, and nothing else (novox/hq ADR 0357).
watch := NewWatch(monitor, func(event string, body any) error { return stdio.Emit(event, body) })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go watch.Run(ctx)
if err := stdio.Serve("", tools(monitor, ReadUsers, UsersWritten, watch)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
+433
View File
@@ -0,0 +1,433 @@
package main
// The watch: every user's slow consumer and every connection the bus closed for breaking its rules,
// said on the bus (novox/hq ADR 0357, issue 270).
//
// **The server says these of other users only in its record of closed connections.** It closes every
// client it judges a slow consumer, and keeps the last 10,000 closed connections with the user and the
// reason (`/connz?state=closed&auth=1`) — about two days of the mesh's traffic. It publishes neither in
// the mesh's account; a system account would hear them, and the bus has none. So the bundle that already
// reads the record for `nats_closed_connections` looks at it every WatchEvery and publishes each closure
// that matters as an event the controller raises as S9.
//
// **Said at least once, counted once.** A closure is its user, its connection id and when it opened —
// the id alone is reused after the server restarts. Each one said is remembered while this process runs;
// one whose publish failed is tried again at the next look. On start the last hour (S9's window) is said
// again, so a restart repeats closures rather than losing them; the controller counts each by its
// identity, so a repeat changes no count.
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"sync"
"time"
)
const (
// WatchEvery is how often the watch reads the record of closed connections. With the controller's
// watchdogs ticking every 30 s too, a closure is a condition about a minute after the server closed it.
WatchEvery = 30 * time.Second
// WatchSaysAgain is how far back the first look says closures again: S9's window, an hour.
WatchSaysAgain = time.Hour
// EventSlowConsumer and EventConnectionClosed are the events the watch publishes (module.json `emits`).
EventSlowConsumer = "bus.slow-consumer"
EventConnectionClosed = "bus.connection-closed"
// EventWatch is the watch's heartbeat, after every look: whether it is healthy and why not. The controller
// raises a watch that stops saying it, or says it is not healthy (novox/hq ADR 0357, review 274): a dead
// watch must never read as no slow consumers.
EventWatch = "bus.watch"
)
// notSaid are the reasons a closure is routine: the client's own choice, the network or a machine asleep
// (D15 says a user dropped in a loop), the server's own shutdown, and a refused login, which names no user.
// Every other reason is said — one the module does not know included — because a new way for the bus to
// throw a client out is exactly what must not pass in silence.
var notSaid = map[string]bool{
ClientClosed: true,
"Stale Connection": true,
"Read Error": true,
"Write Error": true,
"TLS Handshake Failure": true,
"Authentication Timeout": true,
"Authentication Failure": true,
"Server Shutdown": true,
}
// isSlowConsumer says a closure's reason is the server judging the client a slow consumer: "Slow Consumer
// (Pending Bytes)" or "Slow Consumer (Write Deadline)".
func isSlowConsumer(reason string) bool { return strings.HasPrefix(reason, "Slow Consumer") }
// Closure is one closed connection as the watch says it. The user and the connection's own name, never a
// credential: the record the server keeps names who logged in, and nothing they logged in with.
type Closure struct {
User string `json:"user"`
Name string `json:"name,omitempty"`
CID uint64 `json:"cid"`
Reason string `json:"reason"`
Opened time.Time `json:"opened"`
Closed time.Time `json:"closed"`
// Lived is how long the connection was open, in seconds.
Lived float64 `json:"lived_seconds"`
// Pending is what the server still had to write to it when it closed, in bytes, where it says.
Pending int64 `json:"pending_bytes,omitempty"`
}
// identity is what tells one closure from another across a server restart.
func (c Closure) identity() string {
return fmt.Sprintf("%s/%d/%s", c.User, c.CID, c.Opened.UTC().Format(time.RFC3339Nano))
}
// event is the event a closure is said as.
func (c Closure) event() string {
if isSlowConsumer(c.Reason) {
return EventSlowConsumer
}
return EventConnectionClosed
}
// Closures are the closed connections in the record that are to be said, closed at or after since, oldest
// first.
func Closures(connz []byte, since time.Time) ([]Closure, error) {
var doc struct {
Connections []struct {
CID uint64 `json:"cid"`
Name string `json:"name"`
AuthorizedUser string `json:"authorized_user"`
Start time.Time `json:"start"`
Stop *time.Time `json:"stop"`
Reason string `json:"reason"`
Pending int64 `json:"pending_bytes"`
} `json:"connections"`
}
if err := json.Unmarshal(connz, &doc); err != nil {
return nil, fmt.Errorf("the bus's record of closed connections cannot be read: %w", err)
}
var out []Closure
for _, c := range doc.Connections {
if c.Stop == nil || c.Stop.Before(since) || c.AuthorizedUser == "" || notSaid[c.Reason] {
continue
}
reason := c.Reason
if reason == "" {
reason = "unsaid"
}
out = append(out, Closure{User: c.AuthorizedUser, Name: c.Name, CID: c.CID, Reason: reason,
Opened: c.Start.UTC(), Closed: c.Stop.UTC(), Lived: c.Stop.Sub(c.Start).Seconds(), Pending: c.Pending})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Closed.Before(out[j].Closed) })
return out, nil
}
// Watch is the watch's state, shared with the tools that report it.
type Watch struct {
monitor Monitor
emit func(string, any) error
now func() time.Time
started time.Time
mu sync.Mutex
// said is every closure said, by identity, with when it closed: forgotten once older than the hour, since
// the record is never read that far back again.
said map[string]time.Time
// failed are the closures whose publish failed, by identity, tried again at every look until said or
// older than the hour.
failed map[string]failedClosure
// lastLook is when the watch last looked; lastRead when a look last read the record whole.
lastLook, lastRead time.Time
// failingSince is when the reads began to fail, zero while they succeed.
failingSince time.Time
lastErr string
saidN map[string]int
newest []Closure
// faults are the looks that panicked and were recovered: the loop goes on, and each is said.
faults int
lastFault string
}
type failedClosure struct {
closure Closure
err string
since time.Time
}
// NewWatch is a watch that has not looked yet.
func NewWatch(monitor Monitor, emit func(string, any) error) *Watch {
return &Watch{monitor: monitor, emit: emit, now: time.Now, started: time.Now(), said: map[string]time.Time{},
saidN: map[string]int{}, failed: map[string]failedClosure{}}
}
// readPage is how many closed connections one request asks for, newest first.
const readPage = 256
// readSince reads the record newest first (`sort=stop`), a page at a time, until a page reaches back past since
// or the record ends: a look reads only what closed since the last one, not all 10,000 kept.
func (w *Watch) readSince(ctx context.Context, since time.Time) ([]Closure, error) {
var out []Closure
for offset := 0; offset < maxClosedKept; offset += readPage {
page, err := w.monitor(ctx, fmt.Sprintf("/connz?state=closed&auth=1&sort=stop&limit=%d&offset=%d", readPage, offset))
if err != nil {
return nil, err
}
closures, oldest, n, err := closuresOfPage(page, since)
if err != nil {
return nil, err
}
out = append(out, closures...)
if n < readPage || (!oldest.IsZero() && oldest.Before(since)) {
break
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Closed.Before(out[j].Closed) })
return out, nil
}
// closuresOfPage is one page's closures to say, its oldest stop and how many connections it held.
func closuresOfPage(page []byte, since time.Time) ([]Closure, time.Time, int, error) {
var doc struct {
Connections []struct {
Stop *time.Time `json:"stop"`
} `json:"connections"`
}
if err := json.Unmarshal(page, &doc); err != nil {
return nil, time.Time{}, 0, fmt.Errorf("the bus's record of closed connections cannot be read: %w", err)
}
var oldest time.Time
for _, c := range doc.Connections {
if c.Stop != nil && (oldest.IsZero() || c.Stop.Before(oldest)) {
oldest = *c.Stop
}
}
closures, err := Closures(page, since)
return closures, oldest, len(doc.Connections), err
}
// Look reads what closed since the last look and says every closure not said yet, and tries again every
// closure whose publish failed. A fault in a look is recovered and said: the watch goes on.
func (w *Watch) Look(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
w.mu.Lock()
w.faults++
w.lastFault = fmt.Sprintf("a look stopped on a fault at %s: %v", w.now().UTC().Format(time.RFC3339), r)
w.mu.Unlock()
fmt.Fprintln(os.Stderr, w.lastFault)
}
}()
now := w.now()
hour := now.Add(-WatchSaysAgain)
w.mu.Lock()
// The first look says the last hour again; a later one reads from one interval before the last whole
// read, so a closure the server recorded late is not missed — the said set tells what was said.
since := hour
if !w.lastRead.IsZero() && w.lastRead.Add(-WatchEvery).After(hour) {
since = w.lastRead.Add(-WatchEvery)
}
w.mu.Unlock()
read, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
closures, err := w.readSince(read, since)
w.mu.Lock()
w.lastLook = now
for id, closed := range w.said {
if closed.Before(hour) {
delete(w.said, id)
}
}
for id, f := range w.failed {
if f.closure.Closed.Before(hour) {
delete(w.failed, id) // S9 would no longer say it
}
}
if err != nil {
w.lastErr = err.Error()
if w.failingSince.IsZero() {
w.failingSince = now
}
fmt.Fprintf(os.Stderr, "the watch could not read the bus's closed connections: %v\n", err)
} else {
w.lastErr, w.failingSince, w.lastRead = "", time.Time{}, now
}
todo := map[string]Closure{}
for _, c := range closures {
if _, done := w.said[c.identity()]; !done {
todo[c.identity()] = c
}
}
for id, f := range w.failed {
if _, done := w.said[id]; !done {
todo[id] = f.closure
}
}
w.mu.Unlock()
order := make([]Closure, 0, len(todo))
for _, c := range todo {
order = append(order, c)
}
sort.SliceStable(order, func(i, j int) bool { return order[i].Closed.Before(order[j].Closed) })
for _, c := range order {
if w.emit == nil {
continue
}
err := w.emit(c.event(), c)
w.mu.Lock()
if err != nil {
// Tried again at the next look: it stays out of the said set.
f := w.failed[c.identity()]
if f.since.IsZero() {
f.since = now
}
f.closure, f.err = c, err.Error()
w.failed[c.identity()] = f
w.mu.Unlock()
fmt.Fprintf(os.Stderr, "%s for %s not published, tried again at the next look: %v\n", c.event(), c.User, err)
continue
}
delete(w.failed, c.identity())
w.said[c.identity()] = c.Closed
w.saidN[c.event()]++
w.newest = append(w.newest, c)
if len(w.newest) > 10 {
w.newest = w.newest[len(w.newest)-10:]
}
w.mu.Unlock()
}
w.beat()
}
// Beat is the watch's heartbeat as it publishes it.
type Beat struct {
At time.Time `json:"at"`
Healthy bool `json:"healthy"`
Why string `json:"why,omitempty"`
LastRead *time.Time `json:"last_read,omitempty"`
Every string `json:"every"`
}
// beat says the watch is there, and whether it is healthy. One that cannot be published is the controller's to
// notice by its absence.
func (w *Watch) beat() {
if w.emit == nil {
return
}
w.mu.Lock()
healthy, why := w.healthLocked()
b := Beat{At: w.now().UTC(), Healthy: healthy, Why: why, Every: WatchEvery.String()}
if !w.lastRead.IsZero() {
at := w.lastRead.UTC()
b.LastRead = &at
}
w.mu.Unlock()
if err := w.emit(EventWatch, b); err != nil {
fmt.Fprintf(os.Stderr, "the watch's heartbeat not published: %v\n", err)
}
}
// Run is the watch's life: a look every WatchEvery until the context ends. A look that faults is recovered in
// Look, so the loop never ends but with the context.
func (w *Watch) Run(ctx context.Context) {
// The first look one interval in, not at once: the runtime that publishes for this bundle answers only
// once the bundle serves, and the first look reads the last hour back anyway.
t := time.NewTicker(WatchEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
w.Look(ctx)
}
}
// WatchStale is how long the watch may go without reading the record whole, or keep a closure unpublished,
// before it is not healthy: three looks.
const WatchStale = 3 * WatchEvery
// Health is the watch's own verdict: healthy while it has read the record whole within WatchStale (after the
// same grace from its start) and holds no closure unpublished for longer. Said in every heartbeat, so the
// controller raises a watch that cannot read (novox/hq ADR 0357, review 274).
func (w *Watch) Health() (bool, string) {
w.mu.Lock()
defer w.mu.Unlock()
return w.healthLocked()
}
func (w *Watch) healthLocked() (bool, string) {
now := w.now()
switch {
case w.lastRead.IsZero() && now.Sub(w.started) > WatchStale:
why := fmt.Sprintf("the watch of slow consumers has not read the bus's closed connections since it started %s ago",
now.Sub(w.started).Round(time.Second))
if w.lastErr != "" {
why += ": " + w.lastErr
}
return false, why
case !w.lastRead.IsZero() && now.Sub(w.lastRead) > WatchStale:
why := fmt.Sprintf("the watch of slow consumers last read the bus's closed connections %s ago",
now.Sub(w.lastRead).Round(time.Second))
if w.lastErr != "" {
why += ": " + w.lastErr
}
return false, why
}
for _, f := range w.failed {
if now.Sub(f.since) > WatchStale {
return false, fmt.Sprintf("the watch could not publish %s for %s for %s: %s", f.closure.event(),
f.closure.User, now.Sub(f.since).Round(time.Second), f.err)
}
}
return true, ""
}
// Report is the watch as the tool `nats_bus_watch` shows it.
func (w *Watch) Report() map[string]any {
w.mu.Lock()
defer w.mu.Unlock()
healthy, why := w.healthLocked()
out := map[string]any{
"healthy": healthy,
"every": WatchEvery.String(),
"reads_back": WatchSaysAgain.String(),
"events": []string{EventSlowConsumer, EventConnectionClosed},
"said": w.saidN,
"remembered": len(w.said),
"newest_said": w.newest,
"not_said_reasons": func() []string {
var r []string
for k := range notSaid {
r = append(r, k)
}
sort.Strings(r)
return r
}(),
}
if why != "" {
out["why"] = why
}
if w.faults > 0 {
out["faults_recovered"], out["last_fault"] = w.faults, w.lastFault
}
if !w.lastRead.IsZero() {
out["last_read"] = w.lastRead.UTC()
}
if !w.lastLook.IsZero() {
out["last_look"] = w.lastLook.UTC()
}
if w.lastErr != "" {
out["last_error"] = w.lastErr
}
if len(w.failed) > 0 {
failed := map[string]string{}
for id, f := range w.failed {
failed[id] = f.err
}
out["not_published"] = failed
}
return out
}
+336
View File
@@ -0,0 +1,336 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"sort"
"strings"
"sync"
"testing"
"time"
)
// The watch (novox/hq ADR 0357, issue 270): every user's slow consumer and every connection the bus closed
// for breaking its rules, said once as an event the controller raises as S9.
var watchNow = time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
// closedRecord is a record of closed connections as the server serves it, with a password-shaped field the
// server never serves, to prove nothing but the user is carried.
const closedRecord = `{"connections":[
{"cid":10,"name":"g14.claude-code","authorized_user":"g14.claude-code","start":"2026-10-14T11:40:00Z","stop":"2026-10-14T11:58:00Z","reason":"Slow Consumer (Write Deadline)","pending_bytes":334000},
{"cid":11,"name":"mesh-controller verb node","authorized_user":"controller","start":"2026-10-14T11:58:00Z","stop":"2026-10-14T11:58:01Z","reason":"Client Closed"},
{"cid":12,"name":"mesh-host/g14","authorized_user":"node.g14","start":"2026-10-14T10:00:00Z","stop":"2026-10-14T11:59:00Z","reason":"Stale Connection"},
{"cid":13,"name":"g14.node-tools","authorized_user":"g14.node-tools","start":"2026-10-14T10:00:00Z","stop":"2026-10-14T11:59:10Z","reason":"Read Error"},
{"cid":14,"name":"shanks.desk","authorized_user":"shanks.desk","start":"2026-10-14T11:00:00Z","stop":"2026-10-14T11:59:20Z","reason":"Maximum Message Payload Exceeded"},
{"cid":15,"name":"","authorized_user":"","start":"2026-10-14T11:59:00Z","stop":"2026-10-14T11:59:30Z","reason":"Authentication Failure"},
{"cid":16,"name":"ace.media","authorized_user":"ace.media","start":"2026-10-14T09:00:00Z","stop":"2026-10-14T10:30:00Z","reason":"Slow Consumer (Pending Bytes)"},
{"cid":17,"name":"ace.future","authorized_user":"ace.future","start":"2026-10-14T11:00:00Z","stop":"2026-10-14T11:59:40Z","reason":"A Reason Not Yet Invented","password":"never-carried"},
{"cid":18,"name":"ace.route-proxy","authorized_user":"ace.route-proxy","start":"2026-10-14T11:00:00Z","stop":"2026-10-14T11:59:50Z","reason":"Server Shutdown"}
]}`
type published struct {
event string
body Closure
}
type recorder struct {
mu sync.Mutex
got []published
beats []Beat
fail error
}
func (r *recorder) emit(event string, body any) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.fail != nil {
return r.fail
}
raw, _ := json.Marshal(body)
if event == EventWatch {
var b Beat
_ = json.Unmarshal(raw, &b)
r.beats = append(r.beats, b)
return nil
}
if strings.Contains(string(raw), "never-carried") || strings.Contains(strings.ToLower(string(raw)), "password") {
panic("a credential was published: " + string(raw))
}
var c Closure
_ = json.Unmarshal(raw, &c)
r.got = append(r.got, published{event, c})
return nil
}
func (r *recorder) said() []string {
r.mu.Lock()
defer r.mu.Unlock()
var out []string
for _, p := range r.got {
out = append(out, p.event+" "+p.body.User)
}
sort.Strings(out)
return out
}
func recordOf(doc *string) Monitor {
return func(_ context.Context, path string) ([]byte, error) {
if !strings.HasPrefix(path, "/connz?state=closed&auth=1") {
return nil, errors.New("the watch asked " + path)
}
return []byte(*doc), nil
}
}
func watchAt(doc *string, r *recorder) *Watch {
w := NewWatch(recordOf(doc), r.emit)
w.now = func() time.Time { return watchNow }
return w
}
func TestTheWatchSaysEachClosureTheServerDroppedNamingItsUserAndNothingRoutine(t *testing.T) {
doc := closedRecord
r := &recorder{}
watchAt(&doc, r).Look(context.Background())
want := []string{
"bus.connection-closed ace.future", // a reason the module does not know is said
"bus.connection-closed shanks.desk", // a limit broken
"bus.slow-consumer g14.claude-code", // the slow consumer research 036 could not name
}
if got := r.said(); strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("said %v, want %v (none for the client's own close, the network, a refused login, the "+
"server's shutdown, or a closure older than the hour)", got, want)
}
for _, p := range r.got {
if p.body.User == "g14.claude-code" {
if p.body.CID != 10 || p.body.Reason != "Slow Consumer (Write Deadline)" || p.body.Pending != 334000 ||
!p.body.Closed.Equal(time.Date(2026, 10, 14, 11, 58, 0, 0, time.UTC)) || p.body.Lived != 18*60 {
t.Fatalf("the slow consumer said as %+v", p.body)
}
}
}
}
func TestTheWatchSaysAClosureOnceWhileItRuns(t *testing.T) {
doc := closedRecord
r := &recorder{}
w := watchAt(&doc, r)
w.Look(context.Background())
w.Look(context.Background())
if got := len(r.said()); got != 3 {
t.Fatalf("said %d events over two looks at the same record, want 3: %v", got, r.said())
}
}
func TestAClosureWhosePublishFailedIsTriedAgainAtTheNextLook(t *testing.T) {
doc := closedRecord
r := &recorder{fail: errors.New("the runtime did not answer")}
w := watchAt(&doc, r)
w.Look(context.Background())
if len(r.said()) != 0 {
t.Fatal("a failed publish was counted as said")
}
if report := w.Report(); len(report["not_published"].(map[string]string)) != 3 {
t.Fatalf("the watch does not say what it could not publish: %v", report)
}
r.fail = nil
w.Look(context.Background())
if got := len(r.said()); got != 3 {
t.Fatalf("said %d after the runtime came back, want the 3 that failed", got)
}
if _, left := w.Report()["not_published"]; left {
t.Fatal("a closure published at last is still said not published")
}
}
func TestAWatchStartedAgainSaysTheLastHourAgain(t *testing.T) {
doc := closedRecord
first := &recorder{}
watchAt(&doc, first).Look(context.Background())
again := &recorder{}
watchAt(&doc, again).Look(context.Background())
if strings.Join(first.said(), "|") != strings.Join(again.said(), "|") || len(again.said()) != 3 {
t.Fatalf("a restarted watch said %v, the first %v: the last hour must be said again, for the controller "+
"to count once", again.said(), first.said())
}
}
func TestAReusedConnectionIDWithANewOpeningIsANewClosure(t *testing.T) {
doc := `{"connections":[{"cid":10,"authorized_user":"g14.claude-code","start":"2026-10-14T11:40:00Z","stop":"2026-10-14T11:50:00Z","reason":"Slow Consumer (Pending Bytes)"}]}`
r := &recorder{}
w := watchAt(&doc, r)
w.Look(context.Background())
// The server restarted and numbered connections from one again.
doc = `{"connections":[
{"cid":10,"authorized_user":"g14.claude-code","start":"2026-10-14T11:40:00Z","stop":"2026-10-14T11:50:00Z","reason":"Slow Consumer (Pending Bytes)"},
{"cid":10,"authorized_user":"g14.claude-code","start":"2026-10-14T12:00:05Z","stop":"2026-10-14T12:00:20Z","reason":"Slow Consumer (Pending Bytes)"}]}`
w.now = func() time.Time { return watchNow.Add(WatchEvery) }
w.Look(context.Background())
if got := len(r.said()); got != 2 {
t.Fatalf("said %d, want 2: the same connection id opened again is another closure", got)
}
}
func TestTheWatchSaysItCannotReadTheRecord(t *testing.T) {
r := &recorder{}
w := NewWatch(func(context.Context, string) ([]byte, error) { return nil, errors.New("connection refused") }, r.emit)
w.Look(context.Background())
if report := w.Report(); !strings.Contains(report["last_error"].(string), "connection refused") {
t.Fatalf("a record that could not be read is not said: %v", report)
}
}
// **A dead watch is heard** (review 274): every look ends with a heartbeat saying whether the watch is
// healthy; a record it cannot read for longer than three looks is said not healthy, with why, rather than read
// as no slow consumers; and it recovers with a read.
func TestAWatchThatCannotReadForThreeLooksSaysSoInItsHeartbeat(t *testing.T) {
broken := true
doc := closedRecord
clock := watchNow
r := &recorder{}
w := NewWatch(func(ctx context.Context, path string) ([]byte, error) {
if broken {
return nil, errors.New("connection refused")
}
return recordOf(&doc)(ctx, path)
}, r.emit)
w.now, w.started = func() time.Time { return clock }, clock
w.Look(context.Background())
if len(r.beats) != 1 || !r.beats[0].Healthy {
t.Fatalf("a watch that just started beat %+v", r.beats)
}
clock = clock.Add(WatchStale + time.Second)
w.Look(context.Background())
last := r.beats[len(r.beats)-1]
if last.Healthy || !strings.Contains(last.Why, "connection refused") || !last.At.Equal(clock) {
t.Fatalf("a watch that has not read for %s beat %+v", WatchStale, last)
}
broken = false
w.Look(context.Background())
if last := r.beats[len(r.beats)-1]; !last.Healthy || last.LastRead == nil {
t.Fatalf("a watch that reads again beat %+v", last)
}
}
// A closure that cannot be published for longer than three looks is not healthy either.
func TestAClosureUnpublishedForThreeLooksIsNotHealthy(t *testing.T) {
doc := closedRecord
r := &recorder{fail: errors.New("the runtime did not answer")}
clock := watchNow
w := watchAt(&doc, r)
w.now, w.started = func() time.Time { return clock }, clock
w.Look(context.Background())
clock = clock.Add(WatchStale + time.Second)
w.Look(context.Background())
if ok, why := w.Health(); ok || !strings.Contains(why, "could not publish") {
t.Fatalf("closures unpublished for %s read healthy %v: %q", WatchStale, ok, why)
}
}
// **A look that panics does not end the watch** (review 274): it is recovered and said, and the next look runs.
func TestALookThatPanicsIsRecoveredAndTheWatchGoesOn(t *testing.T) {
calls := 0
doc := closedRecord
r := &recorder{}
w := NewWatch(func(ctx context.Context, path string) ([]byte, error) {
calls++
if calls == 1 {
panic("a nil map somewhere")
}
return recordOf(&doc)(ctx, path)
}, r.emit)
w.now = func() time.Time { return watchNow }
w.Look(context.Background())
w.Look(context.Background())
if len(r.said()) != 3 {
t.Fatalf("after a look that panicked the next said %v", r.said())
}
if report := w.Report(); report["faults_recovered"] != 1 {
t.Fatalf("the recovered fault is not said: %v", report)
}
}
// **The record is read newest first, and only back to the last look** (review 274), never all 10,000 kept.
func TestALaterLookReadsOnlyWhatClosedSinceTheLastOne(t *testing.T) {
var asked []string
page := func(n int, newest time.Time) []byte {
var conns []string
for i := range n {
stop := newest.Add(-time.Duration(i) * time.Second)
conns = append(conns, fmt.Sprintf(`{"cid":%d,"authorized_user":"u","start":"%s","stop":"%s","reason":"Client Closed"}`,
1000-i, stop.Add(-time.Minute).Format(time.RFC3339), stop.Format(time.RFC3339)))
}
return []byte(`{"connections":[` + strings.Join(conns, ",") + `]}`)
}
clock := watchNow
w := NewWatch(func(_ context.Context, path string) ([]byte, error) {
asked = append(asked, path)
if !strings.Contains(path, "sort=stop") {
return nil, errors.New("not read newest first: " + path)
}
if strings.Contains(path, "offset=0") {
return page(readPage, clock), nil // a full page of the last few minutes
}
return page(readPage, clock.Add(-2*time.Hour)), nil // older than any look reaches
}, (&recorder{}).emit)
w.now = func() time.Time { return clock }
w.Look(context.Background())
first := len(asked)
asked = nil
clock = clock.Add(WatchEvery)
w.Look(context.Background())
if len(asked) != 1 || first > 2 {
t.Fatalf("the first look asked %d pages and a later one %d (%v): a later look reads back only to the last", first,
len(asked), asked)
}
}
// The failed-publish record forgets a closure older than the hour: S9 would no longer say it.
func TestAClosureThatCouldNotBePublishedIsForgottenAfterTheHour(t *testing.T) {
doc := closedRecord
r := &recorder{fail: errors.New("the runtime did not answer")}
clock := watchNow
w := watchAt(&doc, r)
w.now = func() time.Time { return clock }
w.Look(context.Background())
doc = `{"connections":[]}`
clock = clock.Add(2 * time.Hour)
w.Look(context.Background())
if _, left := w.Report()["not_published"]; left {
t.Fatal("closures older than the hour are still kept as not published")
}
}
func TestTheManifestDeclaresTheToolsServedTheWatchesEventsAndItsInterval(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Tools []string `json:"tools"`
Emits []string `json:"emits"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
var served []string
for _, tool := range tools(nil, nil, nil, NewWatch(nil, nil)) {
served = append(served, tool.Name)
}
sort.Strings(served)
sort.Strings(m.Tools)
if strings.Join(served, ",") != strings.Join(m.Tools, ",") {
t.Fatalf("served %v, the manifest lists %v", served, m.Tools)
}
sort.Strings(m.Emits)
if strings.Join(m.Emits, ",") != EventConnectionClosed+","+EventSlowConsumer+","+EventWatch {
t.Fatalf("the manifest emits %v: the runtime may publish only what is declared", m.Emits)
}
if WatchEvery != 30*time.Second || WatchSaysAgain != time.Hour {
t.Fatalf("the watch looks every %s and reads back %s; ADR 0357 says 30s and an hour", WatchEvery, WatchSaysAgain)
}
}
+6 -1
View File
@@ -24,7 +24,11 @@
"capabilities": [
"container-runtime"
],
"emits": [],
"emits": [
"bus.slow-consumer",
"bus.connection-closed",
"bus.watch"
],
"consumes": [],
"listens": [
{
@@ -39,6 +43,7 @@
"nats_server",
"nats_connections",
"nats_closed_connections",
"nats_bus_watch",
"nats_subscriptions",
"nats_streams",
"nats_backlog",
+1 -1
View File
@@ -20,7 +20,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-route-proxy ./examples/route-proxy
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-route-proxy ./examples/route-proxy
# A small runtime with the public CA roots the ACME client needs to reach a real authority, and run
# as root so it can bind :80 and :443 — the two privileged ports a public front door listens on.
+2 -3
View File
@@ -4,11 +4,10 @@ package main
// formatting", "too much text, no formatting, no summary"). The router's words are shown as given —
// `exact-render` — and formatted, never reworded:
//
// - the title in bold: the headline, an answered ask's too (issue 714);
// - the title in bold: the headline, or how an answered ask ended;
// - the body as it is, a line that is a key with its value (as a settings proposal lists them, "+ key: value")
// in monospace;
// - the note — who asked, what each answer does, the expiry, the asker's details; once answered, how it ended
// and who acts on it — apart, after a blank line,
// - the note — who asked, what each answer does, the expiry, the asker's details — apart, after a blank line,
// in a blockquote Telegram shows collapsed to its first lines, expanded on a tap.
//
// Every character Telegram's HTML would read as markup is escaped first, so a message carrying '<' neither
@@ -84,20 +84,3 @@ func TestTheNoteIsEscaped(t *testing.T) {
t.Errorf("%q", got)
}
}
// novox/hq issue 714: an answered ask keeps its question whole, with how it ended below it. Over Telegram's bound,
// the end of the body (the asker's Details) is cut; the headline and the answer are never.
func TestAnAnsweredAskOverTheBoundKeepsItsHeadlineAndItsAnswer(t *testing.T) {
answer := "You chose Approve at 20:03 on Telegram.\nTold to mesh-knowledge, which acts on it itself."
m := asks.Message{Handle: "h-714", To: "42", Title: "Fixes reach every machine at once, unchecked",
Body: "A fix goes to every machine in one step.\n• No machine goes first\n" + strings.Repeat("Details line. ", 400),
Note: answer}
s := partsOf(m)
if n := len([]rune(s.plain())); n > MaxLength {
t.Fatalf("the answered ask is %d characters, over %d", n, MaxLength)
}
if s.title != m.Title || s.note != answer || !strings.HasPrefix(s.body, "A fix goes to every machine in one step.\n• No machine goes first") ||
!strings.HasSuffix(s.body, cutNote) {
t.Errorf("the headline, the summary or the answer was cut:\n%s", s.plain())
}
}