Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
789b0a9474 | ||
|
|
eb97a3d9a2 | ||
|
|
8b2e2300d8 | ||
|
|
2f2daea415 | ||
|
|
3b48f5962a | ||
|
|
2b1678f315 | ||
|
|
ab739d02dd | ||
|
|
a60020b51a | ||
|
|
a8d84d1730 | ||
|
|
9e8521afb5 | ||
|
|
8c60604eb7 | ||
|
|
13cd710071 | ||
|
|
0914a33fba | ||
|
|
b5417c28d0 | ||
|
|
5f6a41146c | ||
|
|
c71c513027 | ||
|
|
2e32f47e45 | ||
|
|
9492278d93 | ||
|
|
5ce2d07c7f | ||
|
|
373f339750 | ||
|
|
48f0662fde | ||
|
|
7913b56d66 | ||
|
|
ab1811d003 | ||
|
|
c79337579d | ||
|
|
3a84044ece | ||
|
|
c51357f8af | ||
|
|
2833a049aa | ||
|
|
6f7f07635e | ||
|
|
0f76b6e232 | ||
|
|
3e16c055be | ||
|
|
0361c44a03 | ||
|
|
d1debdcce8 | ||
|
|
0e7d9e74d2 | ||
|
|
be53f5167a | ||
|
|
1d8a244ba4 | ||
|
|
2edef5a00a | ||
|
|
2130f99612 | ||
|
|
ed5690cee4 | ||
|
|
fadf5025cc | ||
|
|
baa5ef802e | ||
|
|
5b20e419ec | ||
|
|
7e3e62062b | ||
|
|
7e0001e304 | ||
|
|
d94764f001 | ||
|
|
482232e8c5 | ||
|
|
70c1110c53 | ||
|
|
e223262408 | ||
|
|
dbe2949f84 | ||
|
|
b7d9590fdc | ||
|
|
7ee2869d60 | ||
|
|
0b74c04aa8 | ||
|
|
e1c8965ddb | ||
|
|
47ef2644ac | ||
|
|
cb7bf94a77 | ||
|
|
8e821ef449 | ||
|
|
07b9bd0ed4 | ||
|
|
02427a60ef | ||
|
|
8661bcfa22 | ||
|
|
6049537655 | ||
|
|
bf3e801f67 | ||
|
|
50861c74f8 | ||
|
|
b15846dd6e | ||
|
|
a44d490d97 | ||
|
|
af5cf53a34 | ||
|
|
2bb5d344f6 | ||
|
|
e846c289c8 | ||
|
|
0fcc560fec | ||
|
|
acf8a76646 | ||
|
|
b62bb15839 | ||
|
|
ff9d3ee6a0 | ||
|
|
c8255eb15d | ||
|
|
4ded07bc63 | ||
|
|
054dc9618c | ||
|
|
ebf7f3773d | ||
|
|
c876ad4a83 | ||
|
|
12a5e7be73 | ||
|
|
23a2e1e204 | ||
|
|
66d22dd53e | ||
|
|
7ed119995f | ||
|
|
faebc3569a | ||
|
|
1faee6e279 | ||
|
|
6f7d06726c | ||
|
|
f09daabec3 | ||
|
|
bb5a342863 | ||
|
|
eadf301f9f | ||
|
|
9c166398f2 | ||
|
|
e209d46faf | ||
|
|
f1f866050e | ||
|
|
4d15a155e6 | ||
|
|
e46eeb8298 | ||
|
|
5f491e0c09 | ||
|
|
73404a3c3f | ||
|
|
305c2ba247 | ||
|
|
8ad121d5c3 | ||
|
|
305601d25e | ||
|
|
910bc58fe0 | ||
|
|
0096beeff9 | ||
|
|
6aa30eae66 | ||
|
|
bb60de6d61 | ||
|
|
a84d0094a0 | ||
|
|
dd39d4ace7 | ||
|
|
5bd40fa406 | ||
|
|
0d723484a8 | ||
|
|
8d8dcd5ebb | ||
|
|
cd23928320 | ||
|
|
8922c89059 | ||
|
|
789d2d9d64 | ||
|
|
1725ffc799 | ||
|
|
a72eb3dbb2 | ||
|
|
c0726ac264 | ||
|
|
1906081937 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
66
|
62
|
||||||
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
||||||
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
||||||
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
||||||
|
|||||||
@@ -23,9 +23,51 @@
|
|||||||
#
|
#
|
||||||
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
|
# 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.
|
# /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
|
set -eu
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
|
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"
|
tsc="$deps/typescript/bin/tsc"
|
||||||
if [ ! -f "$tsc" ]; then
|
if [ ! -f "$tsc" ]; then
|
||||||
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
|
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
|
||||||
@@ -39,8 +81,6 @@ touched=$({
|
|||||||
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
|
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
|
||||||
} | sed '/^$/d' | sort -u)
|
} | sed '/^$/d' | sort -u)
|
||||||
|
|
||||||
work=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$work"' EXIT
|
|
||||||
failed=""
|
failed=""
|
||||||
for m in $touched; do
|
for m in $touched; do
|
||||||
d="modules/$m"
|
d="modules/$m"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-builder ./cmd/mesh-builder
|
||||||
|
|
||||||
# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build
|
# 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,
|
# machine's whole job is shelling out to git and docker — it needs a real userland to do that in,
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ that repository's main (`build.artifacts[].context`); this module ships the pack
|
|||||||
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
|
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
|
||||||
same thing; retire that once this is assigned where it was.
|
same thing; retire that once this is assigned where it was.
|
||||||
|
|
||||||
|
A send that replaces the agent's container does not throw away the build it runs: the container declares
|
||||||
|
`busy` as `/usr/local/bin/mesh-builder busy` (novox/hq ADR 0335). The node-engine asks it before replacing it; from that
|
||||||
|
ask the agent takes no new build, the node-engine leaves it running while it says a build is in hand, and
|
||||||
|
replaces it once it says idle, or after 15 minutes at the latest.
|
||||||
|
|
||||||
## The package registry (novox/hq ADR 0336)
|
## The package registry (novox/hq ADR 0336)
|
||||||
|
|
||||||
The package registry (`npm-package-registry`, provided by the gitea module) is an optional requirement.
|
The package registry (`npm-package-registry`, provided by the gitea module) is an optional requirement.
|
||||||
|
|||||||
@@ -115,3 +115,57 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Fatal("the build agent has no environment file")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,12 @@
|
|||||||
"path": "${dir:workspace}",
|
"path": "${dir:workspace}",
|
||||||
"class": "cache",
|
"class": "cache",
|
||||||
"why": "a build's working copy, cloned again for every build"
|
"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)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -54,12 +60,17 @@
|
|||||||
"type": "directory",
|
"type": "directory",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "judge-cache",
|
||||||
|
"type": "directory",
|
||||||
|
"mode": "0700"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "agent-env",
|
"id": "agent-env",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:mesh-state}/build-agent.env",
|
"path": "${dir:mesh-state}/build-agent.env",
|
||||||
"mode": "0600",
|
"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}\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}\nMESH_JUDGE_CACHE=${dir:judge-cache}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
@@ -72,6 +83,7 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"${dir:mesh-state}:/run/mesh:ro",
|
"${dir:mesh-state}:/run/mesh:ro",
|
||||||
"${dir:workspace}:${dir:workspace}",
|
"${dir:workspace}:${dir:workspace}",
|
||||||
|
"${dir:judge-cache}:${dir:judge-cache}",
|
||||||
"/var/run/docker.sock:/var/run/docker.sock"
|
"/var/run/docker.sock:/var/run/docker.sock"
|
||||||
],
|
],
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
@@ -85,6 +97,9 @@
|
|||||||
"timeout": "5s",
|
"timeout": "5s",
|
||||||
"looks": 3,
|
"looks": 3,
|
||||||
"grace": "60s"
|
"grace": "60s"
|
||||||
|
},
|
||||||
|
"busy": {
|
||||||
|
"command": "/usr/local/bin/mesh-builder busy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ Two directories, declared, so the mesh refuses a second module owning either:
|
|||||||
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
|
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
|
||||||
account of its own), that account's, `0700`. The module owns the directory —
|
account of its own), that account's, `0700`. The module owns the directory —
|
||||||
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
||||||
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
|
in it (history, projects, local settings, a person's own rules and skills) is the person's
|
||||||
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
|
and is never read or written (hq ADR 0182) — but for each project's memory folder, which the module
|
||||||
|
carries between nodes (hq ADR 0343, below). Unassigned, the module leaves the directory: the host
|
||||||
removes a directory only when it is empty.
|
removes a directory only when it is empty.
|
||||||
|
|
||||||
And, for where the agent runs as an account of its own (hq ADR 0266):
|
And, for where the agent runs as an account of its own (hq ADR 0266):
|
||||||
@@ -69,8 +70,37 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
|||||||
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
||||||
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
||||||
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
||||||
|
| a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `<kind>.<project and path, base64url>`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text |
|
||||||
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
|
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
|
||||||
|
|
||||||
|
## A session's memory follows the operator (hq ADR 0343)
|
||||||
|
|
||||||
|
A session started on another node starts with what the sessions on every other node learned (hq issue 367). The
|
||||||
|
module's long-running process carries each project's memory folder, `~/.claude/projects/<project>/memory/`, and
|
||||||
|
nothing else of the home. A project is one entry of the projects directory and a path is relative, no part empty,
|
||||||
|
`.` or `..`: a value in the state naming anything else is never taken, and nothing is touched for it.
|
||||||
|
|
||||||
|
- **Picked up** every ten seconds: a file whose text differs from the version last carried goes into the `memory`
|
||||||
|
state. Not carried, and listed with why: a symbolic link, a file over 128 KiB or too large for one value once
|
||||||
|
encoded, and a file holding a key block, a
|
||||||
|
signed token, a known token prefix or a password in an address.
|
||||||
|
- **Written** on every other node of the same home kind, only where its file is still the version the change
|
||||||
|
replaced, or the version it last carried (a node that was away), or there is none; staged in the folder and renamed in, never through a symbolic link. The version
|
||||||
|
replaced is kept first in `memory-copies/` in the module's state directory, 30 days.
|
||||||
|
- **A file changed on two nodes** becomes one file holding both, the newer first, then a line naming each version's
|
||||||
|
node and time, then the older (two versions too large for one file: the newer in it, the older beside it as
|
||||||
|
`<name>.older-from-<node>`); what was written is carried, so every node ends on it, and the operator is warned through
|
||||||
|
the operator channel's `notify` (a warning, cleared when the file next changes without a conflict).
|
||||||
|
- **A removal** of a carried file, from a folder that still exists, is a marker in the state for 30 days: a node still
|
||||||
|
holding the removed version moves it into the copies; a node that changed it keeps it and carries it again. A
|
||||||
|
missing folder, a key the state lost and a deleted key remove nothing, and neither does a file or folder that
|
||||||
|
cannot be read on a look: it is kept everywhere and listed as not carried.
|
||||||
|
- **The two home kinds never mix**: the operator's own homes carry among themselves, an agent account's among
|
||||||
|
themselves (hq ADR 0266), so a confined agent's notes never reach a session run as the operator.
|
||||||
|
- **No tool writes memory** (hq issue 340): `claude_code_memory` only lists what is carried — fingerprints, never the
|
||||||
|
text — the files not carried, the open conflicts and the copies kept. A value put straight into the state by a
|
||||||
|
holder of a tool runner's credential is not yet told apart (hq issue 638).
|
||||||
|
|
||||||
## The mesh's tools first (hq ADR 0245)
|
## The mesh's tools first (hq ADR 0245)
|
||||||
|
|
||||||
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
|
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
|
||||||
@@ -149,9 +179,12 @@ said in the render's answer. What was registered before is still rendered and ma
|
|||||||
hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes
|
hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes
|
||||||
active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator
|
active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator
|
||||||
channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that
|
channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that
|
||||||
proves who answered (Telegram) carries either. The phone shows the whole text when it is short and every line may
|
proves who answered (Telegram) carries either. The ask reads at a glance (hq issue 639): its explanation is one
|
||||||
leave the mesh, else its first lines and a fingerprint; the whole text is read at the controller's terminal with
|
line saying what the proposal does — the proposer's `summary`, at most 300 characters, or the module's own words —
|
||||||
`mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. Decline, or no answer within a day, discards it.
|
and its Details, which the phone shows collapsed after the answers, carry the text (whole when it is short and every
|
||||||
|
line may leave the mesh, else its first lines), where it came from and its fingerprint; the whole text is read at the
|
||||||
|
controller's terminal with `mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. A proposal made before
|
||||||
|
that change is asked, and its warrant held, in the form it was made in (`form`). Decline, or no answer within a day, discards it.
|
||||||
**Every node proves the approval itself**: it reads the router's own record of the ask under this module's name,
|
**Every node proves the approval itself**: it reads the router's own record of the ask under this module's name,
|
||||||
judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act,
|
judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act,
|
||||||
the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed
|
the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed
|
||||||
|
|||||||
@@ -285,6 +285,25 @@ func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The node's line names the generation it applied and why the manager moved it, and who asked (novox/hq issue 625).
|
||||||
|
func TestAnAppliedBindingSaysWhyAndWho(t *testing.T) {
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
var asked []string
|
||||||
|
done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7,
|
||||||
|
Why: "the switch verb", By: "g14.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"g14.node-tools"`} {
|
||||||
|
if !strings.Contains(done, want) {
|
||||||
|
t.Fatalf("the line %s lacks %s", done, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(done, "at-7") {
|
||||||
|
t.Fatal("the line carries the token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
|
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
|
||||||
p, w := node(t, "laptop")
|
p, w := node(t, "laptop")
|
||||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
|
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
|
||||||
|
|||||||
@@ -152,6 +152,36 @@ func (s stateOf) Create(key string, value any) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||||
|
|
||||||
|
// memoryStateOf adapts the SDK's state to the memory carrier: every write at the revision read (ADR 0343).
|
||||||
|
type memoryStateOf struct{ s stdio.KeptState }
|
||||||
|
|
||||||
|
func (s memoryStateOf) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||||
|
e, err := s.s.Get(key)
|
||||||
|
if err != nil || e == nil {
|
||||||
|
return nil, 0, false, err
|
||||||
|
}
|
||||||
|
return e.Value, e.Revision, true, nil
|
||||||
|
}
|
||||||
|
func (s memoryStateOf) Create(key string, value any) error {
|
||||||
|
_, err := s.s.Create(key, value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func (s memoryStateOf) Update(key string, value any, revision uint64) error {
|
||||||
|
_, err := s.s.Update(key, value, revision)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func (s memoryStateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||||
|
func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
|
||||||
|
|
||||||
|
// memoryCarrier carries this node's memory folders (ADR 0343); nil until the long-running half starts it.
|
||||||
|
var memoryCarrier *MemoryCarrier
|
||||||
|
|
||||||
|
// notifyOperator tells the operator through the operator channel's notify verb (ADR 0253).
|
||||||
|
func notifyOperator(args map[string]any) error {
|
||||||
|
_, err := ask("seat:operator-channel.notify", args)
|
||||||
|
return err
|
||||||
|
}
|
||||||
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
||||||
e, err := s.s.Get(key)
|
e, err := s.s.Get(key)
|
||||||
if err != nil || e == nil {
|
if err != nil || e == nil {
|
||||||
@@ -354,6 +384,9 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
|||||||
out = append(out, stdio.Tool{Name: "claude_code_registrations",
|
out = append(out, stdio.Tool{Name: "claude_code_registrations",
|
||||||
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
|
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
|
||||||
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
|
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
|
||||||
|
out = append(out, stdio.Tool{Name: "claude_code_memory",
|
||||||
|
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
|
||||||
|
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
|
||||||
out = append(out, proposalTools(p)...)
|
out = append(out, proposalTools(p)...)
|
||||||
judge := NewJudge(p)
|
judge := NewJudge(p)
|
||||||
out = append(out, judgeTool(judge), judgedTool(judge))
|
out = append(out, judgeTool(judge), judgedTool(judge))
|
||||||
@@ -430,6 +463,12 @@ func main() {
|
|||||||
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
||||||
Now: time.Now, Write: writeManaged}
|
Now: time.Now, Write: writeManaged}
|
||||||
go proposing.watch(p)
|
go proposing.watch(p)
|
||||||
|
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
|
||||||
|
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
|
||||||
|
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
|
||||||
|
return handle(c.Key, c.Op, c.Value, c.Revision)
|
||||||
|
})
|
||||||
|
})
|
||||||
go run(p, view, configView)
|
go run(p, view, configView)
|
||||||
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
||||||
say("%v", err)
|
say("%v", err)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,968 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// A session's memory follows the operator (novox/hq ADR 0343, issue 367). Every test runs against temporary homes
|
||||||
|
// and a state in memory with the bus's watch semantics: never the live memory folders.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memState is the `memory` state in memory: revisions, create and compare-and-set, and every change handed to every
|
||||||
|
// watcher, the writer included, as the bus does.
|
||||||
|
type memState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
values map[string]json.RawMessage
|
||||||
|
revs map[string]uint64
|
||||||
|
rev uint64
|
||||||
|
pending []change
|
||||||
|
watchers []*MemoryCarrier
|
||||||
|
}
|
||||||
|
|
||||||
|
type change struct {
|
||||||
|
key, op string
|
||||||
|
value json.RawMessage
|
||||||
|
rev uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemState() *memState {
|
||||||
|
return &memState{values: map[string]json.RawMessage{}, revs: map[string]uint64{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errChanged = errors.New("wrong last revision")
|
||||||
|
|
||||||
|
func (s *memState) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
v, ok := s.values[key]
|
||||||
|
return v, s.revs[key], ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) write(key string, value any) {
|
||||||
|
raw, _ := json.Marshal(value)
|
||||||
|
s.rev++
|
||||||
|
s.values[key], s.revs[key] = raw, s.rev
|
||||||
|
s.pending = append(s.pending, change{key, "put", raw, s.rev})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) Create(key string, value any) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, ok := s.values[key]; ok {
|
||||||
|
return errChanged
|
||||||
|
}
|
||||||
|
s.write(key, value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) Update(key string, value any, revision uint64) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.revs[key] != revision {
|
||||||
|
return errChanged
|
||||||
|
}
|
||||||
|
s.write(key, value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) Keys() ([]string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var keys []string
|
||||||
|
for k := range s.values {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memState) Delete(key string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.values, key)
|
||||||
|
delete(s.revs, key)
|
||||||
|
s.rev++
|
||||||
|
s.pending = append(s.pending, change{key, "delete", nil, s.rev})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pump hands every change so far to every watcher, until none is left.
|
||||||
|
func (s *memState) pump(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
s.mu.Lock()
|
||||||
|
todo := s.pending
|
||||||
|
s.pending = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
if len(todo) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range todo {
|
||||||
|
for _, w := range s.watchers {
|
||||||
|
if err := w.Take(c.key, c.op, c.value, c.rev); err != nil {
|
||||||
|
t.Logf("%s taking %s: %v", w.Node, c.key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range s.watchers {
|
||||||
|
if err := w.Drain(); err != nil && !errors.Is(err, errChanged) {
|
||||||
|
t.Logf("%s applying: %v", w.Node, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("the state never settled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// clock is a clock a test moves.
|
||||||
|
type clock struct{ now time.Time }
|
||||||
|
|
||||||
|
func (c *clock) Now() time.Time { return c.now }
|
||||||
|
|
||||||
|
// memNode is one node's carrier over its own temporary home and state directory.
|
||||||
|
type memNode struct {
|
||||||
|
*MemoryCarrier
|
||||||
|
notices []map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemNode(t *testing.T, root, name, kind string, state *memState, c *clock) *memNode {
|
||||||
|
t.Helper()
|
||||||
|
n := &memNode{}
|
||||||
|
home := filepath.Join(root, name, "home")
|
||||||
|
stateDir := filepath.Join(root, name, "state")
|
||||||
|
for _, d := range []string{filepath.Join(home, ".claude", "projects"), stateDir} {
|
||||||
|
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n.MemoryCarrier = &MemoryCarrier{Home: home, Node: name, State: stateDir, Kind: kind, Store: state, Now: c.Now,
|
||||||
|
wake: make(chan struct{}, 1),
|
||||||
|
Notify: func(a map[string]any) error { n.notices = append(n.notices, a); return nil }}
|
||||||
|
state.watchers = append(state.watchers, n.MemoryCarrier)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *memNode) path(project, file string) string {
|
||||||
|
return filepath.Join(n.Home, ".claude", "projects", project, "memory", filepath.FromSlash(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *memNode) write(t *testing.T, project, file, text string, at time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
p := n.path(project, file)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = os.Chtimes(p, at, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *memNode) read(t *testing.T, project, file string) (string, bool) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(n.path(project, file))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(raw), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *memNode) look(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if err := n.Look(); err != nil && !errors.Is(err, errChanged) {
|
||||||
|
t.Fatalf("%s's look: %v", n.Node, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copies is every text kept in the node's copies.
|
||||||
|
func (n *memNode) copies(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
var out []string
|
||||||
|
_ = filepath.WalkDir(n.MemoryCarrier.copies(), func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err == nil && !d.IsDir() {
|
||||||
|
raw, _ := os.ReadFile(p)
|
||||||
|
out = append(out, string(raw))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func holdsText(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = "-home-op-projects-novox-hq"
|
||||||
|
|
||||||
|
func twoNodes(t *testing.T) (*memState, *clock, *memNode, *memNode) {
|
||||||
|
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
||||||
|
root := t.TempDir()
|
||||||
|
return state, c, newMemNode(t, root, "g14", HomeOperator, state, c), newMemNode(t, root, "shanks", HomeOperator, state, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// carried writes a file on g14 and carries it to shanks.
|
||||||
|
func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) {
|
||||||
|
t.Helper()
|
||||||
|
a.write(t, project, file, text, c.now)
|
||||||
|
a.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
b.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if got, _ := b.read(t, project, file); got != text {
|
||||||
|
t.Fatalf("not carried to %s: %q", b.Node, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §5, §6: a memory file written on one node appears unchanged on another, at any depth, and is carried once.
|
||||||
|
func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
g14.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now)
|
||||||
|
g14.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
for file, want := range map[string]string{"MEMORY.md": "- [state](state.md) — where things stand\n", "notes/state.md": "ADR 0343 proposed.\n"} {
|
||||||
|
if got, ok := shanks.read(t, project, file); !ok || got != want {
|
||||||
|
t.Fatalf("%s on shanks: %q, %v", file, got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before := state.rev
|
||||||
|
shanks.look(t)
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if state.rev != before {
|
||||||
|
t.Fatalf("a carried file was carried again: %d changes", state.rev-before)
|
||||||
|
}
|
||||||
|
// A change on shanks comes back to g14, over the version it replaced, which g14 keeps.
|
||||||
|
c.now = c.now.Add(time.Minute)
|
||||||
|
shanks.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
|
||||||
|
shanks.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if got, _ := g14.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
|
||||||
|
t.Fatalf("the change did not reach g14: %q", got)
|
||||||
|
}
|
||||||
|
if !holdsText(g14.copies(t), "ADR 0343 proposed.\n") {
|
||||||
|
t.Fatal("the version replaced on g14 was not kept")
|
||||||
|
}
|
||||||
|
if len(g14.notices)+len(shanks.notices) != 0 {
|
||||||
|
t.Fatalf("a plain change warned: %v %v", g14.notices, shanks.notices)
|
||||||
|
}
|
||||||
|
// No value in the state is anything but a version of a memory file, and no tool answer carries the text.
|
||||||
|
raw, _ := json.Marshal(g14.Status())
|
||||||
|
if strings.Contains(string(raw), "accepted") {
|
||||||
|
t.Fatalf("the memory tool answered the text: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §1, §2: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are
|
||||||
|
// listed with why.
|
||||||
|
func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
projectDir := filepath.Join(g14.Home, ".claude", "projects", project)
|
||||||
|
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||||
|
for path, text := range map[string]string{
|
||||||
|
filepath.Join(projectDir, "session.jsonl"): "{}\n",
|
||||||
|
filepath.Join(g14.Home, ".claude", "settings.json"): "{}\n",
|
||||||
|
filepath.Join(g14.Home, ".claude", "history.jsonl"): "{}\n",
|
||||||
|
} {
|
||||||
|
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g14.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
|
||||||
|
g14.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now)
|
||||||
|
g14.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
|
||||||
|
g14.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
|
||||||
|
outside := filepath.Join(t.TempDir(), "elsewhere.md")
|
||||||
|
_ = os.WriteFile(outside, []byte("not memory\n"), 0o600)
|
||||||
|
if err := os.Symlink(outside, g14.path(project, "link.md")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
||||||
|
if _, ok := shanks.read(t, project, file); ok {
|
||||||
|
t.Errorf("%s was carried", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, file := range []string{"MEMORY.md", "hash.md"} {
|
||||||
|
if _, ok := shanks.read(t, project, file); !ok {
|
||||||
|
t.Errorf("%s was not carried", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
|
||||||
|
t.Error("a session was carried")
|
||||||
|
}
|
||||||
|
for _, f := range []string{"settings.json", "history.jsonl"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", f)); err == nil {
|
||||||
|
t.Errorf("%s was carried", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys, _ := state.Keys()
|
||||||
|
if len(keys) != 2 {
|
||||||
|
t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys)
|
||||||
|
}
|
||||||
|
not, _ := g14.Status()["not carried"].(map[string]string)
|
||||||
|
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
||||||
|
if not[project+"/"+file] == "" {
|
||||||
|
t.Errorf("%s is not listed as not carried: %v", file, not)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4: the operator's homes and an agent account's homes never take each other's memory.
|
||||||
|
func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
|
||||||
|
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
||||||
|
root := t.TempDir()
|
||||||
|
laptop := newMemNode(t, root, "g14", HomeOperator, state, c)
|
||||||
|
control := newMemNode(t, root, "novox", HomeAgent, state, c)
|
||||||
|
control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now)
|
||||||
|
control.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if _, ok := laptop.read(t, project, "MEMORY.md"); ok {
|
||||||
|
t.Fatal("a confined agent's memory reached the operator's home")
|
||||||
|
}
|
||||||
|
laptop.write(t, project, "MEMORY.md", "the operator's\n", c.now)
|
||||||
|
laptop.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if got, _ := control.read(t, project, "MEMORY.md"); got != "from a confined agent\n" {
|
||||||
|
t.Fatalf("the operator's memory reached the agent account's home: %q", got)
|
||||||
|
}
|
||||||
|
// A value of the other kind put under this kind's key is refused too.
|
||||||
|
key := MemoryKey(HomeOperator, project, "x.md")
|
||||||
|
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "novox"})
|
||||||
|
if err := laptop.Take(key, "put", raw, 99); err == nil {
|
||||||
|
t.Fatal("a value of another kind was taken")
|
||||||
|
}
|
||||||
|
// Which kind a node carries for.
|
||||||
|
if k, _ := HomeKind(Paths{Agent: "jochen", Operator: "jochen", Home: "/home/jochen"}); k != HomeOperator {
|
||||||
|
t.Fatalf("the operator's own home is %q", k)
|
||||||
|
}
|
||||||
|
if k, _ := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/agent", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != HomeAgent {
|
||||||
|
t.Fatalf("an agent account's home is %q", k)
|
||||||
|
}
|
||||||
|
if k, why := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/jochen", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != "" || why == "" {
|
||||||
|
t.Fatalf("a confined agent in the operator's home carries %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §7, §8: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with
|
||||||
|
// one warning; it clears once the file is tidied.
|
||||||
|
func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "- base\n")
|
||||||
|
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n", c.now.Add(time.Minute))
|
||||||
|
shanks.write(t, project, "MEMORY.md", "- base\n- learned on shanks\n", c.now.Add(2*time.Minute))
|
||||||
|
c.now = c.now.Add(3 * time.Minute)
|
||||||
|
g14.look(t)
|
||||||
|
shanks.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
g14.look(t)
|
||||||
|
shanks.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
a, _ := g14.read(t, project, "MEMORY.md")
|
||||||
|
b, _ := shanks.read(t, project, "MEMORY.md")
|
||||||
|
if a != b {
|
||||||
|
t.Fatalf("the nodes did not end on one text:\n%s\n----\n%s", a, b)
|
||||||
|
}
|
||||||
|
newer, older := strings.Index(a, "learned on shanks"), strings.Index(a, "learned on g14")
|
||||||
|
marker := strings.Index(a, "<!-- novox mesh: this file was changed on two nodes")
|
||||||
|
if newer < 0 || older < 0 || marker < 0 || !(newer < marker && marker < older) {
|
||||||
|
t.Fatalf("not both versions, the newer first, with the line between:\n%s", a)
|
||||||
|
}
|
||||||
|
if !strings.Contains(a[marker:], "changed on shanks") || !strings.Contains(a[marker:], "changed on g14") {
|
||||||
|
t.Fatalf("the line does not name both nodes:\n%s", a)
|
||||||
|
}
|
||||||
|
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
|
||||||
|
if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil {
|
||||||
|
t.Fatalf("not one warning: %v", notices)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"headline", "explanation", "summary", "resolved"} {
|
||||||
|
s, _ := notices[0][field].(string)
|
||||||
|
if s == "" || strings.ContainsAny(s, "/~:") && field != "explanation" || strings.Contains(s, ".md") {
|
||||||
|
t.Errorf("the warning's %s is empty or names a path: %q", field, s)
|
||||||
|
}
|
||||||
|
// The plain rule (ADR 0253 §3): it never sends the operator elsewhere.
|
||||||
|
if field != "summary" && (strings.Contains(strings.ToLower(s), "agent") || strings.Contains(strings.ToLower(s), "session")) {
|
||||||
|
t.Errorf("the warning's %s sends the operator elsewhere: %q", field, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e, _ := notices[0]["explanation"].(string); !strings.HasPrefix(e, "Needs you: ") {
|
||||||
|
t.Errorf("the explanation does not open with its verdict: %q", e)
|
||||||
|
}
|
||||||
|
if h, _ := notices[0]["headline"].(string); len([]rune(h)) > 60 {
|
||||||
|
t.Errorf("the headline is longer than sixty characters: %q", h)
|
||||||
|
}
|
||||||
|
all := append(g14.copies(t), shanks.copies(t)...)
|
||||||
|
for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} {
|
||||||
|
if !holdsText(all, v) {
|
||||||
|
t.Errorf("the version %q was not kept", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if st := g14.Status(); len(st["conflicts"].([]conflict))+len(shanks.Status()["conflicts"].([]conflict)) != 1 {
|
||||||
|
t.Fatalf("the conflict is not listed: %v", st)
|
||||||
|
}
|
||||||
|
// Tidied on either node, the warning clears.
|
||||||
|
c.now = c.now.Add(time.Minute)
|
||||||
|
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n- learned on shanks\n", c.now)
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
cleared := false
|
||||||
|
for _, n := range append(g14.notices, shanks.notices...) {
|
||||||
|
if n["clear"] == true {
|
||||||
|
cleared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cleared {
|
||||||
|
t.Fatal("the warning did not clear once the file was tidied")
|
||||||
|
}
|
||||||
|
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "- base\n- learned on g14\n- learned on shanks\n" {
|
||||||
|
t.Fatalf("the tidied file did not reach shanks: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The order of the two versions: the later change first, equal times by node name.
|
||||||
|
func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
|
||||||
|
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||||
|
m := string(MergeVersions([]byte("old\n"), "g14", at, []byte("new\n"), "shanks", at.Add(time.Second)))
|
||||||
|
if strings.Index(m, "new") > strings.Index(m, "old") {
|
||||||
|
t.Fatalf("the newer is not first: %s", m)
|
||||||
|
}
|
||||||
|
m = string(MergeVersions([]byte("from shanks\n"), "shanks", at, []byte("from ace\n"), "ace", at))
|
||||||
|
if strings.Index(m, "from ace") > strings.Index(m, "from shanks") {
|
||||||
|
t.Fatalf("equal times are not ordered by node name: %s", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §9: a removal moves the other node's unchanged file into the copies.
|
||||||
|
func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "old.md", "an old note\n")
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
||||||
|
if err := os.Remove(g14.path(project, "old.md")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if _, ok := shanks.read(t, project, "old.md"); ok {
|
||||||
|
t.Fatal("the removal did not reach shanks")
|
||||||
|
}
|
||||||
|
if !holdsText(shanks.copies(t), "an old note\n") {
|
||||||
|
t.Fatal("the removed version was not kept on shanks")
|
||||||
|
}
|
||||||
|
raw, _, ok, _ := state.Get(MemoryKey(HomeOperator, project, "old.md"))
|
||||||
|
var v MemoryVersion
|
||||||
|
_ = json.Unmarshal(raw, &v)
|
||||||
|
if !ok || !v.Removed || v.Text != "" {
|
||||||
|
t.Fatalf("the removal is not a marker in the state: %s", raw)
|
||||||
|
}
|
||||||
|
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||||
|
t.Fatal("another file was touched")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §9: a change outlives a removal, whichever reaches the state first.
|
||||||
|
func TestAChangeOutlivesARemoval(t *testing.T) {
|
||||||
|
for _, removalFirst := range []bool{true, false} {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
||||||
|
_ = os.Remove(g14.path(project, "note.md"))
|
||||||
|
shanks.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
||||||
|
if removalFirst {
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
shanks.look(t)
|
||||||
|
} else {
|
||||||
|
shanks.look(t)
|
||||||
|
g14.look(t)
|
||||||
|
}
|
||||||
|
state.pump(t)
|
||||||
|
g14.look(t)
|
||||||
|
shanks.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
for _, n := range []*memNode{g14, shanks} {
|
||||||
|
if got, _ := n.read(t, project, "note.md"); got != "v2\n" {
|
||||||
|
t.Fatalf("removal first %v: the change did not outlive the removal on %s: %q", removalFirst, n.Node, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §9: a missing folder, a state that lost its keys and a deleted key remove nothing.
|
||||||
|
func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
||||||
|
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
||||||
|
// The folder gone on g14.
|
||||||
|
if err := os.RemoveAll(filepath.Join(g14.Home, ".claude", "projects", project, "memory")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||||
|
t.Fatal("a missing folder removed the file elsewhere")
|
||||||
|
}
|
||||||
|
// A deleted key.
|
||||||
|
_ = state.Delete(key)
|
||||||
|
state.pump(t)
|
||||||
|
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||||
|
t.Fatal("a deleted key removed the file")
|
||||||
|
}
|
||||||
|
// The state lost: the file is carried again, not removed.
|
||||||
|
shanks.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if _, _, ok, _ := state.Get(key); !ok {
|
||||||
|
t.Fatal("a file whose key the state lost was not carried again")
|
||||||
|
}
|
||||||
|
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||||
|
t.Fatalf("the file was touched on shanks: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §10: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other.
|
||||||
|
func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
g14.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
|
||||||
|
g14.write(t, project, "laptop.md", "only on the laptop\n", c.now)
|
||||||
|
shanks.write(t, project, "MEMORY.md", "- workstation's index\n", c.now.Add(time.Hour))
|
||||||
|
shanks.write(t, project, "workstation.md", "only on the workstation\n", c.now)
|
||||||
|
shanks.write(t, project, "same.md", "same\n", c.now)
|
||||||
|
g14.write(t, project, "same.md", "same\n", c.now)
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
shanks.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
for _, n := range []*memNode{g14, shanks} {
|
||||||
|
index, _ := n.read(t, project, "MEMORY.md")
|
||||||
|
if !strings.Contains(index, "laptop's index") || !strings.Contains(index, "workstation's index") ||
|
||||||
|
strings.Index(index, "workstation's index") > strings.Index(index, "laptop's index") {
|
||||||
|
t.Fatalf("%s's index does not hold both, the newer first:\n%s", n.Node, index)
|
||||||
|
}
|
||||||
|
for file, want := range map[string]string{"laptop.md": "only on the laptop\n", "workstation.md": "only on the workstation\n", "same.md": "same\n"} {
|
||||||
|
if got, _ := n.read(t, project, file); got != want {
|
||||||
|
t.Fatalf("%s's %s: %q", n.Node, file, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := len(g14.notices) + len(shanks.notices); n != 1 {
|
||||||
|
t.Fatalf("%d warnings for one conflict", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing is written through a symbolic link: a memory folder that is one is neither read nor written.
|
||||||
|
func TestNothingIsWrittenThroughASymbolicLink(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
elsewhere := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(shanks.Home, ".claude", "projects", project), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(elsewhere, filepath.Join(shanks.Home, ".claude", "projects", project, "memory")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
|
||||||
|
t.Fatalf("written through a symbolic link: %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The manifest declares the state and the tool; the tool reads and never writes.
|
||||||
|
func TestTheManifestDeclaresTheMemoryState(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
State []string `json:"state"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &m)
|
||||||
|
if !holdsText(m.State, MemoryState) {
|
||||||
|
t.Fatalf("the manifest does not declare the %q state: %v", MemoryState, m.State)
|
||||||
|
}
|
||||||
|
var carrier *MemoryCarrier
|
||||||
|
if st := carrier.Status(); st["carrying"] != false {
|
||||||
|
t.Fatalf("a carrier not started says it carries: %v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replay hands one node the whole current set, as a watch begins when the node comes back.
|
||||||
|
func (s *memState) replay(t *testing.T, n *memNode) {
|
||||||
|
t.Helper()
|
||||||
|
keys, _ := s.Keys()
|
||||||
|
for _, k := range keys {
|
||||||
|
raw, rev, _, _ := s.Get(k)
|
||||||
|
if err := n.Take(k, "put", raw, rev); err != nil {
|
||||||
|
t.Logf("%s taking %s: %v", n.Node, k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := n.Drain(); err != nil && !errors.Is(err, errChanged) {
|
||||||
|
t.Logf("%s applying: %v", n.Node, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §6: a node that was away while a file changed twice takes the newest version, whose base it never saw, because
|
||||||
|
// its own file holds no change it did not carry; the version replaced is kept.
|
||||||
|
func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "v1\n")
|
||||||
|
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
|
||||||
|
for _, v := range []string{"v2\n", "v3\n"} {
|
||||||
|
c.now = c.now.Add(time.Minute)
|
||||||
|
g14.write(t, project, "MEMORY.md", v, c.now)
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
}
|
||||||
|
state.watchers = append(state.watchers, shanks.MemoryCarrier)
|
||||||
|
state.replay(t, shanks)
|
||||||
|
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "v3\n" {
|
||||||
|
t.Fatalf("the node that was away holds %q", got)
|
||||||
|
}
|
||||||
|
if !holdsText(shanks.copies(t), "v1\n") {
|
||||||
|
t.Fatal("the version replaced was not kept")
|
||||||
|
}
|
||||||
|
if len(g14.notices)+len(shanks.notices) != 0 {
|
||||||
|
t.Fatal("taking a newer version warned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §2, §7: two versions too large to share one file are kept in two: the newer in the file, the older beside it,
|
||||||
|
// both carried; and a text that would not fit in one value of the state is not carried.
|
||||||
|
func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "big.md", "base\n")
|
||||||
|
older := strings.Repeat("from g14 ", MemoryMostBytes/9-100) + "\n"
|
||||||
|
newer := strings.Repeat("from shanks ", MemoryMostBytes/12-100) + "\n"
|
||||||
|
g14.write(t, project, "big.md", older, c.now.Add(time.Minute))
|
||||||
|
shanks.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
|
||||||
|
c.now = c.now.Add(3 * time.Minute)
|
||||||
|
g14.look(t)
|
||||||
|
shanks.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
g14.look(t)
|
||||||
|
shanks.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
beside := OlderBeside("big.md", "g14")
|
||||||
|
if beside != "big.older-from-g14.md" {
|
||||||
|
t.Fatalf("the older is named %q", beside)
|
||||||
|
}
|
||||||
|
for _, n := range []*memNode{g14, shanks} {
|
||||||
|
if got, _ := n.read(t, project, "big.md"); got != newer {
|
||||||
|
t.Fatalf("%s's file does not hold the newer version", n.Node)
|
||||||
|
}
|
||||||
|
if got, _ := n.read(t, project, beside); got != older {
|
||||||
|
t.Fatalf("%s does not hold the older version beside it", n.Node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
|
||||||
|
if len(notices) != 1 || !strings.Contains(notices[0]["explanation"].(string), "second file beside it") {
|
||||||
|
t.Fatalf("not one warning saying the older is beside it: %v", notices)
|
||||||
|
}
|
||||||
|
// Under the size limit, but too large once encoded: not carried, and said.
|
||||||
|
g14.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if _, ok := shanks.read(t, project, "angles.md"); ok {
|
||||||
|
t.Fatal("a text too large for one value was carried")
|
||||||
|
}
|
||||||
|
not, _ := g14.Status()["not carried"].(map[string]string)
|
||||||
|
if not[project+"/angles.md"] == "" {
|
||||||
|
t.Fatalf("it is not listed as not carried: %v", not)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateHolds says whether any value of the state holds s.
|
||||||
|
func (s *memState) holds(sub string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, v := range s.values {
|
||||||
|
var mv MemoryVersion
|
||||||
|
_ = json.Unmarshal(v, &mv)
|
||||||
|
if strings.Contains(mv.Text, sub) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// putRaw puts a value straight into the state, as a holder of a tool runner's credential could.
|
||||||
|
func (s *memState) putRaw(t *testing.T, v MemoryVersion) string {
|
||||||
|
t.Helper()
|
||||||
|
key := MemoryKey(v.Kind, v.Project, v.Path)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.write(key, v)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review of #276, 1: a value whose project or path leads out of a memory folder is never taken, and nothing is
|
||||||
|
// written for it.
|
||||||
|
func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
root := filepath.Dir(filepath.Dir(shanks.Home))
|
||||||
|
for _, bad := range []struct{ project, path string }{
|
||||||
|
{"../../../escaped", "x.md"}, {"..", "x.md"}, {"a/b", "x.md"}, {"", "x.md"},
|
||||||
|
{project, "../x.md"}, {project, "/abs.md"}, {project, "a//x.md"}, {project, "./x.md"}, {project, "a/../../x.md"},
|
||||||
|
} {
|
||||||
|
text := "planted\n"
|
||||||
|
state.putRaw(t, MemoryVersion{Kind: HomeOperator, Project: bad.project, Path: bad.path, Text: text,
|
||||||
|
Digest: digestOf([]byte(text)), Node: "elsewhere", Changed: c.now})
|
||||||
|
if NameProblem(bad.project, bad.path) == "" {
|
||||||
|
t.Errorf("%q %q is taken as a name", bad.project, bad.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.pump(t)
|
||||||
|
g14.look(t)
|
||||||
|
shanks.look(t)
|
||||||
|
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err == nil && !d.IsDir() {
|
||||||
|
if raw, _ := os.ReadFile(p); string(raw) == "planted\n" {
|
||||||
|
t.Errorf("a planted value was written at %s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if NameProblem(project, "notes/a.md") != "" || NameProblem(project, "MEMORY.md") != "" {
|
||||||
|
t.Fatal("a memory file's own name is refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review of #276, 2: a file or a folder that cannot be read is kept everywhere, never carried as a removal, and is
|
||||||
|
// said as not carried.
|
||||||
|
func TestWhatCannotBeReadIsNeverTakenForGone(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root reads a file whatever its mode")
|
||||||
|
}
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "notes/deep.md", "deep\n")
|
||||||
|
file, folder := g14.path(project, "MEMORY.md"), filepath.Dir(g14.path(project, "notes/deep.md"))
|
||||||
|
_ = os.Chmod(file, 0)
|
||||||
|
_ = os.Chmod(folder, 0)
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(file, 0o644); _ = os.Chmod(folder, 0o755) })
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
for _, f := range []string{"MEMORY.md", "notes/deep.md"} {
|
||||||
|
if _, ok := shanks.read(t, project, f); !ok {
|
||||||
|
t.Errorf("%s, unreadable on g14, was removed on shanks", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
not, _ := g14.Status()["not carried"].(map[string]string)
|
||||||
|
if len(not) == 0 {
|
||||||
|
t.Fatalf("nothing is said as not carried: %v", not)
|
||||||
|
}
|
||||||
|
keys, _ := state.Keys()
|
||||||
|
for _, k := range keys {
|
||||||
|
raw, _, _, _ := state.Get(k)
|
||||||
|
var v MemoryVersion
|
||||||
|
_ = json.Unmarshal(raw, &v)
|
||||||
|
if v.Removed {
|
||||||
|
t.Errorf("%s/%s was carried as a removal", v.Project, v.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review of #276, 3: no text reaches the state without being screened, on the removal and the conflict paths too.
|
||||||
|
func TestNoUnscreenedTextReachesTheState(t *testing.T) {
|
||||||
|
token := "ghp_" + strings.Repeat("A1b2C3d4", 5) + "\n"
|
||||||
|
big := strings.Repeat("x", MemoryMostBytes+10) + "\n"
|
||||||
|
for _, text := range []string{token, big} {
|
||||||
|
// The removal path: removed on g14, changed on shanks to what is never carried.
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
||||||
|
_ = os.Remove(g14.path(project, "note.md"))
|
||||||
|
shanks.write(t, project, "note.md", text, c.now.Add(time.Minute))
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if state.holds(text[:20]) {
|
||||||
|
t.Errorf("the removal path put text that is never carried into the state")
|
||||||
|
}
|
||||||
|
if got, _ := shanks.read(t, project, "note.md"); got != text {
|
||||||
|
t.Errorf("the change on shanks was not kept")
|
||||||
|
}
|
||||||
|
// The conflict path: changed on g14, and on shanks to what is never carried.
|
||||||
|
state, c, g14, shanks = twoNodes(t)
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
||||||
|
g14.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
||||||
|
shanks.write(t, project, "note.md", text, c.now.Add(2*time.Minute))
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
shanks.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if state.holds(text[:20]) {
|
||||||
|
t.Errorf("the conflict path put text that is never carried into the state")
|
||||||
|
}
|
||||||
|
if got, _ := shanks.read(t, project, "note.md"); got != text {
|
||||||
|
t.Errorf("shanks's own version was not kept as it was")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review of #276, 4: a conflict whose file cannot be written leaves the state as it was, says so once, and merges
|
||||||
|
// once when the folder is writable again.
|
||||||
|
func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root writes a folder whatever its mode")
|
||||||
|
}
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "base\n")
|
||||||
|
g14.write(t, project, "MEMORY.md", "base\nfrom g14\n", c.now.Add(time.Minute))
|
||||||
|
shanks.write(t, project, "MEMORY.md", "base\nfrom shanks\n", c.now.Add(2*time.Minute))
|
||||||
|
folder := filepath.Dir(shanks.path(project, "MEMORY.md"))
|
||||||
|
_ = os.Chmod(folder, 0o500)
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(folder, 0o700) })
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_ = shanks.Look()
|
||||||
|
state.pump(t)
|
||||||
|
}
|
||||||
|
if state.holds("<!-- novox mesh") {
|
||||||
|
t.Fatal("a merge that could not be written here reached the state")
|
||||||
|
}
|
||||||
|
unwritten := 0
|
||||||
|
for _, n := range shanks.notices {
|
||||||
|
if strings.HasSuffix(n["key"].(string), ".unwritten") {
|
||||||
|
unwritten++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if unwritten != 1 {
|
||||||
|
t.Fatalf("%d warnings that the file could not be written", unwritten)
|
||||||
|
}
|
||||||
|
_ = os.Chmod(folder, 0o700)
|
||||||
|
shanks.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
for _, n := range []*memNode{g14, shanks} {
|
||||||
|
got, _ := n.read(t, project, "MEMORY.md")
|
||||||
|
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from g14") || !strings.Contains(got, "from shanks") {
|
||||||
|
t.Fatalf("%s does not hold both versions once:\n%s", n.Node, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review of #276, minor: a file that changed between its reading and its writing is not written over; text that is
|
||||||
|
// not UTF-8 is not carried.
|
||||||
|
func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
_ = state
|
||||||
|
g14.write(t, project, "a.md", "read\n", c.now)
|
||||||
|
g14.write(t, project, "a.md", "changed meanwhile\n", c.now)
|
||||||
|
err := g14.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
|
||||||
|
if !errors.Is(err, errChangedHere) {
|
||||||
|
t.Fatalf("written over a change: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := g14.read(t, project, "a.md"); got != "changed meanwhile\n" {
|
||||||
|
t.Fatalf("the change was replaced: %q", got)
|
||||||
|
}
|
||||||
|
g14.write(t, project, "latin1.md", "caf\xe9\n", c.now)
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
if _, ok := shanks.read(t, project, "latin1.md"); ok {
|
||||||
|
t.Fatal("text that is not UTF-8 was carried")
|
||||||
|
}
|
||||||
|
not, _ := g14.Status()["not carried"].(map[string]string)
|
||||||
|
if !strings.Contains(not[project+"/latin1.md"], "UTF-8") {
|
||||||
|
t.Fatalf("it is not said why: %v", not)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §9: a node that was away takes a removal of a version newer than the one it holds, when it holds no change of
|
||||||
|
// its own: the removed file does not come back.
|
||||||
|
func TestARemovalReachesANodeThatWasAway(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
carriedOnBoth(t, state, c, g14, shanks, "old.md", "v1\n")
|
||||||
|
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
|
||||||
|
c.now = c.now.Add(time.Minute)
|
||||||
|
g14.write(t, project, "old.md", "v2\n", c.now)
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
_ = os.Remove(g14.path(project, "old.md"))
|
||||||
|
g14.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
state.watchers = append(state.watchers, shanks.MemoryCarrier)
|
||||||
|
state.replay(t, shanks)
|
||||||
|
shanks.look(t)
|
||||||
|
state.pump(t)
|
||||||
|
g14.look(t)
|
||||||
|
if _, ok := shanks.read(t, project, "old.md"); ok {
|
||||||
|
t.Fatal("the removal did not reach the node that was away")
|
||||||
|
}
|
||||||
|
if _, ok := g14.read(t, project, "old.md"); ok {
|
||||||
|
t.Fatal("the removed file came back")
|
||||||
|
}
|
||||||
|
if !holdsText(shanks.copies(t), "v1\n") {
|
||||||
|
t.Fatal("the version removed on the node that was away was not kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slowState answers every read only once it is let go, as a state a whole bus away does under load.
|
||||||
|
type slowState struct {
|
||||||
|
*memState
|
||||||
|
gate chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s slowState) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||||
|
<-s.gate
|
||||||
|
return s.memState.Get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue 367, seen live: the runtime hands a watch its current values one at a time and gives them all ten seconds;
|
||||||
|
// shanks's handler read the state and waited for its look for each of a few hundred values, so the watch never
|
||||||
|
// started and nothing written on g14 reached it. The handler now only notes a change: it returns at once while a
|
||||||
|
// look holds the carrier and while the state is slow to answer, and Drain applies what it noted.
|
||||||
|
func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
|
||||||
|
state, c, g14, shanks := twoNodes(t)
|
||||||
|
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
||||||
|
g14.look(t)
|
||||||
|
state.mu.Lock()
|
||||||
|
state.pending = nil
|
||||||
|
state.mu.Unlock()
|
||||||
|
slow := slowState{memState: state, gate: make(chan struct{})}
|
||||||
|
shanks.Store = slow
|
||||||
|
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
||||||
|
raw, rev, _, _ := state.Get(key)
|
||||||
|
shanks.mu.Lock() // a look running
|
||||||
|
done := make(chan error, 1)
|
||||||
|
start := time.Now()
|
||||||
|
for i := 0; i < 400; i++ {
|
||||||
|
go func() { done <- shanks.Take(key, "put", raw, rev) }()
|
||||||
|
}
|
||||||
|
for i := 0; i < 400; i++ {
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("the watch handler waited for the look or the state (%d of 400 returned)", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if took := time.Since(start); took > time.Second {
|
||||||
|
t.Fatalf("400 values took %s to hand over", took)
|
||||||
|
}
|
||||||
|
shanks.mu.Unlock()
|
||||||
|
close(slow.gate)
|
||||||
|
if err := shanks.Drain(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
||||||
|
t.Fatalf("what the watch noted was not applied: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -298,6 +298,9 @@ type BindingState struct {
|
|||||||
Licence string `json:"licence"`
|
Licence string `json:"licence"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Generation int64 `json:"generation"`
|
Generation int64 `json:"generation"`
|
||||||
|
// Why and By are what moved the binding and who asked, as the manager recorded it (novox/hq issue 625).
|
||||||
|
Why string `json:"why,omitempty"`
|
||||||
|
By string `json:"by,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
||||||
@@ -433,6 +436,15 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
// The line says which binding this was and why the manager moved it, so a node's journal alone answers
|
||||||
|
// what changed here and who asked (novox/hq issue 625).
|
||||||
|
out["generation"] = b.Generation
|
||||||
|
if b.Why != "" {
|
||||||
|
out["why"] = b.Why
|
||||||
|
}
|
||||||
|
if b.By != "" {
|
||||||
|
out["by"] = b.By
|
||||||
|
}
|
||||||
raw, _ := json.Marshal(out)
|
raw, _ := json.Marshal(out)
|
||||||
return string(raw), nil
|
return string(raw), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func proposalTools(p Paths) []stdio.Tool {
|
|||||||
text, _ = a["content"].(string)
|
text, _ = a["content"].(string)
|
||||||
}
|
}
|
||||||
return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"),
|
return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"),
|
||||||
Text: text, Remove: boolArg(a, "remove")}, proposing.Store, proposing.Bus, proposing.Now())
|
Text: text, Remove: boolArg(a, "remove"), Summary: strArg(a, "summary")}, proposing.Store, proposing.Bus, proposing.Now())
|
||||||
}},
|
}},
|
||||||
{Name: "claude_code_proposals",
|
{Name: "claude_code_proposals",
|
||||||
Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " +
|
Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " +
|
||||||
@@ -164,14 +164,14 @@ func questionTools(p Paths, notLaunched error) []stdio.Tool {
|
|||||||
"operator's word to you; it authorises no call that needs the operator's approval.",
|
"operator's word to you; it authorises no call that needs the operator's approval.",
|
||||||
Input: map[string]any{
|
Input: map[string]any{
|
||||||
"headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"),
|
"headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"),
|
||||||
"context": str("what the operator decides and why it matters, in plain words, at most 1500 bytes"),
|
"context": str("what the operator decides and why it matters, in plain words: two or three short sentences, one line, at most 300 characters"),
|
||||||
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
|
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
|
||||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||||
"label": str("the button's words, at most 24 characters"),
|
"label": str("the button's words, at most 24 characters"),
|
||||||
"means": str("what you do when it is chosen, in plain words"),
|
"means": str("what you do when it is chosen, in plain words"),
|
||||||
"recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"},
|
"recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"},
|
||||||
}, "required": []string{"label", "means"}}},
|
}, "required": []string{"label", "means"}}},
|
||||||
"why": str("why the recommended option is the one you recommend"),
|
"why": str("why the recommended option is the one you recommend, at most 120 bytes"),
|
||||||
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
|
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
|
||||||
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
|
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
|
||||||
"expires_minutes": map[string]any{"type": "integer",
|
"expires_minutes": map[string]any{"type": "integer",
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ type Proposal struct {
|
|||||||
Digest string `json:"digest"`
|
Digest string `json:"digest"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
At time.Time `json:"at"`
|
At time.Time `json:"at"`
|
||||||
|
// Summary is what the operator's phone shows of it, at a glance, in its proposer's words; empty, the module
|
||||||
|
// says what it does (novox/hq issue 639).
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
// Form is how its ask is composed, so every node composes the same one again to hold the warrant to it: 0 for
|
||||||
|
// a proposal made before issue 639 (its text in the explanation), AskGlance for one read at a glance.
|
||||||
|
Form int `json:"form,omitempty"`
|
||||||
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
||||||
Asked string `json:"asked,omitempty"`
|
Asked string `json:"asked,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -164,9 +170,109 @@ func (p Proposal) Act(option string) asks.Act {
|
|||||||
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AskGlance is the form of an ask read at a glance (Proposal.Form, Question.Form; novox/hq issue 639).
|
||||||
|
const AskGlance = 1
|
||||||
|
|
||||||
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
|
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
|
||||||
// router's warrant to it.
|
// router's warrant to it — in the form it was asked in.
|
||||||
func (p Proposal) Ask() asks.Ask {
|
func (p Proposal) Ask() asks.Ask {
|
||||||
|
if p.Form == AskGlance {
|
||||||
|
return p.glanceAsk()
|
||||||
|
}
|
||||||
|
return p.listedAsk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation
|
||||||
|
// the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its
|
||||||
|
// Details, which the router notes after the answers, collapsed where the channel can.
|
||||||
|
func (p Proposal) glanceAsk() asks.Ask {
|
||||||
|
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
||||||
|
shown, whole := excerpt(p.Text)
|
||||||
|
part := ""
|
||||||
|
if p.Action != ActionRemove && !whole {
|
||||||
|
part = " Part shown"
|
||||||
|
}
|
||||||
|
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
|
||||||
|
if len([]rune(headline)) > asks.HeadlineLength {
|
||||||
|
headline = verb + " Claude instructions?" + part
|
||||||
|
}
|
||||||
|
var d strings.Builder
|
||||||
|
if p.Action == ActionRemove {
|
||||||
|
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. The approved text it removes has the fingerprint %s.",
|
||||||
|
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Fingerprint())
|
||||||
|
} else {
|
||||||
|
if whole {
|
||||||
|
d.WriteString("The text:\n" + shown + "\n")
|
||||||
|
} else {
|
||||||
|
d.WriteString("NOT SHOWN IN FULL here. Its first lines:\n" + shown + "\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC: %d lines, %d bytes, fingerprint %s.",
|
||||||
|
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Lines(), len(p.Text), p.Fingerprint())
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&d, "\nRead all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'.", p.ID)
|
||||||
|
does := "the text becomes part of those sessions' instructions"
|
||||||
|
if p.Action == ActionRemove {
|
||||||
|
does = "the instructions are removed from those sessions"
|
||||||
|
}
|
||||||
|
return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
|
||||||
|
}
|
||||||
|
|
||||||
|
// summary is what the proposal does, at a glance: its proposer's words, or the module's.
|
||||||
|
func (p Proposal) summary() string {
|
||||||
|
if s := strings.TrimSpace(p.Summary); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
lines := fmt.Sprintf("these %d lines", p.Lines())
|
||||||
|
if p.Lines() == 1 {
|
||||||
|
lines = "this line"
|
||||||
|
}
|
||||||
|
where := strings.ToUpper(p.where()[:1]) + p.where()[1:]
|
||||||
|
switch p.Action {
|
||||||
|
case ActionRemove:
|
||||||
|
return fmt.Sprintf("%s stops reading the instructions '%s' from its next start.", where, p.Name)
|
||||||
|
case ActionReplace:
|
||||||
|
return fmt.Sprintf("%s reads %s in place of the instructions '%s', as the mesh's own word, from its next start until "+
|
||||||
|
"they are removed the same way.", where, lines, p.Name)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s reads %s as the mesh's own word, from its next start until they are removed the same way.", where, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummaryProblem says why a proposer's summary cannot be shown at a glance, or "".
|
||||||
|
func SummaryProblem(summary string) string {
|
||||||
|
switch s := strings.TrimSpace(summary); {
|
||||||
|
case s == "":
|
||||||
|
return ""
|
||||||
|
case strings.Contains(s, "\n"):
|
||||||
|
return "the summary is more than one line"
|
||||||
|
case len([]rune(s)) > asks.SummaryLength:
|
||||||
|
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength)
|
||||||
|
case !shownLine(s):
|
||||||
|
return "the summary names a path, an address, a host name or a long word, which a phone is not shown"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its two
|
||||||
|
// options, each binding its act.
|
||||||
|
func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
|
||||||
|
a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes"
|
||||||
|
a.About = "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name
|
||||||
|
if len(p.Nodes) == 0 {
|
||||||
|
a.About = "instructions." + p.Scope + "." + p.Name
|
||||||
|
}
|
||||||
|
for _, o := range []struct{ id, label, does string }{
|
||||||
|
{OptionApprove, "Approve", does},
|
||||||
|
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
|
||||||
|
} {
|
||||||
|
binds, _ := asks.ActDigest(p.Act(o.id))
|
||||||
|
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the
|
||||||
|
// warrant of a proposal made then.
|
||||||
|
func (p Proposal) listedAsk() asks.Ask {
|
||||||
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
||||||
shown, whole := excerpt(p.Text)
|
shown, whole := excerpt(p.Text)
|
||||||
part := ""
|
part := ""
|
||||||
@@ -617,6 +723,8 @@ type ProposeInput struct {
|
|||||||
Name string
|
Name string
|
||||||
Text string
|
Text string
|
||||||
Remove bool
|
Remove bool
|
||||||
|
// Summary is what the operator's phone shows of it, in one line (novox/hq issue 639); empty, the module says it.
|
||||||
|
Summary string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
|
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
|
||||||
@@ -696,7 +804,11 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
|
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
|
||||||
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC()}
|
if problem := SummaryProblem(in.Summary); problem != "" {
|
||||||
|
return refuse(problem)
|
||||||
|
}
|
||||||
|
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC(),
|
||||||
|
Summary: strings.TrimSpace(in.Summary), Form: AskGlance}
|
||||||
switch {
|
switch {
|
||||||
case in.Remove:
|
case in.Remove:
|
||||||
if !isActive {
|
if !isActive {
|
||||||
|
|||||||
@@ -147,14 +147,17 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
|||||||
if err := a.Check(t0); err != nil {
|
if err := a.Check(t0); err != nil {
|
||||||
t.Fatalf("the router would refuse the ask: %v", err)
|
t.Fatalf("the router would refuse the ask: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(a.Explanation, "Add instructions 'handoff-from-g14' to every Claude session on the mesh?") {
|
// At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the
|
||||||
t.Errorf("the phone does not say what is asked first: %q", a.Explanation)
|
// router notes collapsed after the answers.
|
||||||
|
if a.Explanation != "Every Claude session on the mesh reads these 4 lines as the mesh's own word, from its next start until they are removed the same way." ||
|
||||||
|
asks.Glance(a.Explanation) != nil {
|
||||||
|
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
|
||||||
}
|
}
|
||||||
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
|
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
|
||||||
t.Errorf("headline: %q", a.Headline)
|
t.Errorf("headline: %q", a.Headline)
|
||||||
}
|
}
|
||||||
if !strings.Contains(a.Explanation, "The whole text:\n\n"+strings.TrimRight(handover, "\n")) {
|
if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on g14 at ") {
|
||||||
t.Errorf("a short text is shown whole: %q", a.Explanation)
|
t.Errorf("a short text is not shown whole: %q", a.Details)
|
||||||
}
|
}
|
||||||
if len(a.Options) != 2 {
|
if len(a.Options) != 2 {
|
||||||
t.Fatalf("options: %+v", a.Options)
|
t.Fatalf("options: %+v", a.Options)
|
||||||
@@ -294,7 +297,7 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
rid := out["proposed"].(string)
|
rid := out["proposed"].(string)
|
||||||
if !strings.HasPrefix(r.published[rid].Explanation, "Remove the instructions 'handoff-from-g14' from every Claude session on the mesh?") {
|
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-g14' from its next start." {
|
||||||
t.Errorf("removal ask: %q", r.published[rid].Explanation)
|
t.Errorf("removal ask: %q", r.published[rid].Explanation)
|
||||||
}
|
}
|
||||||
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
||||||
@@ -326,7 +329,7 @@ func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
id := out["proposed"].(string)
|
id := out["proposed"].(string)
|
||||||
if !strings.HasPrefix(r.published[id].Explanation, "Add instructions 'builds' to every Claude session on shanks?") {
|
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on shanks reads this line as the mesh's own word") {
|
||||||
t.Errorf("%q", r.published[id].Explanation)
|
t.Errorf("%q", r.published[id].Explanation)
|
||||||
}
|
}
|
||||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||||
@@ -417,15 +420,16 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
id := out["proposed"].(string)
|
id := out["proposed"].(string)
|
||||||
a := r.published[id]
|
a := r.published[id]
|
||||||
if strings.Contains(a.Explanation, "/etc/claude-code") || !strings.Contains(a.Explanation, "[1 line(s) not shown here") {
|
if strings.Contains(a.Details, "/etc/claude-code") || !strings.Contains(a.Details, "[1 line(s) not shown here") ||
|
||||||
t.Errorf("a line with a path was shown: %q", a.Explanation)
|
!strings.HasPrefix(a.Details, "NOT SHOWN IN FULL here.") || !strings.HasSuffix(a.Headline, "Part shown") {
|
||||||
|
t.Errorf("a line with a path was shown, or the cut not said: %q", a.Details)
|
||||||
}
|
}
|
||||||
if !strings.Contains(a.Explanation, "mesh-cli ask claude-code claude_code_proposals") ||
|
if !strings.Contains(a.Details, "mesh-cli ask claude-code claude_code_proposals") ||
|
||||||
!strings.Contains(a.Explanation, "fingerprint there must be "+out["fingerprint"].(string)) {
|
!strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) {
|
||||||
t.Errorf("the phone does not say where to read all of it: %q", a.Explanation)
|
t.Errorf("the phone does not say where to read all of it: %q", a.Details)
|
||||||
}
|
}
|
||||||
if len(a.Explanation) > 3000 {
|
if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil {
|
||||||
t.Errorf("the explanation is %d bytes", len(a.Explanation))
|
t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation)
|
||||||
}
|
}
|
||||||
list, err := ListProposals(p, store, r, id, t0)
|
list, err := ListProposals(p, store, r, id, t0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -739,3 +743,33 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
|
|||||||
t.Errorf("decided %q", got)
|
t.Errorf("decided %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A proposer's summary is the explanation, held to a glance; a proposal made before issue 639 is composed as it was
|
||||||
|
// asked, so the warrant for it still holds after the change.
|
||||||
|
func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
|
||||||
|
p, _ := node(t, "g14")
|
||||||
|
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||||
|
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
|
||||||
|
Summary: "Every session learns how hand-overs are written, so the next one reads them the same way."}, store, r, t0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id := out["proposed"].(string)
|
||||||
|
a := r.published[id]
|
||||||
|
if a.Explanation != "Every session learns how hand-overs are written, so the next one reads them the same way." {
|
||||||
|
t.Errorf("the proposer's summary is not the explanation: %q", a.Explanation)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{strings.Repeat("word ", 70), "one\ntwo", "Read it in /etc/claude-code first."} {
|
||||||
|
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "other", Text: handover, Summary: bad}, store, r, t0); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "the summary") {
|
||||||
|
t.Errorf("the summary %q was taken: %v", bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prop, _ := readProposal(store, id)
|
||||||
|
older := prop
|
||||||
|
older.Form = 0
|
||||||
|
listed := older.Ask()
|
||||||
|
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || listed.Digest() == prop.Ask().Digest() {
|
||||||
|
t.Errorf("a proposal made before is not composed as it was asked: %q", listed.Explanation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ const (
|
|||||||
// The bounds of what a question says.
|
// The bounds of what a question says.
|
||||||
const (
|
const (
|
||||||
FewestQuestionOptions = 2
|
FewestQuestionOptions = 2
|
||||||
MostContextBytes = 1500
|
MostContextBytes = asks.SummaryLength
|
||||||
MostMeansBytes = 200
|
MostMeansBytes = 200
|
||||||
MostWhyBytes = 300
|
MostWhyBytes = 120
|
||||||
MostOnExpiryBytes = 200
|
MostOnExpiryBytes = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,6 +131,8 @@ type Question struct {
|
|||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
At time.Time `json:"at"`
|
At time.Time `json:"at"`
|
||||||
Expires time.Time `json:"expires"`
|
Expires time.Time `json:"expires"`
|
||||||
|
// Form is how its ask is composed (AskGlance since novox/hq issue 639), so the answer is held to the ask asked.
|
||||||
|
Form int `json:"form,omitempty"`
|
||||||
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
||||||
Asked string `json:"asked,omitempty"`
|
Asked string `json:"asked,omitempty"`
|
||||||
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
|
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
|
||||||
@@ -160,8 +162,41 @@ func (q Question) Act(option string) asks.Act {
|
|||||||
return act
|
return act
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask is the ask for a question, composed from it alone.
|
// Ask is the ask for a question, composed from it alone, in the form it was asked in.
|
||||||
func (q Question) Ask() asks.Ask {
|
func (q Question) Ask() asks.Ask {
|
||||||
|
if q.Form == AskGlance {
|
||||||
|
return q.glanceAsk()
|
||||||
|
}
|
||||||
|
return q.listedAsk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended
|
||||||
|
// answer and why as its one point; who asked and what the answer does are its Details.
|
||||||
|
func (q Question) glanceAsk() asks.Ask {
|
||||||
|
explanation := strings.TrimSpace(q.Context)
|
||||||
|
if rec, ok := q.recommended(); ok {
|
||||||
|
explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
|
||||||
|
}
|
||||||
|
details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+
|
||||||
|
"to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
|
||||||
|
return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q Question) withOptions(a asks.Ask) asks.Ask {
|
||||||
|
a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry
|
||||||
|
for _, o := range q.Options {
|
||||||
|
does := o.Means
|
||||||
|
if o.Recommended {
|
||||||
|
does = "Recommended. " + does
|
||||||
|
}
|
||||||
|
binds, _ := asks.ActDigest(q.Act(o.ID))
|
||||||
|
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then.
|
||||||
|
func (q Question) listedAsk() asks.Ask {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(strings.TrimSpace(q.Context))
|
b.WriteString(strings.TrimSpace(q.Context))
|
||||||
if rec, ok := q.recommended(); ok {
|
if rec, ok := q.recommended(); ok {
|
||||||
@@ -199,8 +234,10 @@ func QuestionProblem(q Question) string {
|
|||||||
switch {
|
switch {
|
||||||
case strings.TrimSpace(q.Context) == "":
|
case strings.TrimSpace(q.Context) == "":
|
||||||
add("it gives no context: say in plain words what the operator decides and why it matters")
|
add("it gives no context: say in plain words what the operator decides and why it matters")
|
||||||
case len(q.Context) > MostContextBytes:
|
case len([]rune(q.Context)) > MostContextBytes:
|
||||||
add("its context is %d bytes, more than the %d a phone shows in one glance", len(q.Context), MostContextBytes)
|
add("its context is %d characters, more than the %d a phone shows in one glance", len([]rune(q.Context)), MostContextBytes)
|
||||||
|
case strings.Contains(strings.TrimSpace(q.Context), "\n"):
|
||||||
|
add("its context is more than one line: a phone shows it at a glance as one short paragraph")
|
||||||
}
|
}
|
||||||
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
|
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
|
||||||
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
|
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
|
||||||
@@ -322,7 +359,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
|||||||
}
|
}
|
||||||
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
|
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
|
||||||
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
|
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
|
||||||
Expires: now.UTC().Add(life)}
|
Expires: now.UTC().Add(life), Form: AskGlance}
|
||||||
for i, o := range in.Options {
|
for i, o := range in.Options {
|
||||||
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
|
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
|
||||||
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
|
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
|
||||||
|
|||||||
@@ -68,10 +68,13 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
|||||||
if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") {
|
if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") {
|
||||||
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
|
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
|
||||||
}
|
}
|
||||||
for _, want := range []string{"Recommended: Merge now, because it is reviewed and the checks are green.",
|
// At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details.
|
||||||
"the agent session fix-589 on g14", "performs nothing by itself"} {
|
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
|
||||||
if !strings.Contains(a.Explanation, want) {
|
t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
|
||||||
t.Errorf("the explanation does not say %q:\n%s", want, a.Explanation)
|
}
|
||||||
|
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
|
||||||
|
if !strings.Contains(a.Details, want) || strings.Contains(a.Explanation, want) {
|
||||||
|
t.Errorf("the details do not say %q:\n%s", want, a.Details)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := a.Check(t0); err != nil {
|
if err := a.Check(t0); err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ module claude-code
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
git.novox.be/novox/mesh-sdk/go v0.1.13 h1:Su4JYpZ+zhNovkGA2DgxiZdcuHpjw2tPJzc/7PljhXg=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.13/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
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=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"holdings",
|
"holdings",
|
||||||
"config",
|
"config",
|
||||||
"proposals",
|
"proposals",
|
||||||
"questions"
|
"questions",
|
||||||
|
"memory"
|
||||||
],
|
],
|
||||||
"reads": [
|
"reads": [
|
||||||
"claude-licence-manager.bindings"
|
"claude-licence-manager.bindings"
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"claude_code_grant",
|
"claude_code_grant",
|
||||||
"claude_code_add_api_key",
|
"claude_code_add_api_key",
|
||||||
"claude_code_registrations",
|
"claude_code_registrations",
|
||||||
|
"claude_code_memory",
|
||||||
"claude_code_mcp_list",
|
"claude_code_mcp_list",
|
||||||
"claude_code_mcp_register",
|
"claude_code_mcp_register",
|
||||||
"claude_code_mcp_unregister",
|
"claude_code_mcp_unregister",
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ func text(a map[string]any, k string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
||||||
|
// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625).
|
||||||
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
||||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
||||||
Run: func(a map[string]any) (any, error) {
|
Run: func(a map[string]any) (any, error) {
|
||||||
@@ -260,7 +261,8 @@ func verb(name, description string, input map[string]any, run func(ctx context.C
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return run(context.Background(), m, a)
|
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)})
|
||||||
|
return run(ctx, m, a)
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +290,7 @@ func tools() []stdio.Tool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.Bind(ctx, c, l, "bind")
|
return m.Bind(ctx, c, l)
|
||||||
}),
|
}),
|
||||||
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it. A login on a node does this by itself (ADR 0209); this is for moving one without a login, or back.",
|
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it. A login on a node does this by itself (ADR 0209); this is for moving one without a login, or back.",
|
||||||
map[string]any{"consumer": consumer, "licence": str("the licence to move to")},
|
map[string]any{"consumer": consumer, "licence": str("the licence to move to")},
|
||||||
@@ -297,7 +299,7 @@ func tools() []stdio.Tool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.Bind(ctx, c, l, "switch")
|
return m.Bind(ctx, c, l)
|
||||||
}),
|
}),
|
||||||
verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.",
|
verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.",
|
||||||
map[string]any{"consumer": consumer},
|
map[string]any{"consumer": consumer},
|
||||||
@@ -306,7 +308,7 @@ func tools() []stdio.Tool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.Release(ctx, c, "release")
|
return m.Release(ctx, c)
|
||||||
}),
|
}),
|
||||||
verb("refresh", "Refresh a licence now, or every due licence when none is named; under each licence's lease, so it never races the daemon. Answers the outcome, never a token.",
|
verb("refresh", "Refresh a licence now, or every due licence when none is named; under each licence's lease, so it never races the daemon. Answers the outcome, never a token.",
|
||||||
map[string]any{"licence": str("a licence; absent for every due one")},
|
map[string]any{"licence": str("a licence; absent for every due one")},
|
||||||
|
|||||||
@@ -59,6 +59,54 @@ type BindingState struct {
|
|||||||
Licence string `json:"licence"`
|
Licence string `json:"licence"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Generation int64 `json:"generation"`
|
Generation int64 `json:"generation"`
|
||||||
|
// Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it.
|
||||||
|
Why string `json:"why,omitempty"`
|
||||||
|
By string `json:"by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
|
||||||
|
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
|
||||||
|
type Cause struct {
|
||||||
|
Why string
|
||||||
|
By string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Itself names this module where it moved a binding by a rule of its own, with no caller.
|
||||||
|
const Itself = "claude-licence-manager"
|
||||||
|
|
||||||
|
type causeKey struct{}
|
||||||
|
|
||||||
|
// WithCause is ctx carrying why what runs in it changes a binding, and who asked.
|
||||||
|
func WithCause(ctx context.Context, c Cause) context.Context {
|
||||||
|
return context.WithValue(ctx, causeKey{}, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CauseOf is the cause ctx carries, or "a rule of this module's own" when none: never empty, so a record always
|
||||||
|
// says something, and a change nobody named a cause for reads as this module's own.
|
||||||
|
func CauseOf(ctx context.Context) Cause {
|
||||||
|
if c, ok := ctx.Value(causeKey{}).(Cause); ok && c.By != "" {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return Cause{Why: "a rule of this module's own", By: Itself}
|
||||||
|
}
|
||||||
|
|
||||||
|
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
|
||||||
|
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
|
||||||
|
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
|
||||||
|
_ = m.Store.Audit(ctx, what, detail)
|
||||||
|
m.Log("%s", line)
|
||||||
|
_ = m.Emit("licence."+what, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
|
||||||
|
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
|
||||||
|
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
|
||||||
|
if from != "" {
|
||||||
|
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
|
||||||
|
}
|
||||||
|
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
|
||||||
|
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
|
||||||
|
"why": c.Why, "by": c.By})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings are the manager's own, declared with defaults (design 39 §8).
|
// Settings are the manager's own, declared with defaults (design 39 §8).
|
||||||
@@ -267,11 +315,12 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its account's report"})
|
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
|
||||||
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
|
||||||
|
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation,
|
||||||
|
Why: c.Why, By: c.By}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.Log("bound %s to %s, the licence its account already has", rep.Node, l.Name)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -351,21 +400,21 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
|
|
||||||
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
|
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
|
||||||
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
|
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
|
||||||
|
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
|
||||||
|
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
|
||||||
|
ctx = WithCause(ctx, cause)
|
||||||
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
|
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
} else if b == nil || b.Licence != l.Name {
|
} else if b == nil || b.Licence != l.Name {
|
||||||
if _, err := m.Store.Bind(ctx, c.Node, l.Name); err != nil {
|
moved, err := m.Store.Bind(ctx, c.Node, l.Name)
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
from := ""
|
from := ""
|
||||||
if b != nil {
|
if b != nil {
|
||||||
from = b.Licence
|
from = b.Licence
|
||||||
}
|
}
|
||||||
_ = m.Store.Audit(ctx, map[bool]string{true: "switched", false: "bound"}[b != nil],
|
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
|
||||||
map[string]any{"consumer": c.Node, "licence": l.Name, "from": from, "by": "a login there"})
|
|
||||||
if b != nil {
|
|
||||||
m.Log("%s moved from %s to %s: a login there", c.Node, from, l.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, rep := range reports {
|
for _, rep := range reports {
|
||||||
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
||||||
@@ -374,8 +423,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
|||||||
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
|
if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
|
||||||
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its login"})
|
m.moved(ctx, rep.Node, "", l.Name, b.Generation,
|
||||||
|
Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := m.publishAdvance(ctx, l); err != nil {
|
if err := m.publishAdvance(ctx, l); err != nil {
|
||||||
@@ -399,14 +449,17 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
|
|||||||
l.RotatedAt = m.Now().UnixMilli()
|
l.RotatedAt = m.Now().UnixMilli()
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state.
|
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
|
||||||
|
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
|
||||||
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
|
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
|
||||||
bindings, err := m.Store.Advance(ctx, l.Name)
|
bindings, err := m.Store.Advance(ctx, l.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
c := CauseOf(ctx)
|
||||||
for _, b := range bindings {
|
for _, b := range bindings {
|
||||||
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation,
|
||||||
|
Why: c.Why, By: c.By}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,7 +535,19 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
|||||||
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = m.Store.Audit(ctx, "rotated", map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt})
|
c := CauseOf(ctx)
|
||||||
|
if force {
|
||||||
|
c.Why = "a refresh asked for: " + c.Why
|
||||||
|
} else {
|
||||||
|
c = Cause{Why: "due: older than its cadence or near its expiry", By: Itself}
|
||||||
|
if by := CauseOf(ctx); by.By != Itself {
|
||||||
|
c.By = by.By
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
|
||||||
|
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
|
||||||
|
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
|
||||||
|
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By})
|
||||||
if err := m.publishAdvance(ctx, *l); err != nil {
|
if err := m.publishAdvance(ctx, *l); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -594,8 +659,9 @@ func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation.
|
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
|
||||||
func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[string]any, error) {
|
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
|
||||||
|
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
|
||||||
l, err := m.Store.Licence(ctx, licence)
|
l, err := m.Store.Licence(ctx, licence)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -611,19 +677,21 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
from, what := "", "bound"
|
from := ""
|
||||||
if before != nil {
|
if before != nil {
|
||||||
from, what = before.Licence, "switched"
|
from = before.Licence
|
||||||
}
|
}
|
||||||
_ = m.Store.Audit(ctx, what, map[string]any{"consumer": consumer, "licence": licence, "from": from, "by": by})
|
c := CauseOf(ctx)
|
||||||
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
m.moved(ctx, consumer, from, licence, b.Generation, c)
|
||||||
|
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation,
|
||||||
|
Why: c.Why, By: c.By}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release unbinds a consumer; its node keeps its last token, which expires within hours.
|
// Release unbinds a consumer; its node keeps its last token, which expires within hours.
|
||||||
func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]any, error) {
|
func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, error) {
|
||||||
was, err := m.Store.Binding(ctx, consumer)
|
was, err := m.Store.Binding(ctx, consumer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -634,7 +702,9 @@ func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]
|
|||||||
if err := m.DeleteBinding(ctx, consumer); err != nil {
|
if err := m.DeleteBinding(ctx, consumer); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = m.Store.Audit(ctx, "released", map[string]any{"consumer": consumer, "licence": was.Licence, "by": by})
|
c := CauseOf(ctx)
|
||||||
|
m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
|
||||||
|
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By})
|
||||||
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,7 +755,10 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
|
|||||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from})
|
c := CauseOf(ctx)
|
||||||
|
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
|
||||||
|
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
|
||||||
|
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
|
||||||
if err := m.publishAdvance(ctx, l); err != nil {
|
if err := m.publishAdvance(ctx, l); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,8 +132,13 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
|
|||||||
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
|
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
|
||||||
t.Fatalf("bindings %v, state %v", bs, mm.state)
|
t.Fatalf("bindings %v, state %v", bs, mm.state)
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(strings.Join(mm.events, "|"), "licence.adopted") {
|
// The adoption is said, and so is each binding it made (novox/hq issue 625).
|
||||||
t.Fatalf("events %v", mm.events)
|
events := strings.Join(mm.events, "|")
|
||||||
|
for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`,
|
||||||
|
`licence.bound {"by":"claude-licence-manager","consumer":"server"`} {
|
||||||
|
if !strings.Contains(events, want) {
|
||||||
|
t.Fatalf("events lack %s: %v", want, mm.events)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,13 +239,13 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
|||||||
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := mm.m.Bind(ctx, "laptop", "api", "test"); err != nil {
|
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
|
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
|
||||||
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
|
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
|
||||||
}
|
}
|
||||||
if _, err := mm.m.Release(ctx, "laptop", "test"); err != nil {
|
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, ok := mm.state["laptop"]; ok {
|
if _, ok := mm.state["laptop"]; ok {
|
||||||
@@ -454,3 +459,73 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
|
|||||||
t.Fatal("nothing was kept")
|
t.Fatal("nothing was kept")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
|
||||||
|
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release.
|
||||||
|
func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
var lines []string
|
||||||
|
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||||
|
if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
|
||||||
|
t.Fatalf("a login's binding said %+v", s)
|
||||||
|
}
|
||||||
|
file := filepath.Join(t.TempDir(), "key")
|
||||||
|
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
|
||||||
|
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
|
||||||
|
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
||||||
|
t.Fatalf("the node is told %+v", s)
|
||||||
|
}
|
||||||
|
said := strings.Join(lines, "\n")
|
||||||
|
if !strings.Contains(said, "switched laptop from "+licence1+" to api (generation ") ||
|
||||||
|
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
|
||||||
|
t.Fatalf("the journal said:\n%s", said)
|
||||||
|
}
|
||||||
|
var switched string
|
||||||
|
for _, e := range mm.events {
|
||||||
|
if strings.HasPrefix(e, "licence.switched ") {
|
||||||
|
switched = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
|
||||||
|
if !strings.Contains(switched, want) {
|
||||||
|
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last := mm.store.Audits[len(mm.store.Audits)-1]
|
||||||
|
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
|
||||||
|
t.Fatalf("the audit kept %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
|
||||||
|
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
|
||||||
|
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
|
||||||
|
t.Fatalf("the journal said:\n%s", said)
|
||||||
|
}
|
||||||
|
for _, e := range mm.events {
|
||||||
|
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
|
||||||
|
t.Fatalf("an event carries a secret: %s", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A call with no cause is said as this module's own, never as nobody.
|
||||||
|
func TestNoCauseReadsAsTheModulesOwn(t *testing.T) {
|
||||||
|
if c := CauseOf(context.Background()); c.By != Itself || c.Why == "" {
|
||||||
|
t.Fatalf("no cause read as %+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module claude-licence-manager
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.7
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac h1:jnZ/n+fFMo7VQPNNXK1gjStHIJPw3SPbzPxsf/PpZqw=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -8,7 +8,11 @@
|
|||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"postgres-database": {
|
"postgres-database": {
|
||||||
"name": "claude_licences"
|
"name": "claude_licences",
|
||||||
|
"readable": [
|
||||||
|
"audit",
|
||||||
|
"binding"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -59,6 +63,10 @@
|
|||||||
"emits": [
|
"emits": [
|
||||||
"licence.adopted",
|
"licence.adopted",
|
||||||
"licence.refused",
|
"licence.refused",
|
||||||
|
"licence.bound",
|
||||||
|
"licence.switched",
|
||||||
|
"licence.released",
|
||||||
|
"licence.rotated",
|
||||||
"licence.failing",
|
"licence.failing",
|
||||||
"usage.read"
|
"usage.read"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -233,7 +233,13 @@ func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
|
|||||||
case m.Silent:
|
case m.Silent:
|
||||||
urgency = "low"
|
urgency = "low"
|
||||||
}
|
}
|
||||||
args := map[string]any{"summary": m.Title, "body": html.EscapeString(m.Body), "urgency": urgency, "app_name": "mesh"}
|
// The note — who asked, what each answer does, the expiry — follows the body after a blank line: a
|
||||||
|
// notification has no smaller text, and the note is never left out (novox/hq issue 639).
|
||||||
|
text := strings.TrimSpace(m.Body)
|
||||||
|
if note := strings.TrimSpace(m.Note); note != "" {
|
||||||
|
text = strings.TrimSpace(text + "\n\n" + note)
|
||||||
|
}
|
||||||
|
args := map[string]any{"summary": m.Title, "body": html.EscapeString(text), "urgency": urgency, "app_name": "mesh"}
|
||||||
if replace != 0 {
|
if replace != 0 {
|
||||||
args["replace_id"] = replace
|
args["replace_id"] = replace
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The note follows the body after a blank line: the desk has no smaller text, and leaves nothing out (issue 639).
|
||||||
|
func TestTheNoteFollowsTheBody(t *testing.T) {
|
||||||
|
d, shown, _, _ := newDesk(t, "laptop")
|
||||||
|
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Body: "The summary.", Note: "Asked by mesh-knowledge at 16:05.",
|
||||||
|
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||||
|
if last := (*shown)[len(*shown)-1]; last.args["body"] != "The summary.\n\nAsked by mesh-knowledge at 16:05." {
|
||||||
|
t.Errorf("the body: %q", last.args["body"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnEditReplacesTheNotification(t *testing.T) {
|
func TestAnEditReplacesTheNotification(t *testing.T) {
|
||||||
d, shown, _, _ := newDesk(t, "laptop")
|
d, shown, _, _ := newDesk(t, "laptop")
|
||||||
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ module deskchannel
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.10
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
|
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
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=
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# dev-tools
|
||||||
|
|
||||||
|
The tools a machine needs to develop the mesh's own code (novox/hq issue 626). Every node is a development
|
||||||
|
machine: an agent working on a change builds it and runs its tests there, with these tools, before it opens a
|
||||||
|
pull request. The lab is the QA step after that: it tests the work in a sandbox, through its seat at the mesh
|
||||||
|
scope, and is not where development happens.
|
||||||
|
|
||||||
|
Assign it to every node.
|
||||||
|
|
||||||
|
## Owns
|
||||||
|
|
||||||
|
| what | package | class (ADR 0182) |
|
||||||
|
|---|---|---|
|
||||||
|
| the Go toolchain, for `go build` and `go test` in every Go module | `go` | package |
|
||||||
|
| a C compiler, for `go test -race` and cgo | `gcc` | package |
|
||||||
|
| make | `make` | package |
|
||||||
|
| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | `nodejs`, `npm` | package |
|
||||||
|
| Python, for the scripts some checks run | `python` | package |
|
||||||
|
| git and jq | `git`, `jq` | package |
|
||||||
|
|
||||||
|
All are in the distribution's official repositories, so the node-engine's package manager installs them on any
|
||||||
|
node. The versions are the distribution's: the toolchain images that run `mesh/repo-check` stay the judge of a
|
||||||
|
pull request, and a local run is the developer's early answer, not the verdict.
|
||||||
|
|
||||||
|
Nothing else is placed: no file, no service, no environment. A module's own dependencies come from its lock
|
||||||
|
file (`go.mod`, `package-lock.json`), as in the toolchain images.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"module": "dev-tools",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "go",
|
||||||
|
"type": "package",
|
||||||
|
"package": "go"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c-compiler",
|
||||||
|
"type": "package",
|
||||||
|
"package": "gcc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "make",
|
||||||
|
"type": "package",
|
||||||
|
"package": "make"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nodejs",
|
||||||
|
"type": "package",
|
||||||
|
"package": "nodejs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "npm",
|
||||||
|
"type": "package",
|
||||||
|
"package": "npm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "python",
|
||||||
|
"type": "package",
|
||||||
|
"package": "python"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "git",
|
||||||
|
"type": "package",
|
||||||
|
"package": "git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "jq",
|
||||||
|
"type": "package",
|
||||||
|
"package": "jq"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
How loaded a machine's storage is, read from `/proc` and `/sys` and nothing else: the share of CPU time
|
How loaded a machine's storage is, read from `/proc` and `/sys` and nothing else: the share of CPU time
|
||||||
spent waiting on I/O, I/O pressure stall, each disk's busy share, throughput, IOPS, wait and queue
|
spent waiting on I/O, I/O pressure stall, each disk's busy share, throughput, IOPS, wait and queue
|
||||||
depth, the filesystems' space, and the units, containers and processes doing the I/O (novox/hq issue
|
depth, the filesystems' space, and the units, containers and processes doing the I/O (novox/hq issue
|
||||||
312). It changes nothing and declares no resources, so it can be assigned to every machine.
|
312). It also reads a machine's CPU use by systemd unit and by container, from each cgroup's cpu.stat,
|
||||||
|
so an overload outside every container can be named (novox/hq issue 741). It changes nothing and
|
||||||
|
declares no resources, so it can be assigned to every machine.
|
||||||
|
|
||||||
## Why it exists, and why here
|
## Why it exists, and why here
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ machine's resources a seat, these tools become its verbs.
|
|||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
All four only read.
|
All five only read.
|
||||||
|
|
||||||
| tool | what | replaces |
|
| tool | what | replaces |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -30,6 +32,7 @@ All four only read.
|
|||||||
| `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` |
|
| `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` |
|
||||||
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
|
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
|
||||||
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
|
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
|
||||||
|
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` |
|
||||||
|
|
||||||
### How each number is read
|
### How each number is read
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,10 @@ func (m *Machine) bottomDevices() map[string]bool {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// unitIO is every unit's storage I/O, by its cgroup's path: the innermost .service or .scope, because
|
// unitPaths is every systemd unit's cgroup path under /sys/fs/cgroup: the innermost .service or
|
||||||
// io.stat counts a cgroup's descendants too and an outer unit would count them twice.
|
// .scope, because a cgroup's own stat file counts its descendants too and an outer unit would count
|
||||||
func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO {
|
// them twice. Shared by every reader that walks the cgroup tree (unitIO here, unitCPU in cpu.go).
|
||||||
|
func (m *Machine) unitPaths() []string {
|
||||||
base := m.path("/sys/fs/cgroup")
|
base := m.path("/sys/fs/cgroup")
|
||||||
var units []string
|
var units []string
|
||||||
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
||||||
@@ -89,11 +90,19 @@ func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO {
|
|||||||
outer[dir] = true
|
outer[dir] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out := map[string]cgroupIO{}
|
out := make([]string, 0, len(units))
|
||||||
for _, u := range units {
|
for _, u := range units {
|
||||||
if outer[u] {
|
if !outer[u] {
|
||||||
continue
|
out = append(out, u)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// unitIO is every unit's storage I/O, by its cgroup's path.
|
||||||
|
func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO {
|
||||||
|
out := map[string]cgroupIO{}
|
||||||
|
for _, u := range m.unitPaths() {
|
||||||
text := m.read("/sys/fs/cgroup/" + u + "/io.stat")
|
text := m.read("/sys/fs/cgroup/" + u + "/io.stat")
|
||||||
if text == "" {
|
if text == "" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cgroupCPU is a cgroup's processor time, from its cpu.stat.
|
||||||
|
type cgroupCPU struct {
|
||||||
|
usageUsec uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCPUStat reads a cgroup's cpu.stat ("usage_usec 123\nuser_usec …\nsystem_usec …"), readable by
|
||||||
|
// every account, unlike /proc/<pid>/stat for another account's process.
|
||||||
|
func parseCPUStat(s string) cgroupCPU {
|
||||||
|
var c cgroupCPU
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
k, v, ok := strings.Cut(line, " ")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if k == "usage_usec" {
|
||||||
|
c.usageUsec, _ = strconv.ParseUint(strings.TrimSpace(v), 10, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// unitCPU is every unit's processor time, by its cgroup's path. A cgroup whose cpu.stat cannot be
|
||||||
|
// read is left out, never an error for the whole answer.
|
||||||
|
func (m *Machine) unitCPU() map[string]cgroupCPU {
|
||||||
|
out := map[string]cgroupCPU{}
|
||||||
|
for _, u := range m.unitPaths() {
|
||||||
|
text := m.read("/sys/fs/cgroup/" + u + "/cpu.stat")
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[u] = parseCPUStat(text)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MachineCPU is the machine's CPU in use over the window, from /proc/stat's cpu line. CPUsBusy is
|
||||||
|
// rounded only for display; raw, unexported, is the same count unrounded, for topCPU's share and
|
||||||
|
// unaccounted math (review round 1 of novox/hq issue 741: a small busy time that rounds CPUsBusy to
|
||||||
|
// 0.0 must not zero out every unit's share along with it).
|
||||||
|
type MachineCPU struct {
|
||||||
|
CPUs int `json:"cpus"`
|
||||||
|
BusyPct float64 `json:"busy_pct"` // % of all CPUs together
|
||||||
|
CPUsBusy float64 `json:"cpus_busy"` // as a count of CPUs, e.g. 7.5 of 12, rounded for display
|
||||||
|
raw float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// machineCPUOver is the machine's CPU in use between two readings of /proc/stat: everything but idle
|
||||||
|
// and iowait, counted against the window's total ticks over every CPU.
|
||||||
|
func machineCPUOver(a, b cpuTimes) MachineCPU {
|
||||||
|
total := float64(b.total() - a.total())
|
||||||
|
if total <= 0 {
|
||||||
|
return MachineCPU{CPUs: b.cpus}
|
||||||
|
}
|
||||||
|
idle := sub(b.idle, a.idle) + sub(b.iowait, a.iowait)
|
||||||
|
busy := total - idle
|
||||||
|
if busy < 0 {
|
||||||
|
busy = 0
|
||||||
|
}
|
||||||
|
raw := busy / total * float64(b.cpus)
|
||||||
|
return MachineCPU{CPUs: b.cpus, BusyPct: round1(busy / total * 100), CPUsBusy: round1(raw), raw: raw}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPUUsage is a unit's or a container's share of CPU between two readings of its cgroup's cpu.stat.
|
||||||
|
// Cgroup is its cgroup's path, relative to /sys/fs/cgroup, said beside Unit always (review round 1 of
|
||||||
|
// novox/hq issue 741): two cgroups can share a leaf name (an outer "init.scope" and a user session's
|
||||||
|
// "user.slice/user@1000.service/init.scope"), and the path is how they are told apart.
|
||||||
|
type CPUUsage struct {
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Cgroup string `json:"cgroup,omitempty"`
|
||||||
|
Container string `json:"container,omitempty"`
|
||||||
|
CPUPct float64 `json:"cpu_pct"` // % of one CPU: 100 = one CPU busy the whole window
|
||||||
|
SharePct float64 `json:"share_pct"` // this unit's share of the machine's busy time
|
||||||
|
raw float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// topCPU ranks the units, or the containers, with the most CPU time between two readings of
|
||||||
|
// cpu.stat, busiest first. unaccountedPct is the machine's busy time no unit accounts for, as a
|
||||||
|
// share of it (never below 0): the sum is always taken over every unit, whichever by ranks by, so a
|
||||||
|
// limit on the list never changes it.
|
||||||
|
func (m *Machine) topCPU(a, b map[string]cgroupCPU, window time.Duration, by string, limit int, cpusBusy float64) ([]CPUUsage, float64) {
|
||||||
|
windowUsec := window.Seconds() * 1e6
|
||||||
|
type entry struct {
|
||||||
|
path, unit, container string
|
||||||
|
pct float64
|
||||||
|
}
|
||||||
|
var entries []entry
|
||||||
|
var names map[string]string
|
||||||
|
var sumPct float64
|
||||||
|
for path, end := range b {
|
||||||
|
start, ok := a[path]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := sub(end.usageUsec, start.usageUsec)
|
||||||
|
if d == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pct := d / windowUsec * 100
|
||||||
|
sumPct += pct
|
||||||
|
unit := filepath.Base(path)
|
||||||
|
var container string
|
||||||
|
if id := containerID.FindStringSubmatch("/" + path); id != nil {
|
||||||
|
if names == nil {
|
||||||
|
names = m.containerNames()
|
||||||
|
}
|
||||||
|
container = id[1][:12]
|
||||||
|
if n, ok := names[id[1]]; ok {
|
||||||
|
container = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries = append(entries, entry{path: path, unit: unit, container: container, pct: pct})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Units are keyed by their cgroup's own path, never by its leaf name (filepath.Base): two cgroups can
|
||||||
|
// share a leaf name (an outer "init.scope" and a user session's nested one), and keying by the name
|
||||||
|
// alone would merge them into one row (review round 1 of novox/hq issue 741). Containers stay keyed
|
||||||
|
// by their name, as before.
|
||||||
|
groups := map[string]*CPUUsage{}
|
||||||
|
for _, e := range entries {
|
||||||
|
key := e.path
|
||||||
|
if by == "container" {
|
||||||
|
if e.container == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = e.container
|
||||||
|
}
|
||||||
|
g, ok := groups[key]
|
||||||
|
if !ok {
|
||||||
|
g = &CPUUsage{Unit: e.unit, Cgroup: e.path, Container: e.container}
|
||||||
|
if by == "container" {
|
||||||
|
g.Unit, g.Cgroup = "", ""
|
||||||
|
}
|
||||||
|
groups[key] = g
|
||||||
|
}
|
||||||
|
g.raw += e.pct
|
||||||
|
}
|
||||||
|
out := make([]CPUUsage, 0, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
out = append(out, *g)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].raw != out[j].raw {
|
||||||
|
return out[i].raw > out[j].raw
|
||||||
|
}
|
||||||
|
return out[i].Unit+out[i].Cgroup+out[i].Container < out[j].Unit+out[j].Cgroup+out[j].Container
|
||||||
|
})
|
||||||
|
if len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
out[i].CPUPct = round1(out[i].raw)
|
||||||
|
if cpusBusy > 0 {
|
||||||
|
out[i].SharePct = round1(out[i].raw / (cpusBusy * 100) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unaccounted := cpusBusy*100 - sumPct
|
||||||
|
if unaccounted < 0 {
|
||||||
|
unaccounted = 0
|
||||||
|
}
|
||||||
|
unaccountedPct := 0.0
|
||||||
|
if cpusBusy > 0 {
|
||||||
|
unaccountedPct = round1(unaccounted / (cpusBusy * 100) * 100)
|
||||||
|
}
|
||||||
|
return out, unaccountedPct
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// aCPUHog is a machine whose CPU is 75 % busy (1.5 of 2 CPUs) over a 2 s window: a unit, another unit,
|
||||||
|
// and a container, each with a known usage_usec delta, leaving part of the machine's busy time
|
||||||
|
// unaccounted — the shape of issue 739, where 750 % of 12 CPUs went to processes outside containers.
|
||||||
|
func aCPUHog(t *testing.T) *fake {
|
||||||
|
f := newFake(t)
|
||||||
|
f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0 0 0\ncpu0 0 0 0 0 0 0 0 0 0 0\ncpu1 0 0 0 0 0 0 0 0 0 0\nintr 1\n")
|
||||||
|
f.then("/proc/stat", "cpu 100 0 50 50 0 0 0 0 0 0\ncpu0 50 0 25 25 0 0 0 0 0 0\ncpu1 50 0 25 25 0 0 0 0 0 0\nintr 2\n")
|
||||||
|
|
||||||
|
zero := "usage_usec 0\nuser_usec 0\nsystem_usec 0\n"
|
||||||
|
cg := func(path string, usec int) {
|
||||||
|
f.file("/sys/fs/cgroup/"+path+"/cpu.stat", zero)
|
||||||
|
f.then("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec "+itoa(usec)+"\nuser_usec "+itoa(usec)+"\nsystem_usec 0\n")
|
||||||
|
}
|
||||||
|
cg("system.slice/alpha.service", 1000000) // 50 % of one CPU over 2 s
|
||||||
|
cg("system.slice/beta.service", 400000) // 20 %
|
||||||
|
cg("system.slice/docker-"+store+".scope", 600000) // 30 %, named "cpu-hog" by the runtime
|
||||||
|
f.answers["docker ps --no-trunc --format {{.ID}}\t{{.Names}}"] = store + "\tcpu-hog\n"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCPUTopRanksUnitsAndContainersByCPUOverTheWindow(t *testing.T) {
|
||||||
|
f := aCPUHog(t)
|
||||||
|
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := out.(map[string]any)
|
||||||
|
if m["by"] != "unit" || f.waited != 2*time.Second {
|
||||||
|
t.Errorf("the default: %+v, waited %s", m, f.waited)
|
||||||
|
}
|
||||||
|
cpu := m["cpu"].(MachineCPU)
|
||||||
|
if cpu.CPUs != 2 || cpu.BusyPct != 75 || cpu.CPUsBusy != 1.5 {
|
||||||
|
t.Errorf("machine cpu %+v", cpu)
|
||||||
|
}
|
||||||
|
top := m["top"].([]CPUUsage)
|
||||||
|
if len(top) != 3 {
|
||||||
|
t.Fatalf("three units moved: %+v", top)
|
||||||
|
}
|
||||||
|
if top[0].Unit != "alpha.service" || top[0].CPUPct != 50 || top[0].SharePct != 33.3 {
|
||||||
|
t.Errorf("busiest first: %+v", top[0])
|
||||||
|
}
|
||||||
|
if top[1].Container != "cpu-hog" || top[1].CPUPct != 30 || top[1].SharePct != 20 {
|
||||||
|
t.Errorf("the container, named by the runtime: %+v", top[1])
|
||||||
|
}
|
||||||
|
if top[2].Unit != "beta.service" || top[2].CPUPct != 20 || top[2].SharePct != 13.3 {
|
||||||
|
t.Errorf("the least busy: %+v", top[2])
|
||||||
|
}
|
||||||
|
if m["unaccounted_pct"] != 33.3 {
|
||||||
|
t.Errorf("unaccounted: %+v (machine busy 150%% of one CPU, units sum to 100%%, 50%% of 150%% left over)", m["unaccounted_pct"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCPUTopByContainerNamesOnlyContainers(t *testing.T) {
|
||||||
|
out, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"by": "container", "seconds": 2.0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
top := out.(map[string]any)["top"].([]CPUUsage)
|
||||||
|
if len(top) != 1 || top[0].Container != "cpu-hog" || top[0].Unit != "" || top[0].CPUPct != 30 {
|
||||||
|
t.Errorf("by container: %+v", top)
|
||||||
|
}
|
||||||
|
if _, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"by": "process"}); err == nil {
|
||||||
|
t.Error("by=process was accepted; cpu_top only ranks units and containers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCPUTopLimitsHowManyAreNamed(t *testing.T) {
|
||||||
|
out, err := tool(t, aCPUHog(t).machine(), "cpu_top").Run(map[string]any{"limit": 1.0, "seconds": 2.0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := out.(map[string]any)
|
||||||
|
top := m["top"].([]CPUUsage)
|
||||||
|
if len(top) != 1 || top[0].Unit != "alpha.service" {
|
||||||
|
t.Errorf("limit 1: %+v", top)
|
||||||
|
}
|
||||||
|
// The limit narrows the list, never the unaccounted share, which is taken over every unit.
|
||||||
|
if m["unaccounted_pct"] != 33.3 {
|
||||||
|
t.Errorf("unaccounted with limit 1: %+v", m["unaccounted_pct"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCPUTopLeavesOutACgroupThatAppearsOrVanishesWithinTheWindow(t *testing.T) {
|
||||||
|
f := aCPUHog(t)
|
||||||
|
f.then("/sys/fs/cgroup/system.slice/short-lived.service/cpu.stat", "usage_usec 500000\n")
|
||||||
|
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
top := out.(map[string]any)["top"].([]CPUUsage)
|
||||||
|
for _, u := range top {
|
||||||
|
if u.Unit == "short-lived.service" {
|
||||||
|
t.Errorf("a unit only in the second reading was counted: %+v", top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(f.calls, ","), "docker ps") {
|
||||||
|
t.Errorf("the container runtime was never asked: %v", f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aTinyLoad is a machine with 12 CPUs, only 0.04 of them busy over a 2 s window (busy 1 of 300 total
|
||||||
|
// ticks): rounded to one decimal for display, CPUsBusy reads 0.0, but a unit using half of that busy
|
||||||
|
// time still has a non-zero share — review round 1's decision that share_pct and unaccounted_pct are
|
||||||
|
// computed from the unrounded busy time, never from the rounded CPUsBusy.
|
||||||
|
func aTinyLoad(t *testing.T) *fake {
|
||||||
|
f := newFake(t)
|
||||||
|
cpus := "cpu0 0 0 0 0 0 0 0 0 0 0\ncpu1 0 0 0 0 0 0 0 0 0 0\ncpu2 0 0 0 0 0 0 0 0 0 0\ncpu3 0 0 0 0 0 0 0 0 0 0\n" +
|
||||||
|
"cpu4 0 0 0 0 0 0 0 0 0 0\ncpu5 0 0 0 0 0 0 0 0 0 0\ncpu6 0 0 0 0 0 0 0 0 0 0\ncpu7 0 0 0 0 0 0 0 0 0 0\n" +
|
||||||
|
"cpu8 0 0 0 0 0 0 0 0 0 0\ncpu9 0 0 0 0 0 0 0 0 0 0\ncpu10 0 0 0 0 0 0 0 0 0 0\ncpu11 0 0 0 0 0 0 0 0 0 0\n"
|
||||||
|
f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0 0 0\n"+cpus)
|
||||||
|
f.then("/proc/stat", "cpu 1 0 0 299 0 0 0 0 0 0\n"+cpus)
|
||||||
|
|
||||||
|
f.file("/sys/fs/cgroup/system.slice/tiny.service/cpu.stat", "usage_usec 0\n")
|
||||||
|
f.then("/sys/fs/cgroup/system.slice/tiny.service/cpu.stat", "usage_usec 60000\n") // 3 % of one CPU over 2 s
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCPUTopSharesAreNonZeroEvenWhenRoundedCPUsBusyIsZero(t *testing.T) {
|
||||||
|
out, err := tool(t, aTinyLoad(t).machine(), "cpu_top").Run(map[string]any{"seconds": 2.0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := out.(map[string]any)
|
||||||
|
cpu := m["cpu"].(MachineCPU)
|
||||||
|
if cpu.CPUs != 12 || cpu.CPUsBusy != 0 {
|
||||||
|
t.Fatalf("the machine 0.04 CPUs busy, rounded for display: %+v", cpu)
|
||||||
|
}
|
||||||
|
top := m["top"].([]CPUUsage)
|
||||||
|
if len(top) != 1 || top[0].Unit != "tiny.service" || top[0].CPUPct != 3 || top[0].SharePct != 75 {
|
||||||
|
t.Fatalf("a non-zero share from the unrounded busy time: %+v", top)
|
||||||
|
}
|
||||||
|
if m["unaccounted_pct"] != 25.0 {
|
||||||
|
t.Fatalf("unaccounted from the unrounded busy time: %+v (want 25, so shares plus unaccounted add to 100)",
|
||||||
|
m["unaccounted_pct"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCPUTopKeepsCgroupsWithTheSameUnitNameAsSeparateRows is review round 1's other decision: units are keyed
|
||||||
|
// by their cgroup path, never by filepath.Base, so two cgroups that both end in "init.scope" — one at the
|
||||||
|
// root, one under a user's session — are two rows, not one merged by their shared leaf name.
|
||||||
|
func TestCPUTopKeepsCgroupsWithTheSameUnitNameAsSeparateRows(t *testing.T) {
|
||||||
|
f := aCPUHog(t)
|
||||||
|
cg := func(path string, usec int) {
|
||||||
|
f.file("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec 0\n")
|
||||||
|
f.then("/sys/fs/cgroup/"+path+"/cpu.stat", "usage_usec "+itoa(usec)+"\n")
|
||||||
|
}
|
||||||
|
cg("init.scope", 1000000) // the root init, 50 % of one CPU over 2 s
|
||||||
|
cg("user.slice/user@1000.service/init.scope", 400000) // a user session's init, 20 %
|
||||||
|
|
||||||
|
out, err := tool(t, f.machine(), "cpu_top").Run(map[string]any{"seconds": 2.0, "limit": 10.0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
top := out.(map[string]any)["top"].([]CPUUsage)
|
||||||
|
var inits []CPUUsage
|
||||||
|
for _, u := range top {
|
||||||
|
if u.Unit == "init.scope" {
|
||||||
|
inits = append(inits, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(inits) != 2 {
|
||||||
|
t.Fatalf("two cgroups named init.scope, merged into %d row(s): %+v", len(inits), top)
|
||||||
|
}
|
||||||
|
byCgroup := map[string]CPUUsage{}
|
||||||
|
for _, u := range inits {
|
||||||
|
byCgroup[u.Cgroup] = u
|
||||||
|
}
|
||||||
|
if u, ok := byCgroup["init.scope"]; !ok || u.CPUPct != 50 {
|
||||||
|
t.Errorf("the root init.scope: %+v", byCgroup)
|
||||||
|
}
|
||||||
|
if u, ok := byCgroup["user.slice/user@1000.service/init.scope"]; !ok || u.CPUPct != 20 {
|
||||||
|
t.Errorf("the user session's init.scope: %+v", byCgroup)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,57 @@ func Tools(m *Machine) []stdio.Tool {
|
|||||||
return out, nil
|
return out, nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "cpu_top",
|
||||||
|
Description: "What used the most CPU over a few seconds, from each unit's cgroup cpu.stat, which needs no root: the machine's " +
|
||||||
|
"CPU in use (as % of all CPUs and as CPUs busy, from /proc/stat), then by=unit (the default) each systemd unit and " +
|
||||||
|
"container ranked by CPU, busiest first, with the busy time no unit accounts for; by=container only the containers, " +
|
||||||
|
"by name. Replaces top, systemd-cgtop. (r)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"seconds": map[string]any{"type": "integer", "description": "how long to sample, in seconds (default 3, at most 10)"},
|
||||||
|
"limit": map[string]any{"type": "integer", "description": "how many units or containers to name (default 15, at most 50)"},
|
||||||
|
"by": map[string]any{"type": "string", "enum": []string{"unit", "container"}, "description": "what to rank (default unit)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
n, err := count(args, "seconds", 3, 1, 10)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w := time.Duration(n) * time.Second
|
||||||
|
limit, err := count(args, "limit", 15, 1, 50)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
by := text(args, "by")
|
||||||
|
switch by {
|
||||||
|
case "":
|
||||||
|
by = "unit"
|
||||||
|
case "unit", "container":
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("by is unit or container, not %q", by)
|
||||||
|
}
|
||||||
|
ac, err := parseCPU(m.read("/proc/stat"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a := m.unitCPU()
|
||||||
|
m.Wait(w)
|
||||||
|
bc, err := parseCPU(m.read("/proc/stat"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b := m.unitCPU()
|
||||||
|
machine := machineCPUOver(ac, bc)
|
||||||
|
top, unaccounted := m.topCPU(a, b, w, by, limit, machine.raw)
|
||||||
|
return map[string]any{
|
||||||
|
"window_s": w.Seconds(),
|
||||||
|
"by": by,
|
||||||
|
"cpu": machine,
|
||||||
|
"top": top,
|
||||||
|
"unaccounted_pct": unaccounted,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "disk_filesystems",
|
Name: "disk_filesystems",
|
||||||
Description: "The space of every filesystem that stores data (not proc, tmpfs or overlay layers): size, used and free in GiB, " +
|
Description: "The space of every filesystem that stores data (not proc, tmpfs or overlay layers): size, used and free in GiB, " +
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"disk_load",
|
"disk_load",
|
||||||
"disk_top",
|
"disk_top",
|
||||||
"disk_filesystems",
|
"disk_filesystems",
|
||||||
"disk_devices"
|
"disk_devices",
|
||||||
|
"cpu_top"
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
|
|||||||
+16
-4
@@ -4,6 +4,13 @@ The forge, and the mesh's npm package registry: it claims the `git` and `npm-pac
|
|||||||
Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`,
|
Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`,
|
||||||
`tools/`, `provisioner/`). This README covers the two Go bundles beside it.
|
`tools/`, `provisioner/`). This README covers the two Go bundles beside it.
|
||||||
|
|
||||||
|
**What a registry consumer is told** (`serves.npm-package-registry`): where the registry answers on the
|
||||||
|
private network (`scheme`, `port`, `npm-path`), and its `public-root`, the address the forge writes every
|
||||||
|
tarball at: `https://` and the forge's route's composed public name, under the npm path
|
||||||
|
(`${bound:route:name-web}`, novox/hq ADR 0346). A lock file holds that address, and npm 12 installs a
|
||||||
|
locked tarball only from the registry it is configured with, so a consumer that installs from lock files
|
||||||
|
trusts that root. On a machine with no public domain the key is left out, and the plan says so.
|
||||||
|
|
||||||
## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347)
|
## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347)
|
||||||
|
|
||||||
Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked.
|
Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked.
|
||||||
@@ -45,7 +52,10 @@ 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 |
|
| `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):
|
**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. A rule covers a branch as the
|
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
|
||||||
forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob
|
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
|
(`*`, `**`, `?`, `[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
|
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
|
||||||
@@ -59,7 +69,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
|
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
|
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
|
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 forge's admin 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 tools' 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
|
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.
|
branch not at the commit the forge named is a failure, never a success.
|
||||||
|
|
||||||
@@ -68,8 +78,10 @@ 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
|
`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`.
|
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`), and one
|
It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), as the
|
||||||
tool call takes at most 50 seconds.
|
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.
|
||||||
`go test ./cmd/forge` runs it against a fake forge: the files of one commit created, updated and deleted on
|
`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
|
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
|
unreadable rules refused before anything is sent; a patch sent to the forge; a branch read back at another
|
||||||
|
|||||||
+187
-14
@@ -8,6 +8,7 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
|||||||
import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
||||||
import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js";
|
import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js";
|
||||||
import { isCommit } from "./checks.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
|
/** 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. */
|
* call, whether the names came from a caller or from the forge's own answer. */
|
||||||
@@ -101,6 +102,94 @@ export interface OpenPull {
|
|||||||
/** The head's statuses by context, the newest of each, with its state (novox/hq issue 438): what tells a check
|
/** 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. */
|
* that started and never answered — pending — from one that answered. */
|
||||||
checks: Record<string, HeadCheck>;
|
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. */
|
/** One status on a head, the newest of its context: its state, what it said, and when it was set. */
|
||||||
@@ -332,8 +421,9 @@ export class GiteaClient {
|
|||||||
|
|
||||||
/** The open pull requests of every repository the mesh's account sees, updated since a moment when one is given,
|
/** 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. */
|
* 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 }[]> {
|
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 }[] = [];
|
labels?: { id: number; name: string }[] }[]> {
|
||||||
|
const out: { owner: string; name: string; number: number; updated_at?: string; labels?: { id: number; name: string }[] }[] = [];
|
||||||
for (let page = 1; page <= 20; page++) {
|
for (let page = 1; page <= 20; page++) {
|
||||||
const qs = new URLSearchParams({ type: "pulls", state: "open", limit: "50", page: String(page) });
|
const qs = new URLSearchParams({ type: "pulls", state: "open", limit: "50", page: String(page) });
|
||||||
if (since) qs.set("since", since);
|
if (since) qs.set("since", since);
|
||||||
@@ -343,7 +433,8 @@ export class GiteaClient {
|
|||||||
const [fo, fn] = String(r.full_name ?? "").split("/");
|
const [fo, fn] = String(r.full_name ?? "").split("/");
|
||||||
const owner = String(r.owner ?? fo ?? "");
|
const owner = String(r.owner ?? fo ?? "");
|
||||||
const name = String(r.name ?? fn ?? "");
|
const name = String(r.name ?? fn ?? "");
|
||||||
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at });
|
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 (!found || found.length < 50) break;
|
if (!found || found.length < 50) break;
|
||||||
}
|
}
|
||||||
@@ -354,19 +445,26 @@ export class GiteaClient {
|
|||||||
* requires and the contexts its head has a status of, in any state. What mesh-delivery reads to say a pull
|
* 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
|
* 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. */
|
* fault in the announcer cannot hide itself. Reads only. */
|
||||||
async openPulls(): Promise<OpenPull[]> {
|
async openPulls(readyAuthors: string[] = []): Promise<OpenPull[]> {
|
||||||
const out: OpenPull[] = [];
|
const out: OpenPull[] = [];
|
||||||
const rules = new Map<string, BranchProtection[] | undefined>();
|
const rules = new Map<string, BranchProtection | null | undefined>();
|
||||||
for (const found of await this.searchOpenPulls()) {
|
for (const found of await this.searchOpenPulls()) {
|
||||||
const full = `${found.owner}/${found.name}`;
|
const full = `${found.owner}/${found.name}`;
|
||||||
const pull = await this.getPullRequest(found.owner, found.name, found.number);
|
const pull = await this.getPullRequest(found.owner, found.name, found.number);
|
||||||
if (pull.state !== "open" || !pull.head_sha) continue;
|
if (pull.state !== "open" || !pull.head_sha) continue;
|
||||||
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
|
// The base's protection as the forge applies it to that branch (appliedProtection): readable without administration.
|
||||||
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
|
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);
|
||||||
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
|
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,
|
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 ?? []) : [],
|
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [],
|
||||||
statuses: Object.keys(checks), checks });
|
statuses: Object.keys(checks), checks, labels: labels.map((l) => l.name), ...(ready ? { ready } : {}) });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -580,16 +678,44 @@ export class GiteaClient {
|
|||||||
|
|
||||||
// ---- Branch protection ----
|
// ---- Branch protection ----
|
||||||
|
|
||||||
/** A repository's branch protection rules. */
|
/** 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. */
|
||||||
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
|
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
|
||||||
return (await this.request<BranchProtection[]>(`${repoPath(owner, repo)}/branch_protections`)) ?? [];
|
return (await this.request<BranchProtection[]>(`${repoPath(owner, repo)}/branch_protections`)) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The rule named for a branch, null when it has none. */
|
/** The rule named for a branch, null when it has none — from the rules' list, so a repository administrator's. */
|
||||||
async branchProtection(owner: string, repo: string, rule: string): Promise<BranchProtection | null> {
|
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;
|
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
|
/** 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
|
* 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. */
|
* asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */
|
||||||
@@ -639,6 +765,24 @@ export class GiteaClient {
|
|||||||
return this.requestText(`${repoPath(owner, repo)}/pulls/${index}.diff`);
|
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. */
|
/** Every comment on an issue or pull request, oldest first. */
|
||||||
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
|
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
|
||||||
indexOf(index);
|
indexOf(index);
|
||||||
@@ -803,12 +947,26 @@ export class GiteaAdmin {
|
|||||||
return { status: res.status, body };
|
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. */
|
/** 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 {
|
private static fail(path: string, res: AdminResponse): never {
|
||||||
const detail = typeof res.body === "string" ? res.body : JSON.stringify(res.body);
|
const detail = typeof res.body === "string" ? res.body : JSON.stringify(res.body);
|
||||||
throw new Error(`Gitea admin ${path}: ${res.status} ${detail}`);
|
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
|
/** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent
|
||||||
* create won the race) are all success. */
|
* create won the race) are all success. */
|
||||||
async ensureOrg(name: string): Promise<void> {
|
async ensureOrg(name: string): Promise<void> {
|
||||||
@@ -832,12 +990,16 @@ export class GiteaAdmin {
|
|||||||
// unit the team does not have — so code read must be said here: without it gitea answers a
|
// 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
|
// 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.
|
// credentialed clone failed against a team that named only packages.
|
||||||
const units = {
|
return this.ensureTeamWith(org, team, {
|
||||||
permission: "read",
|
permission: "read",
|
||||||
units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" },
|
units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" },
|
||||||
includes_all_repositories: true,
|
includes_all_repositories: true,
|
||||||
can_create_org_repo: false,
|
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);
|
const found = await this.findTeam(org, team);
|
||||||
if (found !== null) {
|
if (found !== null) {
|
||||||
const patch = await this.request(`/teams/${found}`, {
|
const patch = await this.request(`/teams/${found}`, {
|
||||||
@@ -872,7 +1034,7 @@ export class GiteaAdmin {
|
|||||||
* The edit path is taken only when the user actually exists. A 422 from the create is also what
|
* 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
|
* 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. */
|
* 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): Promise<void> {
|
async ensureUser(username: string, password: string, email: string, plain = false): Promise<void> {
|
||||||
const res = await this.request("/admin/users", {
|
const res = await this.request("/admin/users", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username, email, password, must_change_password: false }),
|
body: JSON.stringify({ username, email, password, must_change_password: false }),
|
||||||
@@ -886,7 +1048,9 @@ export class GiteaAdmin {
|
|||||||
// login_name is required by the admin edit endpoint; for a local user it is the username.
|
// 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
|
// 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.
|
// password, so the provisioner's check reports it lost; applying again must undo both.
|
||||||
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }),
|
// 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 } : {}) }),
|
||||||
});
|
});
|
||||||
if (patch.status === 200) return;
|
if (patch.status === 200) return;
|
||||||
GiteaAdmin.fail(`/admin/users/${username}`, patch);
|
GiteaAdmin.fail(`/admin/users/${username}`, patch);
|
||||||
@@ -905,6 +1069,15 @@ export class GiteaAdmin {
|
|||||||
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
|
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Take a user off a team; one not on it is already off. */
|
||||||
|
async removeUserFromTeam(teamId: number, username: string): Promise<void> {
|
||||||
|
const res = await this.request(`/teams/${teamId}/members/${encodeURIComponent(username)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (res.status === 204 || res.status === 200 || res.status === 404) return;
|
||||||
|
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a consumer's user logs in with exactly this password and is still a member of the
|
* Whether a consumer's user logs in with exactly this password and is still a member of the
|
||||||
* package team. Read-only: the password is checked as the consumer presents it, basic auth on the
|
* package team. Read-only: the password is checked as the consumer presents it, basic auth on the
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"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
|
// gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull
|
||||||
@@ -211,7 +213,32 @@ 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).
|
// Read the rules, or refuse: a branch the rules could not be read for is never written (fails closed).
|
||||||
var rules []Protection
|
var rules []Protection
|
||||||
if err := f.GetJSON(ctx, repo+"/branch_protections", &rules); err != nil {
|
if err := f.GetJSON(ctx, repo+"/branch_protections", &rules); err != nil {
|
||||||
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := targetRefused(a.Branch, r.DefaultBranch, rules); err != nil {
|
if err := targetRefused(a.Branch, r.DefaultBranch, rules); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"gitea/internal/forgehttp"
|
"gitea/internal/forgehttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin account
|
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the tools' own account (novox/hq
|
||||||
// (internal/forgehttp, shared with the npm-registry bundle beside it).
|
// ADR 0365 §2; internal/forgehttp, shared with the npm-registry bundle beside it).
|
||||||
|
|
||||||
// Forge is the forge's HTTP interface.
|
// Forge is the forge's HTTP interface.
|
||||||
type Forge struct {
|
type Forge struct {
|
||||||
@@ -91,12 +91,15 @@ func (p Protection) name() string {
|
|||||||
return p.BranchName
|
return p.BranchName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Branch is one branch with the commit it points at.
|
// 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.
|
||||||
type Branch struct {
|
type Branch struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Commit struct {
|
Commit struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"commit"`
|
} `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.
|
// branch is the named branch, nil when the forge has none of that name.
|
||||||
|
|||||||
@@ -619,11 +619,17 @@ func TestToolsAreTheOnesTheReadmeNamesAndTheManifestBuilds(t *testing.T) {
|
|||||||
if len(a.Loads) != 1 || a.Loads[0] != a.Binary {
|
if len(a.Loads) != 1 || a.Loads[0] != a.Binary {
|
||||||
t.Errorf("the bundle loads %v, not its binary %s", a.Loads, a.Binary)
|
t.Errorf("the bundle loads %v, not its binary %s", a.Loads, a.Binary)
|
||||||
}
|
}
|
||||||
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} {
|
// 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"} {
|
||||||
if a.Env[k] == "" {
|
if a.Env[k] == "" {
|
||||||
t.Errorf("the bundle is not given %s", 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 {
|
if !found {
|
||||||
|
|||||||
+167
-83
@@ -40,10 +40,11 @@ import { emit, on } from "@novox/mesh-sdk/events";
|
|||||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||||
import { commitStatusTool } from "./delivery.js";
|
import { commitStatusTool } from "./delivery.js";
|
||||||
import { mergeTool } from "./merge.js";
|
import { mergeTool } from "./merge.js";
|
||||||
import { GiteaClient, movedSince } from "./client.js";
|
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
|
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
|
||||||
type RepoCheckFacts, reposToLook } from "./pulls.js";
|
repoCheckFacts, reposToLook } from "./pulls.js";
|
||||||
import { GateWriter, clonedAs, type BuildStarted } from "./gate.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";
|
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
|
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
||||||
@@ -145,57 +146,9 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
|||||||
// at once.
|
// at once.
|
||||||
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
|
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
|
||||||
if (primedMerges && fresh) {
|
if (primedMerges && fresh) {
|
||||||
// What it changed, asked for only now: a module is rebuilt because a file inside its own
|
if (await announceMerge(client, repo, pull)) pace.miss(`the merge of ${repo.full_name}#${pull.number}`);
|
||||||
// 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);
|
announced.add(pull.merge_commit_sha);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@@ -206,6 +159,73 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
|||||||
lastLook = began;
|
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
|
// **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
|
// 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
|
// announces nothing twice; the first look on a machine with no record announces only what moved in the
|
||||||
@@ -228,6 +248,31 @@ function keepHeads(): void {
|
|||||||
writeFileSync(tmp, JSON.stringify({ heads }));
|
writeFileSync(tmp, JSON.stringify({ heads }));
|
||||||
renameSync(tmp, pullsRecord);
|
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 = "";
|
let lastPullLook = "";
|
||||||
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
|
// **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.
|
// heuristic, and a heuristic that misses is caught by the next sweep rather than never.
|
||||||
@@ -259,15 +304,8 @@ async function pollPulls(client: GiteaClient): Promise<void> {
|
|||||||
const key = `${repo.full_name}#${pull.number}`;
|
const key = `${repo.full_name}#${pull.number}`;
|
||||||
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
|
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
// The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5).
|
if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
|
||||||
await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url, pull));
|
} else heads[key] = String(pull.head_sha);
|
||||||
// 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;
|
changed = true;
|
||||||
}
|
}
|
||||||
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
|
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
|
||||||
@@ -362,22 +400,6 @@ 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}`);
|
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,
|
// **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
|
// 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.
|
// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending.
|
||||||
@@ -391,10 +413,16 @@ 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
|
// **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
|
// 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.
|
// reach, nothing is served, as the tools' bundle serves nothing then.
|
||||||
registerModuleTools("gitea", () => (gitea ? [commitStatusTool({
|
/** 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({
|
||||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||||
gates,
|
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
|
// **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.
|
// the head is an open pull request's this module announced. Any other build — a branch, a merge's — says nothing.
|
||||||
@@ -413,12 +441,65 @@ 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) {
|
if (gitea) {
|
||||||
const client = gitea;
|
const client = gitea;
|
||||||
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
|
// 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.
|
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
|
||||||
let failing: string | null = null;
|
let failing: string | null = null;
|
||||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
const tick = (fn: () => Promise<void>, every: number | (() => number)): void => {
|
||||||
|
const everyMs = (): number => (typeof every === "number" ? every : every());
|
||||||
// **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a
|
// **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
|
// 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.
|
// could each announce the same merge before either recorded it.
|
||||||
@@ -433,12 +514,15 @@ if (gitea) {
|
|||||||
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
|
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
|
||||||
failing = why;
|
failing = why;
|
||||||
})
|
})
|
||||||
.finally(() => setTimeout(run, everyMs));
|
.finally(() => setTimeout(run, everyMs()));
|
||||||
run();
|
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(() => pollRepos(client), 60_000);
|
||||||
tick(() => pollMerged(client), 30_000);
|
tick(() => pollMerged(client), () => pace.every());
|
||||||
tick(() => pollPulls(client), 30_000);
|
tick(() => pollPulls(client), () => pace.every());
|
||||||
await on("mesh-controller.checked", (event) => {
|
await on("mesh-controller.checked", (event) => {
|
||||||
const c = (event.body ?? {}) as Checked;
|
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).
|
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
// Package forgehttp is the forge's HTTP interface as this module's Go bundles reach it (cmd/forge,
|
// Package forgehttp is the forge's HTTP interface as this module's Go bundles reach it (cmd/forge,
|
||||||
// cmd/npm-registry): the forge on its own machine, as its admin account, whose password the vault delivered
|
// cmd/npm-registry), on the forge's own machine. One copy for both bundles (novox/hq issue 539).
|
||||||
// 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).
|
// 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.
|
||||||
package forgehttp
|
package forgehttp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -25,12 +35,31 @@ type Client struct {
|
|||||||
URL string // e.g. http://127.0.0.1:3000
|
URL string // e.g. http://127.0.0.1:3000
|
||||||
User string
|
User string
|
||||||
PasswordFile string
|
PasswordFile string
|
||||||
HTTP *http.Client // when nil, a client with Timeout
|
// TokenFile, when set, is the tools' account's token (MESH_GITEA_TOKEN_FILE), sent instead of the admin login;
|
||||||
Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset.
|
// 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.
|
||||||
func FromEnv() (Client, []string) {
|
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{
|
c := Client{
|
||||||
URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"),
|
URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"),
|
||||||
User: os.Getenv("MESH_GITEA_ADMIN_USER"),
|
User: os.Getenv("MESH_GITEA_ADMIN_USER"),
|
||||||
@@ -80,14 +109,45 @@ func (c *Client) password() (string, error) {
|
|||||||
return strings.TrimSpace(string(raw)), nil
|
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;
|
// 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
|
// 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.
|
// 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) {
|
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
|
var body io.Reader
|
||||||
if payload != nil {
|
if payload != nil {
|
||||||
raw, err := json.Marshal(payload)
|
raw, err := json.Marshal(payload)
|
||||||
@@ -100,7 +160,9 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.SetBasicAuth(c.User, password)
|
if err := c.authorize(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
if payload != nil {
|
if payload != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -118,6 +180,9 @@ 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)
|
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 {
|
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:
|
case res.StatusCode == http.StatusNotFound:
|
||||||
return nil, fmt.Errorf("%s %s: %w", method, path, ErrNotFound)
|
return nil, fmt.Errorf("%s %s: %w", method, path, ErrNotFound)
|
||||||
case res.StatusCode >= 300:
|
case res.StatusCode >= 300:
|
||||||
|
|||||||
@@ -54,9 +54,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
|||||||
const method = args.method ? String(args.method) : "merge";
|
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(", ")}`);
|
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);
|
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 rather than left
|
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here as well as by the
|
||||||
// to the forge, whose administrator — the account this tool's token is minted for — may merge past a
|
// forge, which holds the tools' own account to it (novox/hq ADR 0365 §2). The protection is the one the forge
|
||||||
// rule that lets it. A rule that cannot be read is no rule passed.
|
// 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 the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d,
|
// **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
|
// 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
|
// head was and nothing told it which head the review had approved. The caller names it; a head that moved
|
||||||
@@ -80,7 +81,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 ` +
|
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`);
|
`${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.branchProtection(owner, repo, pull.base);
|
const rule = await gitea.appliedProtection(owner, repo, pull.base);
|
||||||
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
|
// 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).
|
// 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) {
|
if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) {
|
||||||
|
|||||||
@@ -42,7 +42,47 @@
|
|||||||
"issue.opened",
|
"issue.opened",
|
||||||
"pull.merged",
|
"pull.merged",
|
||||||
"pull.updated",
|
"pull.updated",
|
||||||
"pull.closed"
|
"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"
|
||||||
],
|
],
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"mesh-controller.checked",
|
"mesh-controller.checked",
|
||||||
@@ -61,14 +101,22 @@
|
|||||||
"port": 22,
|
"port": 22,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"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 \u2014 not 22, which the machine's own daemon holds and a module does not take"
|
"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)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"serves": {
|
"serves": {
|
||||||
"npm-package-registry": {
|
"npm-package-registry": {
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"npm-path": "/api/packages/novox/npm/"
|
"npm-path": "/api/packages/novox/npm/",
|
||||||
|
"public-root": "https://${bound:route:name-web}/api/packages/novox/npm/"
|
||||||
},
|
},
|
||||||
"git": {
|
"git": {
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
@@ -108,6 +156,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
@@ -121,6 +176,14 @@
|
|||||||
"path": "${dir:mesh-state}/state",
|
"path": "${dir:mesh-state}/state",
|
||||||
"mode": "0700"
|
"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",
|
"id": "state",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
@@ -137,7 +200,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/server.env",
|
"path": "${dir:state}/server.env",
|
||||||
"mode": "0600",
|
"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}\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}\nGITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "data",
|
"id": "data",
|
||||||
@@ -148,6 +211,13 @@
|
|||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
|
"health": {
|
||||||
|
"kind": "http",
|
||||||
|
"endpoint": "web",
|
||||||
|
"path": "/api/healthz",
|
||||||
|
"status": 200,
|
||||||
|
"needs": "postgres-database"
|
||||||
|
},
|
||||||
"name": "gitea",
|
"name": "gitea",
|
||||||
"image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef",
|
"image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef",
|
||||||
"env": {
|
"env": {
|
||||||
@@ -199,7 +269,8 @@
|
|||||||
"path": "${dir:mesh-state}/config.json",
|
"path": "${dir:mesh-state}/config.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "{}\n",
|
"content": "{}\n",
|
||||||
"merge": "json"
|
"merge": "json",
|
||||||
|
"takes-settings": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -237,8 +308,13 @@
|
|||||||
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||||
|
"MESH_GITEA_TOOLS_USER": "mesh-tools",
|
||||||
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
|
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
|
||||||
"MESH_RECEIVES": "${dir:grants}/npm.json"
|
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -270,8 +346,8 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
|
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
|
||||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
"MESH_GITEA_TOOLS_USER": "mesh-tools",
|
||||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret"
|
"MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
"build": "tsc client.ts token.ts index.ts provisioner/index.ts provisioner/teams.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
//
|
//
|
||||||
// The `npm-package-registry` interface: a consumer authenticates to the npm registry at
|
// The `npm-package-registry` interface: a consumer authenticates to the npm registry at
|
||||||
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
|
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
|
||||||
// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org
|
// read packages under the `@novox` scope, and write them unless it asked for read only (teams.ts). The
|
||||||
// `novox`; a consumer is a gitea *user* placed on that org's package team.
|
// registry's npm owner is the gitea org `novox`; a consumer is a gitea *user* on one of that org's teams.
|
||||||
//
|
//
|
||||||
// **The user name and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives
|
// **The user name and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives
|
||||||
// the login and hands it to both ends, and mints the password. gitea creates a user under exactly
|
// the login and hands it to both ends, and mints the password. gitea creates a user under exactly
|
||||||
@@ -24,11 +24,9 @@
|
|||||||
|
|
||||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||||
import { GiteaAdmin } from "../client.js";
|
import { GiteaAdmin } from "../client.js";
|
||||||
|
import { grant, holds } from "./teams.js";
|
||||||
|
|
||||||
// The npm registry owner: a gitea org named `novox`, whose package team every consumer joins so it
|
// Which team a consumer joins — `packages` to publish, `readers` to read only — is teams.ts's.
|
||||||
// can read and write packages under the `@novox` scope (ADR 0076).
|
|
||||||
const ORG = "novox";
|
|
||||||
const PACKAGE_TEAM = "packages";
|
|
||||||
|
|
||||||
const gitea = GiteaAdmin.fromEnv();
|
const gitea = GiteaAdmin.fromEnv();
|
||||||
|
|
||||||
@@ -38,19 +36,10 @@ const gitea = GiteaAdmin.fromEnv();
|
|||||||
// module needs the mesh to say where each provision's file is — not yet possible, and not faked.
|
// module needs the mesh to say where each provision's file is — not yet possible, and not faked.
|
||||||
runProvisioner("npm-package-registry", {
|
runProvisioner("npm-package-registry", {
|
||||||
async create(p: Provision): Promise<void> {
|
async create(p: Provision): Promise<void> {
|
||||||
// The org and its package team are the same for every consumer; ensuring them per-create is
|
// The org and its teams are ensured per create: idempotent, and no separate bootstrap step. The
|
||||||
// idempotent and needs no separate bootstrap step.
|
// user carries the consumer's login and the mesh's minted password, set every run so a rotation
|
||||||
await gitea.ensureOrg(ORG);
|
// takes; membership of its team is what grants it what it may do.
|
||||||
const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true);
|
await grant(gitea, p);
|
||||||
// The user carries the consumer's login and the mesh's minted password, set every run so a
|
|
||||||
// rotation takes. Membership of the package team is what grants read+write on packages.
|
|
||||||
//
|
|
||||||
// The address is gitea's own convention for one that is not real: its email validation
|
|
||||||
// requires a dotted domain, so `@localhost` was refused at create — the fault that had this
|
|
||||||
// grant retrying for a day — while `@noreply.localhost` is the shape gitea itself gives
|
|
||||||
// hidden addresses.
|
|
||||||
await gitea.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`);
|
|
||||||
await gitea.addUserToTeam(teamId, p.as);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async remove(p: { as: string }): Promise<void> {
|
async remove(p: { as: string }): Promise<void> {
|
||||||
@@ -60,6 +49,6 @@ runProvisioner("npm-package-registry", {
|
|||||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||||
async holds(p: Provision): Promise<boolean> {
|
async holds(p: Provision): Promise<boolean> {
|
||||||
return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password);
|
return holds(gitea, p);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Which team of the forge's npm owner a consumer of `npm-package-registry` joins, and the per-consumer
|
||||||
|
// half of the provisioner around it — pure of the forge's address, so it is tested with a stand-in.
|
||||||
|
//
|
||||||
|
// **Two teams, by what the consumer said it needs** (novox/hq issue 624). A consumer that publishes —
|
||||||
|
// the build agent, whose builds publish the SDK and every TypeScript module's packages — joins `packages`:
|
||||||
|
// code read on the org's repositories and write on its packages. A consumer that contributes
|
||||||
|
// `{"access": "read"}` joins `readers`: code read and package read, and no write. The lab is the first: it
|
||||||
|
// clones branches that are not yet reviewed and runs their code beside its credential, and code that is
|
||||||
|
// not reviewed must not be able to publish the mesh's packages with it.
|
||||||
|
|
||||||
|
/** The npm registry's owner: the gitea org every consumer's packages live under (ADR 0076). */
|
||||||
|
export const ORG = "novox";
|
||||||
|
/** The team whose members read and write the org's packages. */
|
||||||
|
export const PACKAGE_TEAM = "packages";
|
||||||
|
/** The team whose members read the org's code and packages, and write nothing. */
|
||||||
|
export const READ_TEAM = "readers";
|
||||||
|
|
||||||
|
/** The team a consumer joins, from what it contributed; an access it may not ask for is refused. */
|
||||||
|
export function teamFor(values: Readonly<Record<string, unknown>> | undefined): { team: string; packageWrite: boolean } {
|
||||||
|
const access = values?.access;
|
||||||
|
if (access === undefined || access === "write") return { team: PACKAGE_TEAM, packageWrite: true };
|
||||||
|
if (access === "read") return { team: READ_TEAM, packageWrite: false };
|
||||||
|
throw new Error(`npm-package-registry: access ${JSON.stringify(access)} is not "read" or "write"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the adapter asks of the forge's admin API (client.ts GiteaAdmin). */
|
||||||
|
export interface Forge {
|
||||||
|
ensureOrg(org: string): Promise<void>;
|
||||||
|
ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number>;
|
||||||
|
ensureUser(username: string, password: string, email: string): Promise<void>;
|
||||||
|
addUserToTeam(teamId: number, username: string): Promise<void>;
|
||||||
|
removeUserFromTeam(teamId: number, username: string): Promise<void>;
|
||||||
|
holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Consumer {
|
||||||
|
readonly as: string;
|
||||||
|
readonly password: string;
|
||||||
|
readonly values: Readonly<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* grant makes the consumer's user, with exactly the mesh's password, a member of its one team. A consumer
|
||||||
|
* that asks for read only is also taken out of the package team, so one that asked for write before and
|
||||||
|
* read now loses the write at once.
|
||||||
|
*/
|
||||||
|
export async function grant(forge: Forge, p: Consumer): Promise<void> {
|
||||||
|
const { team, packageWrite } = teamFor(p.values);
|
||||||
|
await forge.ensureOrg(ORG);
|
||||||
|
const teamId = await forge.ensureTeam(ORG, team, packageWrite);
|
||||||
|
// The address is gitea's own convention for one that is not real: its email validation requires a
|
||||||
|
// dotted domain, so `@localhost` was refused at create, while `@noreply.localhost` is the shape gitea
|
||||||
|
// itself gives hidden addresses.
|
||||||
|
await forge.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`);
|
||||||
|
await forge.addUserToTeam(teamId, p.as);
|
||||||
|
if (!packageWrite) {
|
||||||
|
await forge.removeUserFromTeam(await forge.ensureTeam(ORG, PACKAGE_TEAM, true), p.as);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** holds is whether the forge still holds the consumer as the mesh gave it: its login, in its team. */
|
||||||
|
export async function holds(forge: Forge, p: Consumer): Promise<boolean> {
|
||||||
|
return forge.holdsTeamMember(ORG, teamFor(p.values).team, p.as, p.password);
|
||||||
|
}
|
||||||
+19
-1
@@ -9,7 +9,7 @@
|
|||||||
//
|
//
|
||||||
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
|
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
|
||||||
|
|
||||||
import type { CommitStatus, GiteaPull, GiteaRepo } from "./client.js";
|
import type { CommitStatus, GiteaClient, GiteaPull, GiteaRepo } from "./client.js";
|
||||||
import { movedSince } 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. */
|
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
|
||||||
@@ -176,6 +176,24 @@ export interface RepoCheckFacts {
|
|||||||
required?: boolean;
|
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. */
|
/** 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 — " +
|
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";
|
"a person adds one, or lifts the requirement from the branch's protection";
|
||||||
|
|||||||
@@ -47,11 +47,18 @@ 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")) {
|
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" });
|
return send({ number: 14, state: "open", base: { ref: "main" }, head: { ref: "y", sha: "bbb2222" }, html_url: "s" });
|
||||||
}
|
}
|
||||||
if (url.startsWith("/api/v1/repos/novox/hq/branch_protections")) {
|
// The base's protection, read from the branch as the tools' account may (novox/hq ADR 0365); the rules' list is refused it.
|
||||||
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/repo-check"] }]);
|
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/mesh-sdk/branch_protections")) {
|
if (url.startsWith("/api/v1/repos/novox/hq/branches/main")) {
|
||||||
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }]);
|
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.includes("/commits/aaa1111/status")) return send({ statuses: [] });
|
if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] });
|
||||||
if (url.includes("/commits/bbb2222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending",
|
if (url.includes("/commits/bbb2222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending",
|
||||||
@@ -137,3 +144,122 @@ 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({ ref: "refs/pull/7/head" }), "refs/pull/7/head");
|
||||||
assert.equal(headBranchOf(undefined), undefined);
|
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"]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const state = {
|
|||||||
// What the tools said on the bus.
|
// What the tools said on the bus.
|
||||||
emitted: [] as { type: string; body: any }[],
|
emitted: [] as { type: string; body: any }[],
|
||||||
rules: [] as 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",
|
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
|
// 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
|
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
|
||||||
@@ -59,6 +62,7 @@ beforeEach(() => {
|
|||||||
state.trunk = TRUNK;
|
state.trunk = TRUNK;
|
||||||
state.emitted = [];
|
state.emitted = [];
|
||||||
state.rules = [protectedMain()];
|
state.rules = [protectedMain()];
|
||||||
|
state.admin = false;
|
||||||
state.head = REVIEWED;
|
state.head = REVIEWED;
|
||||||
state.landed = "";
|
state.landed = "";
|
||||||
state.merged = false;
|
state.merged = false;
|
||||||
@@ -87,8 +91,18 @@ const server = createServer(async (req, res) => {
|
|||||||
if (method !== "GET") state.sent.push({ method, path, body });
|
if (method !== "GET") state.sent.push({ method, path, body });
|
||||||
const repo = "/repos/novox/mesh-catalog";
|
const repo = "/repos/novox/mesh-catalog";
|
||||||
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
|
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);
|
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
|
||||||
if (method === "GET" && path === `${repo}/branches/main`) return json(res, 200, { name: "main", commit: { id: state.trunk } });
|
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}/pulls/7/files`) {
|
if (method === "GET" && path === `${repo}/pulls/7/files`) {
|
||||||
return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []);
|
return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []);
|
||||||
}
|
}
|
||||||
@@ -199,6 +213,7 @@ test("a protection rule covers a branch as the forge decides it: classes, altern
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("gitea_api still writes what an author writes", async () => {
|
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/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/pulls/7/reviews", method: "POST", body: { event: "COMMENT" } });
|
||||||
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/branch_protections" });
|
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/branch_protections" });
|
||||||
@@ -206,6 +221,7 @@ test("gitea_api still writes what an author writes", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("gitea_branch_protection_set only strengthens", 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>) =>
|
const set = (args: Record<string, unknown>) =>
|
||||||
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args });
|
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/);
|
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/);
|
||||||
@@ -396,6 +412,7 @@ 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 () => {
|
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 });
|
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", 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/);
|
await assert.rejects(del("novox", "mesh-catalog", "novox/mesh-catalog"), /not a repository of the tools' own user/);
|
||||||
@@ -427,6 +444,7 @@ 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 () => {
|
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,
|
state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true,
|
||||||
status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true });
|
status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true });
|
||||||
const set = (args: Record<string, unknown>) =>
|
const set = (args: Record<string, unknown>) =>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// 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"));
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { grant, holds, PACKAGE_TEAM, READ_TEAM, teamFor, type Forge } from "../provisioner/teams.ts";
|
||||||
|
|
||||||
|
// Defends novox/hq issue 624: the lab reads the forge with a credential of its own that can write no
|
||||||
|
// package. A consumer that contributes {"access": "read"} joins a team with code and package read only.
|
||||||
|
|
||||||
|
function aForge(): { forge: Forge; did: string[] } {
|
||||||
|
const did: string[] = [];
|
||||||
|
const teams: Record<string, number> = {};
|
||||||
|
const forge: Forge = {
|
||||||
|
async ensureOrg(org) { did.push(`org ${org}`); },
|
||||||
|
async ensureTeam(org, team, packageWrite) {
|
||||||
|
did.push(`team ${org}/${team} packages:${packageWrite ? "write" : "read"}`);
|
||||||
|
return (teams[team] ??= Object.keys(teams).length + 1);
|
||||||
|
},
|
||||||
|
async ensureUser(username) { did.push(`user ${username}`); },
|
||||||
|
async addUserToTeam(id, username) { did.push(`add ${username} to ${id}`); },
|
||||||
|
async removeUserFromTeam(id, username) { did.push(`remove ${username} from ${id}`); },
|
||||||
|
async holdsTeamMember(org, team, username) { did.push(`holds ${username} in ${org}/${team}`); return true; },
|
||||||
|
};
|
||||||
|
return { forge, did };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a consumer that asks nothing, or write, publishes: the package team", () => {
|
||||||
|
assert.deepEqual(teamFor({}), { team: PACKAGE_TEAM, packageWrite: true });
|
||||||
|
assert.deepEqual(teamFor({ access: "write" }), { team: PACKAGE_TEAM, packageWrite: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a consumer that asks to read joins the readers, who write no package", () => {
|
||||||
|
assert.deepEqual(teamFor({ access: "read" }), { team: READ_TEAM, packageWrite: false });
|
||||||
|
assert.throws(() => teamFor({ access: "admin" }), /not "read" or "write"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reader is made, put on the readers team, and taken off the package team", async () => {
|
||||||
|
const { forge, did } = aForge();
|
||||||
|
await grant(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } });
|
||||||
|
assert.deepEqual(did, [
|
||||||
|
"org novox",
|
||||||
|
"team novox/readers packages:read",
|
||||||
|
"user lab-shanks",
|
||||||
|
"add lab-shanks to 1",
|
||||||
|
"team novox/packages packages:write",
|
||||||
|
"remove lab-shanks from 2",
|
||||||
|
]);
|
||||||
|
did.length = 0;
|
||||||
|
await holds(forge, { as: "lab-shanks", password: "pw", values: { access: "read" } });
|
||||||
|
assert.deepEqual(did, ["holds lab-shanks in novox/readers"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a publisher is made and put on the package team, as before", async () => {
|
||||||
|
const { forge, did } = aForge();
|
||||||
|
await grant(forge, { as: "build-agent-ace", password: "pw", values: {} });
|
||||||
|
assert.deepEqual(did, ["org novox", "team novox/packages packages:write", "user build-agent-ace", "add build-agent-ace to 1"]);
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
import { test, after } from "node:test";
|
import { test, after } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -29,6 +29,12 @@ interface Forge {
|
|||||||
mints: number;
|
mints: number;
|
||||||
lastScopes: string[] | null;
|
lastScopes: string[] | null;
|
||||||
tokens: Map<string, string>;
|
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[]>;
|
scopesOf: Map<string, string[]>;
|
||||||
admins: Map<string, string>;
|
admins: Map<string, string>;
|
||||||
pullState: string;
|
pullState: string;
|
||||||
@@ -39,9 +45,14 @@ interface Forge {
|
|||||||
|
|
||||||
function fakeForge(): Promise<Forge> {
|
function fakeForge(): Promise<Forge> {
|
||||||
const forge = {
|
const forge = {
|
||||||
|
mintedFor: "",
|
||||||
mints: 0,
|
mints: 0,
|
||||||
lastScopes: null as string[] | null,
|
lastScopes: null as string[] | null,
|
||||||
tokens: new Map<string, string>(), // name -> value
|
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
|
scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does
|
||||||
admins: new Map([[ADMIN, PASSWORD]]),
|
admins: new Map([[ADMIN, PASSWORD]]),
|
||||||
pullState: "open",
|
pullState: "open",
|
||||||
@@ -73,7 +84,14 @@ function fakeForge(): Promise<Forge> {
|
|||||||
const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/);
|
const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/);
|
||||||
if (tokens) {
|
if (tokens) {
|
||||||
const user = basic(req);
|
const user = basic(req);
|
||||||
if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" });
|
// 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 (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
const { name, scopes } = await body(req);
|
const { name, scopes } = await body(req);
|
||||||
if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" });
|
if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" });
|
||||||
@@ -82,12 +100,35 @@ function fakeForge(): Promise<Forge> {
|
|||||||
const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`;
|
const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`;
|
||||||
forge.tokens.set(name, sha1);
|
forge.tokens.set(name, sha1);
|
||||||
forge.scopesOf.set(sha1, scopes);
|
forge.scopesOf.set(sha1, scopes);
|
||||||
return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) });
|
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: [] })));
|
||||||
}
|
}
|
||||||
if (req.method === "DELETE" && tokens[2]) {
|
if (req.method === "DELETE" && tokens[2]) {
|
||||||
const name = decodeURIComponent(tokens[2]);
|
// gitea: "identified by ID and if not available by name" — and a name held twice is refused.
|
||||||
if (!forge.tokens.has(name)) return json(res, 404, { message: "token not found" });
|
const key = decodeURIComponent(tokens[2]);
|
||||||
forge.tokens.delete(name);
|
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);
|
||||||
return json(res, 204, null);
|
return json(res, 204, null);
|
||||||
}
|
}
|
||||||
return json(res, 405, { message: "method not allowed" });
|
return json(res, 405, { message: "method not allowed" });
|
||||||
@@ -153,6 +194,17 @@ function fakeForge(): Promise<Forge> {
|
|||||||
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
|
{ 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\/([^/]+)$/);
|
const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/);
|
||||||
if (adminUser && req.method === "PATCH") {
|
if (adminUser && req.method === "PATCH") {
|
||||||
const h = req.headers.authorization ?? "";
|
const h = req.headers.authorization ?? "";
|
||||||
@@ -181,6 +233,14 @@ function fakeForge(): Promise<Forge> {
|
|||||||
get pullTitle() { return forge.pullTitle; },
|
get pullTitle() { return forge.pullTitle; },
|
||||||
get branchDeleted() { return forge.branchDeleted; },
|
get branchDeleted() { return forge.branchDeleted; },
|
||||||
tokens: forge.tokens,
|
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,
|
scopesOf: forge.scopesOf,
|
||||||
admins: forge.admins,
|
admins: forge.admins,
|
||||||
close: () => new Promise((r) => server.close(() => r())),
|
close: () => new Promise((r) => server.close(() => r())),
|
||||||
@@ -335,6 +395,56 @@ test("concurrent first calls share one mint", async () => {
|
|||||||
assert.equal(forge.mints, before + 1);
|
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 () => {
|
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);
|
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
|
forge.admins.delete(ADMIN); // the forge's data came from a predecessor; mesh-admin was never created there
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
// 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));
|
||||||
|
});
|
||||||
@@ -173,3 +173,29 @@ 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);
|
const bare = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "pass" }, null, words);
|
||||||
assert.match(bare, /head dfb1415aaaaa merged into trunk a2c2c35bbbbb, pass/);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
+216
-30
@@ -12,7 +12,20 @@
|
|||||||
// hands a token's value out exactly once, so a token not kept is a token lost;
|
// 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
|
// - 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
|
// a fault: the forge's data was restored from a predecessor and the token the file names never
|
||||||
// existed there.
|
// 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.
|
||||||
//
|
//
|
||||||
// An explicitly configured token still wins, and is never minted over: if it is rejected, that is
|
// An explicitly configured token still wins, and is never minted over: if it is rejected, that is
|
||||||
// reported, not repaired — somebody chose it.
|
// reported, not repaired — somebody chose it.
|
||||||
@@ -20,8 +33,10 @@
|
|||||||
// The token is never logged. Lines say that one was minted, reused or renewed, and where it is
|
// The token is never logged. Lines say that one was minted, reused or renewed, and where it is
|
||||||
// kept; never what it is.
|
// kept; never what it is.
|
||||||
|
|
||||||
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { dirname, join } from "node:path";
|
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. */
|
/** The name the token carries in the forge's own list — one per mesh runtime, found by name. */
|
||||||
export const TOKEN_NAME = "mesh-tools";
|
export const TOKEN_NAME = "mesh-tools";
|
||||||
@@ -39,7 +54,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
|
* `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.
|
* 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
|
* 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 forge drops the old one by name — so the scope a kept token had does not outlive this.
|
* 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.
|
||||||
*
|
*
|
||||||
* A token kept from before a scope was added lacks it: the forge answers such a call with
|
* 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
|
* `403 token does not have at least one of required scope(s)`, and the client treats that like a
|
||||||
@@ -95,6 +110,14 @@ export interface MintedTokenOptions {
|
|||||||
readonly file: string;
|
readonly file: string;
|
||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly scopes?: readonly 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 log?: (line: string) => void;
|
||||||
readonly fetch?: typeof fetch;
|
readonly fetch?: typeof fetch;
|
||||||
}
|
}
|
||||||
@@ -106,12 +129,14 @@ export class MintedToken implements TokenSource {
|
|||||||
private inflight: Promise<string> | null = null;
|
private inflight: Promise<string> | null = null;
|
||||||
private readonly name: string;
|
private readonly name: string;
|
||||||
private readonly scopes: readonly string[];
|
private readonly scopes: readonly string[];
|
||||||
|
private readonly account: string;
|
||||||
private readonly log: (line: string) => void;
|
private readonly log: (line: string) => void;
|
||||||
private readonly fetchImpl: typeof fetch;
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
|
||||||
constructor(private readonly opts: MintedTokenOptions) {
|
constructor(private readonly opts: MintedTokenOptions) {
|
||||||
this.name = opts.name ?? TOKEN_NAME;
|
this.name = opts.name ?? TOKEN_NAME;
|
||||||
this.scopes = opts.scopes ?? TOKEN_SCOPES;
|
this.scopes = opts.scopes ?? TOKEN_SCOPES;
|
||||||
|
this.account = opts.account ?? opts.admin;
|
||||||
this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`));
|
this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`));
|
||||||
this.fetchImpl = opts.fetch ?? fetch;
|
this.fetchImpl = opts.fetch ?? fetch;
|
||||||
}
|
}
|
||||||
@@ -124,9 +149,9 @@ export class MintedToken implements TokenSource {
|
|||||||
*/
|
*/
|
||||||
static fromEnv(url: string, env: NodeJS.ProcessEnv = process.env): MintedToken {
|
static fromEnv(url: string, env: NodeJS.ProcessEnv = process.env): MintedToken {
|
||||||
const opts = MintedToken.optionsFromEnv(url, env);
|
const opts = MintedToken.optionsFromEnv(url, env);
|
||||||
// One source per kept file in a process. The watcher and the tools entrypoint both build a
|
// One source per kept file in a process: two sources in one process would each renew on a 401.
|
||||||
// client in the same runtime; two sources over one file would each renew on a 401 and drop the
|
// This covers one process only. The watcher and the tools are two processes (the runtime starts
|
||||||
// other's token by name, forever. Shared, a renewal is one renewal.
|
// each entrypoint as its own), and those are kept apart by the mint's lock file (mint, below).
|
||||||
const shared = MintedToken.shared.get(opts.file);
|
const shared = MintedToken.shared.get(opts.file);
|
||||||
if (shared) return shared;
|
if (shared) return shared;
|
||||||
const source = new MintedToken(opts);
|
const source = new MintedToken(opts);
|
||||||
@@ -148,7 +173,17 @@ export class MintedToken implements TokenSource {
|
|||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`);
|
throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`);
|
||||||
}
|
}
|
||||||
return { url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token") };
|
// 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"));
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async current(): Promise<string> {
|
async current(): Promise<string> {
|
||||||
@@ -159,11 +194,13 @@ export class MintedToken implements TokenSource {
|
|||||||
if (kept !== null && this.keptScopes() === this.scopesLine()) {
|
if (kept !== null && this.keptScopes() === this.scopesLine()) {
|
||||||
this.held = kept;
|
this.held = kept;
|
||||||
this.log(`reusing the token kept at ${this.opts.file}`);
|
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;
|
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");
|
if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them", kept);
|
||||||
}
|
}
|
||||||
return this.mint("no token kept — minting one");
|
return this.mint("no token kept — minting one", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async renew(rejected: string): Promise<string> {
|
async renew(rejected: string): Promise<string> {
|
||||||
@@ -177,7 +214,7 @@ export class MintedToken implements TokenSource {
|
|||||||
return kept;
|
return kept;
|
||||||
}
|
}
|
||||||
this.held = null;
|
this.held = null;
|
||||||
return this.mint("the forge rejected the kept token — minting a fresh one");
|
return this.mint("the forge rejected the kept token — minting a fresh one", rejected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the forge's scoped tokens say when a kept token predates a scope the tools now need. */
|
/** What the forge's scoped tokens say when a kept token predates a scope the tools now need. */
|
||||||
@@ -185,17 +222,95 @@ export class MintedToken implements TokenSource {
|
|||||||
return status === 403 && /required scope/i.test(body);
|
return status === 403 && /required scope/i.test(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One mint at a time: concurrent first calls share it, rather than each minting its own. */
|
/**
|
||||||
private mint(why: string): Promise<string> {
|
* 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> {
|
||||||
if (this.inflight === null) {
|
if (this.inflight === null) {
|
||||||
this.log(why);
|
this.log(why);
|
||||||
this.inflight = this.doMint().finally(() => {
|
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 = null;
|
this.inflight = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return this.inflight;
|
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 {
|
private read(): string | null {
|
||||||
try {
|
try {
|
||||||
const token = readFileSync(this.opts.file, "utf8").replace(/\n$/, "");
|
const token = readFileSync(this.opts.file, "utf8").replace(/\n$/, "");
|
||||||
@@ -206,9 +321,10 @@ export class MintedToken implements TokenSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The scopes as kept beside the token: sorted, one line. */
|
/** The scopes as kept beside the token: sorted, one line, and whose it is when it is not the admin's. */
|
||||||
private scopesLine(): string {
|
private scopesLine(): string {
|
||||||
return [...this.scopes].sort().join(",");
|
const scopes = [...this.scopes].sort().join(",");
|
||||||
|
return this.account === this.opts.admin ? scopes : `${scopes} for ${this.account}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The scopes the kept token was minted with, "" when not kept (a token kept before they were). */
|
/** The scopes the kept token was minted with, "" when not kept (a token kept before they were). */
|
||||||
@@ -231,17 +347,20 @@ export class MintedToken implements TokenSource {
|
|||||||
renameSync(`${this.opts.file}.scopes.tmp`, `${this.opts.file}.scopes`);
|
renameSync(`${this.opts.file}.scopes.tmp`, `${this.opts.file}.scopes`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async doMint(): Promise<string> {
|
/** One call to the forge's API with the admin login, the password read now. */
|
||||||
let password: string;
|
private password(): string {
|
||||||
try {
|
try {
|
||||||
password = readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, "");
|
return readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, "");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`cannot read the admin password at ${this.opts.passwordFile}: ${(err as Error).message}`);
|
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 authorization = "Basic " + Buffer.from(`${this.opts.admin}:${password}`).toString("base64");
|
||||||
const tokens = `${this.opts.url.replace(/\/+$/, "")}/api/v1/users/${encodeURIComponent(this.opts.admin)}/tokens`;
|
const api = `${this.opts.url.replace(/\/+$/, "")}/api/v1`;
|
||||||
const call = async (method: string, path = "", body?: unknown): Promise<{ status: number; body: any }> => {
|
const admin: AdminCall = async (method, path, body) => {
|
||||||
const res = await this.fetchImpl(tokens + path, {
|
const res = await this.fetchImpl(api + path, {
|
||||||
method,
|
method,
|
||||||
headers: { "Content-Type": "application/json", Authorization: authorization },
|
headers: { "Content-Type": "application/json", Authorization: authorization },
|
||||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
@@ -253,30 +372,97 @@ export class MintedToken implements TokenSource {
|
|||||||
}
|
}
|
||||||
return { status: res.status, body: parsed };
|
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 });
|
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 === 401 || res.status === 403) throw new AdminRefused(this.opts.admin, res.status);
|
||||||
if (res.status === 400 || res.status === 422) {
|
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
|
// 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. It is ours to replace: drop it by name and mint again.
|
// went while the forge's data stayed, or two processes once minted at once and left two. It is
|
||||||
this.log(`the forge already holds a token named "${this.name}" — replacing it`);
|
// ours to replace: find every one by this name in the account's list and delete each by its id
|
||||||
const dropped = await call("DELETE", `/${encodeURIComponent(this.name)}`);
|
// (gitea refuses a delete by a name held twice: "multiple matches for token name"), then mint.
|
||||||
if (dropped.status !== 204 && dropped.status !== 404) {
|
await this.dropNamed(admin, tokensOf(this.account), this.account, true);
|
||||||
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 });
|
res = await call("POST", "", { name: this.name, scopes: this.scopes });
|
||||||
}
|
}
|
||||||
if (res.status !== 201 && res.status !== 200) {
|
if (res.status !== 201 && res.status !== 200) {
|
||||||
throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} ${detail(res.body)}`);
|
throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} ${detail(res.body)}`);
|
||||||
}
|
}
|
||||||
const token = typeof res.body?.sha1 === "string" ? res.body.sha1 : null;
|
const token = typeof res.body?.sha1 === "string" ? res.body.sha1 : null;
|
||||||
if (!token) throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} but no token in the reply`);
|
if (!token) throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} but no token in the reply`);
|
||||||
|
|
||||||
this.keep(token);
|
this.keep(token);
|
||||||
this.held = token;
|
this.held = token;
|
||||||
this.log(`minted a token for "${this.opts.admin}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
|
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);
|
||||||
return token;
|
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 {
|
function detail(body: unknown): string {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
import { emit } from "@novox/mesh-sdk/events";
|
import { emit } from "@novox/mesh-sdk/events";
|
||||||
import { GiteaClient, type BranchProtection } from "../client.js";
|
import { GiteaAdmin, GiteaClient, readyAuthors, type BranchProtection } from "../client.js";
|
||||||
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
|
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
|
||||||
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
|
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
|
||||||
import {
|
import {
|
||||||
@@ -59,13 +59,13 @@ function namesChecked(tools: ToolDefinition[]): ToolDefinition[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
|
||||||
return namesChecked(allGiteaTools(gitea));
|
return namesChecked(allGiteaTools(gitea, admin));
|
||||||
}
|
}
|
||||||
|
|
||||||
// gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one
|
// 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).
|
// writer (novox/hq issue 456, ADR 0323).
|
||||||
function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
|
||||||
return [
|
return [
|
||||||
// ---- Repositories ----
|
// ---- Repositories ----
|
||||||
{
|
{
|
||||||
@@ -401,8 +401,11 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
},
|
},
|
||||||
run: async (args) => {
|
run: async (args) => {
|
||||||
const owner = String(args.owner), repo = String(args.repo);
|
const owner = String(args.owner), repo = String(args.repo);
|
||||||
if (!args.branch) return { rules: (await gitea.branchProtections(owner, repo)).map(summarised) };
|
// The one read made as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the
|
||||||
const rule = await gitea.branchProtection(owner, repo, String(args.branch));
|
// 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;
|
||||||
return { branch: String(args.branch), rule: rule ? summarised(rule) : null };
|
return { branch: String(args.branch), rule: rule ? summarised(rule) : null };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -504,7 +507,7 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
base = base || "main";
|
base = base || "main";
|
||||||
let rule: BranchProtection | null = null, unreadable: string | undefined;
|
let rule: BranchProtection | null = null, unreadable: string | undefined;
|
||||||
try {
|
try {
|
||||||
rule = await gitea.branchProtection(owner, repo, base);
|
rule = await gitea.appliedProtection(owner, repo, base);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unreadable = err instanceof Error ? err.message : String(err);
|
unreadable = err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
@@ -514,9 +517,9 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "gitea_open_pulls",
|
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). 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), 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.",
|
||||||
input: {},
|
input: {},
|
||||||
run: async () => ({ pulls: await gitea.openPulls() }),
|
run: async () => ({ pulls: await gitea.openPulls(readyAuthors()) }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "gitea_contains",
|
name: "gitea_contains",
|
||||||
@@ -589,8 +592,12 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
const { owner, repo, branch, newBranch } = judged.contents;
|
const { owner, repo, branch, newBranch } = judged.contents;
|
||||||
const found = await gitea.api<{ default_branch?: string }>(`/repos/${checkName("owner", owner)}/${checkName("name", repo)}`);
|
const found = await gitea.api<{ default_branch?: string }>(`/repos/${checkName("owner", owner)}/${checkName("name", repo)}`);
|
||||||
const defaultBranch = String(found?.default_branch ?? "main");
|
const defaultBranch = String(found?.default_branch ?? "main");
|
||||||
const refused = judgeContentsWrite(newBranch ?? branch ?? defaultBranch, defaultBranch,
|
// The protection the forge applies to the branch written, read from the branch: the rules' own list is a
|
||||||
await gitea.branchProtections(owner, repo));
|
// 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] : []);
|
||||||
if (refused) throw new Refused(refused);
|
if (refused) throw new Refused(refused);
|
||||||
}
|
}
|
||||||
// The path judged is the path sent: normalised, with only the allow-listed query keys (guard.ts).
|
// The path judged is the path sent: normalised, with only the allow-listed query keys (guard.ts).
|
||||||
@@ -611,7 +618,7 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
// way, gitea contributes none rather than failing the whole runtime, and says why.
|
// way, gitea contributes none rather than failing the whole runtime, and says why.
|
||||||
registerModuleTools("gitea", (env) => {
|
registerModuleTools("gitea", (env) => {
|
||||||
try {
|
try {
|
||||||
return getGiteaTools(GiteaClient.fromEnv(env));
|
return getGiteaTools(GiteaClient.fromEnv(env), () => GiteaAdmin.fromEnv(env));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`[gitea] no tools — ${err instanceof Error ? err.message : String(err)}`);
|
console.log(`[gitea] no tools — ${err instanceof Error ? err.message : String(err)}`);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
"include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "provisioner/teams.ts", "tools/index.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
// **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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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, ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// 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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module kernel
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
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=
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-2
@@ -5,6 +5,20 @@
|
|||||||
"container-runtime",
|
"container-runtime",
|
||||||
"virtualisation"
|
"virtualisation"
|
||||||
],
|
],
|
||||||
|
"requires": [
|
||||||
|
"npm-package-registry"
|
||||||
|
],
|
||||||
|
"contributes": {
|
||||||
|
"npm-package-registry": {
|
||||||
|
"access": "read"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"binds": {
|
||||||
|
"npm-package-registry": "${dir:state}/package-registry.json"
|
||||||
|
},
|
||||||
|
"secrets": {
|
||||||
|
"npm-package-registry": "${dir:state}/package-registry.secret"
|
||||||
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"own": [
|
"own": [
|
||||||
{
|
{
|
||||||
@@ -33,7 +47,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/lab.env",
|
"path": "${dir:state}/lab.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "MESH_LAB_FORGE=${setting:forge}\n"
|
"content": "MESH_LAB_FORGE=${setting:forge}\nMESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json\nMESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "git",
|
"id": "git",
|
||||||
@@ -90,7 +104,9 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_LAB_WORK": "${dir:work}",
|
"MESH_LAB_WORK": "${dir:work}",
|
||||||
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env"
|
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env",
|
||||||
|
"MESH_LAB_FORGE_BINDING": "${dir:state}/package-registry.json",
|
||||||
|
"MESH_LAB_FORGE_SECRET": "${dir:state}/package-registry.secret"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BASE_IMAGE_ALIAS, BASE_MAX_AGE_DAYS, baseImageFrom, baseProblem, baseScript, buildingBase, cleanCredentials, credentialPath,
|
||||||
|
describeBase, launchRebuild, launchRun, listBaseBuilds, listRuns, readStatus, runRefusal, running, staleBase,
|
||||||
|
type BaseImage,
|
||||||
|
} from "../tools/runs.ts";
|
||||||
|
|
||||||
|
// Defends novox/hq issue 698: the lab's base image was 44 days old, a bed machine raised from it carried a
|
||||||
|
// package database the mirrors no longer served, and genesis-keeps-its-bus stopped on a 404 for
|
||||||
|
// postgresql-libs. Nothing said how old the image was, nothing refused it, and no mesh tool rebuilt it.
|
||||||
|
|
||||||
|
const now = new Date("2026-10-14T01:00:00Z");
|
||||||
|
const listed = (uploaded: string, alias = BASE_IMAGE_ALIAS) => JSON.stringify([
|
||||||
|
{ aliases: [{ name: "images:other" }], uploaded_at: "2026-10-13T00:00:00Z" },
|
||||||
|
{ aliases: [{ name: alias }], uploaded_at: uploaded, created_at: "2026-08-01T00:00:00Z" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
test("the base image's age is read from when this machine made it", () => {
|
||||||
|
const b = baseImageFrom(listed("2026-08-31T09:00:00Z"), now);
|
||||||
|
assert.equal(b.exists, true);
|
||||||
|
assert.equal(b.built, "2026-08-31T09:00:00.000Z");
|
||||||
|
assert.equal(b.ageDays, 43);
|
||||||
|
assert.match(describeBase(b), /built 2026-08-31T09:00:00.000Z \(43 day\(s\) ago; the bound is 14\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an image older than the bound refuses a run, naming the tool that rebuilds it", () => {
|
||||||
|
const stale = baseImageFrom(listed("2026-08-31T09:00:00Z"), now);
|
||||||
|
const reason = runRefusal(stale);
|
||||||
|
assert.ok(reason, "a 43-day-old base image was not refused");
|
||||||
|
assert.match(reason, /older than the lab's bound of 14 days/);
|
||||||
|
assert.match(reason, /lab_base \{"rebuild": true\}/);
|
||||||
|
assert.equal(staleBase(stale), reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an image within the bound runs", () => {
|
||||||
|
const fresh = baseImageFrom(listed(new Date(now.getTime() - BASE_MAX_AGE_DAYS * 86_400_000).toISOString()), now);
|
||||||
|
assert.equal(fresh.ageDays, BASE_MAX_AGE_DAYS);
|
||||||
|
assert.equal(runRefusal(fresh), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a missing image, or one whose age cannot be read, is said and not refused", () => {
|
||||||
|
const none = baseImageFrom(listed("2026-10-01T00:00:00Z", "something-else"), now);
|
||||||
|
assert.equal(none.exists, false);
|
||||||
|
assert.equal(runRefusal(none), undefined);
|
||||||
|
assert.match(describeBase(none), /is not on this machine; rebuild it with lab_base/);
|
||||||
|
const unread = baseImageFrom(undefined, now);
|
||||||
|
assert.equal(runRefusal(unread), undefined);
|
||||||
|
assert.match(describeBase(unread), /its age is not known/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no run starts while the base image is rebuilt", () => {
|
||||||
|
const fresh = baseImageFrom(listed("2026-10-13T00:00:00Z"), now);
|
||||||
|
const reason = runRefusal(fresh, { id: "base-x", started: "2026-10-14T00:59:00Z" });
|
||||||
|
assert.match(reason ?? "", /is being rebuilt \(base-x/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rebuild is mesh-lab's base build from the forge's main, with no secret in its script", () => {
|
||||||
|
const s = baseScript("/work/base-x", "https://forge.invalid", "/work/.base-x.git-credentials");
|
||||||
|
assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/novox\/mesh-lab.git'/);
|
||||||
|
assert.match(s, /src\/cli.ts base build/);
|
||||||
|
assert.ok(s.indexOf("rm -f '/work/.base-x.git-credentials'") < s.indexOf("npm ci"), "the credential outlives the clone");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rebuild is known by its status, and is not a run", () => {
|
||||||
|
const work = mkdtempSync(join(tmpdir(), "lab-base-"));
|
||||||
|
try {
|
||||||
|
mkdirSync(join(work, "base-1"));
|
||||||
|
writeFileSync(join(work, "base-1", "status.json"), JSON.stringify({ id: "base-1", state: "built", started: "2026-10-13T00:00:00Z" }));
|
||||||
|
mkdirSync(join(work, "base-2"));
|
||||||
|
// A build whose process is gone while it says building was lost, and holds nothing up.
|
||||||
|
writeFileSync(join(work, "base-2", "status.json"), JSON.stringify({ id: "base-2", state: "building", started: "2026-10-14T00:00:00Z", pid: 2 ** 22 + 7 }));
|
||||||
|
assert.deepEqual(listBaseBuilds(work).map((b) => `${b.id} ${b.state}`), ["base-2 lost", "base-1 built"]);
|
||||||
|
assert.equal(buildingBase(work), undefined);
|
||||||
|
writeFileSync(join(work, "base-2", "status.json"), JSON.stringify({ id: "base-2", state: "building", started: "2026-10-14T00:00:00Z", pid: process.pid }));
|
||||||
|
assert.equal(buildingBase(work)?.id, "base-2");
|
||||||
|
assert.deepEqual(listRuns(work), []);
|
||||||
|
assert.ok(readFileSync(join(work, "base-1", "status.json"), "utf8"));
|
||||||
|
} finally {
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// **A run and a rebuild asked together: one starts** (review 256). Each asks the package registry, which
|
||||||
|
// waits, and each refused while the other was going; asked at once, both passed their checks before either
|
||||||
|
// wrote its status. The registry is asked first now, and the check and the start are one step.
|
||||||
|
function aLab(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "lab-launch-"));
|
||||||
|
}
|
||||||
|
const fresh: BaseImage = { alias: BASE_IMAGE_ALIAS, exists: true, built: "2026-10-13T00:00:00.000Z", ageDays: 1 };
|
||||||
|
const slowRegistry = () => new Promise<{ registry: string }>((ok) => setTimeout(() => ok({ registry: "https://forge.invalid/r/" }), 20));
|
||||||
|
const launch = { registry: slowRegistry, base: () => fresh };
|
||||||
|
function beginRun(work: string, id: string) {
|
||||||
|
return () => {
|
||||||
|
mkdirSync(join(work, id));
|
||||||
|
const s = { id, state: "running" as const, started: new Date().toISOString(), tests: [], refs: {}, pid: process.pid };
|
||||||
|
writeFileSync(join(work, id, "status.json"), JSON.stringify(s));
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function beginBuild(work: string, id: string) {
|
||||||
|
return () => {
|
||||||
|
mkdirSync(join(work, id));
|
||||||
|
const b = { id, state: "building" as const, started: new Date().toISOString(), pid: process.pid };
|
||||||
|
writeFileSync(join(work, id, "status.json"), JSON.stringify(b));
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a run and a rebuild asked at once: exactly one starts", async () => {
|
||||||
|
for (const runFirst of [true, false]) {
|
||||||
|
const work = aLab();
|
||||||
|
try {
|
||||||
|
// Both asked before either answers, in one order and then the other.
|
||||||
|
const ask = { run: () => launchRun(work, launch, beginRun(work, "run-a")), build: () => launchRebuild(work, launch, beginBuild(work, "base-a")) };
|
||||||
|
const [r, b] = runFirst ? await Promise.all([ask.run(), ask.build()])
|
||||||
|
: (await Promise.all([ask.build(), ask.run()])).reverse() as [Awaited<ReturnType<typeof ask.run>>, Awaited<ReturnType<typeof ask.build>>];
|
||||||
|
assert.equal([r.started, b.started].filter(Boolean).length, 1, `both or neither started: run ${r.started}, rebuild ${b.started}`);
|
||||||
|
assert.equal(listRuns(work).length + listBaseBuilds(work).length, 1);
|
||||||
|
} finally {
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two runs, or two rebuilds, asked at once: one of each starts", async () => {
|
||||||
|
const work = aLab();
|
||||||
|
try {
|
||||||
|
const runs = await Promise.all([launchRun(work, launch, beginRun(work, "run-a")), launchRun(work, launch, beginRun(work, "run-b"))]);
|
||||||
|
assert.deepEqual(runs.map((r) => r.started).sort(), [false, true]);
|
||||||
|
const other = aLab();
|
||||||
|
const builds = await Promise.all([launchRebuild(other, launch, beginBuild(other, "base-a")), launchRebuild(other, launch, beginBuild(other, "base-b"))]);
|
||||||
|
assert.deepEqual(builds.map((b) => b.started).sort(), [false, true]);
|
||||||
|
rmSync(other, { recursive: true, force: true });
|
||||||
|
} finally {
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a run is refused on a stale base, and starts nothing", async () => {
|
||||||
|
const work = aLab();
|
||||||
|
try {
|
||||||
|
const stale: BaseImage = { alias: BASE_IMAGE_ALIAS, exists: true, built: "2026-08-31T09:00:00.000Z", ageDays: 43 };
|
||||||
|
const r = await launchRun(work, { registry: slowRegistry, base: () => stale }, beginRun(work, "run-a"));
|
||||||
|
assert.equal(r.started, false);
|
||||||
|
assert.match(r.started ? "" : r.reason, /lab_base \{"rebuild": true\}/);
|
||||||
|
assert.deepEqual(listRuns(work), []);
|
||||||
|
} finally {
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rebuild or a run that never recorded its process is lost, and holds nothing up", () => {
|
||||||
|
const work = aLab();
|
||||||
|
try {
|
||||||
|
mkdirSync(join(work, "base-x"));
|
||||||
|
writeFileSync(join(work, "base-x", "status.json"), JSON.stringify({ id: "base-x", state: "building", started: "2026-10-14T00:00:00Z" }));
|
||||||
|
mkdirSync(join(work, "run-x"));
|
||||||
|
writeFileSync(join(work, "run-x", "status.json"), JSON.stringify({ id: "run-x", state: "running", started: "2026-10-14T00:00:00Z", tests: [], refs: {} }));
|
||||||
|
assert.equal(listBaseBuilds(work)[0]?.state, "lost");
|
||||||
|
assert.equal(buildingBase(work), undefined);
|
||||||
|
assert.equal(readStatus(work, "run-x")?.state, "lost");
|
||||||
|
assert.equal(running(work), undefined);
|
||||||
|
} finally {
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rebuild's credential left behind is cleaned, and a running one's is kept", () => {
|
||||||
|
const work = aLab();
|
||||||
|
try {
|
||||||
|
for (const id of ["base-old", "base-now", "run-old", "run-now"]) writeFileSync(credentialPath(work, id), "x");
|
||||||
|
const removed = cleanCredentials(work, [credentialPath(work, "base-now"), credentialPath(work, "run-now")]).sort();
|
||||||
|
assert.deepEqual(removed, [credentialPath(work, "base-old"), credentialPath(work, "run-old")].sort());
|
||||||
|
} finally {
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lab_check's problem: a stale, missing or unread base image; a fresh one is none", () => {
|
||||||
|
assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: false }) ?? "", /is not on this machine/);
|
||||||
|
assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: false, problem: "incus could not list the lab's images" }) ?? "", /age is not known/);
|
||||||
|
assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: true, built: "x", ageDays: 30 }) ?? "", /older than the lab's bound/);
|
||||||
|
assert.equal(baseProblem(fresh), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The tools use the gate and the check above (index.ts registers with the SDK when imported, so it is read).
|
||||||
|
test("lab_run and lab_base start through the gate, and lab_check names what is wrong with the base", () => {
|
||||||
|
const index = readFileSync(new URL("../tools/index.ts", import.meta.url), "utf8");
|
||||||
|
assert.match(index, /await launchRun\(work,/);
|
||||||
|
assert.match(index, /await launchRebuild\(work,/);
|
||||||
|
assert.match(index, /const unfit = baseProblem\(base\);\s*if \(unfit\) problems\.push\(unfit\)/);
|
||||||
|
assert.match(index, /if \(args\.rebuild !== true\) return reading;/);
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { installPlan, installStep, NPM_REPOSITORIES, REPOSITORIES, script } from "../tools/runs.ts";
|
import { installPlan, installStep, NPM_REPOSITORIES, outcomeOf, readStatus, REPOSITORIES, script } from "../tools/runs.ts";
|
||||||
|
|
||||||
// Defends novox/hq issue 624: lab_run ran `npm ci` at the root of every checkout it installs, and
|
// Defends novox/hq issue 624: lab_run ran `npm ci` at the root of every checkout it installs, and
|
||||||
// mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its
|
// mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its
|
||||||
@@ -98,3 +98,391 @@ test("a run checks out mesh-tools-go and says where its checkouts are", () => {
|
|||||||
assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/);
|
assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/);
|
||||||
for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo);
|
for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defends novox/hq issue 624, its second half: mesh-tools-go is private, and the lab cloned over HTTP with
|
||||||
|
// no credential ("could not read Username"). A run clones with the forge credential the mesh binds and
|
||||||
|
// seals to the lab, through git's credential store, and the secret is in no script, URL or argument.
|
||||||
|
import { credentialArgs, credentialPath, forgeCredential } from "../tools/runs.ts";
|
||||||
|
|
||||||
|
function aBinding(as: string, secret: string): { binding: string; secret: string } {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "lab-binding-"));
|
||||||
|
writeFileSync(join(dir, "package-registry.json"), JSON.stringify({ provision: "npm-package-registry", at: "10.42.0.1", as,
|
||||||
|
serves: { scheme: "http", port: 3000 } }));
|
||||||
|
writeFileSync(join(dir, "package-registry.secret"), `${secret}\n`);
|
||||||
|
return { binding: join(dir, "package-registry.json"), secret: join(dir, "package-registry.secret") };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the forge credential is the bound account and its sealed secret, for the forge's own origin", () => {
|
||||||
|
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||||
|
assert.equal(forgeCredential("http://novox.internal:20000", b.binding, b.secret),
|
||||||
|
"http://lab-shanks:s3cret-of-forty-characters@novox.internal:20000");
|
||||||
|
assert.equal(forgeCredential("http://novox.internal:20000/", b.binding, ""), undefined);
|
||||||
|
assert.equal(forgeCredential("", b.binding, b.secret), undefined);
|
||||||
|
assert.equal(forgeCredential("http://novox.internal:20000", "/nowhere", b.secret), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a run clones through git's credential store, and its script carries no secret", () => {
|
||||||
|
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
|
||||||
|
const credential = forgeCredential("http://novox.internal:20000", b.binding, b.secret);
|
||||||
|
const path = credentialPath("/work", "run-x");
|
||||||
|
const s = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, path);
|
||||||
|
assert.ok(credential);
|
||||||
|
assert.doesNotMatch(s, /s3cret/);
|
||||||
|
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/novox\.internal:20000\/novox\/mesh-tools-go\.git'/);
|
||||||
|
assert.match(s, /export GIT_TERMINAL_PROMPT=0/);
|
||||||
|
assert.equal(path.startsWith("/work/run-x/"), false, "the credential is kept out of the run's own directory");
|
||||||
|
assert.equal(credentialArgs(undefined), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Held against git itself: the credential in the store is offered to the forge's origin when it asks, and
|
||||||
|
// a clone without it fails at once rather than prompting.
|
||||||
|
test("git presents the stored credential to the forge, and only when one is given", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "lab-git-"));
|
||||||
|
const store = join(dir, "git-credentials");
|
||||||
|
writeFileSync(store, "http://lab-shanks:pw@forge.invalid:20000\n", { mode: 0o600 });
|
||||||
|
const fill = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=forge.invalid:20000\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||||
|
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||||
|
assert.match(fill.stdout, /username=lab-shanks/);
|
||||||
|
assert.match(fill.stdout, /password=pw/);
|
||||||
|
const other = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=elsewhere.invalid\\n\\n' | git ${credentialArgs(store)}credential fill`],
|
||||||
|
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||||
|
assert.doesNotMatch(other.stdout, /password=pw/);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Review 196 on mesh-catalog #272: the credential file outlived a stop, because SIGTERM to the run's
|
||||||
|
// process group killed the wrapper before its cleanup. It is now removed right after the clones, on
|
||||||
|
// every way the script ends, and at the next run's start and in lab_check for what a crash left.
|
||||||
|
import { cleanCredentials, credentialGuard } from "../tools/runs.ts";
|
||||||
|
|
||||||
|
test("a stopped script removes the credential, whatever ends it", () => {
|
||||||
|
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "lab-guard-"));
|
||||||
|
const file = join(dir, ".run-x.git-credentials");
|
||||||
|
writeFileSync(file, "http://u:p@h\n", { mode: 0o600 });
|
||||||
|
const ran = spawnSync("bash", ["-c", `${credentialGuard(file)}\nkill -${signal.slice(3)} $$\nsleep 5`], { encoding: "utf8", timeout: 10_000 });
|
||||||
|
assert.notEqual(ran.status, 0, `${signal}: the script was not stopped`);
|
||||||
|
assert.equal(existsSync(file), false, `${signal} left the credential on disk`);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the script removes the credential as soon as its clones are done", () => {
|
||||||
|
const s = script("/work", "run-x", "http://forge", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
|
||||||
|
const lastClone = s.lastIndexOf("clone --quiet");
|
||||||
|
const removed = s.indexOf("rm -f '/work/.run-x.git-credentials'", lastClone);
|
||||||
|
const building = s.indexOf("npm ci");
|
||||||
|
assert.ok(lastClone > 0 && removed > lastClone && removed < building, "not removed between the clones and the build");
|
||||||
|
assert.match(s, /trap 'rm -f '\\''\/work\/\.run-x\.git-credentials'\\''' EXIT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("credentials a crashed run left are removed, but not the running one's", () => {
|
||||||
|
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
|
||||||
|
for (const n of [".run-a.git-credentials", ".run-b.git-credentials", "run-a", "other"]) writeFileSync(join(work, n), "x");
|
||||||
|
const removed = cleanCredentials(work, join(work, ".run-b.git-credentials"));
|
||||||
|
assert.deepEqual(removed, [join(work, ".run-a.git-credentials")]);
|
||||||
|
assert.ok(existsSync(join(work, ".run-b.git-credentials")) && existsSync(join(work, "run-a")) && existsSync(join(work, "other")));
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The lab asks the forge for read only (review 196): its credential is for clones of branches nobody has
|
||||||
|
// reviewed yet, and must not be able to publish the mesh's packages.
|
||||||
|
test("the lab asks the package registry for read only", () => {
|
||||||
|
const m = JSON.parse(readFileSync(join(import.meta.dirname, "..", "module.json"), "utf8")) as {
|
||||||
|
requires: unknown[]; contributes?: Record<string, Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
assert.ok(m.requires.includes("npm-package-registry"));
|
||||||
|
assert.equal(m.contributes?.["npm-package-registry"]?.access, "read");
|
||||||
|
});
|
||||||
|
|
||||||
|
// lab_check failed for ever on "destination path '<work>/check' already exists": it cloned into one fixed
|
||||||
|
// path and ignored whether removing the last one worked (novox/hq issue 624). Each check now works in a
|
||||||
|
// fresh directory, and what earlier checks left is cleared or named.
|
||||||
|
import { checkDir, clearChecks } from "../tools/runs.ts";
|
||||||
|
|
||||||
|
test("each check gets a fresh directory of its own", () => {
|
||||||
|
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
|
||||||
|
const a = checkDir(work);
|
||||||
|
const b = checkDir(work);
|
||||||
|
assert.notEqual(a, b);
|
||||||
|
assert.ok(a.startsWith(join(work, "check-")) && existsSync(a) && existsSync(b));
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// As root nothing is locked against removal, so the unremovable tree cannot be made: skipped there, before
|
||||||
|
// it makes anything (the merge check runs as root).
|
||||||
|
test("what earlier checks left is cleared, and what cannot be is named rather than tripped over", {
|
||||||
|
skip: process.getuid?.() === 0 ? "running as root, which no directory mode stops from removing anything" : false,
|
||||||
|
}, () => {
|
||||||
|
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
|
||||||
|
for (const d of ["check/src", "check-setup/mesh-lab", "check-abc", "run-x", "keep"]) mkdirSync(join(work, d), { recursive: true });
|
||||||
|
// A tree this account cannot empty: a directory it may not write, with something in it.
|
||||||
|
mkdirSync(join(work, "check-stuck", "locked"), { recursive: true });
|
||||||
|
writeFileSync(join(work, "check-stuck", "locked", "file"), "x");
|
||||||
|
chmodSync(join(work, "check-stuck", "locked"), 0o555);
|
||||||
|
const said = clearChecks(work);
|
||||||
|
chmodSync(join(work, "check-stuck", "locked"), 0o755);
|
||||||
|
for (const gone of ["check", "check-setup", "check-abc"]) assert.equal(existsSync(join(work, gone)), false, gone);
|
||||||
|
assert.ok(existsSync(join(work, "run-x")) && existsSync(join(work, "keep")));
|
||||||
|
assert.equal(said.length, 1);
|
||||||
|
assert.match(said[0] as string, /check-stuck, left by an earlier check, could not be removed/);
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// And as root too: what earlier checks left is cleared.
|
||||||
|
test("leftover checks are cleared, whoever runs the check", () => {
|
||||||
|
const work = mkdtempSync(join(tmpdir(), "lab-work-"));
|
||||||
|
for (const d of ["check/src", "check-setup/mesh-lab", "check-abc", "run-x"]) mkdirSync(join(work, d), { recursive: true });
|
||||||
|
assert.deepEqual(clearChecks(work), []);
|
||||||
|
for (const gone of ["check", "check-setup", "check-abc"]) assert.equal(existsSync(join(work, gone)), false, gone);
|
||||||
|
assert.ok(existsSync(join(work, "run-x")));
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The lab's tools read the bound credential, so the runtime is given that file by the tools bundle's env
|
||||||
|
// (ADR 0192), as gitea and umami are given theirs: the runtime's account may then read exactly it, and no
|
||||||
|
// other credential changes hands (issue 624).
|
||||||
|
test("the lab's tools bundle names its bound credential and binding in its env", () => {
|
||||||
|
const m = JSON.parse(readFileSync(join(import.meta.dirname, "..", "module.json"), "utf8")) as {
|
||||||
|
secrets: Record<string, string>; binds: Record<string, string>;
|
||||||
|
build: { artifacts: { name: string; env?: Record<string, string> }[] };
|
||||||
|
};
|
||||||
|
const env = m.build.artifacts.find((a) => a.name === "code")?.env ?? {};
|
||||||
|
assert.equal(env.MESH_LAB_FORGE_SECRET, m.secrets["npm-package-registry"]);
|
||||||
|
assert.equal(env.MESH_LAB_FORGE_BINDING, m.binds["npm-package-registry"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on shanks): npm 12 refused
|
||||||
|
// mesh-tools' locked @novox/mesh-sdk with EALLOWREMOTE. The lab named the registry by its forge setting's
|
||||||
|
// address (http://novox.internal:20000/…), the lock file holds the address the forge writes every tarball
|
||||||
|
// URL at (https://git.novox.be/…), and npm 12 installs a locked tarball only from its configured registry.
|
||||||
|
// A run names the registry by the address the registry names itself by.
|
||||||
|
import { npmRegistry } from "../tools/runs.ts";
|
||||||
|
|
||||||
|
function aRegistry(answer: () => Response | Promise<Response>): { get: typeof fetch; asked: string[] } {
|
||||||
|
const asked: string[] = [];
|
||||||
|
const get = (async (url: string | URL | Request) => {
|
||||||
|
asked.push(String(url));
|
||||||
|
return answer();
|
||||||
|
}) as typeof fetch;
|
||||||
|
return { get, asked };
|
||||||
|
}
|
||||||
|
|
||||||
|
const packument = (tarball: string) => new Response(JSON.stringify({
|
||||||
|
name: "@novox/mesh-sdk", "dist-tags": { latest: "0.1.14" },
|
||||||
|
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/novox/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
|
||||||
|
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
|
||||||
|
test("a run names the package registry by the address the registry gives its tarballs", async () => {
|
||||||
|
const r = aRegistry(() => packument("https://forge.example.org/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
|
||||||
|
const got = await npmRegistry("http://forge.example.org:3000/", r.get, "https://forge.example.org/api/packages/novox/npm/");
|
||||||
|
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/novox/npm/" });
|
||||||
|
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/novox/npm/@novox%2fmesh-sdk"]);
|
||||||
|
// The forge's own scheme is taken too, with its port.
|
||||||
|
const plain = await npmRegistry("http://forge.example.org:3000",
|
||||||
|
aRegistry(() => packument("http://forge.example.org:3000/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
|
||||||
|
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/novox/npm/" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// What the registry answers may come over plain HTTP, so it never decides where packages come from: only
|
||||||
|
// the forge's public root or its own address, scheme, host and port, under the npm path, with nothing after
|
||||||
|
// it (review of mesh-catalog #275, ADR 0346).
|
||||||
|
test("a registry that does not give an address the lab takes leaves the forge's, and the reason is said", async () => {
|
||||||
|
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||||
|
for (const [answer, why] of [
|
||||||
|
[() => new Response("not found", { status: 404 }), /it answered 404/],
|
||||||
|
[() => new Response("{}", { status: 200 }), /no tarball/],
|
||||||
|
[() => { throw new Error("connect ECONNREFUSED"); }, /ECONNREFUSED/],
|
||||||
|
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/novox\/npm\//],
|
||||||
|
[() => packument(`https://attacker.example.net${sdk}`), /at https:\/\/attacker\.example\.net, which is neither the forge's public root nor its own address/],
|
||||||
|
[() => packument(`https://attacker.example.net/x?u=https://novox.internal${sdk}`), /query or a fragment/],
|
||||||
|
[() => packument(`https://novox.internal${sdk}?x=1`), /query or a fragment/],
|
||||||
|
[() => packument(`https://novox.internal${sdk}#frag`), /query or a fragment/],
|
||||||
|
[() => packument(`https://attacker.example.net/#https://novox.internal${sdk}`), /query or a fragment/],
|
||||||
|
[() => packument(`ftp://novox.internal${sdk}`), /at ftp:\/\/novox\.internal, which is neither/],
|
||||||
|
[() => packument(`file:///api/packages/novox/npm/x.tgz`), /which is neither/],
|
||||||
|
[() => packument(`https://novox.internal/api/packages/novox/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
|
||||||
|
[() => packument(`https://user:pw@novox.internal${sdk}`), /user or password/],
|
||||||
|
] as [() => Response, RegExp][]) {
|
||||||
|
const got = await npmRegistry("http://novox.internal:20000", aRegistry(answer).get);
|
||||||
|
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", String(why));
|
||||||
|
assert.match(got.problem ?? "", /did not give an address the lab takes/);
|
||||||
|
assert.match(got.problem ?? "", why);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a run's npm configuration names the registry it was given, and the forge's own address only without one", () => {
|
||||||
|
const given = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, undefined,
|
||||||
|
"https://forge.example.org/api/packages/novox/npm/");
|
||||||
|
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/novox\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
|
||||||
|
assert.doesNotMatch(given, /@novox:registry=http:\/\/novox\.internal/);
|
||||||
|
const none = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {});
|
||||||
|
assert.match(none, /'@novox:registry=http:\/\/novox\.internal:20000\/api\/packages\/novox\/npm\/'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The public root the forge serves in the lab's binding (novox/hq ADR 0346) is the one other origin a
|
||||||
|
// tarball may come from, by scheme, host and port: the review of #275 found the port ignored.
|
||||||
|
import { publicRootOf } from "../tools/runs.ts";
|
||||||
|
|
||||||
|
test("a tarball is taken at the forge's public root, and at no other port or scheme of its host", async () => {
|
||||||
|
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
|
||||||
|
const root = "https://git.example.org/api/packages/novox/npm/";
|
||||||
|
const at = (tarball: string, publicRoot?: string) =>
|
||||||
|
npmRegistry("http://novox.internal:20000", aRegistry(() => packument(tarball)).get, publicRoot);
|
||||||
|
assert.deepEqual(await at(`https://git.example.org${sdk}`, root), { registry: root });
|
||||||
|
for (const tarball of [`https://git.example.org:8443${sdk}`, `http://git.example.org${sdk}`, `https://example.org${sdk}`]) {
|
||||||
|
const got = await at(tarball, root);
|
||||||
|
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", tarball);
|
||||||
|
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/, tarball);
|
||||||
|
}
|
||||||
|
// Without a public root, the forge's public name is not trusted on its own word.
|
||||||
|
assert.match((await at(`https://git.example.org${sdk}`)).problem ?? "", /neither/);
|
||||||
|
// And the forge's own address only at its own port.
|
||||||
|
assert.match((await at(`http://novox.internal${sdk}`, root)).problem ?? "", /neither/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the public root is read from the binding, and only an https root at the npm path is taken", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "lab-root-"));
|
||||||
|
const binding = (root: unknown) => {
|
||||||
|
const f = join(dir, `b-${Math.random()}.json`);
|
||||||
|
writeFileSync(f, JSON.stringify({ provision: "npm-package-registry", at: "home", serves: { scheme: "http", port: 3000,
|
||||||
|
"npm-path": "/api/packages/novox/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
|
||||||
|
return f;
|
||||||
|
};
|
||||||
|
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/novox/npm/")),
|
||||||
|
{ root: "https://git.example.org/api/packages/novox/npm/" });
|
||||||
|
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/novox/npm/")),
|
||||||
|
{ root: "https://git.example.org:8443/api/packages/novox/npm/" });
|
||||||
|
for (const [root, why] of [
|
||||||
|
[undefined, /serves no public-root/],
|
||||||
|
["http://git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
||||||
|
["https://git.example.org/elsewhere/", /is not https:\/\/<host>/],
|
||||||
|
["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\/<host>/],
|
||||||
|
["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
|
||||||
|
["https://git.example.org/api/packages/novox/npm/\nallow-remote=all", /whitespace or a control character/],
|
||||||
|
["https://${bound:route:name-web}/api/packages/novox/npm/", /not a URL|is not https/],
|
||||||
|
] as [unknown, RegExp][]) {
|
||||||
|
const got = publicRootOf(binding(root));
|
||||||
|
assert.equal(got.root, undefined, String(root));
|
||||||
|
assert.match(got.problem ?? "", why, String(root));
|
||||||
|
}
|
||||||
|
assert.match(publicRootOf(join(dir, "none.json")).problem ?? "", /could not be read/);
|
||||||
|
assert.match(publicRootOf(undefined).problem ?? "", /no npm-package-registry binding/);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// A forge setting that is not http(s) is never a trusted origin (review of #280): a `file:` tarball's origin
|
||||||
|
// is "null", as is a `file:` forge's, and the two must not match.
|
||||||
|
test("a forge setting that is not http(s) trusts no origin of its own", async () => {
|
||||||
|
const got = await npmRegistry("file:///srv/forge", aRegistry(() => packument("file:///api/packages/novox/npm/x.tgz")).get);
|
||||||
|
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/);
|
||||||
|
const root = "https://git.example.org/api/packages/novox/npm/";
|
||||||
|
const viaRoot = await npmRegistry("ftp://novox.internal", aRegistry(() =>
|
||||||
|
packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root);
|
||||||
|
assert.match(viaRoot.problem ?? "", /neither/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Defends novox/hq issue 693: a run whose only test skipped as NOT REACHED read "0 passed, 0 failed", exit 0,
|
||||||
|
// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on shanks wrote it.
|
||||||
|
const oldLog = [
|
||||||
|
"STOPPED at step 16 of 24, base: \t\t\t\tMake sure you have all the referenced content and try again.",
|
||||||
|
"\ufe63 genesis adopts its bus as the nats module in place: one bus, its streams kept, the node connected (588152.016874ms) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:",
|
||||||
|
" \t\t\t\tMake sure you have all the referenced content and try again.",
|
||||||
|
"NotFound: content digest sha256:e2de96513ba9: not found",
|
||||||
|
"ℹ tests 1", "ℹ suites 0", "ℹ pass 0", "ℹ fail 0", "ℹ cancelled 0", "ℹ skipped 1", "ℹ todo 0",
|
||||||
|
"ℹ duration_ms 590254.163993",
|
||||||
|
"",
|
||||||
|
"recorded: test/integration/genesis-keeps-its-bus.test.ts — 0 passed, 0 failed, against mesh-lab 7ed7da1",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// The same run, as the lab's suite says it once it names its skips (mesh-lab, issue 693).
|
||||||
|
const newLog = [
|
||||||
|
"ℹ tests 1", "ℹ pass 0", "ℹ fail 0", "ℹ skipped 1",
|
||||||
|
"",
|
||||||
|
"recorded: test/integration/genesis-keeps-its-bus.test.ts — 0 passed, 0 failed, 1 skipped, against mesh-lab 7ed7da1",
|
||||||
|
"NOT PASSED: no test passed: 1 skipped, 1 of them as NOT REACHED",
|
||||||
|
"skipped: genesis adopts its bus — one bus (test/integration/genesis-keeps-its-bus.test.ts) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base: NotFound: content digest",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
function anEndedRun(log: string, state: "passed" | "failed", exit: number): { work: string; id: string } {
|
||||||
|
const work = mkdtempSync(join(tmpdir(), "lab-runs-"));
|
||||||
|
const id = "run-2026-10-14T00-21-44-014Z";
|
||||||
|
mkdirSync(join(work, id));
|
||||||
|
writeFileSync(join(work, id, "run.log"), log);
|
||||||
|
writeFileSync(join(work, id, "status.json"), JSON.stringify({
|
||||||
|
id, state, exit, started: "2026-10-14T00:21:44.014Z", ended: "2026-10-14T00:33:11.179Z",
|
||||||
|
tests: ["test/integration/genesis-keeps-its-bus.test.ts"], refs: {},
|
||||||
|
}));
|
||||||
|
return { work, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a run whose only test skipped as NOT REACHED, from a lab of before, reads as failed with the reason", () => {
|
||||||
|
const { work, id } = anEndedRun(oldLog, "passed", 0);
|
||||||
|
const s = readStatus(work, id);
|
||||||
|
assert.equal(s?.state, "failed", JSON.stringify(s));
|
||||||
|
assert.equal(s?.exit, 0);
|
||||||
|
assert.equal(s?.outcome?.passed, 0);
|
||||||
|
assert.equal(s?.outcome?.skipped, 1);
|
||||||
|
assert.equal(s?.outcome?.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED");
|
||||||
|
assert.match(s?.outcome?.skips[0]?.reason ?? "", /^ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:$/);
|
||||||
|
// Kept in the run's record, not only answered.
|
||||||
|
const kept = JSON.parse(readFileSync(join(work, id, "status.json"), "utf8"));
|
||||||
|
assert.equal(kept.state, "failed");
|
||||||
|
assert.equal(kept.outcome.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED");
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the lab's own NOT PASSED and skipped lines are read, a test name's punctuation and all", () => {
|
||||||
|
const { work, id } = anEndedRun(newLog, "failed", 1);
|
||||||
|
const s = readStatus(work, id);
|
||||||
|
assert.equal(s?.state, "failed");
|
||||||
|
assert.equal(s?.outcome?.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED");
|
||||||
|
assert.deepEqual(s?.outcome?.skips, [{
|
||||||
|
file: "test/integration/genesis-keeps-its-bus.test.ts",
|
||||||
|
test: "genesis adopts its bus — one bus",
|
||||||
|
reason: "ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base: NotFound: content digest",
|
||||||
|
}]);
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a run that passed tests and skipped one for a declared reason stays passed, its skip listed", () => {
|
||||||
|
const log = [
|
||||||
|
"✔ a module lands (12ms)",
|
||||||
|
"﹣ a builder builds (0.1ms) # set MESH_LAB_BUILDER to a built mesh-builder",
|
||||||
|
"ℹ tests 2", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 1",
|
||||||
|
].join("\n");
|
||||||
|
const { work, id } = anEndedRun(log, "passed", 0);
|
||||||
|
const s = readStatus(work, id);
|
||||||
|
assert.equal(s?.state, "passed");
|
||||||
|
assert.equal(s?.outcome?.notPassed, undefined);
|
||||||
|
assert.equal(s?.outcome?.skips[0]?.reason, "set MESH_LAB_BUILDER to a built mesh-builder");
|
||||||
|
rmSync(work, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a run that ran no test at all is not passed", () => {
|
||||||
|
assert.equal(outcomeOf("ℹ tests 0\nℹ pass 0\nℹ fail 0\nℹ skipped 0\n").notPassed, "no test passed: none ran");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Review 254 on issue 693: a test's own output, printed before the suite reports, is not the suite's verdict.
|
||||||
|
test("a line a test printed that looks like the suite's is not read as the suite's", () => {
|
||||||
|
const log = [
|
||||||
|
"NOT PASSED: a test printed this",
|
||||||
|
"skipped: a forged skip (test/integration/x.test.ts) # FORGED NOT REACHED",
|
||||||
|
"✔ a module lands (12ms)",
|
||||||
|
"ℹ tests 1", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 0",
|
||||||
|
"",
|
||||||
|
"recorded: test/integration/x.test.ts — 1 passed, 0 failed, 0 skipped, against mesh-lab 7ed7da1",
|
||||||
|
].join("\n");
|
||||||
|
const o = outcomeOf(log);
|
||||||
|
assert.equal(o.notPassed, undefined, JSON.stringify(o));
|
||||||
|
assert.deepEqual(o.skips, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("not reached is matched in any case", () => {
|
||||||
|
const log = [
|
||||||
|
"✔ a module lands (12ms)",
|
||||||
|
"﹣ the broker step (0.1ms) # the broker step was not reached",
|
||||||
|
"ℹ tests 2", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 1",
|
||||||
|
].join("\n");
|
||||||
|
assert.equal(outcomeOf(log).notPassed, "1 test(s) skipped as NOT REACHED: their subject was not reached");
|
||||||
|
});
|
||||||
|
|||||||
+121
-32
@@ -4,7 +4,14 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
import { installPlan, listRuns, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
|
||||||
|
stop, tail, writeCredential, baseBuildLog, buildingBase, listBaseBuilds, startBaseBuild, credentialPath, launchRun,
|
||||||
|
launchRebuild, BASE_MAX_AGE_DAYS, describeBase, readBaseImage, baseProblem,
|
||||||
|
} from "./runs.js";
|
||||||
|
|
||||||
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||||
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
|
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
|
||||||
@@ -12,6 +19,18 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
|||||||
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
|
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
|
||||||
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
|
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
|
||||||
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
|
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
|
||||||
|
// The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call.
|
||||||
|
// The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call.
|
||||||
|
const bindingOf = (): string | undefined =>
|
||||||
|
env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined);
|
||||||
|
const registryOf = async (forge: string) => {
|
||||||
|
const root = publicRootOf(bindingOf());
|
||||||
|
const registry = await npmRegistry(forge, fetch, root.root);
|
||||||
|
return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry;
|
||||||
|
};
|
||||||
|
const credentialOf = (forge: string): string | undefined => forgeCredential(forge,
|
||||||
|
env.MESH_LAB_FORGE_BINDING ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING"),
|
||||||
|
env.MESH_LAB_FORGE_SECRET ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_SECRET"));
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: "lab_check",
|
name: "lab_check",
|
||||||
@@ -20,17 +39,39 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
|||||||
run: async () => {
|
run: async () => {
|
||||||
const forge = forgeOf();
|
const forge = forgeOf();
|
||||||
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||||
const dir = `${work}/check`;
|
// What earlier checks left is cleared, and what could not be is said; this check works in a fresh
|
||||||
spawnSync("rm", ["-rf", dir]);
|
// directory of its own either way, and removes it when it is done.
|
||||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
|
const leftovers = clearChecks(work);
|
||||||
if (clone.status !== 0) return { ok: false, output: clone.stderr };
|
const dir = checkDir(work);
|
||||||
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
|
let check: ReturnType<typeof spawnSync>;
|
||||||
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
|
let problems: string[];
|
||||||
|
try {
|
||||||
|
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
|
||||||
|
if (clone.status !== 0) return { ok: false, output: [String(clone.stderr).trim(), ...leftovers].join("\n") };
|
||||||
|
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
|
||||||
|
check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
|
||||||
|
problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge));
|
||||||
|
// And the address a run names the package registry by (npmRegistry): said when the registry does not say it.
|
||||||
|
const registry = await registryOf(forge);
|
||||||
|
if (registry.problem) problems.push(registry.problem);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
// **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every
|
// **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every
|
||||||
// repository it clones answers on the forge, and every one it installs has a lock file where the run
|
// repository it clones answers on the forge, and every one it installs has a lock file where the run
|
||||||
// installs from.
|
// installs from.
|
||||||
const problems = setupProblems(forge, `${work}/check-setup`);
|
// A credential a stopped, crashed or rebooted run or rebuild left behind is removed, and said; one still in
|
||||||
const output = [`${check.stdout}${check.stderr}`.trim(), ...problems].filter(Boolean).join("\n");
|
// use by a running run or rebuild is not.
|
||||||
|
const busy = [running(work)?.id, buildingBase(work)?.id].filter((id): id is string => !!id);
|
||||||
|
const left = cleanCredentials(work, busy.map((id) => credentialPath(work, id)));
|
||||||
|
const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run or rebuild left behind: ${left.join(", ")}` : "";
|
||||||
|
// **And how old the base image is** (novox/hq issue 698): past the bound, missing, or of an age that cannot
|
||||||
|
// be read is a problem here, though lab_run refuses only the first.
|
||||||
|
const base = readBaseImage();
|
||||||
|
const unfit = baseProblem(base);
|
||||||
|
if (unfit) problems.push(unfit);
|
||||||
|
const output = [`${check.stdout}${check.stderr}`.trim(), describeBase(base), removed, ...leftovers, ...problems]
|
||||||
|
.filter(Boolean).join("\n");
|
||||||
return { ok: check.status === 0 && problems.length === 0, output };
|
return { ok: check.status === 0 && problems.length === 0, output };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -62,9 +103,43 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
|||||||
}
|
}
|
||||||
const stranger = Object.keys(refs).filter((r) => !REPOSITORIES.includes(r));
|
const stranger = Object.keys(refs).filter((r) => !REPOSITORIES.includes(r));
|
||||||
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
|
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
|
||||||
const busy = running(work);
|
// **One job at a time, not on a base image past its bound, nor while it is rebuilt** (novox/hq issue 698,
|
||||||
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
|
// review 256): the registry asked first, then the check and the start with nothing awaited between.
|
||||||
return { started: true, run: start(work, forge, tests, refs) };
|
const launched = await launchRun(work, { registry: () => registryOf(forge), base: () => readBaseImage() },
|
||||||
|
(registry) => start(work, forge, tests, refs, credentialOf(forge), registry));
|
||||||
|
if (!launched.started) return launched;
|
||||||
|
return { started: true, run: launched.job, base: describeBase(launched.base), registry: launched.registry,
|
||||||
|
...(launched.problem ? { problem: launched.problem } : {}) };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lab_base",
|
||||||
|
description:
|
||||||
|
"The lab's base image, which every bed machine with a container runtime is raised from: when it was built, its " +
|
||||||
|
`age, the bound lab_run refuses past (${BASE_MAX_AGE_DAYS} days), and the last rebuilds with the newest one's log. ` +
|
||||||
|
"Reads only, unless rebuild is true: then it rebuilds the image with mesh-lab's `base build` from the forge's " +
|
||||||
|
"main, in the background, and answers at once; ask again for the build's state and the image's new age.",
|
||||||
|
input: {
|
||||||
|
rebuild: { type: "boolean", description: "rebuild the image (default false: only read)" },
|
||||||
|
},
|
||||||
|
run: async (args) => {
|
||||||
|
const before = readBaseImage();
|
||||||
|
const builds = listBaseBuilds(work).slice(0, 3);
|
||||||
|
const newest = builds[0];
|
||||||
|
const reading = {
|
||||||
|
base: before, said: describeBase(before), bound: BASE_MAX_AGE_DAYS,
|
||||||
|
...(baseProblem(before) ? { problem: baseProblem(before) } : {}),
|
||||||
|
builds, ...(newest ? { log: baseBuildLog(work, newest.id) } : {}),
|
||||||
|
};
|
||||||
|
if (args.rebuild !== true) return reading;
|
||||||
|
const forge = forgeOf();
|
||||||
|
if (!forge) return { ...reading, started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
|
||||||
|
// Not under a run, nor beside another rebuild: the registry asked first, then the check and the start with
|
||||||
|
// nothing awaited between (review 256).
|
||||||
|
const launched = await launchRebuild(work, { registry: () => registryOf(forge), base: () => before },
|
||||||
|
(registry) => startBaseBuild(work, forge, credentialOf(forge), registry));
|
||||||
|
if (launched.started === false) return { ...reading, started: false, reason: launched.reason };
|
||||||
|
return { ...reading, started: true, build: launched.job };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -99,30 +174,44 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
|||||||
* answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup
|
* answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup
|
||||||
* would go through.
|
* would go through.
|
||||||
*/
|
*/
|
||||||
export function setupProblems(forge: string, dir: string): string[] {
|
export function setupProblems(forge: string, dir: string, credential?: string): string[] {
|
||||||
const problems: string[] = [];
|
const problems: string[] = [];
|
||||||
spawnSync("rm", ["-rf", dir]);
|
spawnSync("rm", ["-rf", dir]);
|
||||||
for (const repo of REPOSITORIES) {
|
// The credential a run would offer, in a file of its own outside the check's clones, removed after.
|
||||||
const url = `${forge}/novox/${repo}.git`;
|
const keep = mkdtempSync(join(tmpdir(), "lab-check-"));
|
||||||
const remote = spawnSync("git", ["ls-remote", "--heads", url, "main"], { encoding: "utf8" });
|
const file = writeCredential(join(keep, "git-credentials"), credential);
|
||||||
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
|
const git = (args: string[]) =>
|
||||||
problems.push(`a run checks out ${repo}, and ${url} answers no main branch`);
|
spawnSync("sh", ["-c", `git ${credentialArgs(file)}${args.map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(" ")}`], {
|
||||||
continue;
|
encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
||||||
}
|
});
|
||||||
if (!NPM_REPOSITORIES.includes(repo)) continue;
|
try {
|
||||||
const at = `${dir}/${repo}`;
|
for (const repo of REPOSITORIES) {
|
||||||
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", url, at], { encoding: "utf8" });
|
const url = `${forge}/novox/${repo}.git`;
|
||||||
if (clone.status !== 0) {
|
const remote = git(["ls-remote", "--heads", url, "main"]);
|
||||||
problems.push(`${repo} could not be cloned from ${url}: ${clone.stderr.trim()}`);
|
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
|
||||||
continue;
|
const said = remote.stderr.trim().split("\n")[0] ?? "";
|
||||||
}
|
problems.push(`a run checks out ${repo}, and ${url} could not be read` +
|
||||||
const plan = installPlan(at);
|
(said ? `: ${said}` : ": no main branch") +
|
||||||
for (const d of plan.unlocked) problems.push(`${repo}: ${d.slice(at.length) || "/"} has a package.json and no package-lock.json, so a run's npm ci cannot install it`);
|
(credential ? "" : " — the lab holds no forge credential (its npm-package-registry binding and secret)"));
|
||||||
if (plan.install.length === 0 && plan.unlocked.length === 0) {
|
continue;
|
||||||
problems.push(`${repo}: no package-lock.json at its root or one directory down, so a run installs nothing of it`);
|
}
|
||||||
|
if (!NPM_REPOSITORIES.includes(repo)) continue;
|
||||||
|
const at = `${dir}/${repo}`;
|
||||||
|
const clone = git(["clone", "--quiet", "--depth", "1", url, at]);
|
||||||
|
if (clone.status !== 0) {
|
||||||
|
problems.push(`${repo} could not be cloned from ${url}: ${clone.stderr.trim()}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const plan = installPlan(at);
|
||||||
|
for (const d of plan.unlocked) problems.push(`${repo}: ${d.slice(at.length) || "/"} has a package.json and no package-lock.json, so a run's npm ci cannot install it`);
|
||||||
|
if (plan.install.length === 0 && plan.unlocked.length === 0) {
|
||||||
|
problems.push(`${repo}: no package-lock.json at its root or one directory down, so a run installs nothing of it`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(keep, { recursive: true, force: true });
|
||||||
|
spawnSync("rm", ["-rf", dir]);
|
||||||
}
|
}
|
||||||
spawnSync("rm", ["-rf", dir]);
|
|
||||||
return problems;
|
return problems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+593
-12
@@ -6,8 +6,8 @@
|
|||||||
// and that file is the whole of what the tools read back: a runtime that restarts mid-run still answers
|
// and that file is the whole of what the tools read back: a runtime that restarts mid-run still answers
|
||||||
// for it, and says it was lost rather than pretending it is still going.
|
// for it, and says it was lost rather than pretending it is still going.
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +60,224 @@ export function installStep(root: string, build: boolean): string {
|
|||||||
fi`;
|
fi`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where the forge serves the mesh's npm packages, under its own address (gitea's npm registry for the novox org). */
|
||||||
|
export const NPM_PATH = "/api/packages/novox/npm/";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registry's public root, as the forge serves it in the lab's npm-package-registry binding
|
||||||
|
* (`serves.public-root`, novox/hq ADR 0346): the forge's route's composed public name under the npm path.
|
||||||
|
* The mesh writes the binding, and it is checked all the same: an https URL whose path is the npm path and
|
||||||
|
* nothing else. Undefined, with why, when the binding has none or it is not that shape — a forge on a
|
||||||
|
* machine with no public domain serves none (ADR 0346 §3).
|
||||||
|
*/
|
||||||
|
export function publicRootOf(bindingFile: string | undefined): { root?: string; problem?: string } {
|
||||||
|
if (!bindingFile) return { problem: "the lab has no npm-package-registry binding" };
|
||||||
|
let said: unknown;
|
||||||
|
try {
|
||||||
|
said = (JSON.parse(readFileSync(bindingFile, "utf8")) as { serves?: Record<string, unknown> }).serves?.["public-root"];
|
||||||
|
} catch (e) {
|
||||||
|
return { problem: `its npm-package-registry binding could not be read: ${e instanceof Error ? e.message : String(e)}` };
|
||||||
|
}
|
||||||
|
if (typeof said !== "string" || !said) return { problem: "its npm-package-registry binding serves no public-root" };
|
||||||
|
if (/[\s\u0000-\u001f\u007f]/.test(said)) return { problem: "the binding's public-root holds whitespace or a control character" };
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(said);
|
||||||
|
} catch {
|
||||||
|
return { problem: `the binding's public-root ${JSON.stringify(said)} is not a URL` };
|
||||||
|
}
|
||||||
|
if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash || u.pathname !== NPM_PATH) {
|
||||||
|
return { problem: `the binding's public-root ${JSON.stringify(said)} is not https://<host>${NPM_PATH}` };
|
||||||
|
}
|
||||||
|
return { root: `${u.origin}${NPM_PATH}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address a run names the @novox registry by: the one the registry names itself by, when that is the
|
||||||
|
* forge's public root or the forge's own address (novox/hq issue 624, ADR 0346).
|
||||||
|
*
|
||||||
|
* **npm 12 installs a locked tarball only from the registry it is configured with.** Its `allow-remote`
|
||||||
|
* defaults to `none`, and a lock file's `resolved` URL counts as the registry's only when its origin and
|
||||||
|
* path are the configured scope registry's; any other is a "remote" tarball and refused (EALLOWREMOTE).
|
||||||
|
* The forge writes every tarball URL — in what it answers, and so in every lock file made against it — at
|
||||||
|
* its own public address, whichever address it was asked at. So the lab asks the registry, at the forge,
|
||||||
|
* for the SDK's newest tarball and names the registry by the origin of that URL.
|
||||||
|
*
|
||||||
|
* **What the registry answers is not trusted for where packages come from** (review of mesh-catalog #275):
|
||||||
|
* it may be fetched over plain HTTP. The tarball URL is parsed, and taken only when it carries no
|
||||||
|
* whitespace, control character, user, password, query or fragment, its path is under the npm path, and
|
||||||
|
* its origin — scheme, host and port — is one the lab already trusts: the public root the forge serves in
|
||||||
|
* the lab's binding (`publicRoot`, ADR 0346), or the forge setting's own. The registry is then built from
|
||||||
|
* the parsed origin and the npm path alone, never from the string answered, so nothing the registry says
|
||||||
|
* reaches the run's npm configuration but a scheme, a host and a port. Anything else falls back to the
|
||||||
|
* forge's own address and says why, so a run still starts and lab_check names it.
|
||||||
|
*/
|
||||||
|
export async function npmRegistry(forge: string, get: typeof fetch = fetch, publicRoot?: string):
|
||||||
|
Promise<{ registry: string; problem?: string }> {
|
||||||
|
const asked = `${forge.replace(/\/+$/, "")}${NPM_PATH}`;
|
||||||
|
const url = `${asked}@novox%2fmesh-sdk`;
|
||||||
|
const fallback = (why: string) => ({
|
||||||
|
registry: asked,
|
||||||
|
problem: `the package registry at ${url} did not give an address the lab takes (${why}), so a run names it ${asked}, and npm 12 refuses a locked @novox tarball at any other`,
|
||||||
|
});
|
||||||
|
let own: URL;
|
||||||
|
try {
|
||||||
|
own = new URL(forge);
|
||||||
|
} catch {
|
||||||
|
return fallback(`the forge ${JSON.stringify(forge)} is not a URL`);
|
||||||
|
}
|
||||||
|
// The forge setting's own origin is trusted only as an http(s) one: any other scheme's origin is
|
||||||
|
// "null", or names no registry npm would fetch from.
|
||||||
|
const trusted = own.protocol === "https:" || own.protocol === "http:" ? [own.origin] : [];
|
||||||
|
if (publicRoot) {
|
||||||
|
try {
|
||||||
|
trusted.unshift(new URL(publicRoot).origin);
|
||||||
|
} catch {
|
||||||
|
// publicRootOf hands only a parsed root; a caller's other string is not trusted.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await get(url, { signal: AbortSignal.timeout(15_000) });
|
||||||
|
if (!res.ok) return fallback(`it answered ${res.status}`);
|
||||||
|
const doc = await res.json() as { "dist-tags"?: { latest?: string }; versions?: Record<string, { dist?: { tarball?: unknown } }> };
|
||||||
|
const latest = doc["dist-tags"]?.latest ?? "";
|
||||||
|
const tarball = doc.versions?.[latest]?.dist?.tarball;
|
||||||
|
if (typeof tarball !== "string" || !tarball) return fallback("no tarball for its latest version");
|
||||||
|
if (/[\s\u0000-\u001f\u007f]/.test(tarball)) return fallback("its tarball URL holds whitespace or a control character");
|
||||||
|
let t: URL;
|
||||||
|
try {
|
||||||
|
t = new URL(tarball);
|
||||||
|
} catch {
|
||||||
|
return fallback("its tarball is not a URL");
|
||||||
|
}
|
||||||
|
if (t.username || t.password) return fallback("its tarball URL carries a user or password");
|
||||||
|
if (t.search || t.hash) return fallback("its tarball URL carries a query or a fragment");
|
||||||
|
if (!t.pathname.startsWith(NPM_PATH)) return fallback(`its tarball is not under ${NPM_PATH}`);
|
||||||
|
if (!trusted.includes(t.origin)) {
|
||||||
|
return fallback(`its tarball is at ${t.origin}, which is neither the forge's public root nor its own address (${trusted.join(", ")})`);
|
||||||
|
}
|
||||||
|
return { registry: `${t.origin}${NPM_PATH}` };
|
||||||
|
} catch (e) {
|
||||||
|
return fallback(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The forge credential a run's clones may present, as a git credential-store line —
|
||||||
|
* scheme://user:password@host[:port] — for the forge's own origin, or undefined when the lab holds none.
|
||||||
|
*
|
||||||
|
* **The mesh's credential for the forge, the way the build agent holds it** (novox/hq issue 624): the lab
|
||||||
|
* requires the package registry, the forge's provisioner binds it an account (`as`) and seals that
|
||||||
|
* account's password, and the forge that answers npm is the forge that hosts the repositories. Private
|
||||||
|
* repositories (mesh-tools-go) are cloned with it; public ones clone as before. Written to a file git reads
|
||||||
|
* through its credential store, never into a URL, an argument, the run's script or its log, and offered
|
||||||
|
* only to the forge's own scheme, host and port.
|
||||||
|
*/
|
||||||
|
export function forgeCredential(forge: string, bindingFile: string, secretFile: string): string | undefined {
|
||||||
|
if (!forge || !bindingFile || !secretFile) return undefined;
|
||||||
|
let as = "";
|
||||||
|
let secret = "";
|
||||||
|
try {
|
||||||
|
as = String((JSON.parse(readFileSync(bindingFile, "utf8")) as { as?: string }).as ?? "");
|
||||||
|
secret = readFileSync(secretFile, "utf8").trim();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!as || !secret) return undefined;
|
||||||
|
let origin: URL;
|
||||||
|
try {
|
||||||
|
origin = new URL(forge);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const line = new URL(`${origin.protocol}//${origin.host}`);
|
||||||
|
line.username = as;
|
||||||
|
line.password = secret;
|
||||||
|
return line.toString().replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a run keeps its forge credential: beside its directory, never in it, and removed when it ends. */
|
||||||
|
export function credentialPath(work: string, id: string): string {
|
||||||
|
return join(work, `.${id}.git-credentials`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** writeCredential puts a credential-store line where git reads it, readable by this account alone. */
|
||||||
|
export function writeCredential(path: string, line: string | undefined): string | undefined {
|
||||||
|
if (!line) return undefined;
|
||||||
|
writeFileSync(path, `${line}\n`, { mode: 0o600 });
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* credentialGuard is the shell that removes the credential however the script ends — its end, an error,
|
||||||
|
* or a stop's SIGTERM to the run's process group, which kills the wrapper before its own cleanup runs
|
||||||
|
* (review 196 on mesh-catalog #272). The script also removes it as soon as its clones are done.
|
||||||
|
*/
|
||||||
|
export function credentialGuard(path: string | undefined): string {
|
||||||
|
if (!path) return "";
|
||||||
|
const rm = `rm -f ${shellQuote(path)}`;
|
||||||
|
return `trap ${shellQuote(rm)} EXIT
|
||||||
|
trap ${shellQuote(`${rm}; exit 143`)} TERM
|
||||||
|
trap ${shellQuote(`${rm}; exit 130`)} INT
|
||||||
|
trap ${shellQuote(`${rm}; exit 129`)} HUP`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cleanCredentials removes every run's and every base rebuild's credential file left in the work directory
|
||||||
|
* but the ones named — what a crash or a reboot left behind, which no trap could remove. Answers what it
|
||||||
|
* removed.
|
||||||
|
*/
|
||||||
|
export function cleanCredentials(work: string, keep?: string | string[]): string[] {
|
||||||
|
if (!existsSync(work)) return [];
|
||||||
|
const kept = new Set([keep ?? []].flat());
|
||||||
|
const removed: string[] = [];
|
||||||
|
for (const name of readdirSync(work)) {
|
||||||
|
if (!/^\.(run|base)-.*\.git-credentials$/.test(name)) continue;
|
||||||
|
const path = join(work, name);
|
||||||
|
if (kept.has(path)) continue;
|
||||||
|
rmSync(path, { force: true });
|
||||||
|
removed.push(path);
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checkDir is a directory of lab_check's own for one check, fresh each time (novox/hq issue 624). The check
|
||||||
|
* cloned into the one fixed path `<work>/check`, removing it first and ignoring whether that worked; a tree
|
||||||
|
* an earlier check left that this account could not remove — one cloned as another account before the
|
||||||
|
* work directory was the operator's — made every check fail on "destination path already exists".
|
||||||
|
*/
|
||||||
|
export function checkDir(work: string): string {
|
||||||
|
mkdirSync(work, { recursive: true });
|
||||||
|
return mkdtempSync(join(work, "check-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clearChecks removes what earlier checks left in the work directory — the fixed `check` and `check-setup`
|
||||||
|
* of before, and any `check-*` — and says, for each it could not remove, which and why, so a check reports
|
||||||
|
* it rather than tripping over it.
|
||||||
|
*/
|
||||||
|
export function clearChecks(work: string): string[] {
|
||||||
|
if (!existsSync(work)) return [];
|
||||||
|
const said: string[] = [];
|
||||||
|
for (const name of readdirSync(work)) {
|
||||||
|
if (name !== "check" && name !== "check-setup" && !name.startsWith("check-")) continue;
|
||||||
|
const path = join(work, name);
|
||||||
|
try {
|
||||||
|
rmSync(path, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
said.push(`${path}, left by an earlier check, could not be removed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return said;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The arguments that have git offer the credential in that file, and only that one. */
|
||||||
|
export function credentialArgs(path: string | undefined): string {
|
||||||
|
if (!path) return "";
|
||||||
|
return `-c credential.helper= -c ${shellQuote(`credential.helper=store --file=${path}`)} `;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunStatus {
|
export interface RunStatus {
|
||||||
id: string;
|
id: string;
|
||||||
state: "checking-out" | "building" | "running" | "passed" | "failed" | "stopped" | "lost";
|
state: "checking-out" | "building" | "running" | "passed" | "failed" | "stopped" | "lost";
|
||||||
@@ -70,6 +288,117 @@ export interface RunStatus {
|
|||||||
commits?: Record<string, string>;
|
commits?: Record<string, string>;
|
||||||
exit?: number;
|
exit?: number;
|
||||||
pid?: number;
|
pid?: number;
|
||||||
|
/** What the suite said of its tests, read from the run's log once it ended (novox/hq issue 693). */
|
||||||
|
outcome?: Outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One skipped test, its file and the reason it gave. */
|
||||||
|
export interface Skip {
|
||||||
|
file: string;
|
||||||
|
test: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a run's suite said of its tests: its totals, each skip with its reason, and — when the run did not
|
||||||
|
* pass although its exit said 0 — why (`notPassed`). Totals the runner did not print are absent.
|
||||||
|
*/
|
||||||
|
export interface Outcome {
|
||||||
|
passed?: number;
|
||||||
|
failed?: number;
|
||||||
|
skipped?: number;
|
||||||
|
skips: Skip[];
|
||||||
|
notPassed?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* outcomeOf reads what a run's log says of its tests (novox/hq issue 693).
|
||||||
|
*
|
||||||
|
* **A run in which no test passed has proven nothing**, and the runner exits 0 when every test skipped:
|
||||||
|
* the genesis bed skipped its only test as "NOT REACHED", the suite printed "0 passed, 0 failed", and the
|
||||||
|
* lab recorded the run as passed. The lab's suite now says it itself — a "NOT PASSED: <why>" line and a
|
||||||
|
* "skipped: <test> (<file>) # <reason>" line per skip after its "recorded:" line, and an exit that is not
|
||||||
|
* 0 — and this reads those lines. For a run of a lab from before (a branch, or an old log), the same rule is
|
||||||
|
* read from the runner's own output: its summary totals, and its skip lines' first line of reason.
|
||||||
|
*
|
||||||
|
* The rule, the lab suite's (mesh-lab src/outcomes.ts): **not passed when no test passed**, or when a test
|
||||||
|
* skipped as NOT REACHED (its subject was not reached); a skip for another reason beside tests that passed
|
||||||
|
* leaves the run passed, its reason listed.
|
||||||
|
*/
|
||||||
|
export function outcomeOf(log: string): Outcome {
|
||||||
|
const plain = log.replace(/\u001b\[[0-9;]*m/g, "");
|
||||||
|
const total = (word: string) => {
|
||||||
|
// The last summary in the log: the runner's own, once per run.
|
||||||
|
const all = [...plain.matchAll(new RegExp(`^\\s*(?:\u2139|#)\\s*${word}\\s+(\\d+)\\s*$`, "gm"))];
|
||||||
|
const last = all[all.length - 1];
|
||||||
|
return last ? Number(last[1]) : undefined;
|
||||||
|
};
|
||||||
|
const outcome: Outcome = { skips: [] };
|
||||||
|
const passed = total("pass");
|
||||||
|
const failed = total("fail");
|
||||||
|
const skipped = total("skipped");
|
||||||
|
if (passed !== undefined) outcome.passed = passed;
|
||||||
|
if (failed !== undefined) outcome.failed = failed;
|
||||||
|
if (skipped !== undefined) outcome.skipped = skipped;
|
||||||
|
|
||||||
|
// **The suite's own lines are read only after its last "recorded:" line** (novox/hq issue 693, review
|
||||||
|
// 254): a test prints what it likes before the suite reports, and a line of its that looked like the
|
||||||
|
// suite's — "NOT PASSED: …", "skipped: … # NOT REACHED" — would otherwise stand for the suite's verdict.
|
||||||
|
const recorded = [...plain.matchAll(/^recorded: .*$/gm)];
|
||||||
|
const lastRecorded = recorded[recorded.length - 1];
|
||||||
|
const suiteSaid = lastRecorded ? plain.slice((lastRecorded.index ?? 0) + lastRecorded[0].length) : "";
|
||||||
|
|
||||||
|
// The suite's own line, "skipped: <test> (<file>) # <reason>": the file in brackets, a .ts, parts it.
|
||||||
|
for (const m of suiteSaid.matchAll(/^skipped: (.*?) \(([^()]+\.ts)\) # (.*)$/gm)) {
|
||||||
|
outcome.skips.push({ file: m[2] ?? "", test: m[1] ?? "", reason: m[3] === "no reason given" ? "" : m[3] ?? "" });
|
||||||
|
}
|
||||||
|
if (outcome.skips.length === 0) {
|
||||||
|
// A lab from before names no skip itself; its runner's spec line does: "﹣ <test> (<ms>ms) # <reason>".
|
||||||
|
for (const m of plain.matchAll(/^\s*\ufe63 (.*?) \([\d.]+m?s\) # (.*)$/gm)) {
|
||||||
|
const reason = (m[2] ?? "").trim();
|
||||||
|
outcome.skips.push({ file: "", test: m[1] ?? "", reason: reason === "SKIP" ? "" : reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const said = suiteSaid.match(/^NOT PASSED: (.*)$/m);
|
||||||
|
if (said) {
|
||||||
|
outcome.notPassed = (said[1] ?? "").trim();
|
||||||
|
} else if ((failed ?? 0) === 0) {
|
||||||
|
// In any case: "not reached" says the same as "NOT REACHED".
|
||||||
|
const unreached = outcome.skips.filter((s) => s.reason.toUpperCase().includes("NOT REACHED")).length;
|
||||||
|
if (passed === 0) {
|
||||||
|
outcome.notPassed = outcome.skips.length > 0 || (skipped ?? 0) > 0
|
||||||
|
? `no test passed: ${skipped ?? outcome.skips.length} skipped${unreached > 0 ? `, ${unreached} of them as NOT REACHED` : ""}`
|
||||||
|
: "no test passed: none ran";
|
||||||
|
} else if (unreached > 0) {
|
||||||
|
outcome.notPassed = `${unreached} test(s) skipped as NOT REACHED: their subject was not reached`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* settled is a run that ended, with its outcome read from its log: a run whose exit said 0 and whose suite
|
||||||
|
* proved nothing reads as failed, with why (novox/hq issue 693). Read once and kept in its status, so the
|
||||||
|
* record says it too; a run still going, stopped or lost is left as it is.
|
||||||
|
*/
|
||||||
|
export function settled(work: string, s: RunStatus): RunStatus {
|
||||||
|
if (s.outcome || !s.ended || (s.state !== "passed" && s.state !== "failed")) return s;
|
||||||
|
let log: string;
|
||||||
|
try {
|
||||||
|
log = readFileSync(join(runDir(work, s.id), "run.log"), "utf8");
|
||||||
|
} catch {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
const outcome = outcomeOf(log);
|
||||||
|
const next: RunStatus = { ...s, outcome };
|
||||||
|
if (next.state === "passed" && outcome.notPassed) next.state = "failed";
|
||||||
|
try {
|
||||||
|
writeFileSync(statusPath(work, s.id), JSON.stringify(next, null, 2));
|
||||||
|
} catch {
|
||||||
|
// Read again next time: what lab_status answers is the same either way.
|
||||||
|
}
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runDir(work: string, id: string): string {
|
export function runDir(work: string, id: string): string {
|
||||||
@@ -84,16 +413,23 @@ export function readStatus(work: string, id: string): RunStatus | undefined {
|
|||||||
try {
|
try {
|
||||||
const s = JSON.parse(readFileSync(statusPath(work, id), "utf8")) as RunStatus;
|
const s = JSON.parse(readFileSync(statusPath(work, id), "utf8")) as RunStatus;
|
||||||
// A run whose process is gone while its status still says it is going was lost — the runtime or
|
// A run whose process is gone while its status still says it is going was lost — the runtime or
|
||||||
// the machine restarted under it. Said, rather than left reading as running for ever.
|
// the machine restarted under it. Said, rather than left reading as running for ever. One that never
|
||||||
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && s.pid && !alive(s.pid)) {
|
// recorded a process (the runtime died between writing its status and starting it) is lost too: it
|
||||||
|
// would otherwise hold up every run after it (review 256).
|
||||||
|
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && gone(s.pid)) {
|
||||||
s.state = "lost";
|
s.state = "lost";
|
||||||
}
|
}
|
||||||
return s;
|
return settled(work, s);
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** gone is whether a job's process is not running: none recorded, or none by that id. */
|
||||||
|
function gone(pid: number | undefined): boolean {
|
||||||
|
return !pid || !alive(pid);
|
||||||
|
}
|
||||||
|
|
||||||
function alive(pid: number): boolean {
|
function alive(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
@@ -125,7 +461,8 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|||||||
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
|
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
|
||||||
* at this run's own tree, so what is built and claimed is exactly what was checked out.
|
* at this run's own tree, so what is built and claimed is exactly what was checked out.
|
||||||
*/
|
*/
|
||||||
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>): string {
|
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||||
|
credential?: string, registry = `${forge}${NPM_PATH}`): string {
|
||||||
const dir = runDir(work, id);
|
const dir = runDir(work, id);
|
||||||
const setState = (state: string) =>
|
const setState = (state: string) =>
|
||||||
`node -e ${shellQuote(
|
`node -e ${shellQuote(
|
||||||
@@ -134,22 +471,26 @@ export function script(work: string, id: string, forge: string, tests: string[],
|
|||||||
const clones = REPOSITORIES.map((repo) => {
|
const clones = REPOSITORIES.map((repo) => {
|
||||||
const ref = refs[repo] ?? "main";
|
const ref = refs[repo] ?? "main";
|
||||||
return [
|
return [
|
||||||
`git clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
|
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
|
||||||
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
|
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}).join("\n");
|
}).join("\n");
|
||||||
const bin = join(dir, "bin");
|
const bin = join(dir, "bin");
|
||||||
return `set -euo pipefail
|
return `set -euo pipefail
|
||||||
|
# A repository the forge will not show fails its clone at once, rather than waiting on a prompt.
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
${credentialGuard(credential)}
|
||||||
cd ${shellQuote(dir)}
|
cd ${shellQuote(dir)}
|
||||||
${setState("checking-out")}
|
${setState("checking-out")}
|
||||||
${clones}
|
${clones}
|
||||||
|
${credential ? `# The forge credential is for the clones, and nothing after them runs with it on disk.\nrm -f ${shellQuote(credential)}` : ""}
|
||||||
node -e ${shellQuote(
|
node -e ${shellQuote(
|
||||||
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
|
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
|
||||||
)}
|
)}
|
||||||
${setState("building")}
|
${setState("building")}
|
||||||
# The @novox scope resolves from the mesh's own package registry on the forge, as the builder
|
# The @novox scope resolves from the mesh's own package registry on the forge, named by the address it
|
||||||
# resolves it; nothing else is asked of it.
|
# names itself by (npmRegistry), which is the address its lock files hold; nothing else is asked of it.
|
||||||
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
|
printf '%s\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))}
|
||||||
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
|
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
|
||||||
${NPM_REPOSITORIES.map((repo) => installStep(join(dir, repo), repo !== "mesh-lab")).join("\n")}
|
${NPM_REPOSITORIES.map((repo) => installStep(join(dir, repo), repo !== "mesh-lab")).join("\n")}
|
||||||
mkdir -p ${shellQuote(bin)}
|
mkdir -p ${shellQuote(bin)}
|
||||||
@@ -174,13 +515,17 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join("
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** start begins a run and returns at once with its status. */
|
/** start begins a run and returns at once with its status. */
|
||||||
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>): RunStatus {
|
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
|
||||||
|
credential?: string, registry?: string): RunStatus {
|
||||||
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||||
const dir = runDir(work, id);
|
const dir = runDir(work, id);
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
const status: RunStatus = { id, state: "checking-out", started: new Date().toISOString(), tests, refs };
|
const status: RunStatus = { id, state: "checking-out", started: new Date().toISOString(), tests, refs };
|
||||||
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
|
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
|
||||||
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs), { mode: 0o700 });
|
// What a crashed or rebooted run left behind goes before this run writes its own.
|
||||||
|
cleanCredentials(work);
|
||||||
|
const credentials = writeCredential(credentialPath(work, id), credential);
|
||||||
|
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials, registry), { mode: 0o700 });
|
||||||
|
|
||||||
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
|
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
|
||||||
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
|
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
|
||||||
@@ -188,6 +533,7 @@ export function start(work: string, forge: string, tests: string[], refs: Record
|
|||||||
node -e ${shellQuote(
|
node -e ${shellQuote(
|
||||||
`const f=${JSON.stringify(statusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));if(s.state!=="stopped"){s.state=process.argv[1]==="0"?"passed":"failed"};s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
|
`const f=${JSON.stringify(statusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));if(s.state!=="stopped"){s.state=process.argv[1]==="0"?"passed":"failed"};s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
|
||||||
)} "$code"
|
)} "$code"
|
||||||
|
rm -f ${shellQuote(credentialPath(work, id))}
|
||||||
cd ${shellQuote(dir)} && rm -rf ${REPOSITORIES.map(shellQuote).join(" ")} bin`;
|
cd ${shellQuote(dir)} && rm -rf ${REPOSITORIES.map(shellQuote).join(" ")} bin`;
|
||||||
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
|
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
|
||||||
child.unref();
|
child.unref();
|
||||||
@@ -220,3 +566,238 @@ export function tail(work: string, id: string, lines: number): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the base image (novox/hq issue 698)
|
||||||
|
//
|
||||||
|
// Every bed machine that needs a container runtime is a clone of one base image, `mesh-lab/base`, which
|
||||||
|
// mesh-lab's `base build` makes from the distribution's current image. A machine raised from it carries the
|
||||||
|
// package database the image was built with. On a rolling distribution the mirrors stop serving what an old
|
||||||
|
// database names within weeks, and a bed that installs a package then fails on a 404 that reads as a mirror
|
||||||
|
// outage (novox/hq issue 205; seen 2026-10-14 when genesis-keeps-its-bus installed postgresql-libs from a
|
||||||
|
// database six weeks old). So the lab names the image's age wherever it is asked, refuses a run on an image
|
||||||
|
// older than the bound, and rebuilds the image when asked to.
|
||||||
|
//
|
||||||
|
// **The bound is 14 days** (BASE_MAX_AGE_DAYS). Arch's mirrors keep a package version for as long as it is
|
||||||
|
// current and drop it soon after it is replaced; an image a fortnight old has reliably installed what beds
|
||||||
|
// install, and the one that failed was 44 days old. A rebuild takes minutes, so a short bound costs little;
|
||||||
|
// a run refused is cheaper than a bed that fails an hour in for a reason that is not the change under test.
|
||||||
|
|
||||||
|
/** The image every bed machine with a runtime is raised from, as mesh-lab names it (src/lifecycle/place.ts). */
|
||||||
|
export const BASE_IMAGE_ALIAS = "mesh-lab/base";
|
||||||
|
|
||||||
|
/** How old the base image may be, in days, before a run is refused (see the head of this file). */
|
||||||
|
export const BASE_MAX_AGE_DAYS = 14;
|
||||||
|
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface BaseImage {
|
||||||
|
alias: string;
|
||||||
|
/** Whether the image is there at all. */
|
||||||
|
exists: boolean;
|
||||||
|
/** When this machine made it, ISO. */
|
||||||
|
built?: string;
|
||||||
|
/** Its age in whole days. */
|
||||||
|
ageDays?: number;
|
||||||
|
/** Why its age could not be read, when it could not. */
|
||||||
|
problem?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* baseImageFrom reads `incus image list <alias> --format json`: the image whose aliases hold the base's
|
||||||
|
* name, and when it was made here (its upload, which a publish sets).
|
||||||
|
*/
|
||||||
|
export function baseImageFrom(listed: string | undefined, now: Date): BaseImage {
|
||||||
|
const alias = BASE_IMAGE_ALIAS;
|
||||||
|
if (listed === undefined) return { alias, exists: false, problem: "incus could not list the lab's images" };
|
||||||
|
let images: Array<{ aliases?: Array<{ name?: string }>; uploaded_at?: string; created_at?: string }>;
|
||||||
|
try {
|
||||||
|
images = JSON.parse(listed);
|
||||||
|
} catch {
|
||||||
|
return { alias, exists: false, problem: "incus listed the lab's images as something that is not JSON" };
|
||||||
|
}
|
||||||
|
const image = (Array.isArray(images) ? images : []).find((i) => (i.aliases ?? []).some((a) => a.name === alias));
|
||||||
|
if (!image) return { alias, exists: false };
|
||||||
|
const made = [image.uploaded_at, image.created_at].map((t) => (t ? Date.parse(t) : NaN))
|
||||||
|
.find((t) => Number.isFinite(t) && t > 0);
|
||||||
|
if (made === undefined) return { alias, exists: true, problem: "incus gives no time the image was made" };
|
||||||
|
return { alias, exists: true, built: new Date(made).toISOString(), ageDays: Math.floor((now.getTime() - made) / DAY) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** readBaseImage asks incus, on this machine, for the base image. */
|
||||||
|
export function readBaseImage(now = new Date()): BaseImage {
|
||||||
|
const listed = spawnSync("incus", ["image", "list", BASE_IMAGE_ALIAS, "--format", "json"], { encoding: "utf8" });
|
||||||
|
return baseImageFrom(listed.status === 0 ? listed.stdout : undefined, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The base image's age in one sentence, as lab_check and lab_run say it. */
|
||||||
|
export function describeBase(b: BaseImage): string {
|
||||||
|
if (b.problem) return `the base image ${b.alias}: its age is not known: ${b.problem}`;
|
||||||
|
if (!b.exists) return `the base image ${b.alias} is not on this machine; rebuild it with lab_base {"rebuild": true}`;
|
||||||
|
return `the base image ${b.alias} was built ${b.built} (${b.ageDays} day(s) ago; the bound is ${BASE_MAX_AGE_DAYS})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* staleBase is why a run is refused for its base image, or undefined when it is not: an image older than the
|
||||||
|
* bound. An image that is missing or whose age cannot be read is not refused here — a bed that needs it says
|
||||||
|
* so itself, and a bed that does not need it runs.
|
||||||
|
*/
|
||||||
|
export function staleBase(b: BaseImage): string | undefined {
|
||||||
|
if (!b.exists || b.ageDays === undefined || b.ageDays <= BASE_MAX_AGE_DAYS) return undefined;
|
||||||
|
return `the base image ${b.alias} was built ${b.built}, ${b.ageDays} days ago, older than the lab's bound of ` +
|
||||||
|
`${BASE_MAX_AGE_DAYS} days: a bed machine raised from it carries a package database the mirrors no longer serve ` +
|
||||||
|
`(novox/hq issues 205, 698). Rebuild it with lab_base {"rebuild": true}, then run again`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* baseProblem is what lab_check and lab_base say is wrong with the base image, or undefined: past the bound, not
|
||||||
|
* on this machine, or of an age that cannot be read (review 256). Only the first refuses a run.
|
||||||
|
*/
|
||||||
|
export function baseProblem(b: BaseImage): string | undefined {
|
||||||
|
if (b.problem || !b.exists) return describeBase(b);
|
||||||
|
return staleBase(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* runRefusal is why lab_run starts no run for the base image, or undefined: a rebuild still going, which
|
||||||
|
* replaces the image a bed raises its machines from, or an image older than the bound.
|
||||||
|
*/
|
||||||
|
export function runRefusal(base: BaseImage, rebuilding?: { id: string; started: string }): string | undefined {
|
||||||
|
if (rebuilding) return `the base image is being rebuilt (${rebuilding.id}, since ${rebuilding.started}); run once lab_base says it is built`;
|
||||||
|
return staleBase(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- its rebuild: mesh-lab's `base build`, in the background, as a run is.
|
||||||
|
|
||||||
|
export interface BaseBuild {
|
||||||
|
id: string;
|
||||||
|
state: "building" | "built" | "failed" | "lost";
|
||||||
|
started: string;
|
||||||
|
ended?: string;
|
||||||
|
exit?: number;
|
||||||
|
pid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatusPath(work: string, id: string): string {
|
||||||
|
return join(work, id, "status.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The base builds, newest first. A build whose process is gone while it says building was lost. */
|
||||||
|
export function listBaseBuilds(work: string): BaseBuild[] {
|
||||||
|
if (!existsSync(work)) return [];
|
||||||
|
const builds: BaseBuild[] = [];
|
||||||
|
for (const id of readdirSync(work).filter((d) => d.startsWith("base-"))) {
|
||||||
|
try {
|
||||||
|
const b = JSON.parse(readFileSync(buildStatusPath(work, id), "utf8")) as BaseBuild;
|
||||||
|
if (b.state === "building" && gone(b.pid)) b.state = "lost";
|
||||||
|
builds.push(b);
|
||||||
|
} catch {
|
||||||
|
// A directory without a status is not a build.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builds.sort((a, b) => b.started.localeCompare(a.started));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The base build still going, if any. */
|
||||||
|
export function buildingBase(work: string): BaseBuild | undefined {
|
||||||
|
return listBaseBuilds(work).find((b) => b.state === "building");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last lines of a base build's log. */
|
||||||
|
export function baseBuildLog(work: string, id: string, lines = 40): string {
|
||||||
|
try {
|
||||||
|
return readFileSync(join(work, id, "build.log"), "utf8").split("\n").slice(-Math.max(1, lines)).join("\n");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* baseScript is what one rebuild runs: mesh-lab's main from the forge, installed the way a run installs it,
|
||||||
|
* then its `base build`, which replaces the image and reads back that it works before publishing it.
|
||||||
|
*/
|
||||||
|
export function baseScript(dir: string, forge: string, credential?: string, registry = `${forge}${NPM_PATH}`): string {
|
||||||
|
const lab = join(dir, "mesh-lab");
|
||||||
|
return `set -euo pipefail
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
${credentialGuard(credential)}
|
||||||
|
cd ${shellQuote(dir)}
|
||||||
|
git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(`${forge}/novox/mesh-lab.git`)} ${shellQuote(lab)}
|
||||||
|
${credential ? `rm -f ${shellQuote(credential)}` : ""}
|
||||||
|
echo "mesh-lab $(git -C ${shellQuote(lab)} rev-parse HEAD)"
|
||||||
|
printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))}
|
||||||
|
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
|
||||||
|
${installStep(lab, false)}
|
||||||
|
cd ${shellQuote(lab)}
|
||||||
|
node --experimental-strip-types src/cli.ts base build
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** startBaseBuild rebuilds the base image in the background and answers at once with the build. */
|
||||||
|
export function startBaseBuild(work: string, forge: string, credential?: string, registry?: string): BaseBuild {
|
||||||
|
const id = `base-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||||
|
const dir = join(work, id);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const build: BaseBuild = { id, state: "building", started: new Date().toISOString() };
|
||||||
|
writeFileSync(buildStatusPath(work, id), JSON.stringify(build, null, 2));
|
||||||
|
// What a crashed or rebooted run or rebuild left behind goes before this one writes its own.
|
||||||
|
cleanCredentials(work);
|
||||||
|
const credentials = writeCredential(credentialPath(work, id), credential);
|
||||||
|
writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, credentials, registry), { mode: 0o700 });
|
||||||
|
const wrapper = `bash ${shellQuote(join(dir, "build.sh"))} > ${shellQuote(join(dir, "build.log"))} 2>&1; code=$?
|
||||||
|
node -e ${shellQuote(
|
||||||
|
`const f=${JSON.stringify(buildStatusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=process.argv[1]==="0"?"built":"failed";s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
|
||||||
|
)} "$code"
|
||||||
|
rm -f ${shellQuote(credentialPath(work, id))}
|
||||||
|
rm -rf ${shellQuote(join(dir, "mesh-lab"))}`;
|
||||||
|
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
|
||||||
|
child.unref();
|
||||||
|
build.pid = child.pid;
|
||||||
|
writeFileSync(buildStatusPath(work, id), JSON.stringify(build, null, 2));
|
||||||
|
return build;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- starting a run or a rebuild: one job on the lab's machine at a time (review 256).
|
||||||
|
//
|
||||||
|
// **Everything that waits is done first; the check and the start are one step.** A run and a rebuild each
|
||||||
|
// refused while the other was going, but each asked the package registry — an await — between its check and
|
||||||
|
// writing its status, so a run and a rebuild asked together could both pass their checks and both start: a
|
||||||
|
// bed raising machines from an image being replaced under it. Here the registry is asked first, and nothing
|
||||||
|
// is awaited between reading what is going and writing the new job's status, so in the one runtime that
|
||||||
|
// serves the lab's tools no other call runs in between.
|
||||||
|
|
||||||
|
export interface Launch {
|
||||||
|
/** The address a run or a rebuild names the package registry by, asked of the forge (npmRegistry). */
|
||||||
|
registry: () => Promise<{ registry: string; problem?: string }>;
|
||||||
|
/** The base image as it is now. */
|
||||||
|
base: () => BaseImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Launched<T> = { started: true; registry: string; problem?: string; base: BaseImage; job: T }
|
||||||
|
| { started: false; reason: string; base: BaseImage };
|
||||||
|
|
||||||
|
/** launchRun starts a run unless a run or a rebuild is going, or the base image is past its bound. */
|
||||||
|
export async function launchRun(work: string, launch: Launch, begin: (registry: string) => RunStatus):
|
||||||
|
Promise<Launched<RunStatus>> {
|
||||||
|
const registry = await launch.registry();
|
||||||
|
// From here to begin(): nothing awaited.
|
||||||
|
const base = launch.base();
|
||||||
|
const busy = running(work);
|
||||||
|
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, base };
|
||||||
|
const refused = runRefusal(base, buildingBase(work));
|
||||||
|
if (refused) return { started: false, reason: refused, base };
|
||||||
|
return { started: true, ...registry, base, job: begin(registry.registry) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** launchRebuild starts a rebuild of the base image unless a run or another rebuild is going. */
|
||||||
|
export async function launchRebuild(work: string, launch: Launch, begin: (registry: string) => BaseBuild):
|
||||||
|
Promise<Launched<BaseBuild>> {
|
||||||
|
const registry = await launch.registry();
|
||||||
|
// From here to begin(): nothing awaited.
|
||||||
|
const base = launch.base();
|
||||||
|
const rebuilding = buildingBase(work);
|
||||||
|
if (rebuilding) return { started: false, reason: `${rebuilding.id} is still rebuilding it`, base };
|
||||||
|
// A run raises its machines from the image; replacing it under one would change what the run tests.
|
||||||
|
const busy = running(work);
|
||||||
|
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}, and raises its machines from this image`, base };
|
||||||
|
return { started: true, ...registry, base, job: begin(registry.registry) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@
|
|||||||
"policy": "record",
|
"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)"
|
"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": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
@@ -56,6 +60,19 @@
|
|||||||
"api-token": "${dir:state}/api-token.secret"
|
"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": [
|
"emits": [
|
||||||
"user.created",
|
"user.created",
|
||||||
"user.deleted",
|
"user.deleted",
|
||||||
@@ -371,7 +388,7 @@
|
|||||||
"id": "resolver",
|
"id": "resolver",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mailu-resolver",
|
"name": "mailu-resolver",
|
||||||
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
|
"image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e",
|
||||||
"health": {
|
"health": {
|
||||||
"kind": "runtime"
|
"kind": "runtime"
|
||||||
},
|
},
|
||||||
@@ -443,7 +460,12 @@
|
|||||||
"name": "mailu-smtp",
|
"name": "mailu-smtp",
|
||||||
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
|
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
|
||||||
"health": {
|
"health": {
|
||||||
"kind": "runtime"
|
"kind": "tool",
|
||||||
|
"tool": "mailu_health",
|
||||||
|
"interval": "60s",
|
||||||
|
"timeout": "30s",
|
||||||
|
"looks": 3,
|
||||||
|
"grace": "120s"
|
||||||
},
|
},
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
@@ -560,7 +582,12 @@
|
|||||||
"name": "mailu-front",
|
"name": "mailu-front",
|
||||||
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
|
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
|
||||||
"health": {
|
"health": {
|
||||||
"kind": "runtime"
|
"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"
|
||||||
},
|
},
|
||||||
"network": "mailu",
|
"network": "mailu",
|
||||||
"env-file": [
|
"env-file": [
|
||||||
@@ -637,6 +664,7 @@
|
|||||||
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
|
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
|
||||||
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
|
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
|
||||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||||
|
"MESH_MAILU_SMTP_CONTAINER": "mailu-smtp",
|
||||||
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||||
}
|
}
|
||||||
@@ -673,7 +701,7 @@
|
|||||||
"jails": [
|
"jails": [
|
||||||
{
|
{
|
||||||
"name": "mailu-front",
|
"name": "mailu-front",
|
||||||
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)",
|
"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,",
|
||||||
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// 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,6 +4,27 @@
|
|||||||
|
|
||||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
import { MailuClient } from "../client.js";
|
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[] {
|
export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
|
||||||
return [
|
return [
|
||||||
@@ -143,12 +164,14 @@ export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tools exist only when the admin API is configured; without it, mailu contributes none rather
|
// The admin tools exist only when the admin API is configured; without it, mailu contributes only its
|
||||||
// than failing the whole runtime.
|
// health tool rather than failing the whole runtime.
|
||||||
registerModuleTools("mailu", (env) => {
|
registerModuleTools("mailu", (env) => {
|
||||||
|
let tools: ToolDefinition[] = [];
|
||||||
try {
|
try {
|
||||||
return getMailuTools(MailuClient.fromEnv(env));
|
tools = getMailuTools(MailuClient.fromEnv(env));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
tools = [];
|
||||||
}
|
}
|
||||||
|
return [...tools, getHealthTool(env)];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
"include": ["client.ts", "health.ts", "index.ts", "tools/index.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// 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 };
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
// diamond need no special handling, and nothing holds a plan.
|
// diamond need no special handling, and nothing holds a plan.
|
||||||
|
|
||||||
import { on, emit } from "@novox/mesh-sdk/events";
|
import { on, emit } from "@novox/mesh-sdk/events";
|
||||||
import { Graph, type Made } from "./store.js";
|
import { Graph } from "./store.js";
|
||||||
|
import { BUILD_EVENTS, placeable, type Built } from "./heard.js";
|
||||||
|
|
||||||
const graph = Graph.fromEnv();
|
const graph = Graph.fromEnv();
|
||||||
|
|
||||||
@@ -21,54 +22,29 @@ const graph = Graph.fromEnv();
|
|||||||
// would block the very apply that brings the overlay up — stopped being true when a step's failure
|
// 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).
|
// 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.
|
* 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,
|
* 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
|
* 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
|
* (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` above.
|
* months ago — see `replay` in heard.ts.
|
||||||
*/
|
*/
|
||||||
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||||
const body = (event.body ?? {}) as Built;
|
const body = (event.body ?? {}) as Built;
|
||||||
if (!body.module || !body.commit) {
|
const placing = placeable(body);
|
||||||
// Said rather than dropped: a build that announced itself without saying what it built is a
|
if (placing.placed === "no") {
|
||||||
// fault in the builder, and a silent discard here would make it look like a missing event.
|
// A check, a try, a dry run and a failure are the build seat's ordinary outcomes: passed over. One
|
||||||
console.error("mesh-catalog: a build event named no module or no commit; ignored", body);
|
// 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);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { module, commit } = placing;
|
||||||
|
|
||||||
const { upgraded, previous } = await graph.register({
|
const { upgraded, previous } = await graph.register({
|
||||||
module: body.module,
|
module,
|
||||||
commit: body.commit,
|
commit,
|
||||||
repository: body.repository ?? "",
|
repository: body.repository ?? "",
|
||||||
path: body.path ?? "",
|
path: body.path ?? "",
|
||||||
ref: body.ref ?? "",
|
ref: body.ref ?? "",
|
||||||
@@ -80,7 +56,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
|||||||
if (body.replay) return;
|
if (body.replay) return;
|
||||||
|
|
||||||
await emit("registered", {
|
await emit("registered", {
|
||||||
module: body.module, commit: body.commit, upgraded,
|
module, commit, upgraded,
|
||||||
});
|
});
|
||||||
|
|
||||||
// **A rebuild that changed nothing is not an upgrade.** Announcing it would ripple outward
|
// **A rebuild that changed nothing is not an upgrade.** Announcing it would ripple outward
|
||||||
@@ -88,7 +64,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
|||||||
if (!upgraded) return;
|
if (!upgraded) return;
|
||||||
|
|
||||||
await emit("upgraded", {
|
await emit("upgraded", {
|
||||||
module: body.module, commit: body.commit, previous,
|
module, commit, previous,
|
||||||
});
|
});
|
||||||
|
|
||||||
// What can be built now — stale, and waiting on nothing that is itself stale.
|
// What can be built now — stale, and waiting on nothing that is itself stale.
|
||||||
@@ -101,9 +77,9 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// As it happens, and what the mesh already held when this module asked what it missed.
|
// As it happens — from the build seat that builds now, and the retired one while it still answers — and
|
||||||
await on("mesh-build-machine.built", placeTheBuild);
|
// what the mesh already held when this module asked what it missed (novox/hq issue 712).
|
||||||
await on("mesh-controller.built-before", placeTheBuild);
|
for (const heard of BUILD_EVENTS) await on(heard, placeTheBuild);
|
||||||
|
|
||||||
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
|
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"postgres-database": "${dir:state}/database.secret"
|
"postgres-database": "${dir:state}/database.secret"
|
||||||
},
|
},
|
||||||
"consumes": [
|
"consumes": [
|
||||||
|
"node-build-agent.built",
|
||||||
"mesh-build-machine.built",
|
"mesh-build-machine.built",
|
||||||
"mesh-controller.built-before"
|
"mesh-controller.built-before"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"pg.d.ts",
|
"pg.d.ts",
|
||||||
"store.ts",
|
"store.ts",
|
||||||
|
"heard.ts",
|
||||||
"index.ts",
|
"index.ts",
|
||||||
"tools/index.ts",
|
"tools/index.ts",
|
||||||
"prepare/index.ts"
|
"prepare/index.ts"
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One build per change (novox/hq ADR 0350): a pull request closed unmerged has its check builds let go of, and a
|
||||||
|
// walk's record says per module whether its build reused a check's build or built, and why.
|
||||||
|
|
||||||
|
// A pull request closed unmerged asks the controller to let go of what its checks built for its merge (§5); one
|
||||||
|
// the controller cannot answer is said, never fatal.
|
||||||
|
func TestAPullRequestClosedUnmergedLetsGoOfItsCheckBuilds(t *testing.T) {
|
||||||
|
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atCC)
|
||||||
|
cc := pr(cat, 101, headCC, "feat/claude-code", "modules/claude-code/module.json")
|
||||||
|
w.h.PullUpdated(cc)
|
||||||
|
w.h.PullClosed(cc)
|
||||||
|
if want := []string{"novox/mesh-catalog#101"}; !reflect.DeepEqual(w.ctl.forgot, want) {
|
||||||
|
t.Fatalf("closed unmerged, the controller was asked to let go of %v", w.ctl.forgot)
|
||||||
|
}
|
||||||
|
if s := w.state(IDOf(cat, headCC)); s != Stopped {
|
||||||
|
t.Fatalf("closed unmerged it is %s", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.ctl.down = true
|
||||||
|
du := pr(cat, 102, headDu, "feat/dunst", "modules/dunst/module.json")
|
||||||
|
w.h.PullUpdated(du)
|
||||||
|
w.h.PullClosed(du)
|
||||||
|
if s := w.state(IDOf(cat, headDu)); s != Stopped {
|
||||||
|
t.Fatalf("with the controller down, a closed delivery is %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The walk's record says per module what its build did, and the delivery keeps it beside its walk (§4).
|
||||||
|
func TestAWalksRecordSaysWhetherEachModuleWasReusedOrBuilt(t *testing.T) {
|
||||||
|
walk := catWalk("plan-1", mergeCC, atCC.Add(time.Minute), "claude-code", "dunst")
|
||||||
|
walk.Modules["claude-code"].Made = "reused from the check of novox/mesh-catalog#101 at head cccccccccccc " +
|
||||||
|
"(merged into trunk 553b71910000): tools sha256:aa"
|
||||||
|
walk.Modules["dunst"].Made = "built: no check of novox/mesh-catalog#101 built it"
|
||||||
|
seen := walk.Seen()
|
||||||
|
if !strings.HasPrefix(seen.Built["claude-code"], "reused from the check of novox/mesh-catalog#101") ||
|
||||||
|
seen.Built["dunst"] != "built: no check of novox/mesh-catalog#101 built it" {
|
||||||
|
t.Fatalf("the delivery keeps %v", seen.Built)
|
||||||
|
}
|
||||||
|
if catWalk("plan-2", mergeCC, atCC, "dunst").Seen().Built != nil {
|
||||||
|
t.Fatal("a walk from a controller that says nothing of its builds reads as if it said something")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,7 +144,9 @@ func (h *Holder) accounted(w Walk) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// held is whether a live delivery follows the walk, or a live delivery it answers follows a newer one.
|
// 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.
|
||||||
func (h *Holder) heldWalk(w Walk) bool {
|
func (h *Holder) heldWalk(w Walk) bool {
|
||||||
for _, d := range h.deliveries {
|
for _, d := range h.deliveries {
|
||||||
if d.State.Final() {
|
if d.State.Final() {
|
||||||
@@ -292,12 +294,28 @@ func (h *Holder) orphanLines(now time.Time) []StalledLine {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
w := s.walk
|
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,
|
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(),
|
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",
|
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 "+
|
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 …` "+
|
"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)})
|
"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
|
return out
|
||||||
}
|
}
|
||||||
@@ -344,6 +362,7 @@ func (h *Holder) forgetWalks(now time.Time) {
|
|||||||
}
|
}
|
||||||
case !w.Open() && now.Sub(s.at) > keptWalkFor:
|
case !w.Open() && now.Sub(s.at) > keptWalkFor:
|
||||||
delete(h.walks, id)
|
delete(h.walks, id)
|
||||||
|
delete(h.deliverErr, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,6 +375,168 @@ type turnWant struct {
|
|||||||
whys []string
|
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
|
// 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).
|
// walk, however many deliveries it carries, and none while one of them is not at its turn (ADR 0276 decision 5).
|
||||||
// Under the lock.
|
// Under the lock.
|
||||||
@@ -388,6 +569,9 @@ func (h *Holder) words() []func() {
|
|||||||
if stopped, _ := h.carriesStopped(walk); len(stopped) > 0 {
|
if stopped, _ := h.carriesStopped(walk); len(stopped) > 0 {
|
||||||
h.waitsWhy[walk] = "it carries " + strings.Join(stopped, ", ") + ", whose delivery was stopped: it is " +
|
h.waitsWhy[walk] = "it carries " + strings.Join(stopped, ", ") + ", whose delivery was stopped: it is " +
|
||||||
"asked to leave that merge out first"
|
"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,
|
h.Logf("[mesh-delivery] the walk %s is not let go: it carries %s, whose delivery was stopped", walk,
|
||||||
strings.Join(stopped, ", "))
|
strings.Join(stopped, ", "))
|
||||||
continue
|
continue
|
||||||
@@ -403,9 +587,14 @@ func (h *Holder) words() []func() {
|
|||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
delete(h.asking, walk)
|
delete(h.asking, walk)
|
||||||
if err != nil {
|
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)
|
h.Logf("[mesh-delivery] the walk %s could not be let go yet: %v", walk, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
delete(h.deliverErr, walk)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
d := h.deliveries[id]
|
d := h.deliveries[id]
|
||||||
if d == nil {
|
if d == nil {
|
||||||
@@ -479,9 +668,28 @@ func (h *Holder) WalksWaiting() []StalledLine {
|
|||||||
switch {
|
switch {
|
||||||
case len(held) > 0:
|
case len(held) > 0:
|
||||||
out = append(out, held...)
|
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] != "":
|
case h.waitsWhy[id] != "":
|
||||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
|
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]})
|
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:
|
case !live:
|
||||||
says := "no delivery that has not ended follows it or is answered by it, so nothing gives it its word: " +
|
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
|
"`mesh-delivery.show` for the deliveries of " + w.Repository
|
||||||
@@ -492,7 +700,7 @@ func (h *Holder) WalksWaiting() []StalledLine {
|
|||||||
"), yet the walk moves " + strings.Join(walkModules(w), ", ") + " — nothing gives it its word"
|
"), 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,
|
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})
|
Bound: "0s", H2: "none: no delivery holds the walk — the operator's", Says: says + h.endedSaid(w)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool {
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
|||||||
@@ -150,6 +150,9 @@ func statusOf(d *Delivery) (string, string) {
|
|||||||
case Delivered:
|
case Delivered:
|
||||||
return "success", "delivered"
|
return "success", "delivered"
|
||||||
case Failed:
|
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)
|
return "failure", clip("failed: "+why, 140)
|
||||||
case Superseded:
|
case Superseded:
|
||||||
return "warning", clip("superseded: "+why, 140)
|
return "warning", clip("superseded: "+why, 140)
|
||||||
@@ -178,7 +181,7 @@ func ViewBody(d *Delivery, g *Group) string {
|
|||||||
var b strings.Builder
|
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))
|
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() {
|
if d.coveredStop() {
|
||||||
fmt.Fprintf(&b, "**Covered** after its stop: a later merge that contains it was delivered: %s\n\n", d.CoveredBy)
|
fmt.Fprintf(&b, "**Covered**: %s\n\n", d.coveredStopSaid())
|
||||||
}
|
}
|
||||||
if d.HeldWhy != "" && d.State == Held {
|
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)
|
fmt.Fprintf(&b, "Held for a person: %s — `mesh-delivery.release` with why lets it go on.\n\n", d.HeldWhy)
|
||||||
|
|||||||
@@ -106,10 +106,24 @@ type fakeController struct {
|
|||||||
moves map[string][]string
|
moves map[string][]string
|
||||||
plans int
|
plans int
|
||||||
down bool
|
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 runs as a check is asked, before it is answered: what the forge held at that moment.
|
||||||
onCheck func()
|
onCheck func()
|
||||||
// planned is what Plan answers instead of "builds app", when set.
|
// planned is what Plan answers instead of "builds app", when set.
|
||||||
planned *DeliveryPlan
|
planned *DeliveryPlan
|
||||||
|
// forgot are the pull requests whose check builds were let go of (novox/hq ADR 0350 §5), "owner/repo#n".
|
||||||
|
forgot []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeController) ForgetChecks(owner, repo string, number int) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.down {
|
||||||
|
return errors.New("down")
|
||||||
|
}
|
||||||
|
c.forgot = append(c.forgot, fmt.Sprintf("%s/%s#%d", owner, repo, number))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeController() *fakeController {
|
func newFakeController() *fakeController {
|
||||||
@@ -170,6 +184,9 @@ func (c *fakeController) Deliver(walk, why string) error {
|
|||||||
if c.down {
|
if c.down {
|
||||||
return errors.New("down")
|
return errors.New("down")
|
||||||
}
|
}
|
||||||
|
if c.refuseDeliver {
|
||||||
|
return errors.New("the controller refuses the word")
|
||||||
|
}
|
||||||
w, known := c.walks[walk]
|
w, known := c.walks[walk]
|
||||||
if !known || w.Delivery == nil || w.Delivery.Go != nil {
|
if !known || w.Delivery == nil || w.Delivery.Go != nil {
|
||||||
return fmt.Errorf("%s waits for nobody", walk)
|
return fmt.Errorf("%s waits for nobody", walk)
|
||||||
@@ -384,6 +401,9 @@ type fakeForge struct {
|
|||||||
// open is what OpenPulls answers; openDown a forge that cannot say.
|
// open is what OpenPulls answers; openDown a forge that cannot say.
|
||||||
open []OpenPull
|
open []OpenPull
|
||||||
openDown bool
|
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) {
|
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
|
||||||
@@ -637,3 +657,25 @@ func aWalk(id, repo, commit string, waits bool) Walk {
|
|||||||
}
|
}
|
||||||
return w
|
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,6 +60,8 @@ type Holder struct {
|
|||||||
waitsWhy map[string]string
|
waitsWhy map[string]string
|
||||||
// asking are the walks whose word is being asked right now, outside the lock: not asked twice meanwhile.
|
// asking are the walks whose word is being asked right now, outside the lock: not asked twice meanwhile.
|
||||||
asking map[string]bool
|
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 are, by walk, the merges a stop gathered to leave out of it, asked once (novox/hq ADR 0299).
|
||||||
leaving map[string][]string
|
leaving map[string][]string
|
||||||
// leaveAsked is when a walk was last asked to leave out the stopped merges it carries (leaveStopped).
|
// leaveAsked is when a walk was last asked to leave out the stopped merges it carries (leaveStopped).
|
||||||
@@ -67,8 +69,17 @@ type Holder struct {
|
|||||||
// leaveRefused is, by walk, what the controller answered when it refused both to leave a stopped merge out and
|
// leaveRefused is, by walk, what the controller answered when it refused both to leave a stopped merge out and
|
||||||
// to stop the walk whole.
|
// to stop the walk whole.
|
||||||
leaveRefused map[string]string
|
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 counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407).
|
||||||
tried owedTries
|
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
|
// owedTries is how many owed effects were tried since this owner started, and when the last was: three numbers
|
||||||
@@ -120,6 +131,7 @@ func (h *Holder) init() {
|
|||||||
h.askTried = map[string]time.Time{}
|
h.askTried = map[string]time.Time{}
|
||||||
h.walkAsked = map[string]time.Time{}
|
h.walkAsked = map[string]time.Time{}
|
||||||
h.unchecked = map[string]unchecked{}
|
h.unchecked = map[string]unchecked{}
|
||||||
|
h.marks = map[string]*marked{}
|
||||||
h.walks = map[string]seenWalk{}
|
h.walks = map[string]seenWalk{}
|
||||||
h.orphans = map[string]time.Time{}
|
h.orphans = map[string]time.Time{}
|
||||||
h.asking = map[string]bool{}
|
h.asking = map[string]bool{}
|
||||||
@@ -300,19 +312,25 @@ func (h *Holder) PullUpdated(p PullEvent) {
|
|||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
id := IDOf(p.repository(), p.HeadSHA)
|
id := IDOf(p.repository(), p.HeadSHA)
|
||||||
now := h.Now()
|
now := h.Now()
|
||||||
|
startedOver := ""
|
||||||
if d := h.deliveries[id]; d != nil {
|
if d := h.deliveries[id]; d != nil {
|
||||||
// Announced again: nothing new — but for a delivery whose verdict is out of date, the announcement is its
|
if startedOver = startedOverFor(d, p.Number); startedOver == "" {
|
||||||
// check asked again against the trunk's head (the merge tool's ask, novox/hq ADR 0323 §5), and its bound
|
// Announced again: nothing new — but for a delivery whose verdict is out of date, the announcement is its
|
||||||
// runs from now (§7). The base head it was announced with is kept, for a check this owner asks after it.
|
// check asked again against the trunk's head (the merge tool's ask, novox/hq ADR 0323 §5), and its bound
|
||||||
if p.BaseSHA != "" && d.BaseSHA != p.BaseSHA {
|
// runs from now (§7). The base head it was announced with is kept, for a check this owner asks after it.
|
||||||
d.BaseSHA = p.BaseSHA
|
if p.BaseSHA != "" && d.BaseSHA != p.BaseSHA {
|
||||||
h.keep(d)
|
d.BaseSHA = p.BaseSHA
|
||||||
|
h.keep(d)
|
||||||
|
}
|
||||||
|
if d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil {
|
||||||
|
d.AskedAgain = &now
|
||||||
|
h.keep(d)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil {
|
// A delivery stopped only because its pull request closed unmerged does not end its commit's delivery for
|
||||||
d.AskedAgain = &now
|
// another pull request with the same head (novox/hq issue 783): started over below, exactly as for a first
|
||||||
h.keep(d)
|
// announcement, under the same id — never a transition out of Stopped.
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
replaced := ""
|
replaced := ""
|
||||||
for _, old := range h.deliveries {
|
for _, old := range h.deliveries {
|
||||||
@@ -326,11 +344,7 @@ func (h *Holder) PullUpdated(p PullEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
d := &Delivery{ID: id, Repository: p.repository(), Commit: p.HeadSHA, Number: p.Number, Title: p.Title,
|
d, t, err := h.freshAnnouncement(p, now, startedOver)
|
||||||
Base: p.Base, Branch: p.Head, HTMLURL: p.HTMLURL, CloneURL: p.CloneURL, Paths: p.Paths,
|
|
||||||
PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid,
|
|
||||||
After: AfterIn(p.Body), Created: now, BaseSHA: p.BaseSHA}
|
|
||||||
t, err := Apply(d, EvAnnounced, false, Facts{Now: now, Why: fmt.Sprintf("%s#%d's head announced", d.Repository, d.Number)})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.refuse(err)
|
h.refuse(err)
|
||||||
return
|
return
|
||||||
@@ -348,6 +362,34 @@ func (h *Holder) PullUpdated(p PullEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startedOverFor is why a delivery already kept at a head's id is started over for a pull request's number rather
|
||||||
|
// than taken as announced again: only when it was stopped solely by an earlier, closed duplicate pull request with
|
||||||
|
// the same head — still Stopped, ClosedUnmerged, never merged, and under another number (novox/hq issue 783).
|
||||||
|
// Empty for the ordinary 'announced again' path, or for any other stop (a person's, a group's, its own close).
|
||||||
|
func startedOverFor(d *Delivery, number int) string {
|
||||||
|
if d != nil && d.State == Stopped && d.ClosedUnmerged && d.MergedAs == "" && d.Number != number {
|
||||||
|
return fmt.Sprintf("#%d with the same head closed unmerged", d.Number)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshAnnouncement makes a delivery for p's head exactly as a first announcement would: its own id, proposed.
|
||||||
|
// startedOver, when not empty, is folded into the transition's Why, naming the pull request this replaces
|
||||||
|
// (novox/hq issue 783); the delivery it replaces is swapped out by the caller keeping the new one under the same
|
||||||
|
// id (keep), never by a transition out of its final state.
|
||||||
|
func (h *Holder) freshAnnouncement(p PullEvent, now time.Time, startedOver string) (*Delivery, Transition, error) {
|
||||||
|
d := &Delivery{ID: IDOf(p.repository(), p.HeadSHA), Repository: p.repository(), Commit: p.HeadSHA, Number: p.Number,
|
||||||
|
Title: p.Title, Base: p.Base, Branch: p.Head, HTMLURL: p.HTMLURL, CloneURL: p.CloneURL, Paths: p.Paths,
|
||||||
|
PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid,
|
||||||
|
After: AfterIn(p.Body), Created: now, BaseSHA: p.BaseSHA}
|
||||||
|
why := fmt.Sprintf("%s#%d's head announced", d.Repository, d.Number)
|
||||||
|
if startedOver != "" {
|
||||||
|
why = fmt.Sprintf("%s; %s", why, startedOver)
|
||||||
|
}
|
||||||
|
t, err := Apply(d, EvAnnounced, false, Facts{Now: now, Why: why})
|
||||||
|
return d, t, err
|
||||||
|
}
|
||||||
|
|
||||||
// takePlaceOf puts a member's new head — a rebase, a push — in its group in the place of the head it superseded,
|
// takePlaceOf puts a member's new head — a rebase, a push — in its group in the place of the head it superseded,
|
||||||
// when the group is closed: a member of it already reached the trunk, and a closed group takes no new member. The
|
// when the group is closed: a member of it already reached the trunk, and a closed group takes no new member. The
|
||||||
// new head has its own check, and the group's composed check runs again for the new heads rather than holding the
|
// new head has its own check, and the group's composed check runs again for the new heads rather than holding the
|
||||||
@@ -479,7 +521,29 @@ func (h *Holder) PullMerged(p PullEvent) {
|
|||||||
}
|
}
|
||||||
now := h.Now()
|
now := h.Now()
|
||||||
var ts []Transition
|
var ts []Transition
|
||||||
if d == nil {
|
if over := startedOverFor(d, p.Number); over != "" {
|
||||||
|
// A delivery stopped only by an earlier, closed duplicate pull request with the same head is not lost when
|
||||||
|
// its merge is heard without the open announcement either: it is started over for the merging pull request
|
||||||
|
// the same way an announcement would, before the merge is taken as usual (novox/hq issue 783). Heard this
|
||||||
|
// way — its own merge, with no open announcement of this pull request — it is made exactly as a first-heard
|
||||||
|
// merge in every field but its id: FromForge, and its check the forge's word on the head, so a long-past
|
||||||
|
// merge replayed from the bus's history still falls under the history rule (issue 313) and is not held for
|
||||||
|
// a person (review round 1).
|
||||||
|
fresh, t, err := h.freshAnnouncement(p, now, over)
|
||||||
|
if err != nil {
|
||||||
|
h.refuse(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fresh.FromForge = true
|
||||||
|
if s := p.HeadChecks["mesh/merge-gate"]; s != "" {
|
||||||
|
fresh.Check = &Verdict{At: now, Gate: verdictOfState(s), Summary: "the head's mesh/merge-gate, as the forge holds it",
|
||||||
|
Repo: verdictOfState(p.HeadChecks["mesh/repo-check"])}
|
||||||
|
}
|
||||||
|
d, ts = fresh, []Transition{t}
|
||||||
|
if d.Check != nil {
|
||||||
|
ts = append(ts, settle(d, Facts{Now: now})...)
|
||||||
|
}
|
||||||
|
} else if d == nil {
|
||||||
head := p.HeadSHA
|
head := p.HeadSHA
|
||||||
if head == "" {
|
if head == "" {
|
||||||
head = p.MergeCommit
|
head = p.MergeCommit
|
||||||
@@ -593,9 +657,12 @@ func verdictOfState(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PullClosed takes a pull request closed unmerged: its open deliveries are stopped.
|
// PullClosed takes a pull request closed unmerged: its open deliveries are stopped.
|
||||||
|
//
|
||||||
|
// **And what its checks built for its merge goes** (novox/hq ADR 0350 §5): nothing will take it now. Asked of the
|
||||||
|
// controller once the deliveries are settled, outside the lock; a refusal is said, and the build seat's holders let
|
||||||
|
// go of it within the week anyway.
|
||||||
func (h *Holder) PullClosed(p PullEvent) {
|
func (h *Holder) PullClosed(p PullEvent) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
|
||||||
for _, d := range h.deliveries {
|
for _, d := range h.deliveries {
|
||||||
if d.Repository == p.repository() && d.Number == p.Number && !d.State.Final() && d.MergedAs == "" {
|
if d.Repository == p.repository() && d.Number == p.Number && !d.State.Final() && d.MergedAs == "" {
|
||||||
d.ClosedUnmerged = true
|
d.ClosedUnmerged = true
|
||||||
@@ -605,6 +672,16 @@ func (h *Holder) PullClosed(p PullEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
if h.Controller == nil || p.Owner == "" || p.Repo == "" || p.Number <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Controller.ForgetChecks(p.Owner, p.Repo, p.Number); err != nil {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.refuse(fmt.Errorf("%s#%d closed, and its check builds stay until the week's sweep: %w", p.repository(),
|
||||||
|
p.Number, err))
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckedEvent is the controller's `checked`.
|
// CheckedEvent is the controller's `checked`.
|
||||||
@@ -983,6 +1060,12 @@ func (h *Holder) Tick() {
|
|||||||
for _, ask := range h.leaveStopped() {
|
for _, ask := range h.leaveStopped() {
|
||||||
ask()
|
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.
|
// plan works out, under the lock, what to ask; each ask applies its own answer.
|
||||||
@@ -1090,6 +1173,48 @@ 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
|
// 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
|
// 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.
|
// 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 {
|
for _, d := range h.deliveries {
|
||||||
ended := d.endedUnstarted()
|
ended := d.endedUnstarted()
|
||||||
if d.coveredStop() && !d.CoverTold {
|
if d.coveredStop() && !d.CoverTold {
|
||||||
@@ -1173,6 +1298,8 @@ func (h *Holder) plan() []func() {
|
|||||||
}
|
}
|
||||||
h.turn(d, "its turn: a delivery in no group")
|
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.
|
// The word, once per walk, when every live delivery it carries is at its turn.
|
||||||
asks = append(asks, h.words()...)
|
asks = append(asks, h.words()...)
|
||||||
|
|
||||||
@@ -1251,7 +1378,7 @@ func (h *Holder) laterOnItsWay(d *Delivery) string {
|
|||||||
// stays final, and the bus's transition event is not said again.
|
// stays final, and the bus's transition event is not said again.
|
||||||
func (h *Holder) tellCovered(d *Delivery, now time.Time) {
|
func (h *Holder) tellCovered(d *Delivery, now time.Time) {
|
||||||
d.CoverTold = true
|
d.CoverTold = true
|
||||||
line := oneLine(fmt.Sprintf("%s delivery %s: stopped, %s", now.UTC().Format(time.RFC3339), d.ID, d.coveredStopSaid()))
|
line := oneLine(fmt.Sprintf("%s delivery %s: %s, %s", now.UTC().Format(time.RFC3339), d.ID, d.State, d.coveredStopSaid()))
|
||||||
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Line: line, Since: now})
|
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Line: line, Since: now})
|
||||||
if d.MergedAs != "" && !sameCommit(d.MergedAs, d.Commit) {
|
if d.MergedAs != "" && !sameCommit(d.MergedAs, d.Commit) {
|
||||||
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.MergedAs, Line: line, Since: now})
|
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.MergedAs, Line: line, Since: now})
|
||||||
@@ -1261,7 +1388,7 @@ func (h *Holder) tellCovered(d *Delivery, now time.Time) {
|
|||||||
state, desc := statusOf(d)
|
state, desc := statusOf(d)
|
||||||
d.owe(Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/delivery", State: state, Line: desc, Since: now})
|
d.owe(Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/delivery", State: state, Line: desc, Since: now})
|
||||||
}
|
}
|
||||||
h.Logf("[mesh-delivery] %s: stopped, %s", d.ID, d.coveredStopSaid())
|
h.Logf("[mesh-delivery] %s: %s, %s", d.ID, d.State, d.coveredStopSaid())
|
||||||
h.keep(d)
|
h.keep(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1272,6 +1399,104 @@ func strandedUncovered(d *Delivery) bool {
|
|||||||
return d.State == Stopped && d.Stranded != "" && d.CoveredBy == ""
|
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
|
// 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.
|
// again after it: what holds it for a person, unless something already ends it.
|
||||||
func (h *Holder) unwalkedPast(d *Delivery, now time.Time) bool {
|
func (h *Holder) unwalkedPast(d *Delivery, now time.Time) bool {
|
||||||
@@ -1714,7 +1939,8 @@ 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
|
// 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
|
// covered member as delivered, so the member after it gets its turn (ADR 0270 decision 2, novox/hq
|
||||||
// issues 442 and 445).
|
// issues 442 and 445).
|
||||||
if d.State.Reached() || d.State == Superseded && d.CoveredBy != "" {
|
// A stop or failure a later walk delivered since counts as reached too (novox/hq issues 480 and 747).
|
||||||
|
if memberReached(d) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if sharing != "" && (d.Walk == nil || d.Walk.ID != sharing) {
|
if sharing != "" && (d.Walk == nil || d.Walk.ID != sharing) {
|
||||||
@@ -1739,7 +1965,7 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa
|
|||||||
}
|
}
|
||||||
if w.why != "" {
|
if w.why != "" {
|
||||||
for _, later := range ordered[i+1:] {
|
for _, later := range ordered[i+1:] {
|
||||||
if a := h.stopInGroup(byID[later.ID], fmt.Sprintf("its group %s stopped: %s superseded, and %s", g.ID,
|
if a := h.stopInGroup(byID[later.ID], d.ID, fmt.Sprintf("its group %s stopped: %s superseded, and %s", g.ID,
|
||||||
d.ID, w.why)); a != nil {
|
d.ID, w.why)); a != nil {
|
||||||
asks = append(asks, a)
|
asks = append(asks, a)
|
||||||
}
|
}
|
||||||
@@ -1766,9 +1992,13 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return asks
|
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:
|
case d.State == Failed || d.State == Stopped:
|
||||||
for _, later := range ordered[i+1:] {
|
for _, later := range ordered[i+1:] {
|
||||||
if a := h.stopInGroup(byID[later.ID], fmt.Sprintf("its group %s stopped: %s %s", g.ID, d.ID, d.State)); a != nil {
|
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 {
|
||||||
asks = append(asks, a)
|
asks = append(asks, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2006,7 +2236,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
|
// 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
|
// 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.
|
// 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, why string) func() {
|
func (h *Holder) stopInGroup(d *Delivery, cause, why string) func() {
|
||||||
if d == nil || d.State.Final() {
|
if d == nil || d.State.Final() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2015,6 +2245,7 @@ func (h *Holder) stopInGroup(d *Delivery, why string) func() {
|
|||||||
h.refuse(err)
|
h.refuse(err)
|
||||||
return nil
|
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})
|
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
|
// 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.
|
// of #221): a merge walked again and then stopped by its group is left out there too.
|
||||||
@@ -2096,6 +2327,12 @@ func (h *Holder) carriesStopped(walk string) ([]string, string) {
|
|||||||
if last := d.Transitions[len(d.Transitions)-1]; last.Event != EvStop || last.By == "" {
|
if last := d.Transitions[len(d.Transitions)-1]; last.Event != EvStop || last.By == "" {
|
||||||
continue
|
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.
|
// 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 {
|
if !slices.ContainsFunc(s.walk.Delivery.Merges, func(m WalkMerge) bool {
|
||||||
return strings.EqualFold(m.Repository, d.Repository) && sameCommit(m.Commit, d.MergedAs)
|
return strings.EqualFold(m.Repository, d.Repository) && sameCommit(m.Commit, d.MergedAs)
|
||||||
@@ -2113,6 +2350,51 @@ func (h *Holder) carriesStopped(walk string) ([]string, string) {
|
|||||||
return out, why
|
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.
|
// lastWhyOf is the reason of a delivery's last transition.
|
||||||
func lastWhyOf(d *Delivery) string {
|
func lastWhyOf(d *Delivery) string {
|
||||||
if len(d.Transitions) == 0 {
|
if len(d.Transitions) == 0 {
|
||||||
@@ -2148,6 +2430,9 @@ func (h *Holder) leaveStopped() []func() {
|
|||||||
if without, _ := h.carriesStopped(id); len(without) > 0 {
|
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 "+
|
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, ", "))
|
"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 != "" {
|
if r := h.leaveRefused[id]; r != "" {
|
||||||
said += "; the controller refused: " + r
|
said += "; the controller refused: " + r
|
||||||
}
|
}
|
||||||
@@ -2171,6 +2456,9 @@ func (h *Holder) leaveStopped() []func() {
|
|||||||
delete(h.leaveRefused, id)
|
delete(h.leaveRefused, id)
|
||||||
continue
|
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
|
// Once per record of it: the record that answers an ask is newer. Asked again after five minutes, should
|
||||||
// the ask have gone nowhere.
|
// the ask have gone nowhere.
|
||||||
if a, asked := h.leaveAsked[id]; asked && seen.walk.Revision <= a.revision && now.Sub(a.at) < 5*time.Minute {
|
if a, asked := h.leaveAsked[id]; asked && seen.walk.Revision <= a.revision && now.Sub(a.at) < 5*time.Minute {
|
||||||
@@ -2292,18 +2580,28 @@ func againWords(a StopAnswer) string {
|
|||||||
func GroupState(g *Group, members []*Delivery) (string, string) {
|
func GroupState(g *Group, members []*Delivery) (string, string) {
|
||||||
count := map[State]int{}
|
count := map[State]int{}
|
||||||
reached, ended := 0, 0
|
reached, ended := 0, 0
|
||||||
var uncovered []string
|
var uncovered, lapsed []string
|
||||||
|
byID := map[string]*Delivery{}
|
||||||
|
for _, d := range members {
|
||||||
|
byID[d.ID] = d
|
||||||
|
}
|
||||||
for _, d := range members {
|
for _, d := range members {
|
||||||
count[d.State]++
|
|
||||||
if d.State.Final() {
|
if d.State.Final() {
|
||||||
ended++
|
ended++
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case d.State.Reached(), d.State == Superseded && d.CoveredBy != "":
|
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).
|
||||||
reached++
|
reached++
|
||||||
|
continue
|
||||||
|
case stopLapsed(d, func(id string) *Delivery { return byID[id] }):
|
||||||
|
lapsed = append(lapsed, d.ID)
|
||||||
|
continue
|
||||||
case d.State == Superseded:
|
case d.State == Superseded:
|
||||||
uncovered = append(uncovered, d.ID)
|
uncovered = append(uncovered, d.ID)
|
||||||
}
|
}
|
||||||
|
count[d.State]++
|
||||||
}
|
}
|
||||||
n := len(members)
|
n := len(members)
|
||||||
switch {
|
switch {
|
||||||
@@ -2313,6 +2611,11 @@ func GroupState(g *Group, members []*Delivery) (string, string) {
|
|||||||
return "stopped", "a member was stopped"
|
return "stopped", "a member was stopped"
|
||||||
case n > 0 && reached == n:
|
case n > 0 && reached == n:
|
||||||
return "delivered", "every member is delivered"
|
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:
|
case g.Closed && n > 0 && ended == n:
|
||||||
return "stopped", supersededWhy + strings.Join(uncovered, ", ")
|
return "stopped", supersededWhy + strings.Join(uncovered, ", ")
|
||||||
case g.Closed && g.Check != nil && (g.Check.OutOfDate != "" || g.Check.Verdict == "" ||
|
case g.Closed && g.Check != nil && (g.Check.OutOfDate != "" || g.Check.Verdict == "" ||
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq issue 783: mesh-catalog commit b5417c28 was pushed while #305 (from branch adr-359-mesh-mail-733) was
|
||||||
|
// open, so this owner recorded its delivery as #305's. #305 then closed unmerged (its base branch had been
|
||||||
|
// deleted), and #315, opened from the same branch and head, merged it as 0914a33f. The delivery stayed stopped
|
||||||
|
// ("the pull request closed unmerged"), though it recorded merged_as 0914a33f: a rule did not hold — a delivery
|
||||||
|
// keyed by commit follows the pull request that merges that commit, and a closed duplicate does not end it — and
|
||||||
|
// the walk that carried the merge waited 64 minutes for this owner's word until a person's `plans go` released it.
|
||||||
|
//
|
||||||
|
// A delivery stopped only because its pull request closed unmerged, with no merge recorded, does not end its
|
||||||
|
// commit's delivery for another pull request with the same head: it is started over, under the same id, for the
|
||||||
|
// pull request that announces or merges it next.
|
||||||
|
|
||||||
|
const (
|
||||||
|
head783, merge783 = "d305d305d305d305", "d315d315d315d315"
|
||||||
|
head783b, merge783b = "d306d306d306d306", "d316d316d316d316"
|
||||||
|
head783c = "d307d307d307d307"
|
||||||
|
head783d, merge783d = "d308d308d308d308", "d318d318d318d318"
|
||||||
|
catalog783 = "novox/mesh-catalog"
|
||||||
|
branch783 = "adr-359-mesh-mail-733"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestADeliveryStoppedByAClosedDuplicateFollowsThePullRequestThatMergesItsCommit is the issue's own shape: #305
|
||||||
|
// announced, closed unmerged (stopped); #315 announced from the same head, checked and readied; #315 merges it,
|
||||||
|
// and the walk that answers that merge is let go — the delivery follows #315, never stuck on #305's stop.
|
||||||
|
func TestADeliveryStoppedByAClosedDuplicateFollowsThePullRequestThatMergesItsCommit(t *testing.T) {
|
||||||
|
w := newWorld(t)
|
||||||
|
id := IDOf(catalog783, head783)
|
||||||
|
|
||||||
|
w.h.PullUpdated(pr(catalog783, 305, head783, branch783, "src/a.go"))
|
||||||
|
w.h.PullClosed(pr(catalog783, 305, head783, branch783))
|
||||||
|
if w.state(id) != Stopped {
|
||||||
|
t.Fatalf("#305 closed unmerged left it %s; want stopped", w.state(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.h.PullUpdated(pr(catalog783, 315, head783, branch783, "src/a.go"))
|
||||||
|
if d := w.delivery(id); d.State == Stopped || d.Number != 315 {
|
||||||
|
t.Fatalf("#315, the same head, left it %s, Number %d; want started over for #315, not stopped", d.State, d.Number)
|
||||||
|
}
|
||||||
|
if d := w.delivery(id); !strings.Contains(d.Transitions[len(d.Transitions)-1].Why, "#305 with the same head closed unmerged") {
|
||||||
|
t.Fatalf("the start-over's transition names what it replaces: %q", d.Transitions[len(d.Transitions)-1].Why)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.h.Checked(verdict(catalog783, 315, head783, "pass"))
|
||||||
|
if d := w.delivery(id); d.State != Ready {
|
||||||
|
t.Fatalf("a passing verdict for #315 left it %s", d.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
walk := w.ctl.put(aWalk("plan-783", catalog783, merge783, true))
|
||||||
|
w.h.WalkMoved(walk)
|
||||||
|
w.h.PullMerged(merged(catalog783, 315, head783, merge783))
|
||||||
|
if d := w.delivery(id); d.State != Published || d.Number != 315 {
|
||||||
|
t.Fatalf("#315's merge left it %s, Number %d; want published under #315", d.State, d.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.settleAll()
|
||||||
|
if len(w.ctl.delivers) != 1 || w.ctl.delivers[0] != "plan-783" {
|
||||||
|
t.Fatalf("the walk that answers #315's merge was not let go: %v", w.ctl.delivers)
|
||||||
|
}
|
||||||
|
if d := w.delivery(id); d.State != Delivering {
|
||||||
|
t.Fatalf("let go, it is %s; want delivering", d.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAMergeWithoutAnAnnouncementStartsOverAStoppedDuplicateToo is decision 4: the merging pull request is never
|
||||||
|
// announced on its own (heard only as `pull.merged`) — the start-over happens in PullMerged itself, not only in
|
||||||
|
// PullUpdated, so a merge heard without the open announcement is not lost either.
|
||||||
|
func TestAMergeWithoutAnAnnouncementStartsOverAStoppedDuplicateToo(t *testing.T) {
|
||||||
|
w := newWorld(t)
|
||||||
|
id := IDOf(catalog783, head783b)
|
||||||
|
|
||||||
|
w.h.PullUpdated(pr(catalog783, 306, head783b, branch783+"-b", "src/b.go"))
|
||||||
|
w.h.PullClosed(pr(catalog783, 306, head783b, branch783+"-b"))
|
||||||
|
if w.state(id) != Stopped {
|
||||||
|
t.Fatalf("#306 closed unmerged left it %s; want stopped", w.state(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
walk := w.ctl.put(aWalk("plan-783b", catalog783, merge783b, true))
|
||||||
|
w.h.WalkMoved(walk)
|
||||||
|
m := merged(catalog783, 316, head783b, merge783b)
|
||||||
|
m.HeadChecks = map[string]string{"mesh/merge-gate": "success", "mesh/repo-check": "success"}
|
||||||
|
w.h.PullMerged(m)
|
||||||
|
if d := w.delivery(id); d.State == Stopped || d.Number != 316 {
|
||||||
|
t.Fatalf("#316's merge, heard with no announcement of its own, left it %s, Number %d; want started over for #316",
|
||||||
|
d.State, d.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.settleAll()
|
||||||
|
if len(w.ctl.delivers) != 1 || w.ctl.delivers[0] != "plan-783b" {
|
||||||
|
t.Fatalf("the walk that answers #316's merge was not let go: %v", w.ctl.delivers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAStoppedDeliveryAnnouncedAgainByItsOwnPullRequestStaysStopped guards decision 3's "any other stopped
|
||||||
|
// delivery ... is untouched": the same pull request announced again after its own close is the ordinary
|
||||||
|
// 'announced again' path, never a start-over, and stays stopped.
|
||||||
|
func TestAStoppedDeliveryAnnouncedAgainByItsOwnPullRequestStaysStopped(t *testing.T) {
|
||||||
|
w := newWorld(t)
|
||||||
|
id := IDOf(catalog783, head783c)
|
||||||
|
|
||||||
|
w.h.PullUpdated(pr(catalog783, 307, head783c, branch783+"-c", "src/c.go"))
|
||||||
|
w.h.PullClosed(pr(catalog783, 307, head783c, branch783+"-c"))
|
||||||
|
if w.state(id) != Stopped {
|
||||||
|
t.Fatalf("#307 closed unmerged left it %s; want stopped", w.state(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.h.PullUpdated(pr(catalog783, 307, head783c, branch783+"-c", "src/c.go"))
|
||||||
|
if d := w.delivery(id); d.State != Stopped || d.Number != 307 {
|
||||||
|
t.Fatalf("#307 announced again by itself left it %s, Number %d; want it to stay stopped", d.State, d.Number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAMergeHeardLongAfterStartingOverAStoppedDuplicateIsHistoryLikeAnyOtherReplayedMerge is review round 1's
|
||||||
|
// decision: a delivery started over from a merge heard without its open announcement (as in decision 4, above)
|
||||||
|
// takes FromForge exactly as a first-heard merge does, so one replayed from the bus's history long after it
|
||||||
|
// happened still falls under the history rule (novox/hq issue 313) and is never held for a person.
|
||||||
|
func TestAMergeHeardLongAfterStartingOverAStoppedDuplicateIsHistoryLikeAnyOtherReplayedMerge(t *testing.T) {
|
||||||
|
w := newWorld(t)
|
||||||
|
id := IDOf(catalog783, head783d)
|
||||||
|
|
||||||
|
w.h.PullUpdated(pr(catalog783, 308, head783d, branch783+"-d", "src/d.go"))
|
||||||
|
w.h.PullClosed(pr(catalog783, 308, head783d, branch783+"-d"))
|
||||||
|
if w.state(id) != Stopped {
|
||||||
|
t.Fatalf("#308 closed unmerged left it %s; want stopped", w.state(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
m := merged(catalog783, 318, head783d, merge783d)
|
||||||
|
m.MergedAt = w.now.Add(-historyLate - time.Minute).UTC().Format(time.RFC3339)
|
||||||
|
w.h.PullMerged(m)
|
||||||
|
|
||||||
|
d := w.delivery(id)
|
||||||
|
if d.State == Stopped || d.Number != 318 {
|
||||||
|
t.Fatalf("#318's merge, heard long after it happened with no announcement of its own, left it %s, Number %d; "+
|
||||||
|
"want started over for #318", d.State, d.Number)
|
||||||
|
}
|
||||||
|
if !d.FromForge {
|
||||||
|
t.Fatal("a merge heard without its own announcement is made from the forge's word: FromForge is false")
|
||||||
|
}
|
||||||
|
if d.History == "" {
|
||||||
|
t.Fatalf("a merge heard %s after it happened is history, never held for a person; History is empty", historyLate+time.Minute)
|
||||||
|
}
|
||||||
|
if d.State == Held {
|
||||||
|
t.Fatalf("history is never held for a person; state is %s, HeldWhy %q", d.State, d.HeldWhy)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,8 +76,9 @@ func run(h *Holder, l *listening) {
|
|||||||
logf("[mesh-delivery] the controller's walks cannot be read: %v", err)
|
logf("[mesh-delivery] the controller's walks cannot be read: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Every open pull request, from the forge itself, every five minutes (novox/hq issue 347).
|
// Every open pull request, from the forge itself, every minute (novox/hq issues 347, 722): what says a
|
||||||
if now.Sub(looked) >= 5*time.Minute {
|
// head nobody checks, and the ready marks a merge is asked on.
|
||||||
|
if now.Sub(looked) >= time.Minute {
|
||||||
looked = now
|
looked = now
|
||||||
h.SeeOpenPulls()
|
h.SeeOpenPulls()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,11 +61,13 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// It calls exactly what its ports ask: the controller's six verbs and the forge's four tools.
|
// It calls exactly what its ports ask: the controller's six verbs and the forge's four tools.
|
||||||
want := []string{}
|
want := []string{}
|
||||||
for _, v := range []string{"delivery-plan", "delivery-order", "delivery-check", "deliver", "delivery-stop", "delivery-walks"} {
|
for _, v := range []string{"delivery-plan", "delivery-order", "delivery-check", "deliver", "delivery-stop", "delivery-walks", "forget-checks"} {
|
||||||
want = append(want, "seat:"+ControllerSeat+"."+v)
|
want = append(want, "seat:"+ControllerSeat+"."+v)
|
||||||
}
|
}
|
||||||
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
|
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")
|
"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")
|
||||||
if !reflect.DeepEqual(m.Invokes, want) {
|
if !reflect.DeepEqual(m.Invokes, want) {
|
||||||
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
|
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user