Compare commits
29
Commits
@@ -0,0 +1,6 @@
|
||||
# Release 57: config and provisioning
|
||||
|
||||
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
|
||||
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
|
||||
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
|
||||
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/predefined
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,153 @@
|
||||
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
|
||||
// 0348 clause 7, design 57 package K2, issue 661).
|
||||
//
|
||||
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
|
||||
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
|
||||
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
|
||||
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
|
||||
// it, naming each module that names the key.
|
||||
//
|
||||
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
|
||||
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
|
||||
// itself, removing a key nobody names is let through here, and said.
|
||||
//
|
||||
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
|
||||
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
|
||||
// module check; this check reads only its keys.
|
||||
//
|
||||
// go run . [-base <the trunk's predefined.json>] <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// File is the list's file at the root of the catalogue.
|
||||
const File = "predefined.json"
|
||||
|
||||
// Keys is the keys of a list, read as a JSON object; an absent file has none.
|
||||
func Keys(path string) (map[string]bool, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", path, err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for k := range keys {
|
||||
out[k] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
|
||||
func Named(root string) (map[string][]string, error) {
|
||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
named := map[string][]string{}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Config map[string]json.RawMessage `json:"config"`
|
||||
Settings map[string]json.RawMessage `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
|
||||
for _, d := range declared {
|
||||
var value struct {
|
||||
Predefined string `json:"predefined"`
|
||||
}
|
||||
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
|
||||
named[value.Predefined] = append(named[value.Predefined], m.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return named, nil
|
||||
}
|
||||
|
||||
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
|
||||
// key nobody names is answered with none.
|
||||
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for k := range base {
|
||||
if !tree[k] {
|
||||
out[k] = named[k]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
|
||||
flag.Parse()
|
||||
if flag.NArg() != 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
|
||||
os.Exit(2)
|
||||
}
|
||||
root := flag.Arg(0)
|
||||
tree, err := Keys(filepath.Join(root, File))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if *basePath == "" {
|
||||
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
|
||||
return
|
||||
}
|
||||
base, err := Keys(*basePath)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
named, err := Named(root)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
removed := Removed(base, tree, named)
|
||||
keys := make([]string, 0, len(removed))
|
||||
for k := range removed {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
failed := false
|
||||
for _, k := range keys {
|
||||
if modules := removed[k]; len(modules) > 0 {
|
||||
failed = true
|
||||
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
|
||||
"clause 7)\n", File, k, strings.Join(modules, ", "))
|
||||
} else {
|
||||
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
|
||||
"value for it is mesh-config's to refuse, not this check's\n", File, k)
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A catalogue whose dunst falls back to font-size, and the trunk's list holding font-size and
|
||||
// agent-managed-settings.
|
||||
func aCatalogue(t *testing.T, list string) (root, base string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
write := func(path, text string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(filepath.Join(root, "modules", "dunst", "module.json"), `{"module": "dunst", "config": {"font-size":
|
||||
{"type": "number", "description": "points", "default": 10, "predefined": "font-size"}}}`)
|
||||
write(filepath.Join(root, "modules", "claude-code", "module.json"), `{"module": "claude-code", "settings": {
|
||||
"managed_settings": {"kind": "preference", "default": "{}", "why": "none"}}}`)
|
||||
write(filepath.Join(root, "modules", "plain", "module.json"), `{"module": "plain"}`)
|
||||
if list != "" {
|
||||
write(filepath.Join(root, File), list)
|
||||
}
|
||||
base = filepath.Join(t.TempDir(), "base.json")
|
||||
write(base, `{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`)
|
||||
return root, base
|
||||
}
|
||||
|
||||
func run(t *testing.T, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
out, err := exec.Command("go", append([]string{"run", "."}, args...)...).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func TestARemovalOfAKeyAModuleStillNamesIsRefused(t *testing.T) {
|
||||
// The planted removal: font-size goes while dunst still falls back to it.
|
||||
root, base := aCatalogue(t, `{"agent-managed-settings": {"type": "object"}}`)
|
||||
out, err := run(t, "-base", base, root)
|
||||
if err == nil || !strings.Contains(out, `the key "font-size" is removed and still named by dunst`) {
|
||||
t.Fatalf("a removal of a named key passed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovalNobodyNamesPassesAndSaysWhatItCannotKnow(t *testing.T) {
|
||||
root, base := aCatalogue(t, `{"font-size": {"type": "number"}}`)
|
||||
out, err := run(t, "-base", base, root)
|
||||
if err != nil || !strings.Contains(out, `"agent-managed-settings" is removed and no module names it`) ||
|
||||
!strings.Contains(out, "mesh-config's to refuse") {
|
||||
t.Fatalf("a removal nobody names: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAListUnchangedOrGrownPasses(t *testing.T) {
|
||||
for _, list := range []string{
|
||||
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`,
|
||||
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}, "accent": {"type": "string"}}`,
|
||||
} {
|
||||
root, base := aCatalogue(t, list)
|
||||
if out, err := run(t, "-base", base, root); err != nil || !strings.Contains(out, "none removed") {
|
||||
t.Fatalf("%s: %v\n%s", list, err, out)
|
||||
}
|
||||
}
|
||||
// No trunk list given: nothing was removed.
|
||||
root, _ := aCatalogue(t, `{}`)
|
||||
if out, err := run(t, root); err != nil || !strings.Contains(out, "none was removed") {
|
||||
t.Fatalf("without a base: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFileRemovedWholeIsARemovalOfEveryKey(t *testing.T) {
|
||||
root, base := aCatalogue(t, "")
|
||||
out, err := run(t, "-base", base, root)
|
||||
if err == nil || !strings.Contains(out, `"font-size" is removed and still named by dunst`) {
|
||||
t.Fatalf("the list removed whole passed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedReadsBothForms(t *testing.T) {
|
||||
root, _ := aCatalogue(t, "")
|
||||
if err := os.WriteFile(filepath.Join(root, "modules", "plain", "module.json"), []byte(`{"module": "plain",
|
||||
"settings": {"size": {"predefined": "font-size"}}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
named, err := Named(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(named["font-size"], ","); got != "dunst,plain" {
|
||||
t.Fatalf("font-size named by %s", got)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
62
|
||||
66
|
||||
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
|
||||
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,51 +23,9 @@
|
||||
#
|
||||
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
|
||||
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
|
||||
#
|
||||
# **In an agent's session, outside the toolchain image** (novox/hq issue 742), the same node_modules is
|
||||
# assembled first, in a throwaway directory under the check's own workspace, the way the image's `deps` stage
|
||||
# assembles it: the toolchain's own package.json and lock (mesh-tools' node-tools/, read from the forge this
|
||||
# clone came from, so the list is never copied here) installed with `npm ci`, then the SDK the mesh last
|
||||
# published (`@novox/mesh-sdk@latest`), the @novox scope from the forge's package registry, read without a
|
||||
# credential. Node and npm are the dev-tools module's; nothing is installed on the machine, no npm
|
||||
# configuration or cache of the account's is read or written. Only when neither TOOLCHAIN_MODULES nor
|
||||
# /app/node_modules is there: the forge's check, in the image, fetches nothing, as before. The forge is
|
||||
# MESH_FORGE (default: the origin remote's), the owner MESH_FORGE_OWNER (default: the origin's owner).
|
||||
set -eu
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
|
||||
if [ -z "${TOOLCHAIN_MODULES:-}" ] && [ ! -f "$deps/typescript/bin/tsc" ]; then
|
||||
origin=$(git remote get-url origin 2>/dev/null || true)
|
||||
forge="${MESH_FORGE:-$(printf '%s\n' "$origin" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')}"
|
||||
owner="${MESH_FORGE_OWNER:-$(printf '%s\n' "$origin" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')}"
|
||||
if [ -z "$forge" ] || [ -z "$owner" ]; then
|
||||
echo "NOT CHECKED: no TypeScript toolchain at $deps, and no forge to assemble one from (origin is '$origin'; set MESH_FORGE and MESH_FORGE_OWNER, or TOOLCHAIN_MODULES)"
|
||||
exit 1
|
||||
fi
|
||||
echo "no TypeScript toolchain at $deps: assembling one from $forge/$owner/mesh-tools node-tools/ and $owner's package registry (novox/hq issue 742)"
|
||||
tc="$work/toolchain"
|
||||
mkdir -p "$tc"
|
||||
printf '@%s:registry=%s/api/packages/%s/npm/\n' "$owner" "$forge" "$owner" >"$work/npmrc"
|
||||
# Read with Node's own fetch (dev-tools declares Node, not curl); a status that is not 2xx fails.
|
||||
if ! { node -e '
|
||||
const [base, dir] = process.argv.slice(1);
|
||||
Promise.all(["package.json", "package-lock.json"].map(async (f) => {
|
||||
const r = await fetch(base + f);
|
||||
if (!r.ok) throw new Error(base + f + ": " + r.status);
|
||||
require("fs").writeFileSync(dir + "/" + f, await r.text());
|
||||
})).catch((e) => { console.error(e.message); process.exit(1); });' \
|
||||
"$forge/$owner/mesh-tools/raw/branch/main/node-tools/" "$tc" &&
|
||||
(cd "$tc" && export npm_config_userconfig="$work/npmrc" npm_config_cache="$work/npm-cache" &&
|
||||
npm ci --ignore-scripts --no-audit --no-fund --loglevel=error &&
|
||||
npm install --ignore-scripts --no-audit --no-fund --no-save --loglevel=error "@$owner/mesh-sdk@latest"); }; then
|
||||
echo "NOT CHECKED: the TypeScript toolchain could not be assembled from $forge (novox/hq issue 742)"
|
||||
exit 1
|
||||
fi
|
||||
deps="$tc/node_modules"
|
||||
fi
|
||||
tsc="$deps/typescript/bin/tsc"
|
||||
if [ ! -f "$tsc" ]; then
|
||||
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
|
||||
@@ -81,6 +39,8 @@ touched=$({
|
||||
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
|
||||
} | sed '/^$/d' | sort -u)
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
failed=""
|
||||
for m in $touched; do
|
||||
d="modules/$m"
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
|
||||
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
|
||||
# the nested ones the controller cannot protect alone yet, and only shrinks.
|
||||
# 7. the list of predefined keys (novox/hq ADR 0348 clause 7): a change to predefined.json removes no key a
|
||||
# module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a
|
||||
# value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and
|
||||
# every manifest against it are judged by the controller's module check in step 1.
|
||||
set -eu
|
||||
|
||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||
@@ -51,6 +55,18 @@ else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with"
|
||||
fi
|
||||
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi)
|
||||
# The list of predefined keys (novox/hq ADR 0348 clause 7): no key removed that a module still names. The trunk's
|
||||
# list at the merge base is what it is judged against; a trunk without the file had none.
|
||||
predefined_base=""
|
||||
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
||||
predefined_base=$(mktemp)
|
||||
if ! git show "$merge_base:predefined.json" >"$predefined_base" 2>/dev/null; then
|
||||
echo '{}' >"$predefined_base"
|
||||
fi
|
||||
else
|
||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen"
|
||||
fi
|
||||
(cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi)
|
||||
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
|
||||
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
|
||||
base_lists=""
|
||||
|
||||
@@ -14,7 +14,7 @@ WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-builder ./cmd/mesh-builder
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder
|
||||
|
||||
# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build
|
||||
# machine's whole job is shelling out to git and docker — it needs a real userland to do that in,
|
||||
|
||||
@@ -115,57 +115,3 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
|
||||
}
|
||||
t.Fatal("the build agent has no environment file")
|
||||
}
|
||||
|
||||
// **The judge cache is the agent's own, beside its workspace and never in it** (novox/hq ADR 0358 §6, issue 704): a
|
||||
// directory resource of its own, kept as the agent's data, mounted into the agent at the same path as on the host —
|
||||
// the container runtime resolves a judge build's mounts on the host — and handed to the agent as MESH_JUDGE_CACHE.
|
||||
func TestTheJudgeCacheIsADirectoryOfItsOwnMountedAtTheSamePath(t *testing.T) {
|
||||
raw, err := os.ReadFile("module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Data struct {
|
||||
Own []struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Class string `json:"class"`
|
||||
} `json:"own"`
|
||||
} `json:"data"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Volumes []string `json:"volumes"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, data, env, mounted := false, false, false, false
|
||||
for _, d := range m.Data.Own {
|
||||
if d.ID == "judge-cache" && d.Path == "${dir:judge-cache}" && d.Class == "cache" {
|
||||
data = true
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
switch {
|
||||
case r.ID == "judge-cache" && r.Type == "directory":
|
||||
dir = true
|
||||
case r.ID == "agent-env":
|
||||
env = strings.Contains(r.Content, "MESH_JUDGE_CACHE=${dir:judge-cache}\n")
|
||||
case r.ID == "server":
|
||||
for _, v := range r.Volumes {
|
||||
if v == "${dir:judge-cache}:${dir:judge-cache}" {
|
||||
mounted = true
|
||||
}
|
||||
if strings.HasPrefix(v, "${dir:workspace}") && strings.Contains(v, "judge-cache") {
|
||||
t.Errorf("the judge cache is mounted inside the workspace: %s", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !dir || !data || !env || !mounted {
|
||||
t.Fatalf("the judge cache: directory %v, data %v, env %v, mounted at the same path %v", dir, data, env, mounted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +39,6 @@
|
||||
"path": "${dir:workspace}",
|
||||
"class": "cache",
|
||||
"why": "a build's working copy, cloned again for every build"
|
||||
},
|
||||
{
|
||||
"id": "judge-cache",
|
||||
"path": "${dir:judge-cache}",
|
||||
"class": "cache",
|
||||
"why": "the Go cache every merge check's judge is built with and the release-branch judges kept per head; mounted into judge builds only, never into a check's containers (novox/hq ADR 0358 §6)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -60,17 +54,12 @@
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "judge-cache",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "agent-env",
|
||||
"type": "file",
|
||||
"path": "${dir:mesh-state}/build-agent.env",
|
||||
"mode": "0600",
|
||||
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\nMESH_JUDGE_CACHE=${dir:judge-cache}\n"
|
||||
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
@@ -83,7 +72,6 @@
|
||||
"volumes": [
|
||||
"${dir:mesh-state}:/run/mesh:ro",
|
||||
"${dir:workspace}:${dir:workspace}",
|
||||
"${dir:judge-cache}:${dir:judge-cache}",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
"restart-on": [
|
||||
|
||||
@@ -52,10 +52,7 @@ base64-encoded. The Go bundle `cmd/forge` serves the commit, and the reads an ag
|
||||
| `gitea_search_code` | r | the lines of a ref's text files matching a literal string or a regular expression, optionally under a path |
|
||||
|
||||
**Where a commit may go** is the rule `gitea_api` already holds for a file written (novox/hq ADR 0266):
|
||||
never the repository's default branch, never a branch a protection rule names. The tools' account may not read the
|
||||
rules themselves (the forge keeps them for a repository's administrator, novox/hq ADR 0365): for it an existing branch
|
||||
is judged by the protection the forge says it applies there, and a branch not made yet by the forge itself, which
|
||||
refuses the commit that would make a branch a rule guards. A rule covers a branch as the
|
||||
never the repository's default branch, never a branch a protection rule names. A rule covers a branch as the
|
||||
forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob
|
||||
(`*`, `**`, `?`, `[abc]`, `[!abc]`, `{a,b}`, `\` escaping) covers case-sensitively with `/` as separator; a glob
|
||||
the forge cannot compile covers its own text. `cmd/forge/rules.go` and `guard.ts` hold this one rule. The rules are read before anything is sent; rules that cannot be read refuse the
|
||||
@@ -69,7 +66,7 @@ the commit the branch (or, for a new branch, its base) was read at, never at its
|
||||
forge's diff endpoint, which applies it; for a small change to a large file this is far shorter than the
|
||||
file. Neither endpoint takes a commit to build on, only a branch (Gitea 1.27), so the parent answered is the
|
||||
one the forge says it made the commit on; when the branch moved between the read and the commit, the answer
|
||||
says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the tools' account, so its sha is not the
|
||||
says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the forge's admin account, so its sha is not the
|
||||
local commit's: the merge check judges the head on the forge. After the write the branch is read back, and a
|
||||
branch not at the commit the forge named is a failure, never a success.
|
||||
|
||||
@@ -78,10 +75,8 @@ is read once and searched in memory; a binary file is passed over, a file over 4
|
||||
`skipped`, and at most `max` matches (default 100) are answered, with `truncated` when more were found. A
|
||||
search that reaches the call's deadline stops there and answers what it found, with `stopped_at_deadline`.
|
||||
|
||||
It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), as the
|
||||
tools' own account (novox/hq ADR 0365 §2, issue 469): the token the TypeScript bundle mints for `mesh-tools` and
|
||||
keeps (`MESH_GITEA_TOKEN_FILE`), sent only when its scopes line names that account, never the admin login. One tool
|
||||
call takes at most 50 seconds.
|
||||
It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), and one
|
||||
tool call takes at most 50 seconds.
|
||||
`go test ./cmd/forge` runs it against a fake forge: the files of one commit created, updated and deleted on
|
||||
a new branch from its base; an existing branch committed on; the default branch, a protected branch and
|
||||
unreadable rules refused before anything is sent; a patch sent to the forge; a branch read back at another
|
||||
|
||||
+14
-178
@@ -8,7 +8,6 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
||||
import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
||||
import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js";
|
||||
import { isCommit } from "./checks.js";
|
||||
import { TOOLS_ACCOUNT } from "./tools-account.js";
|
||||
|
||||
/** A repository's path on the forge, its owner and name each one segment (guard.ts pathSegment): uniform for every
|
||||
* call, whether the names came from a caller or from the forge's own answer. */
|
||||
@@ -102,94 +101,6 @@ export interface OpenPull {
|
||||
/** The head's statuses by context, the newest of each, with its state (novox/hq issue 438): what tells a check
|
||||
* that started and never answered — pending — from one that answered. */
|
||||
checks: Record<string, HeadCheck>;
|
||||
/** The pull request's labels, by name. */
|
||||
labels?: string[];
|
||||
/** Its ready mark (novox/hq issue 722): the label `mesh/ready` and the newest comment "ready at <full head sha>", the
|
||||
* head a reviewer approved. Absent without the label; `head` empty when the label names no head in a comment. */
|
||||
ready?: ReadyMark;
|
||||
}
|
||||
|
||||
/** The label that marks a pull request ready to merge at the head a comment names (novox/hq issue 722). */
|
||||
export const READY_LABEL = "mesh/ready";
|
||||
|
||||
/** A comment that names the head a pull request is marked ready at: "ready at <full sha>", on a line of its own. */
|
||||
const READY_AT = /^\s*ready at ([0-9a-f]{40}|[0-9a-f]{64})\b/im;
|
||||
|
||||
/** A pull request's ready mark: the head it names, who said it and when, and the label's id, to take it off. */
|
||||
export interface ReadyMark {
|
||||
head: string;
|
||||
by?: string;
|
||||
at?: string;
|
||||
label_id: number;
|
||||
}
|
||||
|
||||
/** **The accounts whose "ready at" comment counts** (novox/hq issue 722): the trusted setting ready-authors, which
|
||||
* the mesh writes to the file MESH_GITEA_READY_AUTHORS_FILE names as a line `authors=<account> <account> …`. A file
|
||||
* that cannot be read leaves the mesh's own forge accounts alone, said; never an empty list read as everyone: the
|
||||
* account the tools act as (novox/hq ADR 0365) and the admin account, whose marks were made before it. */
|
||||
export function readyAuthors(file = process.env.MESH_GITEA_READY_AUTHORS_FILE, own = process.env.MESH_GITEA_ADMIN_USER,
|
||||
read: (f: string) => string = (f) => readFileSync(f, "utf8"),
|
||||
tools = process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT): string[] {
|
||||
const fallback = [...new Set([tools, own].filter((a): a is string => !!a))];
|
||||
if (!file) return fallback;
|
||||
try {
|
||||
const line = read(file).split("\n").find((l) => l.startsWith("authors="));
|
||||
const authors = (line ?? "").slice("authors=".length).split(/[\s,]+/).filter(Boolean);
|
||||
if (authors.length) return authors;
|
||||
console.error(`[gitea] ${file} names no account that may mark a pull request ready; only ${fallback.join(", ") || "none"} may`);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${file} cannot be read (${err instanceof Error ? err.message : err}); only ` +
|
||||
`${fallback.join(", ") || "none"} may mark a pull request ready`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** One event of an issue's or pull request's timeline, as the forge keeps it, oldest first: a comment, a push to a
|
||||
* pull request's branch (`pull_push`), a label added (body "1") or taken off (body ""), and the rest. */
|
||||
export interface TimelineEvent {
|
||||
id: number;
|
||||
type: string;
|
||||
body: string;
|
||||
created_at?: string;
|
||||
user?: string;
|
||||
label?: { id: number; name: string };
|
||||
}
|
||||
|
||||
/** The comments that wrote "ready at" without counting, by id: each said once in the log (novox/hq issue 722). */
|
||||
const ignoredSaid = new Set<number>();
|
||||
|
||||
/** **The ready mark a pull request's timeline makes** (novox/hq issue 722), read in the forge's order — every event is
|
||||
* one row of one table, numbered as it happened, so the order is exact where times share a second. A "ready at"
|
||||
* comment counts only when its author is one of the accounts given, and only when it was written after the label
|
||||
* mesh/ready was last added and after the last push to the pull request's branch: a push or the label taken off
|
||||
* undoes every mark before it, so a mark older than the latest push never counts, even with the label still on. A
|
||||
* comment by any other account is ignored, and said once. */
|
||||
export function readyMarkOf(labelID: number, events: TimelineEvent[], authors: string[],
|
||||
say: (line: string) => void = (l) => console.error(l)): ReadyMark {
|
||||
const listed = new Set(authors.map((a) => a.toLowerCase()));
|
||||
let labelled = false;
|
||||
let mark: ReadyMark | undefined;
|
||||
for (const e of [...events].sort((x, y) => x.id - y.id)) {
|
||||
if (e.type === "pull_push") {
|
||||
mark = undefined;
|
||||
} else if (e.type === "label" && e.label?.name === READY_LABEL) {
|
||||
labelled = e.body === "1";
|
||||
mark = undefined;
|
||||
} else if (e.type === "comment") {
|
||||
const m = READY_AT.exec(e.body);
|
||||
if (!m) continue;
|
||||
if (!listed.has(String(e.user ?? "").toLowerCase())) {
|
||||
if (!ignoredSaid.has(e.id)) {
|
||||
ignoredSaid.add(e.id);
|
||||
say(`[gitea] a "ready at ${m[1].slice(0, 12)}" comment (${e.id}) by ${e.user ?? "an unknown account"} is ignored: ` +
|
||||
`only ${authors.join(", ") || "no account"} may mark a pull request ready (novox/hq issue 722)`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (labelled) mark = { head: m[1].toLowerCase(), by: e.user, at: e.created_at, label_id: labelID };
|
||||
}
|
||||
}
|
||||
return mark ?? { head: "", label_id: labelID };
|
||||
}
|
||||
|
||||
/** One status on a head, the newest of its context: its state, what it said, and when it was set. */
|
||||
@@ -421,9 +332,8 @@ export class GiteaClient {
|
||||
|
||||
/** The open pull requests of every repository the mesh's account sees, updated since a moment when one is given,
|
||||
* page by page (novox/hq issue 347): each with its repository and its own update time. */
|
||||
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string;
|
||||
labels?: { id: number; name: string }[] }[]> {
|
||||
const out: { owner: string; name: string; number: number; updated_at?: string; labels?: { id: number; name: string }[] }[] = [];
|
||||
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string }[]> {
|
||||
const out: { owner: string; name: string; number: number; updated_at?: string }[] = [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const qs = new URLSearchParams({ type: "pulls", state: "open", limit: "50", page: String(page) });
|
||||
if (since) qs.set("since", since);
|
||||
@@ -433,8 +343,7 @@ export class GiteaClient {
|
||||
const [fo, fn] = String(r.full_name ?? "").split("/");
|
||||
const owner = String(r.owner ?? fo ?? "");
|
||||
const name = String(r.name ?? fn ?? "");
|
||||
const labels = (Array.isArray(i?.labels) ? i.labels : []).map((l: any) => ({ id: Number(l?.id ?? 0), name: String(l?.name ?? "") }));
|
||||
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at, labels });
|
||||
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at });
|
||||
}
|
||||
if (!found || found.length < 50) break;
|
||||
}
|
||||
@@ -445,26 +354,19 @@ export class GiteaClient {
|
||||
* requires and the contexts its head has a status of, in any state. What mesh-delivery reads to say a pull
|
||||
* request the mesh was never asked to check; asked of the forge, never of what the announcer remembers, so a
|
||||
* fault in the announcer cannot hide itself. Reads only. */
|
||||
async openPulls(readyAuthors: string[] = []): Promise<OpenPull[]> {
|
||||
async openPulls(): Promise<OpenPull[]> {
|
||||
const out: OpenPull[] = [];
|
||||
const rules = new Map<string, BranchProtection | null | undefined>();
|
||||
const rules = new Map<string, BranchProtection[] | undefined>();
|
||||
for (const found of await this.searchOpenPulls()) {
|
||||
const full = `${found.owner}/${found.name}`;
|
||||
const pull = await this.getPullRequest(found.owner, found.name, found.number);
|
||||
if (pull.state !== "open" || !pull.head_sha) continue;
|
||||
// The base's protection as the forge applies it to that branch (appliedProtection): readable without administration.
|
||||
const base = `${full}:${pull.base || "main"}`;
|
||||
if (!rules.has(base)) rules.set(base, await this.appliedProtection(found.owner, found.name, pull.base || "main").catch(() => undefined));
|
||||
const rule = rules.get(base);
|
||||
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
|
||||
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
|
||||
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
|
||||
const labels = found.labels ?? [];
|
||||
// The ready mark (novox/hq issue 722): its timeline is read, whole, only for a pull request that carries the label.
|
||||
const label = labels.find((l) => l.name === READY_LABEL);
|
||||
const ready = label ? readyMarkOf(label.id, await this.timeline(found.owner, found.name, pull.number), readyAuthors)
|
||||
: undefined;
|
||||
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha,
|
||||
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [],
|
||||
statuses: Object.keys(checks), checks, labels: labels.map((l) => l.name), ...(ready ? { ready } : {}) });
|
||||
statuses: Object.keys(checks), checks });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -678,44 +580,16 @@ export class GiteaClient {
|
||||
|
||||
// ---- Branch protection ----
|
||||
|
||||
/** A repository's branch protection rules. The forge answers them to a repository's administrator alone, which the
|
||||
* tools' account is not (novox/hq ADR 0365 §2): for it this is refused (403), and what a branch's protection asks
|
||||
* is read with appliedProtection. */
|
||||
/** A repository's branch protection rules. */
|
||||
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
|
||||
return (await this.request<BranchProtection[]>(`${repoPath(owner, repo)}/branch_protections`)) ?? [];
|
||||
}
|
||||
|
||||
/** The rule named for a branch, null when it has none — from the rules' list, so a repository administrator's. */
|
||||
/** The rule named for a branch, null when it has none. */
|
||||
async branchProtection(owner: string, repo: string, rule: string): Promise<BranchProtection | null> {
|
||||
return (await this.branchProtections(owner, repo)).find((p) => (p.rule_name ?? p.branch_name) === rule) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The protection that covers an existing branch, as the forge applies it, or null when none does. Read from the
|
||||
* branch itself, which anyone who reads the code may read — the rules' own list is a repository administrator's
|
||||
* (novox/hq ADR 0365 §2) — and as the forge matches it, a glob rule included (its name is the rule's). The
|
||||
* branch's `user_can_push` stands for `enable_push`: whether this account may push there. A branch that does not
|
||||
* exist is null: nothing covers it yet, and the forge judges a push that makes it.
|
||||
*/
|
||||
async appliedProtection(owner: string, repo: string, branch: string): Promise<BranchProtection | null> {
|
||||
let b: any;
|
||||
try {
|
||||
b = await this.request<any>(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`);
|
||||
} catch (err) {
|
||||
if (/: 404 /.test(String((err as Error)?.message))) return null;
|
||||
throw err;
|
||||
}
|
||||
if (!b?.protected) return null;
|
||||
return {
|
||||
rule_name: String(b.effective_branch_protection_name || branch),
|
||||
branch_name: branch,
|
||||
enable_push: Boolean(b.user_can_push),
|
||||
enable_status_check: Boolean(b.enable_status_check),
|
||||
status_check_contexts: Array.isArray(b.status_check_contexts) ? b.status_check_contexts.map(String) : [],
|
||||
required_approvals: Number(b.required_approvals ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the
|
||||
* branch is edited — its required statuses replaced by these, everything else it says kept unless
|
||||
* asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */
|
||||
@@ -765,24 +639,6 @@ export class GiteaClient {
|
||||
return this.requestText(`${repoPath(owner, repo)}/pulls/${index}.diff`);
|
||||
}
|
||||
|
||||
/** **Every event of an issue's or pull request's timeline**, oldest first, page by page (novox/hq issue 722): a long
|
||||
* thread hides nothing. Bounded at 100 pages of 50; a longer one is said, and its first 5000 events read. */
|
||||
async timeline(owner: string, repo: string, index: number): Promise<TimelineEvent[]> {
|
||||
indexOf(index);
|
||||
const out: TimelineEvent[] = [];
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const raw = await this.request<any[]>(`${repoPath(owner, repo)}/issues/${index}/timeline?page=${page}&limit=50`);
|
||||
for (const e of raw ?? []) {
|
||||
out.push({ id: Number(e?.id ?? 0), type: String(e?.type ?? ""), body: String(e?.body ?? ""),
|
||||
created_at: e?.created_at, user: e?.user?.login,
|
||||
...(e?.label ? { label: { id: Number(e.label.id ?? 0), name: String(e.label.name ?? "") } } : {}) });
|
||||
}
|
||||
if (!raw || raw.length < 50) return out;
|
||||
}
|
||||
console.error(`[gitea] ${owner}/${repo}#${index} has more than 5000 timeline events; only the first 5000 are read`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every comment on an issue or pull request, oldest first. */
|
||||
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
|
||||
indexOf(index);
|
||||
@@ -947,26 +803,12 @@ export class GiteaAdmin {
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
/** One admin-API call with a JSON body, answering the status and body (the system webhook, novox/hq issue 729). */
|
||||
call(method: string, path: string, body?: unknown): Promise<AdminResponse> {
|
||||
return this.request(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
}
|
||||
|
||||
/** Fail with the forge's own message when a status the caller did not expect comes back. */
|
||||
private static fail(path: string, res: AdminResponse): never {
|
||||
const detail = typeof res.body === "string" ? res.body : JSON.stringify(res.body);
|
||||
throw new Error(`Gitea admin ${path}: ${res.status} ${detail}`);
|
||||
}
|
||||
|
||||
/** A repository's branch protection rules, read with the administrator account — the forge answers them to a
|
||||
* repository's administrator alone, which the tools' account is not (novox/hq ADR 0365 §2, ADR 0375). Only a read. */
|
||||
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
|
||||
const path = `${repoPath(owner, repo)}/branch_protections`;
|
||||
const res = await this.request(path);
|
||||
if (res.status !== 200) GiteaAdmin.fail(path, res);
|
||||
return Array.isArray(res.body) ? res.body : [];
|
||||
}
|
||||
|
||||
/** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent
|
||||
* create won the race) are all success. */
|
||||
async ensureOrg(name: string): Promise<void> {
|
||||
@@ -990,16 +832,12 @@ export class GiteaAdmin {
|
||||
// unit the team does not have — so code read must be said here: without it gitea answers a
|
||||
// member's clone of a private repository with "not found", which is how the builder's first
|
||||
// credentialed clone failed against a team that named only packages.
|
||||
return this.ensureTeamWith(org, team, {
|
||||
const units = {
|
||||
permission: "read",
|
||||
units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" },
|
||||
includes_all_repositories: true,
|
||||
can_create_org_repo: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Ensure a team exists with exactly these units, and return its id (ensureTeam; the tools' team, tools-account.ts). */
|
||||
async ensureTeamWith(org: string, team: string, units: Record<string, unknown>): Promise<number> {
|
||||
};
|
||||
const found = await this.findTeam(org, team);
|
||||
if (found !== null) {
|
||||
const patch = await this.request(`/teams/${found}`, {
|
||||
@@ -1034,7 +872,7 @@ export class GiteaAdmin {
|
||||
* The edit path is taken only when the user actually exists. A 422 from the create is also what
|
||||
* a plain validation failure returns, and reading it as "already there" made the follow-up edit
|
||||
* 404 — burying the create's own message, which is the one that says what is actually wrong. */
|
||||
async ensureUser(username: string, password: string, email: string, plain = false): Promise<void> {
|
||||
async ensureUser(username: string, password: string, email: string): Promise<void> {
|
||||
const res = await this.request("/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, email, password, must_change_password: false }),
|
||||
@@ -1048,9 +886,7 @@ export class GiteaAdmin {
|
||||
// login_name is required by the admin edit endpoint; for a local user it is the username.
|
||||
// active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong
|
||||
// password, so the provisioner's check reports it lost; applying again must undo both.
|
||||
// plain: never a site administrator — one made so by hand is made a plain account again (novox/hq ADR 0365).
|
||||
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false,
|
||||
...(plain ? { admin: false } : {}) }),
|
||||
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }),
|
||||
});
|
||||
if (patch.status === 200) return;
|
||||
GiteaAdmin.fail(`/admin/users/${username}`, patch);
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package main
|
||||
|
||||
// The commit and the reads act as the tools' own account (novox/hq ADR 0365 §2, issue 469): the token the module
|
||||
// keeps for `mesh-tools`, never the admin login. That account is no repository's administrator, so the forge
|
||||
// refuses it the protection rules (403, as gitea's reqAdmin does); the commit then judges an existing branch by the
|
||||
// protection the forge says it applies, and leaves a branch not made yet to the forge's own judgement.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea/internal/forgehttp"
|
||||
)
|
||||
|
||||
const toolsToken = "tools-token-never-shown"
|
||||
|
||||
// asTools serves the fake forge to a bundle holding the tools' token: only that token is taken, the rules are
|
||||
// refused as to a non-administrator, and the branches named protected say so as the forge does.
|
||||
func asTools(t *testing.T, f *fakeForge, protected map[string]string, scopes string) (*Forge, *[]string) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "token")
|
||||
if err := os.WriteFile(file, []byte(toolsToken+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(file+".scopes", []byte(scopes+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var seen []string
|
||||
const repo = "/api/v1/repos/acme/app"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = append(seen, r.Method+" "+r.URL.Path)
|
||||
if _, _, basic := r.BasicAuth(); basic || r.Header.Get("Authorization") != "token "+toolsToken {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == repo+"/branch_protections" {
|
||||
http.Error(w, `{"message":"token does not have the administration of this repository"}`, 403)
|
||||
return
|
||||
}
|
||||
if name := strings.TrimPrefix(r.URL.Path, repo+"/branches/"); r.Method == "GET" && name != r.URL.Path {
|
||||
if rule, ok := protected[name]; ok {
|
||||
_, _ = w.Write([]byte(`{"name":"` + name + `","commit":{"id":"c0"},"protected":true,"effective_branch_protection_name":"` + rule + `"}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
r.SetBasicAuth("mesh-admin", f.password) // the fake's own door, past the token checked above
|
||||
f.handle(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return &Forge{Client: forgehttp.Client{URL: srv.URL, TokenFile: file, Account: "mesh-tools"}}, &seen
|
||||
}
|
||||
|
||||
func TestTheToolsAccountCommitsOnANewBranchWithItsTokenThoughTheRulesAreNotItsToRead(t *testing.T) {
|
||||
f := newFake(t)
|
||||
forge, _ := asTools(t, f, nil, "read:user,write:issue,write:repository for mesh-tools")
|
||||
got, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365",
|
||||
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.writes) != 1 || !got.Created {
|
||||
t.Fatalf("%d writes, %+v", len(f.writes), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAccountIsRefusedAProtectedBranchTheForgeSaysItGuardsAndTheDefaultBranch(t *testing.T) {
|
||||
for branch, rule := range map[string]string{"release/1.0": "release/*", "fix/1-old": "fix/1-old", "main": ""} {
|
||||
f := newFake(t)
|
||||
protected := map[string]string{}
|
||||
if rule != "" {
|
||||
protected[branch] = rule
|
||||
}
|
||||
forge, _ := asTools(t, f, protected, "read:user,write:issue,write:repository for mesh-tools")
|
||||
_, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": branch,
|
||||
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
|
||||
if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "ADR 0266") {
|
||||
t.Errorf("%s: %v", branch, err)
|
||||
}
|
||||
if rule != "" && !strings.Contains(err.Error(), rule) {
|
||||
t.Errorf("%s: the rule %s is not named: %v", branch, rule, err)
|
||||
}
|
||||
if len(f.writes)+len(f.patches) != 0 {
|
||||
t.Errorf("%s: something was written", branch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATokenKeptForTheAdministratorIsNeverSent(t *testing.T) {
|
||||
f := newFake(t)
|
||||
// What the module kept before ADR 0365: the admin's token, its scopes line naming no account.
|
||||
forge, seen := asTools(t, f, nil, "read:user,write:issue,write:repository")
|
||||
_, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365",
|
||||
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
|
||||
if err == nil || !strings.Contains(err.Error(), "not mesh-tools's") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if len(*seen) != 0 {
|
||||
t.Fatalf("the forge was asked %v", *seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBundleHandedATokenFileNeedsNoAdminLogin(t *testing.T) {
|
||||
t.Setenv("MESH_GITEA_URL", "http://127.0.0.1:3000")
|
||||
t.Setenv("MESH_GITEA_TOKEN_FILE", "/state/token")
|
||||
t.Setenv("MESH_GITEA_TOOLS_USER", "")
|
||||
t.Setenv("MESH_GITEA_ADMIN_USER", "")
|
||||
t.Setenv("MESH_GITEA_ADMIN_PASSWORD_FILE", "")
|
||||
f, err := ForgeFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.TokenFile != "/state/token" || f.Account != "mesh-tools" || f.User != "" || f.PasswordFile != "" {
|
||||
t.Fatalf("%+v", f.Client)
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea/internal/forgehttp"
|
||||
)
|
||||
|
||||
// gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull
|
||||
@@ -213,32 +211,7 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
|
||||
// Read the rules, or refuse: a branch the rules could not be read for is never written (fails closed).
|
||||
var rules []Protection
|
||||
if err := f.GetJSON(ctx, repo+"/branch_protections", &rules); err != nil {
|
||||
var status *forgehttp.StatusError
|
||||
if !errors.As(err, &status) || status.Status != http.StatusForbidden {
|
||||
return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err)
|
||||
}
|
||||
// The rules are a repository administrator's to read, and the tools' account is none (novox/hq ADR 0365
|
||||
// §2). What it may read is the protection the forge applies to an existing branch: one protected is
|
||||
// refused here. A branch not made yet is covered by no rule this account can see, and the forge itself
|
||||
// judges the commit that makes it against every rule, refusing one a rule guards.
|
||||
if err := targetRefused(a.Branch, r.DefaultBranch, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := f.branch(ctx, repo, a.Branch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("whether a protection rule guards %s of %s/%s could not be read, so nothing was sent: %w", a.Branch, a.Owner, a.Repo, err)
|
||||
}
|
||||
if b != nil && b.Protected {
|
||||
rule := b.Rule
|
||||
if rule == "" {
|
||||
rule = a.Branch
|
||||
}
|
||||
rules = []Protection{{RuleName: rule}}
|
||||
if !ruleCovers(rule, a.Branch) {
|
||||
return nil, refuse("a commit on %s is refused: the protection rule %s guards it (novox/hq ADR 0266). Put it on a "+
|
||||
"branch of its own. Nothing was sent", a.Branch, rule)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err)
|
||||
}
|
||||
if err := targetRefused(a.Branch, r.DefaultBranch, rules); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"gitea/internal/forgehttp"
|
||||
)
|
||||
|
||||
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the tools' own account (novox/hq
|
||||
// ADR 0365 §2; internal/forgehttp, shared with the npm-registry bundle beside it).
|
||||
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin account
|
||||
// (internal/forgehttp, shared with the npm-registry bundle beside it).
|
||||
|
||||
// Forge is the forge's HTTP interface.
|
||||
type Forge struct {
|
||||
@@ -91,15 +91,12 @@ func (p Protection) name() string {
|
||||
return p.BranchName
|
||||
}
|
||||
|
||||
// Branch is one branch with the commit it points at, and the protection the forge applies to it: what an account
|
||||
// that may not read the rules themselves (the tools' account, novox/hq ADR 0365) knows of them.
|
||||
// Branch is one branch with the commit it points at.
|
||||
type Branch struct {
|
||||
Name string `json:"name"`
|
||||
Commit struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"commit"`
|
||||
Protected bool `json:"protected"`
|
||||
Rule string `json:"effective_branch_protection_name"`
|
||||
}
|
||||
|
||||
// branch is the named branch, nil when the forge has none of that name.
|
||||
|
||||
@@ -619,17 +619,11 @@ func TestToolsAreTheOnesTheReadmeNamesAndTheManifestBuilds(t *testing.T) {
|
||||
if len(a.Loads) != 1 || a.Loads[0] != a.Binary {
|
||||
t.Errorf("the bundle loads %v, not its binary %s", a.Loads, a.Binary)
|
||||
}
|
||||
// The tools' own account, never the admin login (novox/hq ADR 0365 §2).
|
||||
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE"} {
|
||||
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} {
|
||||
if a.Env[k] == "" {
|
||||
t.Errorf("the bundle is not given %s", k)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} {
|
||||
if a.Env[k] != "" {
|
||||
t.Errorf("the bundle is given %s: it acts as the tools' account, not the administrator", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
|
||||
+83
-167
@@ -40,11 +40,10 @@ import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||
import { commitStatusTool } from "./delivery.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
import { GiteaClient, movedSince } from "./client.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
|
||||
repoCheckFacts, reposToLook } from "./pulls.js";
|
||||
type RepoCheckFacts, reposToLook } from "./pulls.js";
|
||||
import { GateWriter, clonedAs, type BuildStarted } from "./gate.js";
|
||||
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
|
||||
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
|
||||
|
||||
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
||||
@@ -146,9 +145,57 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
// at once.
|
||||
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
|
||||
if (primedMerges && fresh) {
|
||||
if (await announceMerge(client, repo, pull)) pace.miss(`the merge of ${repo.full_name}#${pull.number}`);
|
||||
// What it changed, asked for only now: a module is rebuilt because a file inside its own
|
||||
// directory moved, and without this every module built from a repository is rebuilt for a
|
||||
// change to any of them (novox/hq 04-ISSUES/131).
|
||||
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
|
||||
// Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a
|
||||
// change inside one is that module's, held or not, and only a file in none is shared code. Not
|
||||
// said when the list is cut or the forge could not be asked; the mesh then keeps its old rule.
|
||||
let moduleDirs: string[] | null = null;
|
||||
if (!changed.truncated) {
|
||||
try {
|
||||
moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` +
|
||||
`so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
// The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made
|
||||
// from the forge's word when its owner never heard the head.
|
||||
let headChecks: Record<string, string> | null = null;
|
||||
if (pull.head_sha) {
|
||||
try {
|
||||
headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
await emit("pull.merged", {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
body: pull.body,
|
||||
head_sha: pull.head_sha,
|
||||
...(headChecks ? { head_checks: headChecks } : {}),
|
||||
head: pull.head,
|
||||
base: pull.base,
|
||||
merge_commit_sha: pull.merge_commit_sha,
|
||||
merged_at: pull.merged_at,
|
||||
clone_url: repo.clone_url,
|
||||
html_url: pull.html_url,
|
||||
paths: changed.paths,
|
||||
paths_truncated: changed.truncated,
|
||||
// Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten,
|
||||
// not built.
|
||||
removed: changed.removed,
|
||||
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
|
||||
});
|
||||
// Said, because a trigger that fires silently is indistinguishable from one that did not
|
||||
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
|
||||
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
|
||||
}
|
||||
else announced.add(pull.merge_commit_sha);
|
||||
announced.add(pull.merge_commit_sha);
|
||||
changed = true;
|
||||
}
|
||||
@@ -159,73 +206,6 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
lastLook = began;
|
||||
}
|
||||
|
||||
type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string };
|
||||
type PullOf = Awaited<ReturnType<GiteaClient["listPullRequests"]>>[number];
|
||||
|
||||
/** **A merge, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
|
||||
async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
|
||||
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) return false;
|
||||
// Recorded before the first wait, so the other path finds it taken while this one is still asking the forge.
|
||||
announced.add(pull.merge_commit_sha);
|
||||
try {
|
||||
// What it changed, asked for only now: a module is rebuilt because a file inside its own
|
||||
// directory moved, and without this every module built from a repository is rebuilt for a
|
||||
// change to any of them (novox/hq 04-ISSUES/131).
|
||||
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
|
||||
// Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a
|
||||
// change inside one is that module's, held or not, and only a file in none is shared code. Not
|
||||
// said when the list is cut or the forge could not be asked; the mesh then keeps its old rule.
|
||||
let moduleDirs: string[] | null = null;
|
||||
if (!changed.truncated) {
|
||||
try {
|
||||
moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` +
|
||||
`so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
// The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made
|
||||
// from the forge's word when its owner never heard the head.
|
||||
let headChecks: Record<string, string> | null = null;
|
||||
if (pull.head_sha) {
|
||||
try {
|
||||
headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
await emit("pull.merged", {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
body: pull.body,
|
||||
head_sha: pull.head_sha,
|
||||
...(headChecks ? { head_checks: headChecks } : {}),
|
||||
head: pull.head,
|
||||
base: pull.base,
|
||||
merge_commit_sha: pull.merge_commit_sha,
|
||||
merged_at: pull.merged_at,
|
||||
clone_url: repo.clone_url,
|
||||
html_url: pull.html_url,
|
||||
paths: changed.paths,
|
||||
paths_truncated: changed.truncated,
|
||||
// Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten,
|
||||
// not built.
|
||||
removed: changed.removed,
|
||||
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
|
||||
});
|
||||
// Said, because a trigger that fires silently is indistinguishable from one that did not
|
||||
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
|
||||
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
|
||||
} catch (err) {
|
||||
announced.delete(pull.merge_commit_sha);
|
||||
throw err;
|
||||
}
|
||||
keepAnnounced();
|
||||
return true;
|
||||
}
|
||||
|
||||
// **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it
|
||||
// against every machine of its facts before it merges. Kept beside the merges' record, so a restart
|
||||
// announces nothing twice; the first look on a machine with no record announces only what moved in the
|
||||
@@ -248,31 +228,6 @@ function keepHeads(): void {
|
||||
writeFileSync(tmp, JSON.stringify({ heads }));
|
||||
renameSync(tmp, pullsRecord);
|
||||
}
|
||||
/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
|
||||
async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
|
||||
const key = `${repo.full_name}#${pull.number}`;
|
||||
const sha = String(pull.head_sha ?? "");
|
||||
const before = heads[key];
|
||||
// Claimed before the first wait, so the other path finds it taken while this one is still announcing.
|
||||
if (!claimHead(heads, key, sha)) return false;
|
||||
try {
|
||||
// The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5).
|
||||
await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull));
|
||||
} catch (err) {
|
||||
if (before === undefined) delete heads[key];
|
||||
else heads[key] = before;
|
||||
throw err;
|
||||
}
|
||||
keepHeads();
|
||||
// Said, so an operator knows the mesh was asked to check it.
|
||||
console.log(`[gitea] announced ${key} at ${sha.slice(0, 8)} to be checked before it merges`);
|
||||
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
|
||||
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446).
|
||||
await gates.pending(repo.owner, repo.name, sha);
|
||||
return true;
|
||||
}
|
||||
type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
|
||||
|
||||
let lastPullLook = "";
|
||||
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
|
||||
// heuristic, and a heuristic that misses is caught by the next sweep rather than never.
|
||||
@@ -304,8 +259,15 @@ async function pollPulls(client: GiteaClient): Promise<void> {
|
||||
const key = `${repo.full_name}#${pull.number}`;
|
||||
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
|
||||
if (fresh) {
|
||||
if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
|
||||
} else heads[key] = String(pull.head_sha);
|
||||
// The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5).
|
||||
await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url, pull));
|
||||
// Said, so an operator knows the mesh was asked to check it.
|
||||
console.log(`[gitea] announced ${key} at ${String(pull.head_sha).slice(0, 8)} to be checked before it merges`);
|
||||
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
|
||||
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446).
|
||||
await gates.pending(repo.owner, repo.name, String(pull.head_sha));
|
||||
}
|
||||
heads[key] = String(pull.head_sha);
|
||||
changed = true;
|
||||
}
|
||||
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
|
||||
@@ -400,6 +362,22 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
|
||||
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
|
||||
}
|
||||
|
||||
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
|
||||
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
|
||||
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
|
||||
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
|
||||
// required would let an untested repository merge.
|
||||
async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
|
||||
if (c["repo-check"]?.verdict !== "warning") return {};
|
||||
const defined = await client.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
|
||||
if (defined !== false) return { defined };
|
||||
const rules = await client.branchProtections(c.owner, c.repo).catch(() => undefined);
|
||||
if (!rules) return { defined };
|
||||
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === (base || "main"));
|
||||
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
|
||||
return { defined, required };
|
||||
}
|
||||
|
||||
// **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words,
|
||||
// where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last
|
||||
// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending.
|
||||
@@ -413,16 +391,10 @@ const gates = new GateWriter({
|
||||
// **The status tool is served here, beside the writer** (novox/hq issue 456): the runtime starts each bundle as its own
|
||||
// process, so a recheck's pending written from the tools' bundle bypassed this writer's chain. Without a forge to
|
||||
// reach, nothing is served, as the tools' bundle serves nothing then.
|
||||
/** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what
|
||||
* gitea_events says of them (webhook.ts). */
|
||||
const pace = new Pace();
|
||||
if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
|
||||
|
||||
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729).
|
||||
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
|
||||
registerModuleTools("gitea", () => (gitea ? [commitStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
gates,
|
||||
}), mergeTool(gitea, { gates })] : [])]);
|
||||
}), mergeTool(gitea, { gates })] : []));
|
||||
|
||||
// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when
|
||||
// the head is an open pull request's this module announced. Any other build — a branch, a merge's — says nothing.
|
||||
@@ -441,65 +413,12 @@ async function buildStarted(event: { body: unknown }): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** What the forge's event asks of the announcer at once (issue 729): a new head is announced, and a merge. */
|
||||
async function actOn(client: GiteaClient, event: ForgeEvent): Promise<void> {
|
||||
const b = event.body as { owner?: string; repo?: string; number?: number };
|
||||
if (!b.owner || !b.repo || !b.number) return;
|
||||
const head = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"].includes(event.name);
|
||||
const merged = event.name === "forge.pull-request.merged";
|
||||
if (!head && !merged) return;
|
||||
const [repo, pull] = await Promise.all([client.getRepo(b.owner, b.repo), client.getPullRequest(b.owner, b.repo, b.number)]);
|
||||
if (head && pull.state === "open") await announceHead(client, repo, pull);
|
||||
if (merged) await announceMerge(client, repo, pull);
|
||||
}
|
||||
|
||||
/**
|
||||
* **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside
|
||||
* the token; the webhook is made or corrected with the admin account (ADR 0266: the tools' token holds no admin scope).
|
||||
* Answers whether events are heard; a forge that refuses is said, and the polls keep their pace.
|
||||
*/
|
||||
async function listenForForge(client: GiteaClient): Promise<boolean> {
|
||||
const stateDir = process.env.MESH_GITEA_STATE_DIR;
|
||||
const url = process.env.MESH_GITEA_HOOK_URL;
|
||||
if (!stateDir || !url) {
|
||||
console.log("[gitea] no webhook receiver: MESH_GITEA_STATE_DIR or MESH_GITEA_HOOK_URL unset, so the polls alone watch the forge");
|
||||
pace.unheard("MESH_GITEA_STATE_DIR or MESH_GITEA_HOOK_URL unset", false);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const secret = keptSecret(join(stateDir, "webhook.secret"));
|
||||
const port = Number(process.env.MESH_GITEA_HOOK_PORT ?? HOOK_PORT);
|
||||
const host = process.env.MESH_GITEA_HOOK_HOST ?? "0.0.0.0";
|
||||
const server = receiver({ secret, port, heard: (kind, delivery) => pace.heard(kind, delivery),
|
||||
refused: (reason, from) => pace.refused(reason, from),
|
||||
publish: (event) => emit(event.name, event.body),
|
||||
act: (event) => actOn(client, event) });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => resolve());
|
||||
});
|
||||
const hook = await ensureSystemHook(GiteaAdmin.fromEnv(), url.replace(/\/+$/, "") + HOOK_PATH, secret);
|
||||
pace.listening(host, port, hook.url, hook);
|
||||
console.log(`[gitea] listening for the forge's events on ${host}:${port}; its system webhook ${hook.id} ${hook.did} ` +
|
||||
`at ${hook.url} (in place of ${hook.removed.system} system, ${hook.removed.default} default and ` +
|
||||
`${hook.removed.repository} repository webhook(s)${hook.removed.repositories.length ? ` in ${hook.removed.repositories.join(", ")}` : ""}), ` +
|
||||
`${hook.events.length} event families, signed`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const why = err instanceof Error ? err.message : String(err);
|
||||
pace.unheard(why);
|
||||
console.error(`[gitea] the forge's events are not heard, so the polls keep their pace — ${why}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (gitea) {
|
||||
const client = gitea;
|
||||
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
|
||||
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
|
||||
let failing: string | null = null;
|
||||
const tick = (fn: () => Promise<void>, every: number | (() => number)): void => {
|
||||
const everyMs = (): number => (typeof every === "number" ? every : every());
|
||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||
// **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a
|
||||
// pass that outlasts its interval delays the next instead of running beside it — two passes at once
|
||||
// could each announce the same merge before either recorded it.
|
||||
@@ -514,15 +433,12 @@ if (gitea) {
|
||||
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
|
||||
failing = why;
|
||||
})
|
||||
.finally(() => setTimeout(run, everyMs()));
|
||||
.finally(() => setTimeout(run, everyMs));
|
||||
run();
|
||||
};
|
||||
// **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the
|
||||
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
|
||||
await listenForForge(client);
|
||||
tick(() => pollRepos(client), 60_000);
|
||||
tick(() => pollMerged(client), () => pace.every());
|
||||
tick(() => pollPulls(client), () => pace.every());
|
||||
tick(() => pollMerged(client), 30_000);
|
||||
tick(() => pollPulls(client), 30_000);
|
||||
await on("mesh-controller.checked", (event) => {
|
||||
const c = (event.body ?? {}) as Checked;
|
||||
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
// Package forgehttp is the forge's HTTP interface as this module's Go bundles reach it (cmd/forge,
|
||||
// cmd/npm-registry), on the forge's own machine. One copy for both bundles (novox/hq issue 539).
|
||||
//
|
||||
// Two ways in, by what the manifest hands the bundle:
|
||||
//
|
||||
// - **as the tools' account** (novox/hq ADR 0365 §2), when MESH_GITEA_TOKEN_FILE names the token the module
|
||||
// keeps for it (token.ts mints it, for `mesh-tools`, and keeps it at 0600 with its scopes line beside it). A
|
||||
// token whose scopes line does not say it is the tools' account's — one kept from before, the administrator's —
|
||||
// is never sent: the call fails, saying so, until the module has minted the account's own. This bundle mints
|
||||
// nothing; a token the forge rejects is renewed by the module's next call, and said here.
|
||||
// - **as the admin account**, whose password the vault delivered into a file this module owns (novox/hq ADR
|
||||
// 0086), by basic authentication, when no token file is named.
|
||||
//
|
||||
// A credential is read when a call is made and never logged, answered or put in an error.
|
||||
// cmd/npm-registry): the forge on its own machine, as its admin account, whose password the vault delivered
|
||||
// into a file this module owns (novox/hq ADR 0086), by basic authentication. The password is read when a call
|
||||
// is made and never logged, answered or put in an error. One copy for both bundles (novox/hq issue 539).
|
||||
package forgehttp
|
||||
|
||||
import (
|
||||
@@ -35,31 +25,12 @@ type Client struct {
|
||||
URL string // e.g. http://127.0.0.1:3000
|
||||
User string
|
||||
PasswordFile string
|
||||
// TokenFile, when set, is the tools' account's token (MESH_GITEA_TOKEN_FILE), sent instead of the admin login;
|
||||
// Account the account its scopes line must name (MESH_GITEA_TOOLS_USER, default mesh-tools).
|
||||
TokenFile string
|
||||
Account string
|
||||
HTTP *http.Client // when nil, a client with Timeout
|
||||
Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero
|
||||
HTTP *http.Client // when nil, a client with Timeout
|
||||
Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero
|
||||
}
|
||||
|
||||
// ToolsAccount is the account the forge's tools act as when the manifest names none (token.ts, tools-account.ts).
|
||||
const ToolsAccount = "mesh-tools"
|
||||
|
||||
// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset. A bundle
|
||||
// handed MESH_GITEA_TOKEN_FILE acts as the tools' account and needs no admin login.
|
||||
// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset.
|
||||
func FromEnv() (Client, []string) {
|
||||
if file := os.Getenv("MESH_GITEA_TOKEN_FILE"); file != "" {
|
||||
c := Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), TokenFile: file,
|
||||
Account: os.Getenv("MESH_GITEA_TOOLS_USER")}
|
||||
if c.Account == "" {
|
||||
c.Account = ToolsAccount
|
||||
}
|
||||
if c.URL == "" {
|
||||
return c, []string{"MESH_GITEA_URL"}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
c := Client{
|
||||
URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"),
|
||||
User: os.Getenv("MESH_GITEA_ADMIN_USER"),
|
||||
@@ -109,45 +80,14 @@ func (c *Client) password() (string, error) {
|
||||
return strings.TrimSpace(string(raw)), nil
|
||||
}
|
||||
|
||||
// token is the tools' account's token, refused unless its scopes line names that account.
|
||||
func (c *Client) token() (string, error) {
|
||||
raw, err := os.ReadFile(c.TokenFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the tools' token is not kept yet (the module mints it for %s at its first call): %w", c.Account, err)
|
||||
}
|
||||
line, err := os.ReadFile(c.TokenFile + ".scopes")
|
||||
if err != nil || !strings.HasSuffix(strings.TrimSpace(string(line)), " for "+c.Account) {
|
||||
return "", fmt.Errorf("the token kept at %s is not %s's (novox/hq ADR 0365): the module mints that account's own at its next call; nothing was sent", c.TokenFile, c.Account)
|
||||
}
|
||||
token := strings.TrimSpace(string(raw))
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("the token kept at %s is empty; nothing was sent", c.TokenFile)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// authorize puts the bundle's credential on a request: the tools' token, or the admin login.
|
||||
func (c *Client) authorize(req *http.Request) error {
|
||||
if c.TokenFile != "" {
|
||||
token, err := c.token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
return nil
|
||||
}
|
||||
password, err := c.password()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(c.User, password)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do sends one request, with payload as its JSON body when not nil, and answers the body, read up to limit;
|
||||
// 404 is ErrNotFound. One byte past the limit is read, so a body the limit cut is told from one that fits it
|
||||
// exactly: a cut body is refused, never read as if it were whole.
|
||||
func (c *Client) Do(ctx context.Context, method, path string, payload any, limit int64) ([]byte, error) {
|
||||
password, err := c.password()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
raw, err := json.Marshal(payload)
|
||||
@@ -160,9 +100,7 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.authorize(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(c.User, password)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -180,9 +118,6 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit
|
||||
return nil, fmt.Errorf("the forge's answer to %s %s is larger than %d MiB and was not read whole", method, path, limit>>20)
|
||||
}
|
||||
switch {
|
||||
case res.StatusCode == http.StatusUnauthorized && c.TokenFile != "":
|
||||
return nil, fmt.Errorf("the forge rejected the tools' token for %s (401): the module mints it afresh at its next call; nothing was done: %w",
|
||||
method+" "+path, &StatusError{method, path, res.StatusCode, "unauthorized"})
|
||||
case res.StatusCode == http.StatusNotFound:
|
||||
return nil, fmt.Errorf("%s %s: %w", method, path, ErrNotFound)
|
||||
case res.StatusCode >= 300:
|
||||
|
||||
@@ -54,10 +54,9 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
const method = args.method ? String(args.method) : "merge";
|
||||
if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`);
|
||||
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
|
||||
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here as well as by the
|
||||
// forge, which holds the tools' own account to it (novox/hq ADR 0365 §2). The protection is the one the forge
|
||||
// applies to the base, read from the branch: the rules' own list is a repository administrator's. A rule that
|
||||
// cannot be read is no rule passed.
|
||||
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here rather than left
|
||||
// to the forge, whose administrator — the account this tool's token is minted for — may merge past a
|
||||
// rule that lets it. A rule that cannot be read is no rule passed.
|
||||
// **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d,
|
||||
// force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the
|
||||
// head was and nothing told it which head the review had approved. The caller names it; a head that moved
|
||||
@@ -81,7 +80,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` +
|
||||
`${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`);
|
||||
}
|
||||
const rule = await gitea.appliedProtection(owner, repo, pull.base);
|
||||
const rule = await gitea.branchProtection(owner, repo, pull.base);
|
||||
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
|
||||
// branch nothing guards would merge past every check (novox/hq ADR 0266).
|
||||
if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) {
|
||||
|
||||
@@ -42,47 +42,7 @@
|
||||
"issue.opened",
|
||||
"pull.merged",
|
||||
"pull.updated",
|
||||
"pull.closed",
|
||||
"forge.push",
|
||||
"forge.create",
|
||||
"forge.delete",
|
||||
"forge.fork",
|
||||
"forge.status",
|
||||
"forge.pull-request.opened",
|
||||
"forge.pull-request.reopened",
|
||||
"forge.pull-request.synchronized",
|
||||
"forge.pull-request.edited",
|
||||
"forge.pull-request.closed",
|
||||
"forge.pull-request.merged",
|
||||
"forge.pull-request.labeled",
|
||||
"forge.pull-request.unlabeled",
|
||||
"forge.pull-request.assigned",
|
||||
"forge.pull-request.unassigned",
|
||||
"forge.pull-request.reviewed",
|
||||
"forge.pull-request.review-requested",
|
||||
"forge.pull-request.commented",
|
||||
"forge.pull-request.changed",
|
||||
"forge.issue-comment.created",
|
||||
"forge.issue-comment.edited",
|
||||
"forge.issue-comment.deleted",
|
||||
"forge.issues.opened",
|
||||
"forge.issues.reopened",
|
||||
"forge.issues.closed",
|
||||
"forge.issues.edited",
|
||||
"forge.issues.labeled",
|
||||
"forge.issues.unlabeled",
|
||||
"forge.issues.assigned",
|
||||
"forge.issues.unassigned",
|
||||
"forge.issues.changed",
|
||||
"forge.release.published",
|
||||
"forge.release.updated",
|
||||
"forge.release.deleted",
|
||||
"forge.release.changed",
|
||||
"forge.repository.created",
|
||||
"forge.repository.deleted",
|
||||
"forge.repository.changed",
|
||||
"forge.wiki.changed",
|
||||
"forge.other"
|
||||
"pull.closed"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-controller.checked",
|
||||
@@ -101,14 +61,7 @@
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take"
|
||||
},
|
||||
{
|
||||
"name": "forge-events",
|
||||
"port": 3047,
|
||||
"protocol": "tcp",
|
||||
"from": "machine",
|
||||
"why": "the forge's system webhook, every event signed with a secret the module keeps, from the forge's container on the docker bridge to the module's receiver at the bridge's gateway 172.17.0.1 (novox/hq issue 729)"
|
||||
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention \u2014 not 22, which the machine's own daemon holds and a module does not take"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
@@ -156,13 +109,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"ready-authors": {
|
||||
"kind": "preference",
|
||||
"default": "mesh-tools mesh-admin jschoubben",
|
||||
"why": "the forge accounts whose comment \"ready at <full head sha>\" marks a pull request ready for mesh-delivery to merge, separated by spaces: the account the forge's tools act as (novox/hq ADR 0365), the admin account for marks made before the tools acted as their own, and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
@@ -176,14 +122,6 @@
|
||||
"path": "${dir:mesh-state}/state",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "ready-authors",
|
||||
"type": "file",
|
||||
"path": "${dir:mesh-state}/ready-authors",
|
||||
"mode": "0600",
|
||||
"trusted": true,
|
||||
"content": "# The accounts whose \"ready at <sha>\" comment marks a pull request ready (novox/hq issue 722), from the setting\n# ready-authors, written by the mesh. Replaced on every push; change the setting, never this file.\nauthors=${setting:ready-authors}\n"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
@@ -200,7 +138,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\nGITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1\n"
|
||||
"content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
@@ -211,13 +149,6 @@
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"health": {
|
||||
"kind": "http",
|
||||
"endpoint": "web",
|
||||
"path": "/api/healthz",
|
||||
"status": 200,
|
||||
"needs": "postgres-database"
|
||||
},
|
||||
"name": "gitea",
|
||||
"image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef",
|
||||
"env": {
|
||||
@@ -269,8 +200,7 @@
|
||||
"path": "${dir:mesh-state}/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json",
|
||||
"takes-settings": []
|
||||
"merge": "json"
|
||||
}
|
||||
],
|
||||
"provides": [
|
||||
@@ -308,13 +238,8 @@
|
||||
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||
"MESH_GITEA_TOOLS_USER": "mesh-tools",
|
||||
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
|
||||
"MESH_RECEIVES": "${dir:grants}/npm.json",
|
||||
"MESH_GITEA_READY_AUTHORS_FILE": "${dir:mesh-state}/ready-authors",
|
||||
"MESH_GITEA_HOOK_URL": "http://172.17.0.1:3047",
|
||||
"MESH_GITEA_HOOK_PORT": "3047",
|
||||
"MESH_GITEA_HOOK_HOST": "172.17.0.1"
|
||||
"MESH_RECEIVES": "${dir:grants}/npm.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -346,8 +271,8 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
|
||||
"MESH_GITEA_TOOLS_USER": "mesh-tools",
|
||||
"MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token"
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+1
-19
@@ -9,7 +9,7 @@
|
||||
//
|
||||
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
|
||||
|
||||
import type { CommitStatus, GiteaClient, GiteaPull, GiteaRepo } from "./client.js";
|
||||
import type { CommitStatus, GiteaPull, GiteaRepo } from "./client.js";
|
||||
import { movedSince } from "./client.js";
|
||||
|
||||
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
|
||||
@@ -176,24 +176,6 @@ export interface RepoCheckFacts {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
|
||||
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
|
||||
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
|
||||
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
|
||||
// required would let an untested repository merge. The base's protection is the one the forge applies, read
|
||||
// from the branch: the rules' own list is a repository administrator's, which the tools' account is not
|
||||
// (novox/hq ADR 0365).
|
||||
export async function repoCheckFacts(
|
||||
forge: Pick<GiteaClient, "holdsFile" | "appliedProtection">, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
|
||||
if (c["repo-check"]?.verdict !== "warning") return {};
|
||||
const defined = await forge.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
|
||||
if (defined !== false) return { defined };
|
||||
const rule = await forge.appliedProtection(c.owner, c.repo, base || "main").catch(() => undefined);
|
||||
if (rule === undefined) return { defined };
|
||||
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
|
||||
return { defined, required };
|
||||
}
|
||||
|
||||
/** The description of a required repository check that is not defined. */
|
||||
export const UNDEFINED_REQUIRED = "fail: mesh/repo-check is required here and this head defines no merge-check.sh — " +
|
||||
"a person adds one, or lifts the requirement from the branch's protection";
|
||||
|
||||
@@ -47,18 +47,11 @@ test("every open pull request is read with what its base requires and what its h
|
||||
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/pulls/14")) {
|
||||
return send({ number: 14, state: "open", base: { ref: "main" }, head: { ref: "y", sha: "bbb2222" }, html_url: "s" });
|
||||
}
|
||||
// The base's protection, read from the branch as the tools' account may (novox/hq ADR 0365); the rules' list is refused it.
|
||||
if (url.includes("/branch_protections")) {
|
||||
res.statusCode = 403;
|
||||
return res.end(JSON.stringify({ message: "the rules are a repository administrator's" }));
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/branch_protections")) {
|
||||
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/repo-check"] }]);
|
||||
}
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/branches/main")) {
|
||||
return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true,
|
||||
status_check_contexts: ["mesh/repo-check"] });
|
||||
}
|
||||
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branches/main")) {
|
||||
return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true,
|
||||
status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] });
|
||||
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branch_protections")) {
|
||||
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }]);
|
||||
}
|
||||
if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] });
|
||||
if (url.includes("/commits/bbb2222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending",
|
||||
@@ -144,122 +137,3 @@ test("a merged pull request whose branch was deleted is announced with its branc
|
||||
assert.equal(headBranchOf({ ref: "refs/pull/7/head" }), "refs/pull/7/head");
|
||||
assert.equal(headBranchOf(undefined), undefined);
|
||||
});
|
||||
|
||||
// **A pull request marked ready says the head it is marked at** (novox/hq issue 722): the label mesh/ready and the
|
||||
// newest comment "ready at <full head sha>" by a listed account, written after the label was added and after the last
|
||||
// push. Read from the pull request's whole timeline, page by page; without the label nothing is read.
|
||||
const head = "c".repeat(40), older = "d".repeat(40);
|
||||
const ev = (id: number, type: string, body: string, user = "mesh-admin", label?: { id: number; name: string }) =>
|
||||
({ id, type, body, user: { login: user }, created_at: `2026-10-14T10:${String(id % 60).padStart(2, "0")}:00Z`,
|
||||
...(label ? { label } : {}) });
|
||||
const readyLabel = { id: 31, name: "mesh/ready" };
|
||||
|
||||
function forgeWith(timeline: unknown[]) {
|
||||
const asked: string[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
const url = req.url ?? "";
|
||||
asked.push(url);
|
||||
const send = (v: unknown) => res.end(JSON.stringify(v));
|
||||
if (url.startsWith("/api/v1/repos/issues/search")) {
|
||||
return send(url.includes("page=1") ? [
|
||||
{ number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" },
|
||||
labels: [{ id: 31, name: "mesh/ready" }, { id: 2, name: "bug" }] },
|
||||
{ number: 8, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, labels: [] },
|
||||
] : []);
|
||||
}
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/pulls/7")) {
|
||||
return send({ number: 7, state: "open", base: { ref: "main" }, head: { ref: "x", sha: head }, html_url: "h" });
|
||||
}
|
||||
if (url.startsWith("/api/v1/repos/novox/hq/pulls/8")) {
|
||||
return send({ number: 8, state: "open", base: { ref: "main" }, head: { ref: "y", sha: older }, html_url: "h" });
|
||||
}
|
||||
const t = /\/issues\/7\/timeline\?page=(\d+)&limit=50/.exec(url);
|
||||
if (t) {
|
||||
const page = Number(t[1]);
|
||||
return send(timeline.slice((page - 1) * 50, page * 50));
|
||||
}
|
||||
if (url.includes("/branch_protections")) return send([]);
|
||||
if (url.includes("/status")) return send({ statuses: [] });
|
||||
res.statusCode = 404;
|
||||
res.end("{}");
|
||||
});
|
||||
return { server, asked };
|
||||
}
|
||||
|
||||
async function readOpen(timeline: unknown[], authors = ["mesh-admin", "jschoubben"]) {
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
const { server, asked } = forgeWith(timeline);
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
try {
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
return { open: await client.openPulls(authors), asked };
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
test("a pull request's ready mark is read: the label and the head the newest listed ready comment names", async () => {
|
||||
const { open, asked } = await readOpen([
|
||||
ev(1, "pull_push", JSON.stringify({ commit_ids: [head] })),
|
||||
ev(2, "label", "1", "mesh-admin", readyLabel),
|
||||
ev(3, "comment", `ready at ${older}`),
|
||||
ev(4, "comment", `Reviewed: approve.\nready at ${head}\n`),
|
||||
ev(5, "comment", "thanks", "jschoubben"),
|
||||
]);
|
||||
assert.deepEqual(open[0].labels, ["mesh/ready", "bug"]);
|
||||
assert.deepEqual(open[0].ready, { head, by: "mesh-admin", at: "2026-10-14T10:04:00Z", label_id: 31 });
|
||||
assert.equal(open[1].ready, undefined, "no label, no mark");
|
||||
assert.ok(!asked.some((u) => u.includes("/issues/8/")), "no timeline read without the label");
|
||||
});
|
||||
|
||||
test("a ready comment by an account not listed is ignored, and said", async () => {
|
||||
const { readyMarkOf } = await import("../client.ts");
|
||||
const said: string[] = [];
|
||||
const mark = readyMarkOf(31, [
|
||||
ev(1, "pull_push", "{}") as any, ev(2, "label", "1", "mesh-admin", readyLabel) as any,
|
||||
{ ...ev(3, "comment", `ready at ${head}`, "outsider"), user: "outsider" },
|
||||
].map((e: any) => ({ ...e, user: typeof e.user === "string" ? e.user : e.user.login })), ["mesh-admin"], (l) => said.push(l));
|
||||
assert.equal(mark.head, "", "an outsider's mark counts");
|
||||
assert.equal(said.length, 1);
|
||||
assert.match(said[0], /by outsider is ignored/);
|
||||
});
|
||||
|
||||
test("a ready comment older than the latest push, or than the label, never counts", async () => {
|
||||
const pushedAfter = await readOpen([
|
||||
ev(1, "label", "1", "mesh-admin", readyLabel),
|
||||
ev(2, "comment", `ready at ${head}`),
|
||||
ev(3, "pull_push", JSON.stringify({ commit_ids: [head] })),
|
||||
]);
|
||||
assert.equal(pushedAfter.open[0].ready?.head, "", "a mark written before the latest push counts, the label still on");
|
||||
const beforeLabel = await readOpen([
|
||||
ev(1, "pull_push", "{}"),
|
||||
ev(2, "comment", `ready at ${head}`),
|
||||
ev(3, "label", "1", "mesh-admin", readyLabel),
|
||||
]);
|
||||
assert.equal(beforeLabel.open[0].ready?.head, "", "a mark written before the label was added counts");
|
||||
const takenOff = await readOpen([
|
||||
ev(1, "label", "1", "mesh-admin", readyLabel),
|
||||
ev(2, "comment", `ready at ${head}`),
|
||||
ev(3, "label", "", "mesh-admin", readyLabel),
|
||||
]);
|
||||
assert.equal(takenOff.open[0].ready?.head, "", "a mark whose label was taken off since counts");
|
||||
});
|
||||
|
||||
test("a long thread hides nothing: every page of the timeline is read", async () => {
|
||||
const events: unknown[] = [ev(1, "pull_push", "{}"), ev(2, "label", "1", "mesh-admin", readyLabel),
|
||||
ev(3, "comment", `ready at ${older}`)];
|
||||
for (let id = 4; id < 130; id++) events.push(ev(id, "comment", "a long discussion", "jschoubben"));
|
||||
events.push(ev(130, "comment", `ready at ${head}`, "jschoubben"));
|
||||
const { open, asked } = await readOpen(events);
|
||||
assert.equal(open[0].ready?.head, head, "the newest mark, on the third page, is not read");
|
||||
assert.equal(asked.filter((u) => u.includes("/issues/7/timeline")).length, 3);
|
||||
});
|
||||
|
||||
test("the accounts that may mark are the setting's, and only the mesh's own when it cannot be read", async () => {
|
||||
const { readyAuthors } = await import("../client.ts");
|
||||
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "# x\nauthors=mesh-admin jschoubben\n"), ["mesh-admin", "jschoubben"]);
|
||||
// The tools' account and the admin's, whose marks were made before ADR 0365 (issue 469).
|
||||
assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }, "mesh-tools"), ["mesh-tools", "mesh-admin"]);
|
||||
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n", "mesh-tools"), ["mesh-tools", "mesh-admin"]);
|
||||
assert.deepEqual(readyAuthors(undefined, "mesh-admin", undefined, "mesh-tools"), ["mesh-tools", "mesh-admin"]);
|
||||
});
|
||||
|
||||
@@ -30,9 +30,6 @@ const state = {
|
||||
// What the tools said on the bus.
|
||||
emitted: [] as { type: string; body: any }[],
|
||||
rules: [] as any[],
|
||||
// Whether the token is a repository administrator's. The tools' account is not (novox/hq ADR 0365): the forge
|
||||
// refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch.
|
||||
admin: false,
|
||||
lastCommit: "2026-01-01T00:00:00Z",
|
||||
// The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's
|
||||
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
|
||||
@@ -62,7 +59,6 @@ beforeEach(() => {
|
||||
state.trunk = TRUNK;
|
||||
state.emitted = [];
|
||||
state.rules = [protectedMain()];
|
||||
state.admin = false;
|
||||
state.head = REVIEWED;
|
||||
state.landed = "";
|
||||
state.merged = false;
|
||||
@@ -91,18 +87,8 @@ const server = createServer(async (req, res) => {
|
||||
if (method !== "GET") state.sent.push({ method, path, body });
|
||||
const repo = "/repos/novox/mesh-catalog";
|
||||
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
|
||||
if (path.startsWith(`${repo}/branch_protections`) && !state.admin) {
|
||||
return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
|
||||
const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null;
|
||||
if (branch !== null) {
|
||||
// As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it.
|
||||
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
|
||||
return json(res, 200, { name: branch, commit: { id: branch === "main" ? state.trunk : "c0ffee" }, protected: !!rule,
|
||||
...(rule ? { effective_branch_protection_name: rule.rule_name ?? rule.branch_name, enable_status_check: !!rule.enable_status_check,
|
||||
status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: false } : {}) });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/branches/main`) return json(res, 200, { name: "main", commit: { id: state.trunk } });
|
||||
if (method === "GET" && path === `${repo}/pulls/7/files`) {
|
||||
return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []);
|
||||
}
|
||||
@@ -213,7 +199,6 @@ test("a protection rule covers a branch as the forge decides it: classes, altern
|
||||
});
|
||||
|
||||
test("gitea_api still writes what an author writes", async () => {
|
||||
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
||||
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments", method: "POST", body: { body: "hi" } });
|
||||
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT" } });
|
||||
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/branch_protections" });
|
||||
@@ -221,7 +206,6 @@ test("gitea_api still writes what an author writes", async () => {
|
||||
});
|
||||
|
||||
test("gitea_branch_protection_set only strengthens", async () => {
|
||||
state.admin = true; // a repository administrator's token: the tools' account is refused the rules by the forge
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args });
|
||||
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/);
|
||||
@@ -412,7 +396,6 @@ test("gitea_merge_pull_request reads back a fast-forward, and says it cannot rea
|
||||
});
|
||||
|
||||
test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => {
|
||||
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
||||
const del = (owner: string, name: string, confirm: unknown) => tool("gitea_delete_repo").run({ owner, name, confirm });
|
||||
await assert.rejects(del("novox", "mesh-catalog", true), /confirm must be the repository's full name/);
|
||||
await assert.rejects(del("novox", "mesh-catalog", "novox/mesh-catalog"), /not a repository of the tools' own user/);
|
||||
@@ -444,7 +427,6 @@ test("gitea_merge_pull_request merges only into a branch whose protection requir
|
||||
});
|
||||
|
||||
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
|
||||
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
|
||||
state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true,
|
||||
status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true });
|
||||
const set = (args: Record<string, unknown>) =>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// The controller's build-source check asks gitea_branch_protection_get for a repository's rules, which the forge
|
||||
// answers to a repository's administrator alone. The tools' account is not one (novox/hq ADR 0365 §2), so the read
|
||||
// goes with the forge's administrator account, and only the read (ADR 0375, issue 772): a protection edit through the
|
||||
// tools still gets the forge's refusal.
|
||||
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import { GiteaAdmin, GiteaClient } from "../dist/client.js";
|
||||
import { ConfiguredToken } from "../dist/token.js";
|
||||
import { getGiteaTools } from "../dist/tools/index.js";
|
||||
|
||||
const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64");
|
||||
const rules = [
|
||||
{ rule_name: "main", enable_push: false, enable_status_check: true, status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true },
|
||||
{ branch_name: "release", enable_push: false },
|
||||
];
|
||||
const seen: string[] = [];
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const path = (req.url ?? "").replace(/^\/api\/v1/, "");
|
||||
const as = req.headers.authorization === adminBasic ? "admin" : "tools";
|
||||
seen.push(`${req.method} ${path} as ${as}`);
|
||||
const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); };
|
||||
if (path.includes("/branch_protections")) {
|
||||
if (as !== "admin") return send(403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
if (req.method === "GET") return send(200, rules);
|
||||
}
|
||||
send(404, { message: "not found" });
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
after(() => new Promise<void>((r) => server.close(() => r())));
|
||||
const { port } = server.address() as { port: number };
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"));
|
||||
const tool = (name: string) => tools.find((t) => t.name === name)!;
|
||||
|
||||
test("gitea_branch_protection_get answers every rule, read with the administrator account the tools' token cannot read with", async () => {
|
||||
const out: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller" });
|
||||
assert.deepEqual(out.rules.map((r: any) => r.rule), ["main", "release"]);
|
||||
});
|
||||
|
||||
test("gitea_branch_protection_get answers the rule for a branch, null when it has none", async () => {
|
||||
const main: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller", branch: "main" });
|
||||
assert.equal(main.branch, "main");
|
||||
assert.equal(main.rule.rule, "main");
|
||||
assert.equal(main.rule.admin_may_override, false);
|
||||
const none: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller", branch: "dev" });
|
||||
assert.equal(none.rule, null);
|
||||
});
|
||||
|
||||
test("gitea_branch_protection_set still gets the forge's refusal, as the tools' account", async () => {
|
||||
await assert.rejects(
|
||||
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-controller", branch: "main", status_checks: "mesh/merge-gate" }),
|
||||
/403/,
|
||||
);
|
||||
assert.ok(!seen.some((s) => /^(PATCH|POST|PUT|DELETE) .*branch_protections.* as admin$/.test(s)), seen.join("\n"));
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -29,12 +29,6 @@ interface Forge {
|
||||
mints: number;
|
||||
lastScopes: string[] | null;
|
||||
tokens: Map<string, string>;
|
||||
/** Tokens beside `tokens` that carry a name already held there: what two mints at once leave behind. */
|
||||
twins: { id: number; name: string; sha1: string }[];
|
||||
/** How each DELETE named its token: "id" or "name". */
|
||||
deletedBy: string[];
|
||||
/** Plant a token by name, beside any other of the same name, as a raced mint would. */
|
||||
plant(name: string): void;
|
||||
scopesOf: Map<string, string[]>;
|
||||
admins: Map<string, string>;
|
||||
pullState: string;
|
||||
@@ -45,14 +39,9 @@ interface Forge {
|
||||
|
||||
function fakeForge(): Promise<Forge> {
|
||||
const forge = {
|
||||
mintedFor: "",
|
||||
mints: 0,
|
||||
lastScopes: null as string[] | null,
|
||||
tokens: new Map<string, string>(), // name -> value
|
||||
twins: [] as { id: number; name: string; sha1: string }[],
|
||||
deletedBy: [] as string[],
|
||||
ids: new Map<string, number>(), // value -> id, as gitea numbers its tokens
|
||||
nextId: 0,
|
||||
scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does
|
||||
admins: new Map([[ADMIN, PASSWORD]]),
|
||||
pullState: "open",
|
||||
@@ -84,14 +73,7 @@ function fakeForge(): Promise<Forge> {
|
||||
const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/);
|
||||
if (tokens) {
|
||||
const user = basic(req);
|
||||
// gitea's reqSelfOrAdmin: the account itself, or the administrator for another (the tools' account, ADR 0365).
|
||||
if (user === null || (user !== decodeURIComponent(tokens[1]) && user !== ADMIN)) return json(res, 401, { message: "auth required" });
|
||||
// This fake keeps one account's tokens: the tools' account's when the module mints for it (ADR 0365), and the
|
||||
// admin's own list is then empty — nothing of it is retired.
|
||||
if (decodeURIComponent(tokens[1]) === ADMIN && forge.mintedFor === "mesh-tools") {
|
||||
return req.method === "GET" ? json(res, 200, []) : json(res, 404, { message: "token not found" });
|
||||
}
|
||||
if (req.method === "POST") forge.mintedFor = decodeURIComponent(tokens[1]);
|
||||
if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" });
|
||||
if (req.method === "POST") {
|
||||
const { name, scopes } = await body(req);
|
||||
if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" });
|
||||
@@ -100,35 +82,12 @@ function fakeForge(): Promise<Forge> {
|
||||
const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`;
|
||||
forge.tokens.set(name, sha1);
|
||||
forge.scopesOf.set(sha1, scopes);
|
||||
forge.ids.set(sha1, ++forge.nextId);
|
||||
return json(res, 201, { id: forge.ids.get(sha1), name, sha1, scopes, token_last_eight: sha1.slice(-8) });
|
||||
}
|
||||
// gitea's list: every token of the account, a page at a time (at most three to a page here, so a
|
||||
// client that reads only the first page misses some), the values never shown.
|
||||
const listed = (): { id: number; name: string; sha1: string }[] => [
|
||||
...[...forge.tokens].map(([name, sha1]) => ({ id: forge.ids.get(sha1) ?? 0, name, sha1 })),
|
||||
...forge.twins,
|
||||
];
|
||||
if (req.method === "GET" && !tokens[2]) {
|
||||
const page = Math.max(1, Number(url.searchParams.get("page") ?? "1"));
|
||||
const limit = Math.min(3, Math.max(1, Number(url.searchParams.get("limit") ?? "3")));
|
||||
const all = listed();
|
||||
res.setHeader("X-Total-Count", String(all.length));
|
||||
return json(res, 200, all.slice((page - 1) * limit, page * limit)
|
||||
.map((t) => ({ id: t.id, name: t.name, sha1: "", token_last_eight: t.sha1.slice(-8), scopes: [] })));
|
||||
return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) });
|
||||
}
|
||||
if (req.method === "DELETE" && tokens[2]) {
|
||||
// gitea: "identified by ID and if not available by name" — and a name held twice is refused.
|
||||
const key = decodeURIComponent(tokens[2]);
|
||||
const byId = /^\d+$/.test(key);
|
||||
const matches = listed().filter((t) => (byId ? String(t.id) === key : t.name === key));
|
||||
if (matches.length === 0) return json(res, 404, { message: "token not found" });
|
||||
if (matches.length > 1) return json(res, 422, { message: `multiple matches for token name '${key}'` });
|
||||
const t = matches[0];
|
||||
forge.deletedBy.push(byId ? "id" : "name");
|
||||
const twin = forge.twins.findIndex((w) => w.id === t.id);
|
||||
if (twin >= 0) forge.twins.splice(twin, 1);
|
||||
else forge.tokens.delete(t.name);
|
||||
const name = decodeURIComponent(tokens[2]);
|
||||
if (!forge.tokens.has(name)) return json(res, 404, { message: "token not found" });
|
||||
forge.tokens.delete(name);
|
||||
return json(res, 204, null);
|
||||
}
|
||||
return json(res, 405, { message: "method not allowed" });
|
||||
@@ -194,17 +153,6 @@ function fakeForge(): Promise<Forge> {
|
||||
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
|
||||
]);
|
||||
}
|
||||
// What setting up the tools' account asks with the admin login (tools-account.ts, ADR 0365): answered plainly.
|
||||
if (basic(req) === ADMIN) {
|
||||
const p = url.pathname;
|
||||
if (req.method === "GET" && p === "/api/v1/user") return json(res, 200, { login: ADMIN, is_admin: true });
|
||||
if (req.method === "GET" && p === "/api/v1/users/mesh-tools") return json(res, 200, { login: "mesh-tools", is_admin: false });
|
||||
if (req.method === "POST" && p === "/api/v1/admin/users") return json(res, 422, { message: "user already exists" });
|
||||
if (req.method === "PATCH" && p === "/api/v1/admin/users/mesh-tools") return json(res, 200, { login: "mesh-tools" });
|
||||
if (req.method === "GET" && p === "/api/v1/orgs/novox/teams") return json(res, 200, [{ id: 7, name: "mesh-tools" }]);
|
||||
if (req.method === "PATCH" && p === "/api/v1/teams/7") return json(res, 200, { id: 7 });
|
||||
if (req.method === "PUT" && p === "/api/v1/teams/7/members/mesh-tools") return json(res, 204, null);
|
||||
}
|
||||
const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/);
|
||||
if (adminUser && req.method === "PATCH") {
|
||||
const h = req.headers.authorization ?? "";
|
||||
@@ -233,14 +181,6 @@ function fakeForge(): Promise<Forge> {
|
||||
get pullTitle() { return forge.pullTitle; },
|
||||
get branchDeleted() { return forge.branchDeleted; },
|
||||
tokens: forge.tokens,
|
||||
twins: forge.twins,
|
||||
deletedBy: forge.deletedBy,
|
||||
plant: (name: string) => {
|
||||
const sha1 = `planted-${++forge.nextId}`;
|
||||
forge.ids.set(sha1, forge.nextId);
|
||||
if (forge.tokens.has(name)) forge.twins.push({ id: forge.nextId, name, sha1 });
|
||||
else forge.tokens.set(name, sha1);
|
||||
},
|
||||
scopesOf: forge.scopesOf,
|
||||
admins: forge.admins,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
@@ -395,56 +335,6 @@ test("concurrent first calls share one mint", async () => {
|
||||
assert.equal(forge.mints, before + 1);
|
||||
});
|
||||
|
||||
test("two tokens hold the name (two mints raced): both are deleted by their id, and one fresh token is minted", async () => {
|
||||
const { env, file, logs } = await delivered(forge);
|
||||
forge.tokens.clear();
|
||||
forge.twins.length = 0;
|
||||
for (const other of ["laptop", "backup", "ci"]) forge.plant(other); // the account's other tokens: kept
|
||||
forge.plant("mesh-tools");
|
||||
forge.plant("mesh-tools"); // gitea refuses to delete either by name from here on
|
||||
forge.deletedBy.length = 0;
|
||||
const before = forge.mints;
|
||||
|
||||
const repos = await minted(env, logs).listRepos();
|
||||
|
||||
assert.equal(repos.length, 1);
|
||||
assert.equal(forge.mints, before + 1);
|
||||
assert.deepEqual(forge.deletedBy, ["id", "id"]);
|
||||
assert.equal(forge.twins.length, 0);
|
||||
assert.deepEqual([...forge.tokens.keys()].sort(), ["backup", "ci", "laptop", "mesh-tools"]);
|
||||
assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n");
|
||||
assert.ok(logs.some((l) => l.includes('holds 2 tokens named "mesh-tools"')), logs.join("\n"));
|
||||
});
|
||||
|
||||
test("two processes on one kept file, both without a token, mint once: the second waits for the first and reuses it", async () => {
|
||||
const { env, logs } = await delivered(forge);
|
||||
forge.tokens.clear();
|
||||
forge.twins.length = 0;
|
||||
const before = forge.mints;
|
||||
// Two sources over one state directory, as the tools bundle and the watcher are two processes.
|
||||
const first = minted(env, logs);
|
||||
const second = minted(env, logs);
|
||||
|
||||
await Promise.all([first.listRepos(), second.listRepos()]);
|
||||
|
||||
assert.equal(forge.mints, before + 1);
|
||||
assert.equal(forge.twins.length, 0);
|
||||
assert.ok(logs.some((l) => l.includes("another process minted it")), logs.join("\n"));
|
||||
});
|
||||
|
||||
test("a lock left by a process that died mid-mint is taken over, not waited on for ever", async () => {
|
||||
const { env, file, logs } = await delivered(forge);
|
||||
forge.tokens.clear();
|
||||
forge.twins.length = 0;
|
||||
await mkdir(join(env.MESH_GITEA_STATE_DIR!), { recursive: true });
|
||||
await writeFile(`${file}.lock`, `2147483646 ${Date.now()} gone\n`); // a pid no process holds
|
||||
const before = forge.mints;
|
||||
|
||||
assert.equal((await minted(env, logs).listRepos()).length, 1);
|
||||
assert.equal(forge.mints, before + 1);
|
||||
await assert.rejects(stat(`${file}.lock`), /ENOENT/);
|
||||
});
|
||||
|
||||
test("the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing", async () => {
|
||||
const { env, file, logs } = await delivered(forge);
|
||||
forge.admins.delete(ADMIN); // the forge's data came from a predecessor; mesh-admin was never created there
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
// The forge's tools act as an account of their own, not the site administrator (novox/hq ADR 0365 §2, issue 469):
|
||||
// the token the tools and the watcher use is minted for `mesh-tools`, a plain account made by the module with the
|
||||
// administrator's login and given write on the mesh's repositories through one team; the administrator's own
|
||||
// token by that name goes; a token kept from before, the administrator's, is minted again for the account.
|
||||
//
|
||||
// The forge is a fake that keeps accounts, their tokens and the organisation's teams apart, and answers as gitea
|
||||
// does: an administrator may mint for another account (reqSelfOrAdmin), anyone else only for itself; a protection
|
||||
// edit needs the repository's administration, which neither a plain account nor its write team holds.
|
||||
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { mkdtemp, readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { MintedToken } from "../dist/token.js";
|
||||
import { GiteaClient } from "../dist/client.js";
|
||||
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } from "../dist/tools-account.js";
|
||||
import { repoCheckFacts } from "../dist/pulls.js";
|
||||
|
||||
const ADMIN = "mesh-admin";
|
||||
const PASSWORD = "the-vault-minted-this";
|
||||
|
||||
interface Account { admin: boolean; password: string; active: boolean }
|
||||
interface Token { id: number; name: string; sha1: string; user: string; scopes: string[] }
|
||||
interface Team { id: number; name: string; units: any; members: Set<string> }
|
||||
|
||||
function fakeForge() {
|
||||
const users = new Map<string, Account>([[ADMIN, { admin: true, password: PASSWORD, active: true }]]);
|
||||
const tokens: Token[] = [];
|
||||
const teams: Team[] = [];
|
||||
const calls: string[] = [];
|
||||
// What the forge does wrong when a test says so: a token mint or delete it fails, a team another process made
|
||||
// between this one's look and its create (gitea answers the create 422).
|
||||
const faults = { mint: false, deleteToken: false, teamRace: false };
|
||||
let next = 0;
|
||||
const json = (res: ServerResponse, status: number, body: unknown): void => {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(body === null ? "" : JSON.stringify(body));
|
||||
};
|
||||
const read = (req: IncomingMessage): Promise<any> =>
|
||||
new Promise((resolve) => {
|
||||
let t = "";
|
||||
req.on("data", (c) => (t += c));
|
||||
req.on("end", () => resolve(t ? JSON.parse(t) : null));
|
||||
});
|
||||
const who = (req: IncomingMessage): string | null => {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (h.startsWith("Basic ")) {
|
||||
const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":");
|
||||
return users.get(u)?.password === p ? u : null;
|
||||
}
|
||||
if (h.startsWith("token ")) return tokens.find((t) => t.sha1 === h.slice(6))?.user ?? null;
|
||||
return null;
|
||||
};
|
||||
const isAdmin = (u: string | null) => u !== null && users.get(u)?.admin === true;
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://fake");
|
||||
const p = url.pathname.replace(/^\/api\/v1/, "");
|
||||
const me = who(req);
|
||||
calls.push(`${req.method} ${p} as ${me}`);
|
||||
if (me === null) return json(res, 401, { message: "unauthorized" });
|
||||
|
||||
let m = p.match(/^\/users\/([^/]+)\/tokens(?:\/(\d+))?$/);
|
||||
if (m) {
|
||||
const owner = decodeURIComponent(m[1]);
|
||||
if (!(req.headers.authorization ?? "").startsWith("Basic ")) return json(res, 401, { message: "basic auth required" });
|
||||
if (owner !== me && !isAdmin(me)) return json(res, 403, { message: "reqSelfOrAdmin" });
|
||||
if (!users.has(owner)) return json(res, 404, { message: "user does not exist" });
|
||||
if (req.method === "POST") {
|
||||
if (faults.mint) return json(res, 500, { message: "the forge is not well" });
|
||||
const { name, scopes } = await read(req);
|
||||
if (tokens.some((t) => t.user === owner && t.name === name)) return json(res, 400, { message: "token name has already been used" });
|
||||
const t = { id: ++next, name, sha1: `sha-${owner}-${next}`, user: owner, scopes };
|
||||
tokens.push(t);
|
||||
return json(res, 201, { id: t.id, name, sha1: t.sha1, scopes });
|
||||
}
|
||||
if (req.method === "GET") {
|
||||
const page = Number(url.searchParams.get("page") ?? "1");
|
||||
const mine = tokens.filter((t) => t.user === owner);
|
||||
return json(res, 200, page === 1 ? mine.map((t) => ({ id: t.id, name: t.name, sha1: "" })) : []);
|
||||
}
|
||||
if (req.method === "DELETE" && m[2]) {
|
||||
if (faults.deleteToken && owner === ADMIN) return json(res, 500, { message: "the forge is not well" });
|
||||
const i = tokens.findIndex((t) => t.user === owner && t.id === Number(m![2]));
|
||||
if (i < 0) return json(res, 404, { message: "not found" });
|
||||
tokens.splice(i, 1);
|
||||
return json(res, 204, null);
|
||||
}
|
||||
}
|
||||
if (req.method === "GET" && p === "/user") return json(res, 200, { login: me, is_admin: isAdmin(me) });
|
||||
m = p.match(/^\/users\/([^/]+)$/);
|
||||
if (m && req.method === "GET") {
|
||||
const u = users.get(decodeURIComponent(m[1]));
|
||||
if (!u) return json(res, 404, { message: "user does not exist" });
|
||||
return json(res, 200, { login: m[1], is_admin: u.admin, active: u.active, prohibit_login: false });
|
||||
}
|
||||
if (p.startsWith("/admin/") && !isAdmin(me)) return json(res, 403, { message: "must be a site administrator" });
|
||||
if (req.method === "POST" && p === "/admin/users") {
|
||||
const b = await read(req);
|
||||
if (users.has(b.username)) return json(res, 422, { message: "user already exists" });
|
||||
if (!b.password || b.password.length < 8) return json(res, 422, { message: "password is too short" });
|
||||
users.set(b.username, { admin: b.admin === true, password: b.password, active: true });
|
||||
return json(res, 201, { login: b.username, is_admin: b.admin === true });
|
||||
}
|
||||
m = p.match(/^\/admin\/users\/([^/]+)$/);
|
||||
if (m && req.method === "PATCH") {
|
||||
const u = users.get(decodeURIComponent(m[1]));
|
||||
if (!u) return json(res, 404, { message: "user does not exist" });
|
||||
const b = await read(req);
|
||||
if (b.admin !== undefined) u.admin = b.admin;
|
||||
if (b.active !== undefined) u.active = b.active;
|
||||
return json(res, 200, { login: m[1], is_admin: u.admin });
|
||||
}
|
||||
if (p === "/orgs/novox/teams") {
|
||||
if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" });
|
||||
if (req.method === "GET") return json(res, 200, teams.map((t) => ({ id: t.id, name: t.name })));
|
||||
const b = await read(req);
|
||||
const t = { id: ++next, name: b.name, units: b, members: new Set<string>() };
|
||||
teams.push(t);
|
||||
if (faults.teamRace) return json(res, 422, { message: "team already exists" });
|
||||
return json(res, 201, { id: t.id, name: t.name });
|
||||
}
|
||||
m = p.match(/^\/teams\/(\d+)(?:\/members\/([^/]+))?$/);
|
||||
if (m) {
|
||||
if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" });
|
||||
const t = teams.find((x) => x.id === Number(m![1]));
|
||||
if (!t) return json(res, 404, { message: "team does not exist" });
|
||||
if (req.method === "PATCH") { t.units = await read(req); return json(res, 200, { id: t.id }); }
|
||||
if (req.method === "PUT" && m[2]) { t.members.add(decodeURIComponent(m[2])); return json(res, 204, null); }
|
||||
}
|
||||
// Repository administration: a site administrator, or a team member whose team administers the repository.
|
||||
m = p.match(/^\/repos\/novox\/([^/]+)\/branches\/(.+)$/);
|
||||
if (m && req.method === "GET") {
|
||||
if (m[2] === "main") return json(res, 200, { name: "main", protected: true, effective_branch_protection_name: "main",
|
||||
enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], user_can_push: false });
|
||||
if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false });
|
||||
return json(res, 404, { message: "branch does not exist" });
|
||||
}
|
||||
// gitea's reqAdmin on the whole group: reading the rules is administration too.
|
||||
m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/.*)?$/);
|
||||
if (m) {
|
||||
const admins = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.permission === "admin");
|
||||
if (!admins) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
return json(res, 201, { rule_name: "main" });
|
||||
}
|
||||
m = p.match(/^\/repos\/novox\/([^/]+)\/statuses\/([0-9a-f]+)$/);
|
||||
if (m && req.method === "POST") {
|
||||
const writes = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.units_map?.["repo.code"] === "write");
|
||||
if (!writes) return json(res, 403, { message: "write on code required" });
|
||||
return json(res, 201, { context: (await read(req)).context, creator: { login: me } });
|
||||
}
|
||||
if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] });
|
||||
return json(res, 404, { message: "no such route in the fake" });
|
||||
});
|
||||
return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[];
|
||||
faults: typeof faults; close(): Promise<void> }>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as { port: number };
|
||||
resolve({ url: `http://127.0.0.1:${port}`, users, tokens, teams, calls, faults,
|
||||
close: () => new Promise((r) => server.close(() => r())) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const forge = await fakeForge();
|
||||
after(() => forge.close());
|
||||
|
||||
async function environment(): Promise<NodeJS.ProcessEnv & { MESH_GITEA_STATE_DIR: string }> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "gitea-tools-account-"));
|
||||
const passwordFile = join(dir, "admin.secret");
|
||||
await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 });
|
||||
return {
|
||||
MESH_GITEA_URL: forge.url,
|
||||
MESH_GITEA_ADMIN_USER: ADMIN,
|
||||
MESH_GITEA_ADMIN_PASSWORD_FILE: passwordFile,
|
||||
MESH_GITEA_STATE_DIR: join(dir, "state"),
|
||||
};
|
||||
}
|
||||
|
||||
/** A source as a fresh process builds it from the manifest's environment (fromEnv shares one per file). */
|
||||
function fresh(env: NodeJS.ProcessEnv): GiteaClient {
|
||||
return GiteaClient.fromEnv(env);
|
||||
}
|
||||
|
||||
test("the tools' token is minted for an account of their own, which is no administrator and writes through its team", async () => {
|
||||
const env = await environment();
|
||||
await fresh(env).listRepos();
|
||||
|
||||
const account = forge.users.get(TOOLS_ACCOUNT);
|
||||
assert.ok(account, "the module made the tools' account");
|
||||
assert.equal(account!.admin, false, "the tools' account is no administrator");
|
||||
const held = forge.tokens.filter((t) => t.name === "mesh-tools");
|
||||
assert.deepEqual(held.map((t) => t.user), [TOOLS_ACCOUNT], "the one token is the tools' account's, none is the administrator's");
|
||||
assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8"), held[0].sha1 + "\n");
|
||||
|
||||
const team = forge.teams.find((t) => t.name === "mesh-tools");
|
||||
assert.ok(team, "the team is made in the mesh's organisation");
|
||||
assert.ok(team!.members.has(TOOLS_ACCOUNT));
|
||||
assert.equal(team!.units.permission, "write");
|
||||
assert.equal(team!.units.includes_all_repositories, true);
|
||||
assert.equal(team!.units.can_create_org_repo, false, "a repository's maker would administer it");
|
||||
assert.deepEqual(team!.units.units_map, TOOLS_TEAM_UNITS.units_map);
|
||||
assert.ok(!Object.values(team!.units.units_map).includes("admin"));
|
||||
});
|
||||
|
||||
test("with the tools' token, the forge writes a status and refuses a protection edit — the forge itself, not a guard", async () => {
|
||||
const env = await environment();
|
||||
const client = fresh(env);
|
||||
await client.setCommitStatus("novox", "hq", "abc123def4567890abc123def4567890abc12345", { state: "success", context: "mesh/merge-gate", description: "ok" } as any);
|
||||
await assert.rejects(
|
||||
client.setBranchProtection("novox", "hq", "main", { statusChecks: ["mesh/merge-gate"] }),
|
||||
/403/,
|
||||
);
|
||||
});
|
||||
|
||||
test("what a base's protection asks is read from the branch, which the tools' account may read, not from the rules' list it may not", async () => {
|
||||
const env = await environment();
|
||||
const client = fresh(env);
|
||||
await assert.rejects(client.branchProtections("novox", "hq"), /403/);
|
||||
const rule = await client.appliedProtection("novox", "hq", "main");
|
||||
assert.deepEqual(rule?.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]);
|
||||
assert.equal(rule?.enable_status_check, true);
|
||||
assert.equal(rule?.rule_name, "main");
|
||||
assert.equal(await client.appliedProtection("novox", "hq", "feat/unguarded"), null);
|
||||
assert.equal(await client.appliedProtection("novox", "hq", "no/such-branch"), null);
|
||||
});
|
||||
|
||||
test("the administrator's token from before goes: a kept admin token is minted again for the tools' account, and the forge drops the old one", async () => {
|
||||
const env = await environment();
|
||||
// What the module kept before ADR 0365: a token of the administrator's own, by the same name, its scopes beside it.
|
||||
const old = { id: 900, name: "mesh-tools", sha1: "sha-admin-old", user: ADMIN, scopes: ["write:repository", "write:issue", "read:user"] };
|
||||
forge.tokens.push(old);
|
||||
await mkdir(env.MESH_GITEA_STATE_DIR, { recursive: true });
|
||||
await writeFile(join(env.MESH_GITEA_STATE_DIR, "token"), old.sha1 + "\n", { mode: 0o600 });
|
||||
await writeFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "read:user,write:issue,write:repository\n", { mode: 0o600 });
|
||||
|
||||
await fresh(env).listRepos();
|
||||
|
||||
assert.ok(!forge.tokens.some((t) => t.user === ADMIN), "the administrator holds no tools' token any more");
|
||||
const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
|
||||
assert.notEqual(kept, old.sha1);
|
||||
assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT);
|
||||
assert.match(await readFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "utf8"), / for mesh-tools\n$/);
|
||||
});
|
||||
|
||||
test("a tools' account someone made an administrator is made a plain account again at the next mint", async () => {
|
||||
const env = await environment();
|
||||
forge.users.get(TOOLS_ACCOUNT)!.admin = true;
|
||||
await fresh(env).listRepos();
|
||||
assert.equal(forge.users.get(TOOLS_ACCOUNT)!.admin, false);
|
||||
});
|
||||
|
||||
test("the tools never act as the administrator: MESH_GITEA_TOOLS_USER naming it is refused", async () => {
|
||||
const env = await environment();
|
||||
assert.throws(() => MintedToken.fromEnv(forge.url, { ...env, MESH_GITEA_TOOLS_USER: ADMIN }), /names the admin account/);
|
||||
});
|
||||
|
||||
test("the manifest hands the tools their account, and the merge-ready authors name it rather than the administrator", async () => {
|
||||
const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8"));
|
||||
const code = manifest.build.artifacts.find((a: any) => a.name === "code");
|
||||
assert.equal(code.env.MESH_GITEA_TOOLS_USER, TOOLS_ACCOUNT);
|
||||
const authors = String(manifest.settings["ready-authors"].default).split(/\s+/);
|
||||
assert.ok(authors.includes(TOOLS_ACCOUNT));
|
||||
// Kept for now (review of issue 469): a pull request the admin account marked ready before the tools acted as
|
||||
// their own keeps counting as ready after the delivery.
|
||||
assert.ok(authors.includes(ADMIN), "marks made by the admin account before ADR 0365 keep counting");
|
||||
// The Go forge bundle reads the token the tools' account holds, never the administrator's password.
|
||||
const forgeBundle = manifest.build.artifacts.find((a: any) => a.name === "forge");
|
||||
assert.ok(forgeBundle.env.MESH_GITEA_TOKEN_FILE);
|
||||
assert.equal(forgeBundle.env.MESH_GITEA_ADMIN_PASSWORD_FILE, undefined);
|
||||
});
|
||||
|
||||
// Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token.
|
||||
|
||||
test("a repository check said as a warning on a head without merge-check.sh reads the base's protection from the branch", async () => {
|
||||
const client = fresh(await environment());
|
||||
const forgeOf = { holdsFile: async () => false, appliedProtection: client.appliedProtection.bind(client) };
|
||||
const c = { owner: "novox", repo: "hq", commit: "abc123def4567890abc123def4567890abc12345", "repo-check": { verdict: "warning" } } as any;
|
||||
// main requires mesh/repo-check: required, so a head that defines no check fails it for a person to see.
|
||||
assert.deepEqual(await repoCheckFacts(forgeOf, c, "main"), { defined: false, required: true });
|
||||
// A base nothing guards: not required, so the head's repository check passes rather than fails.
|
||||
assert.deepEqual(await repoCheckFacts(forgeOf, c, "feat/unguarded"), { defined: false, required: false });
|
||||
});
|
||||
|
||||
test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => {
|
||||
const env = await environment();
|
||||
const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] };
|
||||
forge.tokens.push(old);
|
||||
forge.faults.mint = true;
|
||||
try {
|
||||
await assert.rejects(fresh(env).listRepos(), /500/);
|
||||
} finally {
|
||||
forge.faults.mint = false;
|
||||
}
|
||||
assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's token was deleted before a new one was kept");
|
||||
forge.tokens.splice(forge.tokens.indexOf(old), 1);
|
||||
});
|
||||
|
||||
test("an old token that cannot be deleted after the mint is said, the new token kept, and deleted at the next start", async () => {
|
||||
const env = await environment();
|
||||
const old = { id: 902, name: "mesh-tools", sha1: "sha-admin-stuck", user: ADMIN, scopes: ["read:user"] };
|
||||
forge.tokens.push(old);
|
||||
forge.faults.deleteToken = true;
|
||||
try {
|
||||
await fresh(env).listRepos();
|
||||
} finally {
|
||||
forge.faults.deleteToken = false;
|
||||
}
|
||||
const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
|
||||
assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT, "the account's token is kept and works");
|
||||
assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1));
|
||||
// The next process reuses the kept token and retires the old one it could not before.
|
||||
const next = new MintedToken({ url: forge.url, admin: ADMIN, passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!,
|
||||
file: join(env.MESH_GITEA_STATE_DIR, "token"), account: TOOLS_ACCOUNT, log: () => {} });
|
||||
assert.equal(await next.current(), kept);
|
||||
assert.ok(!forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's old token was deleted at the next start");
|
||||
});
|
||||
|
||||
test("a team another process made between the look and the create is found, not a failure", async () => {
|
||||
forge.teams.splice(0);
|
||||
forge.faults.teamRace = true;
|
||||
try {
|
||||
await fresh(await environment()).listRepos();
|
||||
} finally {
|
||||
forge.faults.teamRace = false;
|
||||
}
|
||||
const team = forge.teams.find((t) => t.name === "mesh-tools");
|
||||
assert.ok(team?.members.has(TOOLS_ACCOUNT));
|
||||
});
|
||||
@@ -173,29 +173,3 @@ test("a verdict that landed out of date keeps its account on the pull request",
|
||||
const bare = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "pass" }, null, words);
|
||||
assert.match(bare, /head dfb1415aaaaa merged into trunk a2c2c35bbbbb, pass/);
|
||||
});
|
||||
|
||||
test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => {
|
||||
// novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR
|
||||
// 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release
|
||||
// branch's move puts its open verdicts out of date, and leaves those into main alone.
|
||||
const release = "feat/57-config-and-provisioning";
|
||||
const heads: Record<string, string> = { main: TRUNK, [release]: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" };
|
||||
const pulls = [{ number: 315, base: release, head: "3".repeat(40) }, { number: 1, base: "main", head: "1".repeat(40) }];
|
||||
const { forge, statuses } = aForge(pulls, heads);
|
||||
const gates = new GateWriter(forge);
|
||||
const trunks: Record<string, string> = {};
|
||||
await weighTrunks(forge, gates, trunks, "novox", "mesh-controller");
|
||||
|
||||
heads[release] = MOVED;
|
||||
assert.equal(await weighTrunks(forge, gates, trunks, "novox", "mesh-controller"), true);
|
||||
const s = statuses.get("3".repeat(40))!;
|
||||
assert.equal(s["mesh/merge-gate"].state, "pending");
|
||||
assert.match(s["mesh/merge-gate"].description!, /judged against trunk eeeeeeeeeeee, the trunk is now d28ca95ccccc/);
|
||||
assert.equal(s["mesh/repo-check"].state, "pending");
|
||||
assert.equal(statuses.get("1".repeat(40))!["mesh/merge-gate"].state, "success", "a pull request into main is left");
|
||||
assert.equal(trunks[`novox/mesh-controller:${release}`], MOVED);
|
||||
|
||||
// The merge tool's rule reads the same: a verdict judged against the release branch's old head is not current.
|
||||
assert.equal(verdictAtTrunk({ "mesh/merge-gate": { state: "success",
|
||||
description: "head x merged into trunk eeeeeeeeeeee — pass" } }, MOVED).current, false);
|
||||
});
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createHmac } from "node:crypto";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
// **The forge says every event on the bus** (novox/hq issue 729): a signed delivery is published once and acted on,
|
||||
// an unsigned or unverified one is refused, and an event and the poll seeing the same head announce it once.
|
||||
|
||||
const secret = "s".repeat(64);
|
||||
const sign = (body: string, key = secret) => createHmac("sha256", key).update(body).digest("hex");
|
||||
const opened = JSON.stringify({ action: "opened", number: 7, pull_request: { number: 7, title: "t", user: { login: "jo" },
|
||||
head: { ref: "feat/x", sha: "a".repeat(40) }, base: { ref: "main" }, merged: false },
|
||||
repository: { name: "hq", full_name: "novox/hq", owner: { login: "novox" } }, sender: { login: "jo" } });
|
||||
|
||||
async function serve(published: { name: string }[], acted: { name: string }[]) {
|
||||
const { receiver, HOOK_PATH } = await import("../webhook.ts");
|
||||
const server = receiver({ secret, port: 0, log: () => {},
|
||||
publish: async (e) => { published.push(e); }, act: async (e) => { acted.push(e); } });
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}${HOOK_PATH}`;
|
||||
return { server, url };
|
||||
}
|
||||
|
||||
test("a signed event is published on the bus and acted on once", async () => {
|
||||
const published: { name: string }[] = [], acted: { name: string }[] = [];
|
||||
const { server, url } = await serve(published, acted);
|
||||
try {
|
||||
const headers = { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened), "X-Gitea-Delivery": "d-1" };
|
||||
const res = await fetch(url, { method: "POST", body: opened, headers });
|
||||
assert.equal(res.status, 200);
|
||||
// The same delivery sent again is answered and published nowhere.
|
||||
const again = await fetch(url, { method: "POST", body: opened, headers });
|
||||
assert.equal(again.status, 200);
|
||||
await server.idle();
|
||||
assert.deepEqual(published.map((e) => e.name), ["forge.pull-request.opened"]);
|
||||
assert.deepEqual(acted.map((e) => e.name), ["forge.pull-request.opened"]);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("an unsigned event, or one whose signature does not verify, is refused and published nowhere", async () => {
|
||||
const published: { name: string }[] = [], acted: { name: string }[] = [];
|
||||
const { server, url } = await serve(published, acted);
|
||||
try {
|
||||
const unsigned = await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request" } });
|
||||
assert.equal(unsigned.status, 401);
|
||||
assert.match(await unsigned.text(), /unsigned/);
|
||||
const forged = await fetch(url, { method: "POST", body: opened,
|
||||
headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened, "another secret") } });
|
||||
assert.equal(forged.status, 401);
|
||||
assert.match(await forged.text(), /does not verify/);
|
||||
const big = "x".repeat(5 * 1024 * 1024 + 1);
|
||||
const large = await fetch(url, { method: "POST", body: big,
|
||||
headers: { "X-Gitea-Event": "push", "X-Gitea-Signature": sign(big) } });
|
||||
assert.equal(large.status, 413);
|
||||
await server.idle();
|
||||
assert.deepEqual(published, []);
|
||||
assert.deepEqual(acted, []);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("an event and the poll seeing the same head announce it once", async () => {
|
||||
const { claimHead } = await import("../webhook.ts");
|
||||
const heads: Record<string, string> = {};
|
||||
const sha = "a".repeat(40);
|
||||
assert.equal(claimHead(heads, "novox/hq#7", sha), true, "the event claims the head and announces it");
|
||||
assert.equal(claimHead(heads, "novox/hq#7", sha), false, "the poll finds it taken");
|
||||
assert.equal(claimHead(heads, "novox/hq#7", "b".repeat(40)), true, "a new head is announced again");
|
||||
});
|
||||
|
||||
test("each forge event is named in the module's stream and trimmed", async () => {
|
||||
const { eventOf } = await import("../webhook.ts");
|
||||
const merged = eventOf("pull_request", { action: "closed", pull_request: { number: 3, merged: true, merge_commit_sha: "c".repeat(40),
|
||||
head: { sha: "a".repeat(40) }, base: { ref: "main" } }, repository: { name: "hq", owner: { login: "novox" } } });
|
||||
assert.equal(merged.name, "forge.pull-request.merged");
|
||||
assert.equal(merged.body.merge_commit_sha, "c".repeat(40));
|
||||
assert.equal(eventOf("pull_request", { action: "label_updated", pull_request: { number: 3 }, label: { name: "mesh/ready" } }).name,
|
||||
"forge.pull-request.labeled");
|
||||
const comment = eventOf("issue_comment", { action: "created", is_pull: true, issue: { number: 3 },
|
||||
comment: { id: 9, body: "ready at abc", user: { login: "jo" } } });
|
||||
assert.equal(comment.name, "forge.pull-request.commented");
|
||||
assert.equal(comment.body.comment, "ready at abc");
|
||||
assert.equal(eventOf("push", { ref: "refs/heads/main", after: "d".repeat(40) }).name, "forge.push");
|
||||
assert.equal(eventOf("pull_request_review_approved", {}).name, "forge.other");
|
||||
});
|
||||
|
||||
// A forge's admin hooks as 1.27.3 keeps them: POST /admin/hooks makes a system webhook only when config.is_system_webhook
|
||||
// is "true", and a default webhook otherwise; the listing names one kind at a time (type=system, the default, or default).
|
||||
// Repositories hold their own webhooks (a default webhook's copies), listed a page at a time.
|
||||
function fakeForge(start: { id: number; url: string; system: boolean }[] = [],
|
||||
repoHooks: Record<string, { id: number; url: string }[]> = {}, repoCount = 0) {
|
||||
const hooks = start.map((h) => ({ ...h, events: ["push"], active: true }));
|
||||
const repos = [...Object.keys(repoHooks)];
|
||||
for (let i = 0; repos.length < repoCount; i++) repos.push(`novox/r${i}`);
|
||||
let next = 100;
|
||||
const calls: string[] = [];
|
||||
const made: any[] = [];
|
||||
const page = (items: any[], q: URLSearchParams) => {
|
||||
const p = Number(q.get("page") ?? 1), l = Number(q.get("limit") ?? 50);
|
||||
return items.slice((p - 1) * l, p * l);
|
||||
};
|
||||
const admin = { call: async (method: string, path: string, body?: any) => {
|
||||
calls.push(`${method} ${path}`);
|
||||
const u = new URL(path, "http://f");
|
||||
const repoHook = u.pathname.match(/^\/repos\/([^/]+\/[^/]+)\/hooks(?:\/(\d+))?$/);
|
||||
if (method === "GET" && u.pathname === "/repos/search") {
|
||||
return { status: 200, body: { ok: true, data: page(repos.map((full_name) => ({ full_name })), u.searchParams) } };
|
||||
}
|
||||
if (repoHook) {
|
||||
const list = repoHooks[repoHook[1]] ?? [];
|
||||
if (method === "GET") return { status: 200, body: page(list.map((h) => ({ id: h.id, config: { url: h.url } })), u.searchParams) };
|
||||
const i = list.findIndex((h) => h.id === Number(repoHook[2]));
|
||||
if (i < 0) return { status: 404, body: {} };
|
||||
list.splice(i, 1);
|
||||
return { status: 204, body: null };
|
||||
}
|
||||
if (method === "GET") {
|
||||
const type = u.searchParams.get("type") ?? "system";
|
||||
const listed = hooks.filter((h) => type === "all" || h.system === (type === "system"));
|
||||
return { status: 200, body: listed.map((h) => ({ id: h.id, active: h.active, events: h.events,
|
||||
config: { url: h.url, content_type: "json" } })) };
|
||||
}
|
||||
if (method === "POST") {
|
||||
made.push(body);
|
||||
hooks.push({ id: next++, url: body.config.url, system: body.config.is_system_webhook === "true", events: body.events,
|
||||
active: body.active });
|
||||
return { status: 201, body: {} };
|
||||
}
|
||||
if (method === "DELETE") {
|
||||
const id = Number(path.split("/").pop());
|
||||
const i = hooks.findIndex((h) => h.id === id);
|
||||
if (i < 0) return { status: 404, body: {} };
|
||||
hooks.splice(i, 1);
|
||||
return { status: 204, body: null };
|
||||
}
|
||||
return { status: 405, body: {} };
|
||||
} };
|
||||
return { admin, hooks, calls, made, repoHooks };
|
||||
}
|
||||
|
||||
test("the webhook is made a system webhook, which the forge sends every repository's events to", async () => {
|
||||
const { ensureSystemHook } = await import("../webhook.ts");
|
||||
const forge = fakeForge();
|
||||
const url = "http://h:3047/forge-event";
|
||||
const hook = await ensureSystemHook(forge.admin, url, secret);
|
||||
assert.equal(forge.made.length, 1);
|
||||
assert.equal(forge.made[0].config.is_system_webhook, "true", "without it the forge makes a default webhook, which fires for no repository");
|
||||
assert.equal(forge.made[0].config.secret, secret);
|
||||
assert.ok(forge.made[0].events.includes("pull_request") && forge.made[0].events.includes("push"));
|
||||
assert.deepEqual(forge.hooks.map((h) => h.system), [true]);
|
||||
assert.equal(hook.kind, "system");
|
||||
assert.equal(hook.did, "created");
|
||||
assert.equal(hook.url, url);
|
||||
assert.ok(hook.events.includes("pull_request"));
|
||||
});
|
||||
|
||||
test("default webhooks left at the receiver by an earlier start are replaced by one system webhook", async () => {
|
||||
const { ensureSystemHook } = await import("../webhook.ts");
|
||||
const url = "http://h:3047/forge-event";
|
||||
// What the start at 23:05 left: a default webhook, invisible to the system listing, made again by every start.
|
||||
const forge = fakeForge([{ id: 4, url, system: false }, { id: 5, url, system: false }, { id: 6, url: "http://other/x", system: true }]);
|
||||
const hook = await ensureSystemHook(forge.admin, url, secret);
|
||||
assert.deepEqual(hook.removed, { system: 0, default: 2, repository: 0, repositories: [] });
|
||||
assert.equal(hook.did, "replaced");
|
||||
assert.deepEqual(forge.hooks.map((h) => [h.url, h.system]), [["http://other/x", true], [url, true]], "another url is kept");
|
||||
// A second start replaces the system webhook, so the secret kept now is the one the forge signs with (its edit never
|
||||
// changes a secret).
|
||||
const again = await ensureSystemHook(forge.admin, url, secret);
|
||||
assert.deepEqual(again.removed, { system: 1, default: 0, repository: 0, repositories: [] });
|
||||
assert.equal(forge.hooks.filter((h) => h.url === url).length, 1);
|
||||
assert.ok(forge.calls.includes("DELETE /admin/hooks/4") && forge.calls.includes("DELETE /admin/hooks/5"));
|
||||
// The new one is made before any old one is deleted, so no event falls in a gap.
|
||||
const post = forge.calls.indexOf("POST /admin/hooks");
|
||||
assert.ok(post >= 0 && forge.calls.findIndex((c) => c.startsWith("DELETE")) > post);
|
||||
});
|
||||
|
||||
test("a repository's copy of a default webhook at the receiver is deleted, on every page, and said", async () => {
|
||||
const { ensureSystemHook } = await import("../webhook.ts");
|
||||
const url = "http://h:3047/forge-event";
|
||||
// A repository made after the default webhook got its copy, which would send its events a second time; 120
|
||||
// repositories, so the copies sit beyond the first page.
|
||||
const repoHooks: Record<string, { id: number; url: string }[]> = {};
|
||||
for (let i = 0; i < 118; i++) repoHooks[`novox/r${i}`] = [];
|
||||
repoHooks["novox/late"] = [{ id: 31, url }, { id: 32, url: "http://ci/hook" }];
|
||||
repoHooks["novox/later"] = [{ id: 33, url }];
|
||||
const forge = fakeForge([{ id: 4, url, system: false }], repoHooks);
|
||||
const hook = await ensureSystemHook(forge.admin, url, secret);
|
||||
assert.deepEqual(hook.removed, { system: 0, default: 1, repository: 2, repositories: ["novox/late", "novox/later"] });
|
||||
assert.deepEqual(forge.repoHooks["novox/late"].map((h) => h.id), [32], "a repository's other webhook is kept");
|
||||
assert.deepEqual(forge.repoHooks["novox/later"], []);
|
||||
assert.ok(forge.calls.some((c) => /\/repos\/search\?.*page=3/.test(c)), "every page of repositories is read");
|
||||
});
|
||||
|
||||
test("a forge that makes no system webhook is said, never taken as heard", async () => {
|
||||
const { ensureSystemHook } = await import("../webhook.ts");
|
||||
const forge = fakeForge();
|
||||
const admin = { call: async (method: string, path: string, body?: any) => {
|
||||
// A forge that ignores the flag: what 23:05 met, had the module read back.
|
||||
if (method === "POST") body = { ...body, config: { ...body.config, is_system_webhook: undefined } };
|
||||
return forge.admin.call(method, path, body);
|
||||
} };
|
||||
await assert.rejects(ensureSystemHook(admin, "http://h:3047/forge-event", secret), /names 0 new system webhooks/);
|
||||
});
|
||||
|
||||
test("the receiver tells what it verified and what it refused", async () => {
|
||||
const { receiver, HOOK_PATH } = await import("../webhook.ts");
|
||||
const heard: string[] = [], refused: string[] = [];
|
||||
const server = receiver({ secret, port: 0, log: () => {}, publish: async () => {},
|
||||
heard: (kind, delivery) => heard.push(`${kind} ${delivery}`), refused: (reason) => refused.push(reason) });
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}${HOOK_PATH}`;
|
||||
try {
|
||||
await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened), "X-Gitea-Delivery": "d-9" } });
|
||||
await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened, "x") } });
|
||||
await server.idle();
|
||||
assert.deepEqual(heard, ["pull_request d-9"]);
|
||||
assert.deepEqual(refused, ["its signature does not verify"]);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("a forge that never delivers is said loudly once, and unhealthy, when the poll finds what no event said", async () => {
|
||||
const { Pace } = await import("../webhook.ts");
|
||||
let now = 1_000_000;
|
||||
const said: string[] = [];
|
||||
const pace = new Pace(() => now, (l) => said.push(l));
|
||||
const hook = { kind: "system" as const, id: 7, url: "http://h:3047/forge-event", events: ["push"], active: true,
|
||||
did: "created" as const, removed: { system: 0, default: 0, repository: 0, repositories: [] } };
|
||||
pace.listening("172.17.0.1", 3047, hook.url, hook);
|
||||
now += 5 * 60_000;
|
||||
pace.miss("novox/hq#7 at aaaaaaaa");
|
||||
assert.equal(said.length, 0, "five minutes after the start is not yet silence");
|
||||
assert.equal((pace.report() as any).healthy, true);
|
||||
pace.refused("it is unsigned", "172.17.0.3");
|
||||
assert.equal((pace.report() as any).healthy, false, "refused, and none verified");
|
||||
now += 6 * 60_000;
|
||||
pace.miss("the merge of novox/hq#7");
|
||||
pace.miss("novox/hq#8 at bbbbbbbb");
|
||||
assert.equal(said.filter((l) => /NOT HEARD: no delivery ever verified/.test(l)).length, 1, "said once");
|
||||
let r = pace.report() as any;
|
||||
assert.equal(r.healthy, false);
|
||||
assert.equal(r.state, "unhealthy");
|
||||
assert.match(r.why, /never delivered/);
|
||||
assert.equal(r.polls.found_unsaid, 3);
|
||||
assert.equal(r.refused.count, 1);
|
||||
assert.equal(r.refused.last.reason, "it is unsigned");
|
||||
assert.equal(r.hook.kind, "system");
|
||||
assert.equal(r.receiver.port, 3047);
|
||||
// The first delivery clears it.
|
||||
now += 1000;
|
||||
pace.heard("push", "d-1");
|
||||
r = pace.report() as any;
|
||||
assert.equal(r.healthy, true);
|
||||
assert.equal(r.verified.count, 1);
|
||||
assert.equal(r.verified.last.event, "push");
|
||||
assert.equal(r.polls.every_seconds, 300);
|
||||
assert.match(said.join("\n"), /heard again/);
|
||||
});
|
||||
|
||||
test("a forge whose events stop after one delivery is said loudly once per silent spell, and refusals are unhealthy", async () => {
|
||||
const { Pace } = await import("../webhook.ts");
|
||||
let now = 1_000_000;
|
||||
const said: string[] = [];
|
||||
const pace = new Pace(() => now, (l) => said.push(l));
|
||||
const hook = { kind: "system" as const, id: 7, url: "http://h:3047/forge-event", events: ["push"], active: true,
|
||||
did: "created" as const, removed: { system: 0, default: 0, repository: 0, repositories: [] } };
|
||||
pace.listening("172.17.0.1", 3047, hook.url, hook);
|
||||
now += 60_000;
|
||||
pace.heard("push", "d-1");
|
||||
// The hook is deleted by hand: nothing more comes, and the poll finds what no event said.
|
||||
now += 11 * 60_000;
|
||||
pace.miss("novox/hq#7 at aaaaaaaa");
|
||||
assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 0, "one find alone may be the poll beating an event");
|
||||
pace.miss("the merge of novox/hq#7");
|
||||
pace.miss("novox/hq#8 at bbbbbbbb");
|
||||
assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 1, "said once");
|
||||
assert.match(said.find((l) => /NOT HEARD/.test(l)) ?? "", /since the last at/);
|
||||
assert.equal((pace.report() as any).healthy, false);
|
||||
// A delivery ends the spell; the next spell is said again.
|
||||
now += 1000;
|
||||
pace.heard("push", "d-2");
|
||||
assert.equal((pace.report() as any).healthy, true);
|
||||
now += 11 * 60_000;
|
||||
pace.miss("novox/hq#9 at cccccccc");
|
||||
pace.miss("novox/hq#10 at dddddddd");
|
||||
assert.equal(said.filter((l) => /NOT HEARD/.test(l)).length, 2, "a second silent spell is said again");
|
||||
// Every delivery refused after a verified one (a secret that no longer matches) is unhealthy at once.
|
||||
now += 1000;
|
||||
pace.heard("push", "d-3");
|
||||
now += 1000;
|
||||
pace.refused("its signature does not verify", "172.17.0.3");
|
||||
pace.refused("its signature does not verify", "172.17.0.3");
|
||||
const r = pace.report() as any;
|
||||
assert.equal(r.healthy, false);
|
||||
assert.equal(r.refused.since_verified, 2);
|
||||
assert.match(r.why, /2 deliveries refused since the last verified one.*does not verify/);
|
||||
now += 1000;
|
||||
pace.heard("push", "d-4");
|
||||
assert.equal((pace.report() as any).healthy, true, "a verified delivery clears it");
|
||||
});
|
||||
|
||||
test("without a receiver by configuration the polls alone watch, healthy; a receiver that failed is unhealthy", async () => {
|
||||
const { Pace } = await import("../webhook.ts");
|
||||
let now = 1_000_000;
|
||||
const said: string[] = [];
|
||||
const quiet = new Pace(() => now, (l) => said.push(l));
|
||||
quiet.unheard("MESH_GITEA_HOOK_URL unset", false);
|
||||
now += 20 * 60_000;
|
||||
quiet.miss("novox/hq#7 at aaaaaaaa");
|
||||
assert.equal(said.length, 0, "no deliveries were expected");
|
||||
assert.equal((quiet.report() as any).healthy, true);
|
||||
const failed = new Pace(() => now, () => {});
|
||||
failed.unheard("the forge's system webhook could not be made: 403");
|
||||
const r = failed.report() as any;
|
||||
assert.equal(r.healthy, false);
|
||||
assert.match(r.why, /403/);
|
||||
});
|
||||
|
||||
test("the polls look every 30 s until events are heard, every five minutes while they are, and 30 s again on a miss", async () => {
|
||||
const { Pace } = await import("../webhook.ts");
|
||||
let now = 1_000_000;
|
||||
const said: string[] = [];
|
||||
const pace = new Pace(() => now, (l) => said.push(l));
|
||||
assert.equal(pace.every(), 30_000, "no delivery yet");
|
||||
pace.heard();
|
||||
assert.equal(pace.every(), 300_000, "a delivery heard");
|
||||
now += 11 * 60_000;
|
||||
assert.equal(pace.every(), 30_000, "ten minutes without a delivery");
|
||||
pace.heard();
|
||||
now += 1000;
|
||||
pace.miss("novox/hq#7 at aaaaaaaa");
|
||||
assert.equal(pace.every(), 30_000, "the poll found what no event said");
|
||||
assert.match(said.join("\n"), /MISSED novox\/hq#7/);
|
||||
now += 1000;
|
||||
pace.heard();
|
||||
assert.equal(pace.every(), 300_000, "the next delivery");
|
||||
});
|
||||
+30
-216
@@ -12,20 +12,7 @@
|
||||
// hands a token's value out exactly once, so a token not kept is a token lost;
|
||||
// - re-minted when the forge rejects it (401) or the kept file is gone. The one case that is not
|
||||
// a fault: the forge's data was restored from a predecessor and the token the file names never
|
||||
// existed there;
|
||||
// - minted by one process at a time. The runtime starts each of the bundle's entrypoints as a process
|
||||
// of its own (novox/hq ADR 0193): the watcher (index.js) and the tools (tools/index.js) are two
|
||||
// processes over one kept file. Two that minted at once left the forge two tokens by one name, which
|
||||
// gitea then refuses to delete by name — for ever, and every forge tool with it (the 2026-10-14
|
||||
// outage). So a mint holds a lock file beside the kept token, and reads the kept file again once it
|
||||
// holds it: what another process minted meanwhile is reused, not minted over;
|
||||
// - replaced by id: an old token by this name is found in the account's list and deleted by its id,
|
||||
// every one of them, so a name held twice is cleared rather than refused.
|
||||
//
|
||||
// - minted for the tools' account, not the administrator (novox/hq ADR 0365 §2, issue 469): the administrator's
|
||||
// login mints it for `mesh-tools` (tools-account.ts), after making that account and its team, and deletes any
|
||||
// token by this name the administrator itself still holds — the token the tools used before. A token kept
|
||||
// for another account than the one asked (the scopes line says whose) is minted again at the next start.
|
||||
// existed there.
|
||||
//
|
||||
// An explicitly configured token still wins, and is never minted over: if it is rejected, that is
|
||||
// reported, not repaired — somebody chose it.
|
||||
@@ -33,10 +20,8 @@
|
||||
// The token is never logged. Lines say that one was minted, reused or renewed, and where it is
|
||||
// kept; never what it is.
|
||||
|
||||
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { setUpToolsAccount, TOOLS_ACCOUNT, type AdminCall } from "./tools-account.js";
|
||||
|
||||
/** The name the token carries in the forge's own list — one per mesh runtime, found by name. */
|
||||
export const TOKEN_NAME = "mesh-tools";
|
||||
@@ -54,7 +39,7 @@ export const TOKEN_NAME = "mesh-tools";
|
||||
* `gitea_api` (novox/hq 229); whoever may call a tool includes agents, and the forge's administration is the
|
||||
* operator's. The provisioner settles users with the admin account's own login (GiteaAdmin), never this token.
|
||||
* A token kept with a different list is minted again with this one at the next start (the scopes are kept
|
||||
* beside it), and the old one is deleted from the forge by its id — so the scope a kept token had does not outlive this.
|
||||
* beside it), and the forge drops the old one by name — so the scope a kept token had does not outlive this.
|
||||
*
|
||||
* A token kept from before a scope was added lacks it: the forge answers such a call with
|
||||
* `403 token does not have at least one of required scope(s)`, and the client treats that like a
|
||||
@@ -110,14 +95,6 @@ export interface MintedTokenOptions {
|
||||
readonly file: string;
|
||||
readonly name?: string;
|
||||
readonly scopes?: readonly string[];
|
||||
/**
|
||||
* The account the token is minted for: the tools' own (novox/hq ADR 0365 §2). The admin login mints it, after
|
||||
* `setUp` made the account, and the admin's own tokens by this name are deleted. Left out, the token is the
|
||||
* admin's own, as it was before.
|
||||
*/
|
||||
readonly account?: string;
|
||||
/** Run with the admin's password before each mint for `account`: makes the account and its team (tools-account.ts). */
|
||||
readonly setUp?: (password: string) => Promise<void>;
|
||||
readonly log?: (line: string) => void;
|
||||
readonly fetch?: typeof fetch;
|
||||
}
|
||||
@@ -129,14 +106,12 @@ export class MintedToken implements TokenSource {
|
||||
private inflight: Promise<string> | null = null;
|
||||
private readonly name: string;
|
||||
private readonly scopes: readonly string[];
|
||||
private readonly account: string;
|
||||
private readonly log: (line: string) => void;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(private readonly opts: MintedTokenOptions) {
|
||||
this.name = opts.name ?? TOKEN_NAME;
|
||||
this.scopes = opts.scopes ?? TOKEN_SCOPES;
|
||||
this.account = opts.account ?? opts.admin;
|
||||
this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`));
|
||||
this.fetchImpl = opts.fetch ?? fetch;
|
||||
}
|
||||
@@ -149,9 +124,9 @@ export class MintedToken implements TokenSource {
|
||||
*/
|
||||
static fromEnv(url: string, env: NodeJS.ProcessEnv = process.env): MintedToken {
|
||||
const opts = MintedToken.optionsFromEnv(url, env);
|
||||
// One source per kept file in a process: two sources in one process would each renew on a 401.
|
||||
// This covers one process only. The watcher and the tools are two processes (the runtime starts
|
||||
// each entrypoint as its own), and those are kept apart by the mint's lock file (mint, below).
|
||||
// One source per kept file in a process. The watcher and the tools entrypoint both build a
|
||||
// client in the same runtime; two sources over one file would each renew on a 401 and drop the
|
||||
// other's token by name, forever. Shared, a renewal is one renewal.
|
||||
const shared = MintedToken.shared.get(opts.file);
|
||||
if (shared) return shared;
|
||||
const source = new MintedToken(opts);
|
||||
@@ -173,17 +148,7 @@ export class MintedToken implements TokenSource {
|
||||
if (missing.length) {
|
||||
throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`);
|
||||
}
|
||||
// The tools' own account, never the administrator (novox/hq ADR 0365 §2): MESH_GITEA_TOOLS_USER names it.
|
||||
const account = env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT;
|
||||
if (account === admin) throw new Error(`MESH_GITEA_TOOLS_USER names the admin account "${admin}"; the tools act as an account of their own (novox/hq ADR 0365)`);
|
||||
return {
|
||||
url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token"), account,
|
||||
// GiteaAdmin is loaded when first used: client.ts imports this file.
|
||||
setUp: async (password) => {
|
||||
const { GiteaAdmin } = await import("./client.js");
|
||||
await setUpToolsAccount(new GiteaAdmin(url, admin!, password), account, () => randomBytes(24).toString("base64url"));
|
||||
},
|
||||
};
|
||||
return { url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token") };
|
||||
}
|
||||
|
||||
async current(): Promise<string> {
|
||||
@@ -194,13 +159,11 @@ export class MintedToken implements TokenSource {
|
||||
if (kept !== null && this.keptScopes() === this.scopesLine()) {
|
||||
this.held = kept;
|
||||
this.log(`reusing the token kept at ${this.opts.file}`);
|
||||
// A retirement of the admin's old token that failed after the mint is tried again, once a process.
|
||||
if (existsSync(this.retireFile)) await this.locked(() => this.retireAdmin()).catch((err) => this.log(`${err}`));
|
||||
return kept;
|
||||
}
|
||||
if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them", kept);
|
||||
if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them");
|
||||
}
|
||||
return this.mint("no token kept — minting one", null);
|
||||
return this.mint("no token kept — minting one");
|
||||
}
|
||||
|
||||
async renew(rejected: string): Promise<string> {
|
||||
@@ -214,7 +177,7 @@ export class MintedToken implements TokenSource {
|
||||
return kept;
|
||||
}
|
||||
this.held = null;
|
||||
return this.mint("the forge rejected the kept token — minting a fresh one", rejected);
|
||||
return this.mint("the forge rejected the kept token — minting a fresh one");
|
||||
}
|
||||
|
||||
/** What the forge's scoped tokens say when a kept token predates a scope the tools now need. */
|
||||
@@ -222,95 +185,17 @@ export class MintedToken implements TokenSource {
|
||||
return status === 403 && /required scope/i.test(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* One mint at a time: concurrent first calls in this process share it, and a mint in another process
|
||||
* on this machine is waited for (the lock file). Once the lock is held, the kept file is read again:
|
||||
* a token there that is not `stale` (the one rejected, or kept with other scopes) and carries the
|
||||
* scopes asked for was minted by the process that held the lock before — it is reused.
|
||||
*/
|
||||
private mint(why: string, stale: string | null): Promise<string> {
|
||||
/** One mint at a time: concurrent first calls share it, rather than each minting its own. */
|
||||
private mint(why: string): Promise<string> {
|
||||
if (this.inflight === null) {
|
||||
this.log(why);
|
||||
this.inflight = this.locked(async () => {
|
||||
const kept = this.read();
|
||||
if (kept !== null && kept !== stale && this.keptScopes() === this.scopesLine()) {
|
||||
this.held = kept;
|
||||
this.log(`another process minted it meanwhile — reusing the token kept at ${this.opts.file}`);
|
||||
return kept;
|
||||
}
|
||||
return this.doMint();
|
||||
}).finally(() => {
|
||||
this.inflight = this.doMint().finally(() => {
|
||||
this.inflight = null;
|
||||
});
|
||||
}
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
/** How long a mint may hold the lock before another process takes it as left behind, and how long one waits. */
|
||||
static readonly LOCK_STALE_MS = 120_000;
|
||||
static readonly LOCK_WAIT_MS = 150_000;
|
||||
|
||||
/**
|
||||
* Run `work` holding the lock file beside the kept token: created exclusively, holding this process's
|
||||
* pid, the time and a nonce; removed after, only when it is still ours. A lock whose process is gone,
|
||||
* or older than LOCK_STALE_MS, was left by a process that died mid-mint and is taken over — never
|
||||
* waited on for ever, which would be the same lock-out by another road.
|
||||
*/
|
||||
private async locked<T>(work: () => Promise<T>): Promise<T> {
|
||||
const lock = `${this.opts.file}.lock`;
|
||||
mkdirSync(dirname(this.opts.file), { recursive: true, mode: 0o700 });
|
||||
const mine = `${process.pid} ${Date.now()} ${randomBytes(8).toString("hex")}\n`;
|
||||
const deadline = Date.now() + MintedToken.LOCK_WAIT_MS;
|
||||
for (;;) {
|
||||
try {
|
||||
const fd = openSync(lock, "wx", 0o600);
|
||||
try { writeSync(fd, mine); } finally { closeSync(fd); }
|
||||
break;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw new Error(`cannot take the mint lock at ${lock}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (this.leftBehind(lock)) {
|
||||
this.log(`the mint lock at ${lock} was left by a process that is gone — taking it over`);
|
||||
try { unlinkSync(lock); } catch { /* another waiter took it over first */ }
|
||||
continue;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`another process has held the mint lock at ${lock} for over ${MintedToken.LOCK_WAIT_MS / 1000}s — not minting beside it`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50 + Math.floor(Math.random() * 50)));
|
||||
}
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
try {
|
||||
if (readFileSync(lock, "utf8") === mine) unlinkSync(lock);
|
||||
} catch { /* already gone */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the lock's holder is gone: its pid answers no signal, or it is older than LOCK_STALE_MS. */
|
||||
private leftBehind(lock: string): boolean {
|
||||
let text: string;
|
||||
let age: number;
|
||||
try {
|
||||
text = readFileSync(lock, "utf8");
|
||||
age = Date.now() - statSync(lock).mtimeMs;
|
||||
} catch {
|
||||
return false; // released meanwhile: the next attempt takes it
|
||||
}
|
||||
if (age > MintedToken.LOCK_STALE_MS) return true;
|
||||
const pid = Number(text.split(" ")[0]);
|
||||
if (!Number.isInteger(pid) || pid <= 0) return age > 1_000; // half-written, or not ours: give it a moment
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return false;
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
private read(): string | null {
|
||||
try {
|
||||
const token = readFileSync(this.opts.file, "utf8").replace(/\n$/, "");
|
||||
@@ -321,10 +206,9 @@ export class MintedToken implements TokenSource {
|
||||
}
|
||||
}
|
||||
|
||||
/** The scopes as kept beside the token: sorted, one line, and whose it is when it is not the admin's. */
|
||||
/** The scopes as kept beside the token: sorted, one line. */
|
||||
private scopesLine(): string {
|
||||
const scopes = [...this.scopes].sort().join(",");
|
||||
return this.account === this.opts.admin ? scopes : `${scopes} for ${this.account}`;
|
||||
return [...this.scopes].sort().join(",");
|
||||
}
|
||||
|
||||
/** The scopes the kept token was minted with, "" when not kept (a token kept before they were). */
|
||||
@@ -347,20 +231,17 @@ export class MintedToken implements TokenSource {
|
||||
renameSync(`${this.opts.file}.scopes.tmp`, `${this.opts.file}.scopes`);
|
||||
}
|
||||
|
||||
/** One call to the forge's API with the admin login, the password read now. */
|
||||
private password(): string {
|
||||
private async doMint(): Promise<string> {
|
||||
let password: string;
|
||||
try {
|
||||
return readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, "");
|
||||
password = readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, "");
|
||||
} catch (err) {
|
||||
throw new Error(`cannot read the admin password at ${this.opts.passwordFile}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private adminCall(password = this.password()): AdminCall {
|
||||
const authorization = "Basic " + Buffer.from(`${this.opts.admin}:${password}`).toString("base64");
|
||||
const api = `${this.opts.url.replace(/\/+$/, "")}/api/v1`;
|
||||
const admin: AdminCall = async (method, path, body) => {
|
||||
const res = await this.fetchImpl(api + path, {
|
||||
const tokens = `${this.opts.url.replace(/\/+$/, "")}/api/v1/users/${encodeURIComponent(this.opts.admin)}/tokens`;
|
||||
const call = async (method: string, path = "", body?: unknown): Promise<{ status: number; body: any }> => {
|
||||
const res = await this.fetchImpl(tokens + path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", Authorization: authorization },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
@@ -372,97 +253,30 @@ export class MintedToken implements TokenSource {
|
||||
}
|
||||
return { status: res.status, body: parsed };
|
||||
};
|
||||
return admin;
|
||||
}
|
||||
|
||||
/** Where a retirement of the admin's old token that failed is remembered, to be tried again (retireAdmin). */
|
||||
private get retireFile(): string {
|
||||
return `${this.opts.file}.retire-admin`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin's own token by this name goes: what the tools used before must not outlive the move (novox/hq ADR
|
||||
* 0365 §2). Run after the account's token is minted and kept, so a failure here never leaves the tools without
|
||||
* a token: it is said, remembered beside the kept token, and tried again when a process next reads that token.
|
||||
*/
|
||||
private async retireAdmin(admin: AdminCall = this.adminCall()): Promise<void> {
|
||||
if (this.account === this.opts.admin) return;
|
||||
try {
|
||||
const retired = await this.dropNamed(admin, `/users/${encodeURIComponent(this.opts.admin)}/tokens`, this.opts.admin);
|
||||
if (retired > 0) this.log(`deleted ${retired} token(s) named "${this.name}" the admin account "${this.opts.admin}" held: the tools act as "${this.account}" now`);
|
||||
rmSync(this.retireFile, { force: true });
|
||||
} catch (err) {
|
||||
writeFileSync(this.retireFile, `${new Date().toISOString()}\n`, { mode: 0o600 });
|
||||
this.log(`FAILED to delete the token(s) named "${this.name}" the admin account "${this.opts.admin}" holds — tried again at the next start: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async doMint(): Promise<string> {
|
||||
const password = this.password();
|
||||
const admin = this.adminCall(password);
|
||||
const tokensOf = (account: string) => `/users/${encodeURIComponent(account)}/tokens`;
|
||||
const call = (method: string, path = "", body?: unknown) => admin(method, tokensOf(this.account) + path, body);
|
||||
|
||||
if (this.account !== this.opts.admin) {
|
||||
// The tools' account and its team first (novox/hq ADR 0365 §2): a failure here fails the mint, tried again
|
||||
// at the next call.
|
||||
const me = await admin("GET", "/user");
|
||||
if (me.status === 401 || me.status === 403) throw new AdminRefused(this.opts.admin, me.status);
|
||||
await this.opts.setUp?.(password);
|
||||
}
|
||||
|
||||
let res = await call("POST", "", { name: this.name, scopes: this.scopes });
|
||||
if (res.status === 401 || res.status === 403) throw new AdminRefused(this.opts.admin, res.status);
|
||||
if (res.status === 400 || res.status === 422) {
|
||||
// The forge still holds a token by this name whose value we no longer have — the kept file
|
||||
// went while the forge's data stayed, or two processes once minted at once and left two. It is
|
||||
// ours to replace: find every one by this name in the account's list and delete each by its id
|
||||
// (gitea refuses a delete by a name held twice: "multiple matches for token name"), then mint.
|
||||
await this.dropNamed(admin, tokensOf(this.account), this.account, true);
|
||||
// went while the forge's data stayed. It is ours to replace: drop it by name and mint again.
|
||||
this.log(`the forge already holds a token named "${this.name}" — replacing it`);
|
||||
const dropped = await call("DELETE", `/${encodeURIComponent(this.name)}`);
|
||||
if (dropped.status !== 204 && dropped.status !== 404) {
|
||||
throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${this.name}: ${dropped.status} ${detail(dropped.body)}`);
|
||||
}
|
||||
res = await call("POST", "", { name: this.name, scopes: this.scopes });
|
||||
}
|
||||
if (res.status !== 201 && res.status !== 200) {
|
||||
throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} ${detail(res.body)}`);
|
||||
throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} ${detail(res.body)}`);
|
||||
}
|
||||
const token = typeof res.body?.sha1 === "string" ? res.body.sha1 : null;
|
||||
if (!token) throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} but no token in the reply`);
|
||||
if (!token) throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} but no token in the reply`);
|
||||
|
||||
this.keep(token);
|
||||
this.held = token;
|
||||
this.log(`minted a token for "${this.account}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
|
||||
// Only now, with the account's own token kept, does the admin's old one go.
|
||||
await this.retireAdmin(admin);
|
||||
this.log(`minted a token for "${this.opts.admin}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every token by this name an account holds, each by its id (gitea refuses a delete by a name held twice:
|
||||
* "multiple matches for token name"); answers how many. `replacing` says the line for a mint that found its name
|
||||
* taken.
|
||||
*/
|
||||
private async dropNamed(admin: AdminCall, tokens: string, account: string, replacing = false): Promise<number> {
|
||||
const held: number[] = [];
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const listed = await admin("GET", `${tokens}?page=${page}&limit=50`);
|
||||
if (listed.status !== 200 || !Array.isArray(listed.body)) {
|
||||
throw new Error(`Gitea GET /users/${account}/tokens: ${listed.status} ${detail(listed.body)}`);
|
||||
}
|
||||
if (listed.body.length === 0) break;
|
||||
for (const t of listed.body) if (t?.name === this.name && typeof t.id === "number") held.push(t.id);
|
||||
}
|
||||
if (replacing) {
|
||||
this.log(held.length === 1
|
||||
? `the forge already holds a token named "${this.name}" — replacing it`
|
||||
: `the forge holds ${held.length} tokens named "${this.name}" — deleting each by its id and minting one`);
|
||||
}
|
||||
for (const id of held) {
|
||||
const dropped = await admin("DELETE", `${tokens}/${id}`);
|
||||
if (dropped.status !== 204 && dropped.status !== 404) {
|
||||
throw new Error(`Gitea DELETE /users/${account}/tokens/${id} (named ${this.name}): ${dropped.status} ${detail(dropped.body)}`);
|
||||
}
|
||||
}
|
||||
return held.length;
|
||||
}
|
||||
}
|
||||
|
||||
function detail(body: unknown): string {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// The forge's tools act as an account of their own, not as the site administrator (novox/hq ADR 0365 §2,
|
||||
// issue 469).
|
||||
//
|
||||
// The administrator's token reached everything the forge holds, so the tools' boundary rested on their own guards
|
||||
// (guard.ts): a protection edit, say, the forge itself would have taken. Now the tools' token is minted for a plain
|
||||
// account, `mesh-tools`, which is no administrator and writes to the mesh's repositories through one team of the
|
||||
// mesh's organisation:
|
||||
//
|
||||
// - **the account**: made by the module with the administrator's login (that login sets the forge up and does
|
||||
// nothing else), never an administrator — one found made an administrator is made a plain account again. Its
|
||||
// password is random, sent once and kept nowhere: the account is reached only by the tokens the administrator
|
||||
// mints for it (gitea lets an administrator mint for another account: `reqSelfOrAdmin`).
|
||||
// - **the team**: `mesh-tools` in the organisation `novox`, on every repository of it, writing code (pushes to a
|
||||
// branch, commit statuses, merges), issues (notes, comments, labels) and pull requests. No administration of a
|
||||
// repository, so the forge refuses the account a protection edit, a repository's settings and its deletion;
|
||||
// no repository made in the organisation, since its maker would be that repository's administrator.
|
||||
//
|
||||
// Every step is idempotent (GiteaAdmin's, client.ts), and run with the administrator's login before each mint of the tools' token, so a team
|
||||
// or a membership lost behind the module's back is made again at the next mint.
|
||||
|
||||
/** The account the tools act as. */
|
||||
export const TOOLS_ACCOUNT = "mesh-tools";
|
||||
/** The organisation whose repositories are the mesh's, and the team through which the tools' account writes there. */
|
||||
export const TOOLS_ORG = "novox";
|
||||
export const TOOLS_TEAM = "mesh-tools";
|
||||
|
||||
/** What the team grants: write on code, issues and pull requests of every repository of the organisation, and no more. */
|
||||
export const TOOLS_TEAM_UNITS = {
|
||||
permission: "write",
|
||||
units_map: { "repo.code": "write", "repo.issues": "write", "repo.pulls": "write" },
|
||||
includes_all_repositories: true,
|
||||
can_create_org_repo: false,
|
||||
} as const;
|
||||
|
||||
/** One call to the forge's API with the administrator's login: a path under /api/v1, the status and body answered. */
|
||||
export type AdminCall = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: any }>;
|
||||
|
||||
/** What setting the account up needs of the forge's admin API: GiteaAdmin's own (client.ts), shared with the
|
||||
* package provisioner, so a create race (422) and a user made by hand are handled once. */
|
||||
export interface ToolsForge {
|
||||
ensureUser(username: string, password: string, email: string, plain?: boolean): Promise<void>;
|
||||
ensureTeamWith(org: string, team: string, units: Record<string, unknown>): Promise<number>;
|
||||
addUserToTeam(teamId: number, username: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Make the tools' account, plain, a member of its team on every repository of the organisation. Its password is
|
||||
* set afresh each time and kept nowhere: the account is reached only by the tokens the administrator mints. */
|
||||
export async function setUpToolsAccount(forge: ToolsForge, account = TOOLS_ACCOUNT, password: () => string): Promise<void> {
|
||||
await forge.ensureUser(account, password(), `${account}@noreply.localhost`, true);
|
||||
const team = await forge.ensureTeamWith(TOOLS_ORG, TOOLS_TEAM, { name: TOOLS_TEAM, ...TOOLS_TEAM_UNITS });
|
||||
await forge.addUserToTeam(team, account);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GiteaAdmin, GiteaClient, readyAuthors, type BranchProtection } from "../client.js";
|
||||
import { GiteaClient, type BranchProtection } from "../client.js";
|
||||
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
|
||||
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
|
||||
import {
|
||||
@@ -59,13 +59,13 @@ function namesChecked(tools: ToolDefinition[]): ToolDefinition[] {
|
||||
}));
|
||||
}
|
||||
|
||||
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
|
||||
return namesChecked(allGiteaTools(gitea, admin));
|
||||
export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
return namesChecked(allGiteaTools(gitea));
|
||||
}
|
||||
|
||||
// gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one
|
||||
// writer (novox/hq issue 456, ADR 0323).
|
||||
function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
|
||||
function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
return [
|
||||
// ---- Repositories ----
|
||||
{
|
||||
@@ -401,11 +401,8 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo);
|
||||
// The one read made as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the
|
||||
// repository administrator's, which the tools' account is not. No other tool uses this account.
|
||||
const rules = await (admin ? admin() : gitea).branchProtections(owner, repo);
|
||||
if (!args.branch) return { rules: rules.map(summarised) };
|
||||
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null;
|
||||
if (!args.branch) return { rules: (await gitea.branchProtections(owner, repo)).map(summarised) };
|
||||
const rule = await gitea.branchProtection(owner, repo, String(args.branch));
|
||||
return { branch: String(args.branch), rule: rule ? summarised(rule) : null };
|
||||
},
|
||||
},
|
||||
@@ -507,7 +504,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
base = base || "main";
|
||||
let rule: BranchProtection | null = null, unreadable: string | undefined;
|
||||
try {
|
||||
rule = await gitea.appliedProtection(owner, repo, base);
|
||||
rule = await gitea.branchProtection(owner, repo, base);
|
||||
} catch (err) {
|
||||
unreadable = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -517,9 +514,9 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
{
|
||||
name: "gitea_open_pulls",
|
||||
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438), and each pull request's labels and ready mark — the label mesh/ready and the head named by the newest comment \"ready at <full head sha>\" that a listed account (the setting ready-authors) wrote after the label was added and after the last push — which mesh-delivery merges on (issue 722). Reads only.",
|
||||
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438). Reads only.",
|
||||
input: {},
|
||||
run: async () => ({ pulls: await gitea.openPulls(readyAuthors()) }),
|
||||
run: async () => ({ pulls: await gitea.openPulls() }),
|
||||
},
|
||||
{
|
||||
name: "gitea_contains",
|
||||
@@ -592,12 +589,8 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
const { owner, repo, branch, newBranch } = judged.contents;
|
||||
const found = await gitea.api<{ default_branch?: string }>(`/repos/${checkName("owner", owner)}/${checkName("name", repo)}`);
|
||||
const defaultBranch = String(found?.default_branch ?? "main");
|
||||
// The protection the forge applies to the branch written, read from the branch: the rules' own list is a
|
||||
// repository administrator's, which the tools' account is not (novox/hq ADR 0365). A branch not made yet is
|
||||
// left to the forge, which refuses a write that would make a branch a rule guards.
|
||||
const target = newBranch ?? branch ?? defaultBranch;
|
||||
const applied = target === defaultBranch ? null : await gitea.appliedProtection(owner, repo, target);
|
||||
const refused = judgeContentsWrite(target, defaultBranch, applied ? [applied] : []);
|
||||
const refused = judgeContentsWrite(newBranch ?? branch ?? defaultBranch, defaultBranch,
|
||||
await gitea.branchProtections(owner, repo));
|
||||
if (refused) throw new Refused(refused);
|
||||
}
|
||||
// The path judged is the path sent: normalised, with only the allow-listed query keys (guard.ts).
|
||||
@@ -618,7 +611,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
// way, gitea contributes none rather than failing the whole runtime, and says why.
|
||||
registerModuleTools("gitea", (env) => {
|
||||
try {
|
||||
return getGiteaTools(GiteaClient.fromEnv(env), () => GiteaAdmin.fromEnv(env));
|
||||
return getGiteaTools(GiteaClient.fromEnv(env));
|
||||
} catch (err) {
|
||||
console.log(`[gitea] no tools — ${err instanceof Error ? err.message : String(err)}`);
|
||||
return [];
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
// **The forge says every event on the bus** (novox/hq issue 729; to-be 10: an event makes it fast, nothing makes it
|
||||
// necessary). The module keeps one system webhook on its forge for every event, pointing at its own receiver, signed
|
||||
// with a secret it mints and keeps beside its token; the receiver refuses whatever is unsigned or does not verify, and
|
||||
// publishes each event, trimmed, on the module's events as forge.<kind>.<action> (forge.pull-request.opened, forge.push, …),
|
||||
// on the shared EVENTS path its other events take; its own stream (ADR 0363) is a follow-up once issue 725 ships.
|
||||
// The polls stay as the safety net, slowed; the announced-heads and announced-merges records keep anything from
|
||||
// being announced twice, whichever path saw it first.
|
||||
//
|
||||
// The secret is never logged: lines say it was minted or reused and where it is kept, never what it is.
|
||||
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
/** The name the receiver answers on, and the port it listens on unless the manifest says otherwise. */
|
||||
export const HOOK_PATH = "/forge-event";
|
||||
export const HOOK_PORT = 3047;
|
||||
|
||||
/** The secret the forge signs with: read from its 0600 file, or minted and kept there. */
|
||||
export function keptSecret(file: string, log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)): string {
|
||||
try {
|
||||
const kept = readFileSync(file, "utf8").trim();
|
||||
if (kept.length >= 32) {
|
||||
log(`reusing the webhook secret kept at ${file}`);
|
||||
return kept;
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||
}
|
||||
const secret = randomBytes(32).toString("hex");
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.tmp`;
|
||||
writeFileSync(tmp, secret + "\n", { mode: 0o600 });
|
||||
chmodSync(tmp, 0o600);
|
||||
renameSync(tmp, file);
|
||||
log(`minted a webhook secret, kept at ${file}`);
|
||||
return secret;
|
||||
}
|
||||
|
||||
/** Whether a body carries the forge's HMAC-SHA256 signature (X-Gitea-Signature, hex) under the secret. */
|
||||
export function verified(secret: string, body: Buffer | string, signature: string | undefined): boolean {
|
||||
if (!secret || !signature || !/^[0-9a-f]{64}$/i.test(signature.trim())) return false;
|
||||
const want = createHmac("sha256", secret).update(body).digest();
|
||||
const got = Buffer.from(signature.trim(), "hex");
|
||||
return got.length === want.length && timingSafeEqual(got, want);
|
||||
}
|
||||
|
||||
/** One forge event as the bus carries it: its name inside the stream, and what is needed of it — no secrets. */
|
||||
export interface ForgeEvent {
|
||||
name: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Every event name the module emits for the forge, without its forge. prefix (module.json `emits`). */
|
||||
export const EVENTS: readonly string[] = ["push", "create", "delete", "fork", "status", "pull-request.opened", "pull-request.reopened", "pull-request.synchronized", "pull-request.edited", "pull-request.closed", "pull-request.merged", "pull-request.labeled", "pull-request.unlabeled", "pull-request.assigned", "pull-request.unassigned", "pull-request.reviewed", "pull-request.review-requested", "pull-request.commented", "pull-request.changed", "issue-comment.created", "issue-comment.edited", "issue-comment.deleted", "issues.opened", "issues.reopened", "issues.closed", "issues.edited", "issues.labeled", "issues.unlabeled", "issues.assigned", "issues.unassigned", "issues.changed", "release.published", "release.updated", "release.deleted", "release.changed", "repository.created", "repository.deleted", "repository.changed", "wiki.changed", "other"];
|
||||
|
||||
/** The families the module's stream carries (module.json `streams`); any other kind is said as other.<kind>; underscores become hyphens. */
|
||||
export const FAMILIES = ["push", "create", "delete", "fork", "status", "pull_request", "issue_comment", "issues",
|
||||
"release", "repository", "wiki"] as const;
|
||||
|
||||
const ACTIONS: Record<string, string> = { label_updated: "labeled", label_cleared: "unlabeled", synchronized: "synchronized" };
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v ? v : undefined;
|
||||
}
|
||||
function num(v: unknown): number | undefined {
|
||||
return typeof v === "number" ? v : undefined;
|
||||
}
|
||||
function login(u: any): string | undefined {
|
||||
return str(u?.login) ?? str(u?.username);
|
||||
}
|
||||
|
||||
/**
|
||||
* The event a forge delivery is: its kind (X-Gitea-Event) and the payload's action, trimmed to ids, repository, refs,
|
||||
* shas, author, labels and a comment's body (a ready mark is one, ADR 0364). A merge is pull-request.merged, a comment on
|
||||
* a pull request pull-request.commented.
|
||||
*/
|
||||
export function eventOf(kind: string, payload: any): ForgeEvent {
|
||||
// The bus's event names take lower-case letters, digits and hyphens: the forge's underscores become hyphens.
|
||||
const event = forgeEventOf(kind, payload);
|
||||
const name = event.name.toLowerCase().replace(/_/g, "-").replace(/[^a-z0-9.-]/g, "-");
|
||||
// Published on the module's events as it publishes its others, each name declared in its `emits`: one the manifest
|
||||
// does not name is forge.other, saying what the forge called it. Its own stream (ADR 0363) follows issue 725.
|
||||
if (EVENTS.includes(name)) return { name: `forge.${name}`, body: event.body };
|
||||
return { name: "forge.other", body: { ...event.body, kind, said: name } };
|
||||
}
|
||||
|
||||
function forgeEventOf(said: string, payload: any): ForgeEvent {
|
||||
// The forge names some kinds finer than their family (pull_request_label, pull_request_sync): read by the payload.
|
||||
let kind = said;
|
||||
if (said.startsWith("pull_request") && payload?.pull_request) kind = "pull_request";
|
||||
if ((said.startsWith("issue") || said === "pull_request_comment") && payload?.comment) kind = "issue_comment";
|
||||
if (said.startsWith("issue") && !payload?.comment && payload?.issue) kind = "issues";
|
||||
const repo = payload?.repository ?? {};
|
||||
const base: Record<string, unknown> = {
|
||||
owner: login(repo.owner), repo: str(repo.name), full_name: str(repo.full_name), clone_url: str(repo.clone_url),
|
||||
sender: login(payload?.sender),
|
||||
};
|
||||
const family = (FAMILIES as readonly string[]).includes(kind) ? kind : `other`;
|
||||
let action = str(payload?.action);
|
||||
const pull = payload?.pull_request;
|
||||
if (kind === "push") {
|
||||
return { name: "push", body: { ...base, ref: str(payload.ref), before: str(payload.before), after: str(payload.after),
|
||||
commits: Array.isArray(payload.commits) ? payload.commits.length : undefined, pusher: login(payload.pusher) } };
|
||||
}
|
||||
if (kind === "pull_request" && pull) {
|
||||
if (action === "closed" && pull.merged) action = "merged";
|
||||
action = ACTIONS[action ?? ""] ?? action ?? "changed";
|
||||
return { name: `pull_request.${action}`, body: { ...base, number: num(pull.number ?? payload.number),
|
||||
title: str(pull.title), author: login(pull.user), head: str(pull.head?.ref), head_sha: str(pull.head?.sha),
|
||||
base: str(pull.base?.ref), merged: !!pull.merged, merge_commit_sha: str(pull.merge_commit_sha),
|
||||
merged_at: str(pull.merged_at), html_url: str(pull.html_url),
|
||||
labels: Array.isArray(pull.labels) ? pull.labels.map((l: any) => str(l?.name)).filter(Boolean) : undefined,
|
||||
label: str(payload.label?.name) } };
|
||||
}
|
||||
if (kind === "issue_comment") {
|
||||
const issue = payload.issue ?? {};
|
||||
const onPull = !!payload.is_pull || !!issue.pull_request;
|
||||
return { name: onPull ? "pull_request.commented" : `issue_comment.${action ?? "created"}`, body: { ...base,
|
||||
number: num(issue.number), comment_id: num(payload.comment?.id), author: login(payload.comment?.user),
|
||||
comment: str(payload.comment?.body), action } };
|
||||
}
|
||||
if (kind === "create" || kind === "delete") {
|
||||
return { name: kind, body: { ...base, ref: str(payload.ref), ref_type: str(payload.ref_type), sha: str(payload.sha) } };
|
||||
}
|
||||
if (kind === "status") {
|
||||
return { name: "status", body: { ...base, sha: str(payload.sha), context: str(payload.context), state: str(payload.state) } };
|
||||
}
|
||||
const name = family === "other" ? `other.${kind.replace(/[^a-z0-9_-]/gi, "_") || "unknown"}` :
|
||||
["fork"].includes(family) ? family : `${family}.${action ?? "changed"}`;
|
||||
return { name, body: { ...base, number: num(payload?.number ?? payload?.issue?.number), action } };
|
||||
}
|
||||
|
||||
export interface ReceiverOptions {
|
||||
secret: string;
|
||||
port: number;
|
||||
host?: string;
|
||||
/** Publishes one event on the bus. */
|
||||
publish: (event: ForgeEvent) => Promise<void>;
|
||||
/** Acts on it at once, beside publishing it (the announcer). */
|
||||
act?: (event: ForgeEvent) => Promise<void>;
|
||||
/** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the polls know events are
|
||||
* heard, and the module can say when it last heard one. */
|
||||
heard?: (kind: string, delivery: string) => void;
|
||||
/** Told of every refused delivery, with the reason and where it came from. */
|
||||
refused?: (reason: string, from: string) => void;
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
/** The most a delivery may carry: the forge's push of many commits stays well under it. */
|
||||
export const MAX_BODY = 5 * 1024 * 1024;
|
||||
/** How many deliveries' ids are remembered, so a delivery sent again is published once. */
|
||||
export const REMEMBERED = 2000;
|
||||
|
||||
/**
|
||||
* The receiver. A request without a signature is refused before its body is read, a body over MAX_BODY is refused, and
|
||||
* one whose signature does not verify is refused, each with 401 or 413 and a line saying so. A verified delivery is
|
||||
* answered 200 at once and queued: it is published and acted on after the reply, one at a time, so the forge's
|
||||
* delivery timeout is never reached. A delivery whose X-Gitea-Delivery id was already heard is answered and dropped.
|
||||
*/
|
||||
export function receiver(opts: ReceiverOptions): Server & { idle(): Promise<void> } {
|
||||
const log = opts.log ?? ((l) => console.log(`[gitea] ${l}`));
|
||||
const refuse = (from: string, reason: string): void => {
|
||||
log(`refused a forge event from ${from}: ${reason}`);
|
||||
opts.refused?.(reason, from);
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
const server = createServer((req, res) => {
|
||||
if (req.method !== "POST" || (req.url ?? "").split("?")[0] !== HOOK_PATH) {
|
||||
res.statusCode = 404;
|
||||
return res.end();
|
||||
}
|
||||
const from = req.socket.remoteAddress ?? "?";
|
||||
const signature = String(req.headers["x-gitea-signature"] ?? "").trim();
|
||||
if (!signature) {
|
||||
refuse(from, "it is unsigned");
|
||||
res.writeHead(401, { Connection: "close" });
|
||||
res.end("unsigned\n");
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
if (Number(req.headers["content-length"] ?? 0) > MAX_BODY) {
|
||||
refuse(from, `its body is over ${MAX_BODY} bytes`);
|
||||
res.writeHead(413, { Connection: "close" });
|
||||
res.end("too large\n");
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
let over = false;
|
||||
req.on("data", (c: Buffer) => {
|
||||
size += c.length;
|
||||
if (size > MAX_BODY) {
|
||||
if (!over) {
|
||||
over = true;
|
||||
refuse(from, `its body is over ${MAX_BODY} bytes`);
|
||||
res.writeHead(413, { Connection: "close" });
|
||||
res.end("too large\n");
|
||||
req.destroy();
|
||||
}
|
||||
} else chunks.push(c);
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (over) return;
|
||||
const body = Buffer.concat(chunks);
|
||||
if (!verified(opts.secret, body, signature)) {
|
||||
refuse(from, "its signature does not verify");
|
||||
res.statusCode = 401;
|
||||
return res.end("signature does not verify\n");
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(body.toString("utf8"));
|
||||
} catch {
|
||||
refuse(from, "it is signed but not JSON");
|
||||
res.statusCode = 400;
|
||||
return res.end("not JSON\n");
|
||||
}
|
||||
const delivery = String(req.headers["x-gitea-delivery"] ?? "");
|
||||
const kind = String(req.headers["x-gitea-event"] ?? "other");
|
||||
opts.heard?.(kind, delivery);
|
||||
if (delivery && seen.has(delivery)) {
|
||||
res.statusCode = 200;
|
||||
return res.end("already heard\n");
|
||||
}
|
||||
if (delivery) {
|
||||
seen.add(delivery);
|
||||
if (seen.size > REMEMBERED) seen.delete(seen.values().next().value as string);
|
||||
}
|
||||
const event = eventOf(kind, payload);
|
||||
res.statusCode = 200;
|
||||
res.end("queued\n");
|
||||
queue = queue.then(async () => {
|
||||
try {
|
||||
await opts.publish(event);
|
||||
if (opts.act) await opts.act(event);
|
||||
} catch (err) {
|
||||
log(`a forge event ${event.name} could not be published or acted on; the polls catch it — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return Object.assign(server, { idle: () => queue });
|
||||
}
|
||||
|
||||
/** What ensuring the system webhook needs of the forge's administration (GiteaAdmin's login). */
|
||||
export interface HookAdmin {
|
||||
call(method: string, path: string, body?: unknown): Promise<{ status: number; body: any }>;
|
||||
}
|
||||
|
||||
/** The forge's one system webhook as ensured at start: read back from the forge after it was made. */
|
||||
export interface HookState {
|
||||
/** Always "system": a hook the forge's listing of system webhooks names, so it fires for every repository. */
|
||||
kind: "system";
|
||||
id: number;
|
||||
url: string;
|
||||
events: string[];
|
||||
active: boolean;
|
||||
content_type?: string;
|
||||
/** Made where none was, or made in place of the ones there (system, default, or a repository's) at the receiver's url. */
|
||||
did: "created" | "replaced";
|
||||
/** The hooks at the receiver's url it took the place of, by kind: a repository's is a copy of a default webhook, which
|
||||
* would have sent that repository's events a second time. */
|
||||
removed: { system: number; default: number; repository: number; repositories: string[] };
|
||||
}
|
||||
|
||||
/** How long without a verified delivery — since the start, or since the last one — a forge is said silent once the poll
|
||||
* finds what no event said. */
|
||||
export const SILENT_AFTER = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* **How fast the polls look, and whether the forge's events are heard** (issue 729): every five minutes only while the
|
||||
* forge's events are heard — a verified delivery within the last ten minutes, and none missed since. Until the first
|
||||
* delivery, after ten minutes without one, or once the poll found something no event said, every thirty seconds.
|
||||
*
|
||||
* It also keeps what `gitea_events` says: where the receiver listens, the hook as ensured, the verified and refused
|
||||
* deliveries, and the polls' finds no event said. **A forge whose events stop is said** (issue 729): once the receiver
|
||||
* listens and the hook is ensured, when nothing verified for ten minutes — since the start, or since the last delivery —
|
||||
* and the poll has found at least two heads or merges no event said in that quiet (one could be the poll beating an
|
||||
* event by a second), it is logged loudly once per silent spell, and the module's events are said unhealthy until a
|
||||
* delivery verifies. Deliveries refused since the last verified one are said unhealthy too.
|
||||
*/
|
||||
export class Pace {
|
||||
private last = 0;
|
||||
private missed = 0;
|
||||
private readonly started: number;
|
||||
private readonly now: () => number;
|
||||
private readonly log: (l: string) => void;
|
||||
private where: { host: string; port: number; url: string } | null = null;
|
||||
private hook: HookState | null = null;
|
||||
private notHeard: string | null = null;
|
||||
private notHeardFails = false;
|
||||
private verifiedCount = 0;
|
||||
private lastVerified: { at: number; event: string; delivery: string } | null = null;
|
||||
private refusedCount = 0;
|
||||
private refusedSpell = 0;
|
||||
private lastRefused: { at: number; reason: string; from: string } | null = null;
|
||||
private missCount = 0;
|
||||
private lastMiss: { at: number; what: string } | null = null;
|
||||
private silentSince = 0;
|
||||
private quietMisses = 0;
|
||||
constructor(now: () => number = Date.now, log: (l: string) => void = (l) => console.error(`[gitea] ${l}`)) {
|
||||
this.now = now;
|
||||
this.log = log;
|
||||
this.started = now();
|
||||
}
|
||||
/** The receiver listens, and the hook at it was ensured: from now on, deliveries are expected. */
|
||||
listening(host: string, port: number, url: string, hook: HookState): void {
|
||||
this.where = { host, port, url };
|
||||
this.hook = hook;
|
||||
this.notHeard = null;
|
||||
}
|
||||
/** No events are listened for, and why: by configuration (`fails` false), or because the receiver could not listen
|
||||
* or the hook could not be ensured (`fails` true, said unhealthy). The polls alone watch. */
|
||||
unheard(why: string, fails = true): void {
|
||||
this.notHeard = why;
|
||||
this.notHeardFails = fails;
|
||||
}
|
||||
heard(event = "", delivery = ""): void {
|
||||
this.last = this.now();
|
||||
this.verifiedCount++;
|
||||
this.lastVerified = { at: this.last, event, delivery };
|
||||
this.refusedSpell = 0;
|
||||
this.quietMisses = 0;
|
||||
if (this.silentSince) this.log(`the forge's events are heard again: ${event || "a delivery"} verified`);
|
||||
this.silentSince = 0;
|
||||
}
|
||||
refused(reason: string, from: string): void {
|
||||
this.refusedCount++;
|
||||
this.refusedSpell++;
|
||||
this.lastRefused = { at: this.now(), reason, from };
|
||||
}
|
||||
/** The poll found a head or merge no forge event announced. */
|
||||
miss(what: string): void {
|
||||
const t = this.now();
|
||||
if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the poll found it, so the polls look every 30 s again until the next event`);
|
||||
this.missed = t;
|
||||
this.missCount++;
|
||||
this.lastMiss = { at: t, what };
|
||||
// Silent: nothing verified for SILENT_AFTER — never since the start, or not since the last delivery (a hook deleted by
|
||||
// hand, every delivery refused) — while the poll finds what no event said. Said once per silent spell.
|
||||
const quietSince = this.last || this.started;
|
||||
this.quietMisses++;
|
||||
if (this.expecting() && t - quietSince >= SILENT_AFTER && this.quietMisses >= 2 && !this.silentSince) {
|
||||
this.silentSince = t;
|
||||
const minutes = Math.round((t - quietSince) / 60_000);
|
||||
const refusedSince = this.refusedSinceHeard();
|
||||
this.log(`THE FORGE'S EVENTS ARE NOT HEARD: ${this.last ? `no delivery verified since the last at ` +
|
||||
`${new Date(this.last).toISOString()}` : "no delivery ever verified since the start"}, ${minutes} min, from the ` +
|
||||
`system webhook at ${this.hook?.url}${refusedSince ? `; ${refusedSince} refused since, the last because ` +
|
||||
`${this.lastRefused?.reason}` : ""}; the poll found ${what} — the polls alone watch the forge, every 30 s ` +
|
||||
`(novox/hq issue 729)`);
|
||||
}
|
||||
}
|
||||
/** Whether a delivery was refused after the last verified one (or with none verified). */
|
||||
private refusedSinceHeard(): number {
|
||||
return this.lastRefused && this.lastRefused.at >= this.last ? this.refusedSpell : 0;
|
||||
}
|
||||
hearing(): boolean {
|
||||
const t = this.now();
|
||||
return this.last > 0 && t - this.last < 10 * 60_000 && this.last > this.missed;
|
||||
}
|
||||
every(): number {
|
||||
return this.hearing() ? 5 * 60_000 : 30_000;
|
||||
}
|
||||
private expecting(): boolean {
|
||||
return this.where !== null && this.hook !== null;
|
||||
}
|
||||
/** What `gitea_events` answers: healthy, unhealthy or waits (ADR 0283's shape), with every fact behind it. */
|
||||
report(): Record<string, unknown> {
|
||||
const t = this.now();
|
||||
const iso = (ms: number): string => new Date(ms).toISOString();
|
||||
let state: "healthy" | "unhealthy" | "waits" = "healthy";
|
||||
let why: string;
|
||||
const refusedSince = this.refusedSinceHeard();
|
||||
if (this.silentSince) {
|
||||
state = "unhealthy";
|
||||
why = `${this.last ? `no delivery verified since the last at ${iso(this.last)}` : `the forge has never delivered an ` +
|
||||
`event since the start at ${iso(this.started)}`}, while the poll found ${this.missCount} head(s) or merge(s) no ` +
|
||||
`event said, the last ${this.lastMiss?.what} at ${iso(this.lastMiss?.at ?? t)}`;
|
||||
} else if (refusedSince) {
|
||||
state = "unhealthy";
|
||||
why = `${refusedSince} deliveries refused ${this.last ? `since the last verified one at ${iso(this.last)}` :
|
||||
"and none verified"}, the last at ${iso(this.lastRefused?.at ?? t)} because ${this.lastRefused?.reason}`;
|
||||
} else if (!this.expecting()) {
|
||||
why = this.notHeard ? `no events are listened for, so the polls alone watch the forge: ${this.notHeard}` :
|
||||
"the receiver is not listening yet";
|
||||
if (this.notHeard && this.notHeardFails) state = "unhealthy";
|
||||
} else if (this.hearing()) {
|
||||
why = `the forge's events are heard: ${this.verifiedCount} verified, the last at ${iso(this.last)}`;
|
||||
} else if (this.last === 0) {
|
||||
why = `no delivery heard yet; the forge is said silent if the poll finds what no event said ${SILENT_AFTER / 60_000} min after the start`;
|
||||
} else {
|
||||
why = `the last delivery was at ${iso(this.last)}; the polls look every 30 s until the next`;
|
||||
}
|
||||
return {
|
||||
healthy: state !== "unhealthy", state, why,
|
||||
started: iso(this.started),
|
||||
receiver: this.where ? { listening: true, ...this.where } : { listening: false, why: this.notHeard ?? "not started" },
|
||||
hook: this.hook,
|
||||
verified: { count: this.verifiedCount, last: this.lastVerified && { ...this.lastVerified, at: iso(this.lastVerified.at) } },
|
||||
refused: { count: this.refusedCount, since_verified: refusedSince, last: this.lastRefused && { ...this.lastRefused, at: iso(this.lastRefused.at) } },
|
||||
polls: { every_seconds: this.every() / 1000, hearing: this.hearing(), found_unsaid: this.missCount,
|
||||
last_found: this.lastMiss && { ...this.lastMiss, at: iso(this.lastMiss.at) } },
|
||||
silent: this.silentSince ? { since: iso(this.silentSince) } : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Every event family the forge (1.27) can send a webhook. */
|
||||
export const HOOK_EVENTS = ["create", "delete", "fork", "push", "issues", "issue_comment", "pull_request",
|
||||
"pull_request_review_request", "wiki", "repository", "release", "package", "status", "workflow_run", "workflow_job"];
|
||||
|
||||
async function hooksAt(admin: HookAdmin, type: "system" | "default", url: string): Promise<any[]> {
|
||||
const listed = await admin.call("GET", `/admin/hooks?type=${type}&limit=50`);
|
||||
if (listed.status !== 200) throw new Error(`the forge's ${type} webhooks could not be listed: ${listed.status}`);
|
||||
return (Array.isArray(listed.body) ? listed.body : []).filter((h) => h?.config?.url === url);
|
||||
}
|
||||
|
||||
/** Every page of a listing that answers an array (or `{ data: [] }`, the repository search), PAGE at a time. */
|
||||
const PAGE = 50;
|
||||
async function everyPage(admin: HookAdmin, path: string, what: string): Promise<any[]> {
|
||||
const all: any[] = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const r = await admin.call("GET", `${path}${path.includes("?") ? "&" : "?"}page=${page}&limit=${PAGE}`);
|
||||
if (r.status !== 200) throw new Error(`${what} could not be listed: ${r.status}`);
|
||||
const items: any[] = Array.isArray(r.body) ? r.body : Array.isArray(r.body?.data) ? r.body.data : [];
|
||||
all.push(...items);
|
||||
if (items.length < PAGE) return all;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every repository's own webhooks at the receiver's url: copies of a default webhook made at it. */
|
||||
async function repoHooksAt(admin: HookAdmin, url: string): Promise<{ repo: string; id: number }[]> {
|
||||
const found: { repo: string; id: number }[] = [];
|
||||
for (const repo of await everyPage(admin, "/repos/search?sort=id", "the forge's repositories")) {
|
||||
const full = String(repo?.full_name ?? "");
|
||||
if (!full.includes("/")) continue;
|
||||
const hooks = await everyPage(admin, `/repos/${full}/hooks`, `${full}'s webhooks`);
|
||||
for (const h of hooks) if (h?.config?.url === url) found.push({ repo: full, id: Number(h.id) });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* **One system webhook, for every event, at the receiver** (issue 729), made each start and read back.
|
||||
*
|
||||
* The forge (1.27.3, routers/api/v1/utils/hook.go addHook) makes a *default* webhook from POST /admin/hooks unless
|
||||
* `config.is_system_webhook` is "true": a default webhook fires for no repository and is only copied into repositories
|
||||
* made after it, and the listing of system webhooks does not name it — so without the flag every start made another
|
||||
* one and no event came. Its edit (editHook) never changes a webhook's secret nor its kind, so a hook already there is
|
||||
* not corrected but replaced: the new system webhook is made first, with the secret kept now, so no event falls in a
|
||||
* gap; then every other hook at the receiver's url is deleted — system, default, and each repository's copy of a
|
||||
* default one, which would send that repository's events twice. The forge's listing of system webhooks must then name
|
||||
* exactly one at the url, or this fails, saying so.
|
||||
*/
|
||||
export async function ensureSystemHook(admin: HookAdmin, url: string, secret: string): Promise<HookState> {
|
||||
// The forge has no "every event" word: each family is named, and a family name (issues, pull_request) takes all of it.
|
||||
const want = { type: "gitea", active: true, events: HOOK_EVENTS, branch_filter: "*",
|
||||
config: { url, content_type: "json", secret, is_system_webhook: "true" } };
|
||||
const before = new Set((await hooksAt(admin, "system", url)).map((h) => Number(h.id)));
|
||||
const made = await admin.call("POST", "/admin/hooks", want);
|
||||
if (made.status !== 201 && made.status !== 200) throw new Error(`the forge's system webhook could not be made: ${made.status}`);
|
||||
const fresh = (await hooksAt(admin, "system", url)).filter((h) => !before.has(Number(h.id)));
|
||||
if (fresh.length !== 1) {
|
||||
throw new Error(`the forge names ${fresh.length} new system webhooks at ${url} after one was made, not one: its events ` +
|
||||
`are not known to be sent`);
|
||||
}
|
||||
const ours = fresh[0];
|
||||
const defaults = await hooksAt(admin, "default", url);
|
||||
const gone = async (path: string): Promise<void> => {
|
||||
const r = await admin.call("DELETE", path);
|
||||
if (r.status !== 204 && r.status !== 200 && r.status !== 404) throw new Error(`the forge's webhook ${path} could not be deleted: ${r.status}`);
|
||||
};
|
||||
for (const id of before) await gone(`/admin/hooks/${id}`);
|
||||
for (const h of defaults) await gone(`/admin/hooks/${h.id}`);
|
||||
const copies = await repoHooksAt(admin, url);
|
||||
for (const c of copies) await gone(`/repos/${c.repo}/hooks/${c.id}`);
|
||||
const kept = await hooksAt(admin, "system", url);
|
||||
if (kept.length !== 1 || Number(kept[0].id) !== Number(ours.id)) {
|
||||
throw new Error(`the forge names ${kept.length} system webhooks at ${url} once the others were deleted, not the one ` +
|
||||
`made (${ours.id})`);
|
||||
}
|
||||
const removed = { system: before.size, default: defaults.length, repository: copies.length,
|
||||
repositories: [...new Set(copies.map((c) => c.repo))] };
|
||||
return { kind: "system", id: Number(ours.id), url, events: Array.isArray(ours.events) ? ours.events : [],
|
||||
active: !!ours.active, content_type: ours.config?.content_type,
|
||||
did: removed.system + removed.default + removed.repository > 0 ? "replaced" : "created", removed };
|
||||
}
|
||||
|
||||
/** The `gitea_events` tool: what the module knows of the forge's events, from its Pace. */
|
||||
export function eventsTool(pace: Pace): { name: string; description: string; input: Record<string, unknown>;
|
||||
run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> } {
|
||||
return {
|
||||
name: "gitea_events",
|
||||
description: "Whether the forge's events reach the mesh (novox/hq issue 729): where the receiver listens, the " +
|
||||
"system webhook as ensured at the start (its id, url, events, kind, and what it replaced), how many deliveries " +
|
||||
"verified and the last one's time and event, how many were refused and the last reason, how often the polls look " +
|
||||
"and whether events are heard, and how many heads or merges the poll found that no event said. Unhealthy when the " +
|
||||
"forge has delivered nothing that verified for ten minutes (since the start or since the last delivery) while the " +
|
||||
"poll found what no event said, when deliveries were refused since the last verified one, or when the receiver " +
|
||||
"could not listen or the webhook could not be made.",
|
||||
input: {},
|
||||
run: async () => pace.report(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* **Claim a head for announcing, once** (issue 729): true when `sha` is not already the head recorded for `key`, which
|
||||
* it then is. The poll and the forge's event both claim before they announce, so whichever comes second finds it taken.
|
||||
*/
|
||||
export function claimHead(heads: Record<string, string>, key: string, sha: string): boolean {
|
||||
if (!sha || heads[key] === sha) return false;
|
||||
heads[key] = sha;
|
||||
return true;
|
||||
}
|
||||
@@ -26,12 +26,24 @@
|
||||
"why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"org": {
|
||||
"kind": "preference",
|
||||
"default": "mesh",
|
||||
"why": "the organisation influxdb-api names to its consumers; mesh is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11)"
|
||||
},
|
||||
"bucket": {
|
||||
"kind": "preference",
|
||||
"default": "default",
|
||||
"why": "the bucket influxdb-api names to its consumers; default is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11)"
|
||||
}
|
||||
},
|
||||
"serves": {
|
||||
"influxdb-api": {
|
||||
"scheme": "http",
|
||||
"port": 8086,
|
||||
"org": "mesh",
|
||||
"bucket": "default"
|
||||
"org": "${setting:org}",
|
||||
"bucket": "${setting:bucket}"
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
@@ -124,7 +136,13 @@
|
||||
"path": "${dir:mesh-state}/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
"merge": "json",
|
||||
"takes-settings": [
|
||||
"bucket",
|
||||
"org",
|
||||
"url"
|
||||
],
|
||||
"trusted": true
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# kernel
|
||||
|
||||
The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). Today it holds one:
|
||||
**transparent huge pages**, set per machine. The rest of the row (the kernel package, microcode, boot
|
||||
entries) is not built yet.
|
||||
|
||||
## Why (novox/hq issue 746)
|
||||
|
||||
With transparent huge pages `always`, the page cache of a file mapped by a program is collapsed into
|
||||
2 MiB folios (khugepaged), and a small write into such a folio dirties all of it: writeback then writes
|
||||
2 MiB. journald writes its journal files through mmap, and every journal reader (fail2ban's journal
|
||||
jails among them) maps them too. Measured on the control node on 2026-10-14: journald's cgroup had
|
||||
written 2.5 TB in 12 days while the journal grew by about 25-30 KB/s, fail2ban's cgroup 63 GB in 4 h
|
||||
while its own files did not grow, both holding hundreds of MiB of file pages in huge folios; the bursts
|
||||
saturated the mirrored spinning root disk that holds the bus store and the store, and the controller
|
||||
lost its lease four times in 30 minutes. With `madvise`, only memory a program asks to have in huge pages
|
||||
(`madvise(MADV_HUGEPAGE)`) gets them; a database or a JVM that wants them still asks.
|
||||
|
||||
## What it owns
|
||||
|
||||
| | what | notes |
|
||||
|---|---|---|
|
||||
| setting | `transparent-huge-pages` | `always`, `madvise` or `never`. Default `always`, what the distribution's kernel boots with, so assigning the module changes nothing until a machine sets another value |
|
||||
| file | `${dir:state}/huge-pages` | the script, written from the setting: `apply` writes the mode to `/sys/kernel/mm/transparent_hugepage/enabled` and reads it back (a value that does not take fails the unit); `restore` gives back the mode found before the module first set it, kept in `${dir:state}/found` |
|
||||
| file | `/etc/systemd/system/mesh-kernel-huge-pages.service` | oneshot, early in boot (`sysinit.target`, before the services that fill the page cache); reloading it runs `apply`, stopping it runs `restore`, which also removes `found`. Sandboxed: the file system read-only but for the module's state (`/sys` stays writable), no home, no network, no new privileges; the journal's rotation goes over its local socket, which a private network leaves reachable |
|
||||
| service | `mesh-kernel-huge-pages.service` | running and enabled; **reloaded** when the script (so the setting) changes, so a new value is set directly and never passes back through the found one; restarted only when the unit changes; its health is the unit's |
|
||||
|
||||
The per-size controls (`hugepages-<n>kB/enabled`) are left alone: on the machines read, 2048 kB says
|
||||
`inherit` (it follows the mode) and every other size `never`. `kernel_huge_pages` shows each one.
|
||||
|
||||
**A folio already in the page cache keeps its size.** A new mode applies to pages faulted or collapsed
|
||||
from then on. The active journal files would keep their 2 MiB folios until archived, so `apply` rotates
|
||||
the journal (`journalctl --rotate`) whenever it moves the mode away from `always`: new files are mapped
|
||||
under the new mode. A failed rotation is said in the unit's journal and does not fail the setting. No
|
||||
rotation is made while the machine is still starting (`systemctl is-system-running` says `initializing`
|
||||
or `starting`): nothing is in huge pages yet, and the persistent journal opens after this unit.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | answers |
|
||||
|---|---|---|
|
||||
| `kernel_huge_pages` | r | the mode, defrag and shared-memory mode; each per-size control and what `inherit` resolves to; huge pages by kind from `/proc/meminfo` (MiB); the units holding the most file pages in huge folios (`memory.stat` `file_thp`) with their dirty and writeback bytes, khugepaged's collapses and the bytes their cgroup wrote (`io.stat`, the most any one device saw: a mirror and its members each count the same write) |
|
||||
|
||||
## Tests
|
||||
|
||||
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the
|
||||
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); and the script as the mesh
|
||||
writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once,
|
||||
rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a
|
||||
reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel
|
||||
does not take before writing anything, fails when the write does not take, and says when the kernel has
|
||||
no transparent huge pages.
|
||||
@@ -1,241 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// THPDir is where the kernel says and takes its transparent huge pages.
|
||||
const THPDir = "sys/kernel/mm/transparent_hugepage"
|
||||
|
||||
// CgroupRoot is the unified cgroup hierarchy.
|
||||
const CgroupRoot = "sys/fs/cgroup"
|
||||
|
||||
// HugePagesAnswer is what kernel_huge_pages answers.
|
||||
type HugePagesAnswer struct {
|
||||
// Mode is the word the kernel brackets in enabled: always, madvise or never.
|
||||
Mode string `json:"mode"`
|
||||
Defrag string `json:"defrag,omitempty"`
|
||||
Shmem string `json:"shmem_enabled,omitempty"`
|
||||
PerSize []Size `json:"per_size,omitempty"`
|
||||
// Meminfo is the machine's huge pages by kind, in MiB.
|
||||
Meminfo map[string]float64 `json:"meminfo_mib,omitempty"`
|
||||
Units []Unit `json:"units"`
|
||||
Said []string `json:"said"`
|
||||
}
|
||||
|
||||
// Size is one per-size control (hugepages-<n>kB/enabled).
|
||||
type Size struct {
|
||||
Size string `json:"size"`
|
||||
Enabled string `json:"enabled"`
|
||||
// Means is what inherit resolves to: the top-level mode.
|
||||
Means string `json:"means"`
|
||||
}
|
||||
|
||||
// Unit is one cgroup holding file pages in huge folios.
|
||||
type Unit struct {
|
||||
Cgroup string `json:"cgroup"`
|
||||
FileTHP int64 `json:"file_thp_bytes"`
|
||||
FileDirty int64 `json:"file_dirty_bytes"`
|
||||
FileWriteback int64 `json:"file_writeback_bytes"`
|
||||
FileMapped int64 `json:"file_mapped_bytes"`
|
||||
CollapsedByKhug int64 `json:"thp_collapse_alloc"`
|
||||
Written int64 `json:"written_bytes"`
|
||||
}
|
||||
|
||||
// bracketed is the word a sysfs choice file brackets: "always [madvise] never" is madvise.
|
||||
func bracketed(text string) string {
|
||||
open := strings.IndexByte(text, '[')
|
||||
end := strings.IndexByte(text, ']')
|
||||
if open < 0 || end < open {
|
||||
return ""
|
||||
}
|
||||
return text[open+1 : end]
|
||||
}
|
||||
|
||||
// HugePages reads the mode, the per-size controls, meminfo and the top units by file_thp.
|
||||
func (m *Machine) HugePages(units int) (*HugePagesAnswer, error) {
|
||||
enabled, err := m.read(THPDir + "/enabled")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("this kernel has no /%s/enabled: it was built without transparent huge pages", THPDir)
|
||||
}
|
||||
return nil, fmt.Errorf("reading /%s/enabled: %w", THPDir, err)
|
||||
}
|
||||
a := &HugePagesAnswer{Mode: bracketed(enabled), Units: []Unit{}}
|
||||
if s, err := m.read(THPDir + "/defrag"); err == nil {
|
||||
a.Defrag = bracketed(s)
|
||||
}
|
||||
if s, err := m.read(THPDir + "/shmem_enabled"); err == nil {
|
||||
a.Shmem = bracketed(s)
|
||||
}
|
||||
a.Said = append(a.Said, fmt.Sprintf("transparent huge pages: %s, defrag %s", a.Mode, orUnknown(a.Defrag)))
|
||||
|
||||
sizes, _ := m.dirs(THPDir)
|
||||
for _, d := range sizes {
|
||||
if !strings.HasPrefix(d, "hugepages-") {
|
||||
continue
|
||||
}
|
||||
s, err := m.read(THPDir + "/" + d + "/enabled")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sz := Size{Size: strings.TrimPrefix(d, "hugepages-"), Enabled: bracketed(s)}
|
||||
sz.Means = sz.Enabled
|
||||
if sz.Enabled == "inherit" {
|
||||
sz.Means = a.Mode
|
||||
}
|
||||
a.PerSize = append(a.PerSize, sz)
|
||||
}
|
||||
sort.Slice(a.PerSize, func(i, j int) bool { return kib(a.PerSize[i].Size) < kib(a.PerSize[j].Size) })
|
||||
var on []string
|
||||
for _, s := range a.PerSize {
|
||||
if s.Means != "never" {
|
||||
on = append(on, s.Size+" "+s.Means)
|
||||
}
|
||||
}
|
||||
if len(a.PerSize) > 0 {
|
||||
a.Said = append(a.Said, "sizes not never: "+orNone(on))
|
||||
}
|
||||
|
||||
if mi, err := m.read("proc/meminfo"); err == nil {
|
||||
a.Meminfo = meminfo(mi)
|
||||
a.Said = append(a.Said, fmt.Sprintf("huge pages held: anonymous %.0f MiB, shared memory %.0f MiB, file %.0f MiB (mapped at 2 MiB %.0f MiB)",
|
||||
a.Meminfo["AnonHugePages"], a.Meminfo["ShmemHugePages"], a.Meminfo["FileHugePages"], a.Meminfo["FilePmdMapped"]))
|
||||
}
|
||||
|
||||
if units > 0 {
|
||||
all := m.cgroups()
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].FileTHP != all[j].FileTHP {
|
||||
return all[i].FileTHP > all[j].FileTHP
|
||||
}
|
||||
return all[i].Cgroup < all[j].Cgroup
|
||||
})
|
||||
if len(all) > units {
|
||||
all = all[:units]
|
||||
}
|
||||
a.Units = all
|
||||
for _, u := range all {
|
||||
a.Said = append(a.Said, fmt.Sprintf("%s: file in huge folios %s, dirty %s, writeback %s, written %s since it started",
|
||||
u.Cgroup, mib(u.FileTHP), mib(u.FileDirty), mib(u.FileWriteback), mib(u.Written)))
|
||||
}
|
||||
if len(all) == 0 {
|
||||
a.Said = append(a.Said, "no unit holds file pages in huge folios")
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// cgroups walks the leaf services and scopes (to three levels below the root) that hold file_thp.
|
||||
func (m *Machine) cgroups() []Unit {
|
||||
var out []Unit
|
||||
var walk func(rel string, depth int)
|
||||
walk = func(rel string, depth int) {
|
||||
children, _ := m.dirs(path.Join(CgroupRoot, rel))
|
||||
for _, c := range children {
|
||||
p := path.Join(rel, c)
|
||||
if strings.HasSuffix(c, ".slice") {
|
||||
if depth < 3 {
|
||||
walk(p, depth+1)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(c, ".service") && !strings.HasSuffix(c, ".scope") {
|
||||
continue
|
||||
}
|
||||
stat, err := m.read(path.Join(CgroupRoot, p, "memory.stat"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
kv := memoryStat(stat)
|
||||
if kv["file_thp"] == 0 {
|
||||
continue
|
||||
}
|
||||
u := Unit{Cgroup: p, FileTHP: kv["file_thp"], FileDirty: kv["file_dirty"], FileWriteback: kv["file_writeback"],
|
||||
FileMapped: kv["file_mapped"], CollapsedByKhug: kv["thp_collapse_alloc"]}
|
||||
if io, err := m.read(path.Join(CgroupRoot, p, "io.stat")); err == nil {
|
||||
u.Written = mostWritten(io)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
walk("", 0)
|
||||
return out
|
||||
}
|
||||
|
||||
// memoryStat reads memory.stat's "key value" lines.
|
||||
func memoryStat(text string) map[string]int64 {
|
||||
kv := map[string]int64{}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(l)
|
||||
if len(f) != 2 {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.ParseInt(f[1], 10, 64); err == nil {
|
||||
kv[f[0]] = n
|
||||
}
|
||||
}
|
||||
return kv
|
||||
}
|
||||
|
||||
// mostWritten is the most bytes any one device of io.stat saw written: a mirror's member disks and the
|
||||
// mirror itself each count the same write, so the lines are never summed.
|
||||
func mostWritten(text string) int64 {
|
||||
var most int64
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
for _, f := range strings.Fields(l) {
|
||||
if v, ok := strings.CutPrefix(f, "wbytes="); ok {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > most {
|
||||
most = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return most
|
||||
}
|
||||
|
||||
// meminfo reads the huge page lines of /proc/meminfo, in MiB.
|
||||
func meminfo(text string) map[string]float64 {
|
||||
want := map[string]bool{"AnonHugePages": true, "ShmemHugePages": true, "ShmemPmdMapped": true, "FileHugePages": true, "FilePmdMapped": true}
|
||||
out := map[string]float64{}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
k, v, ok := strings.Cut(l, ":")
|
||||
if !ok || !want[k] {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(v)
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.ParseFloat(f[0], 64); err == nil {
|
||||
out[k] = n / 1024
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func kib(size string) int {
|
||||
n, _ := strconv.Atoi(strings.TrimSuffix(size, "kB"))
|
||||
return n
|
||||
}
|
||||
|
||||
func mib(b int64) string { return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20)) }
|
||||
|
||||
func orUnknown(s string) string {
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func orNone(list []string) string {
|
||||
if len(list) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(list, ", ")
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package main
|
||||
|
||||
// Over a stand-in tree holding novox's own files of 2026-10-14 23:25 (read through node-reads for
|
||||
// novox/hq issue 746): the mode, the per-size controls, meminfo's huge page lines, and fail2ban's and
|
||||
// journald's memory.stat and io.stat, cut to the lines the tool reads.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func tree(t *testing.T, files map[string]string) *Machine {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for p, content := range files {
|
||||
full := filepath.Join(root, p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return &Machine{Root: root}
|
||||
}
|
||||
|
||||
func novox(t *testing.T) *Machine {
|
||||
thp := "sys/kernel/mm/transparent_hugepage/"
|
||||
cg := "sys/fs/cgroup/system.slice/"
|
||||
files := map[string]string{
|
||||
thp + "enabled": "[always] madvise never\n",
|
||||
thp + "defrag": "always defer defer+madvise [madvise] never\n",
|
||||
thp + "shmem_enabled": "always within_size [advise] never deny force\n",
|
||||
thp + "hugepages-2048kB/enabled": "always [inherit] madvise never\n",
|
||||
thp + "hugepages-64kB/enabled": "always inherit madvise [never]\n",
|
||||
thp + "hugepages-1024kB/enabled": "always inherit madvise [never]\n",
|
||||
"proc/meminfo": "MemTotal: 65748808 kB\nAnonHugePages: 3579904 kB\nShmemHugePages: 4096 kB\n" +
|
||||
"ShmemPmdMapped: 4096 kB\nFileHugePages: 1693696 kB\nFilePmdMapped: 882688 kB\nHugepagesize: 2048 kB\n",
|
||||
cg + "fail2ban.service/memory.stat": "anon 21815296\nfile 204623872\nfile_mapped 130736128\nfile_dirty 27262976\n" +
|
||||
"file_writeback 4096\nanon_thp 0\nfile_thp 197132288\nthp_fault_alloc 0\nthp_collapse_alloc 550\n",
|
||||
cg + "fail2ban.service/io.stat": "9:0 \n8:16 rbytes=3223552 wbytes=67723415552 rios=240 wios=281159 dbytes=0 dios=0\n" +
|
||||
"8:0 rbytes=6152192 wbytes=67723415552 rios=832 wios=281159 dbytes=0 dios=0\n" +
|
||||
"9:2 rbytes=9375744 wbytes=67723415552 rios=1072 wios=281159 dbytes=0 dios=0\n",
|
||||
cg + "systemd-journald.service/memory.stat": "anon 3227648\nfile 1032015872\nfile_mapped 131928064\nfile_dirty 622592\n" +
|
||||
"file_writeback 159744\nanon_thp 2097152\nfile_thp 379584512\nthp_fault_alloc 0\nthp_collapse_alloc 11741\n",
|
||||
cg + "systemd-journald.service/io.stat": "9:0 \n8:16 rbytes=55209984 wbytes=2498768035840 rios=1317 wios=24719004 dbytes=0 dios=0\n" +
|
||||
"8:0 rbytes=72953856 wbytes=2498768039936 rios=2194 wios=24719005 dbytes=0 dios=0\n" +
|
||||
"9:2 rbytes=128163840 wbytes=2498724843520 rios=3511 wios=24707873 dbytes=0 dios=0\n",
|
||||
cg + "sshd.service/memory.stat": "anon 1000\nfile_thp 0\n",
|
||||
}
|
||||
return tree(t, files)
|
||||
}
|
||||
|
||||
func TestNovoxBeforeTheChange(t *testing.T) {
|
||||
a, err := novox(t).HugePages(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Mode != "always" || a.Defrag != "madvise" || a.Shmem != "advise" {
|
||||
t.Fatalf("modes: %+v", a)
|
||||
}
|
||||
if len(a.PerSize) != 3 || a.PerSize[0].Size != "64kB" || a.PerSize[2].Size != "2048kB" ||
|
||||
a.PerSize[2].Enabled != "inherit" || a.PerSize[2].Means != "always" || a.PerSize[0].Means != "never" {
|
||||
t.Fatalf("per size: %+v", a.PerSize)
|
||||
}
|
||||
if a.Meminfo["FileHugePages"] != 1654 || a.Meminfo["FilePmdMapped"] != 862 {
|
||||
t.Fatalf("meminfo: %v", a.Meminfo)
|
||||
}
|
||||
if len(a.Units) != 2 || a.Units[0].Cgroup != "system.slice/systemd-journald.service" ||
|
||||
a.Units[1].Cgroup != "system.slice/fail2ban.service" {
|
||||
t.Fatalf("units: %+v", a.Units)
|
||||
}
|
||||
// The mirror and its two members each count the write: the most any one saw, never their sum.
|
||||
if a.Units[1].Written != 67723415552 || a.Units[1].FileTHP != 197132288 || a.Units[1].CollapsedByKhug != 550 {
|
||||
t.Fatalf("fail2ban: %+v", a.Units[1])
|
||||
}
|
||||
said := strings.Join(a.Said, "\n")
|
||||
for _, want := range []string{"transparent huge pages: always, defrag madvise", "sizes not never: 2048kB always",
|
||||
"file 1654 MiB (mapped at 2 MiB 862 MiB)", "system.slice/fail2ban.service: file in huge folios 188.0 MiB"} {
|
||||
if !strings.Contains(said, want) {
|
||||
t.Errorf("said lacks %q:\n%s", want, said)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitsAreBoundedAndNoneIsSaid(t *testing.T) {
|
||||
a, err := novox(t).HugePages(1)
|
||||
if err != nil || len(a.Units) != 1 {
|
||||
t.Fatalf("%v %+v", err, a)
|
||||
}
|
||||
b, err := tree(t, map[string]string{"sys/kernel/mm/transparent_hugepage/enabled": "always [madvise] never\n"}).HugePages(10)
|
||||
if err != nil || b.Mode != "madvise" || len(b.Units) != 0 || !strings.Contains(strings.Join(b.Said, "\n"), "no unit holds") {
|
||||
t.Fatalf("%v %+v", err, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKernelWithoutHugePagesIsAnError(t *testing.T) {
|
||||
if _, err := tree(t, map[string]string{"proc/meminfo": ""}).HugePages(10); err == nil ||
|
||||
!strings.Contains(err.Error(), "built without transparent huge pages") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheArgumentIsBounded(t *testing.T) {
|
||||
if _, err := whole(map[string]any{"units": -1.0}, "units", 10, 0, 100); err == nil {
|
||||
t.Error("a negative count was accepted")
|
||||
}
|
||||
if n, _ := whole(map[string]any{"units": 1000.0}, "units", 10, 0, 100); n != 100 {
|
||||
t.Errorf("not bounded: %d", n)
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package main
|
||||
|
||||
// The machine as the tools see it: the files they read, under a root that tests replace with a stand-in
|
||||
// tree. Everything read here is readable by the runtime's account; nothing is escalated.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Machine reads this machine's kernel files under Root ("/" on the machine).
|
||||
type Machine struct {
|
||||
Root string
|
||||
}
|
||||
|
||||
// ThisMachine is the machine the runtime launched this bundle on.
|
||||
func ThisMachine() *Machine { return &Machine{Root: "/"} }
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
func (m *Machine) read(p string) (string, error) {
|
||||
b, err := os.ReadFile(m.path(p))
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func (m *Machine) dirs(p string) ([]string, error) {
|
||||
entries, err := os.ReadDir(m.path(p))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || e.Type()&fs.ModeSymlink != 0 {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument with a default, kept within bounds.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
raw, present := args[key]
|
||||
if !present || raw == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := raw.(float64)
|
||||
if !ok || f != float64(int(f)) {
|
||||
return 0, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least {
|
||||
return 0, fmt.Errorf("%s must be at least %d", key, least)
|
||||
}
|
||||
if n > most {
|
||||
n = most
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// schema is a tool's input: its properties.
|
||||
func schema(properties map[string]any) map[string]any {
|
||||
return map[string]any{"type": "object", "properties": properties}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// The kernel module's tools bundle (novox/hq to-be 42 Phase 1 row 11, issue 746).
|
||||
//
|
||||
// Served by the node's runtime over MCP on stdio through the Go SDK (ADR 0188, ADR 0193). It only reads:
|
||||
// the transparent huge pages the kernel runs with, and which units hold file pages in huge folios, which
|
||||
// is what a small write to a mapped file costs a whole huge page of writeback for (issue 746).
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// binaryName is what the build names this bundle's executable: the manifest's `binary`.
|
||||
const binaryName = "kernel-tools"
|
||||
|
||||
func main() {
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): kernel.
|
||||
if err := stdio.Serve("", tools(ThisMachine())); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func tools(m *Machine) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "kernel_huge_pages",
|
||||
Description: "Transparent huge pages on this machine: the mode in force (always, madvise or never) and defrag, " +
|
||||
"each per-size control with what inherit resolves to, the machine's huge pages by kind (anonymous, " +
|
||||
"shared memory, file, file mapped at 2 MiB) from /proc/meminfo, and the units holding the most file " +
|
||||
"pages in huge folios (memory.stat file_thp) with their dirty and writeback bytes and the bytes their " +
|
||||
"cgroup has written (io.stat, the most any one device saw). A unit with file_thp that writes far more " +
|
||||
"than its files grow is paying a whole huge page of writeback per small write (novox/hq issue 746). " +
|
||||
"Replaces cat /sys/kernel/mm/transparent_hugepage/enabled, grep Huge /proc/meminfo and reading " +
|
||||
"memory.stat by hand. (r)",
|
||||
Input: schema(map[string]any{
|
||||
"units": map[string]any{"type": "integer", "description": "how many units to name, the most file_thp first (default 10, 0 for none, at most 100)"},
|
||||
}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
n, err := whole(args, "units", 10, 0, 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.HugePages(n)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package main
|
||||
|
||||
// The module's shape (novox/hq issue 746): the setting's default is what the kernel boots with, so
|
||||
// assigning the module changes nothing until a machine sets another value; the script is written into the
|
||||
// module's own state and run by a sandboxed oneshot unit that is reloaded (applied again) when the script,
|
||||
// and so the setting, changes and restarted only when the unit does; and the script itself sets, verifies,
|
||||
// gives back and refuses as it says.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheDefaultChangesNothingAndTheUnitFollowsTheSetting(t *testing.T) {
|
||||
m := manifest(t)
|
||||
s := m.Settings["transparent-huge-pages"]
|
||||
if s["kind"] != "preference" || s["default"] != "always" || s["why"] == "" {
|
||||
t.Fatalf("the setting: %v", s)
|
||||
}
|
||||
var ids []string
|
||||
for _, r := range m.Resources {
|
||||
ids = append(ids, r["id"].(string))
|
||||
}
|
||||
if strings.Join(ids, " ") != "state huge-pages unit apply" {
|
||||
t.Fatalf("order: %v", ids)
|
||||
}
|
||||
script := m.resource(t, "huge-pages")
|
||||
// The script runs as root, so the setting it is written from is set at the terminal alone (issue 339).
|
||||
if script["path"] != "${dir:state}/huge-pages" || script["mode"] != "0755" || script["trusted"] != true ||
|
||||
!strings.Contains(script["content"].(string), "want='${setting:transparent-huge-pages}'") {
|
||||
t.Fatalf("the script: %v", script["path"])
|
||||
}
|
||||
unit := m.resource(t, "unit")["content"].(string)
|
||||
for _, line := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=${dir:state}/huge-pages apply",
|
||||
"ExecReload=${dir:state}/huge-pages apply", "ExecStop=${dir:state}/huge-pages restore", "WantedBy=sysinit.target",
|
||||
"DefaultDependencies=no", "ProtectSystem=strict", "ReadWritePaths=${dir:state}", "ProtectHome=yes", "PrivateNetwork=yes",
|
||||
"NoNewPrivileges=yes"} {
|
||||
if !strings.Contains(unit, line+"\n") {
|
||||
t.Errorf("the unit lacks %q", line)
|
||||
}
|
||||
}
|
||||
apply := m.resource(t, "apply")
|
||||
if apply["unit"] != "mesh-kernel-huge-pages.service" || apply["state"] != "running" || apply["boot"] != "enabled" {
|
||||
t.Fatalf("apply: %v", apply)
|
||||
}
|
||||
// A new value reloads (apply again); only a new unit restarts, whose stop would pass back through the
|
||||
// mode the machine was found with.
|
||||
if on := apply["restart-on"].([]any); len(on) != 1 || on[0] != "unit" {
|
||||
t.Fatalf("restart-on: %v", on)
|
||||
}
|
||||
if on := apply["reload-on"].([]any); len(on) != 1 || on[0] != "huge-pages" {
|
||||
t.Fatalf("reload-on: %v", on)
|
||||
}
|
||||
if h := apply["health"].(map[string]any); h["kind"] != "unit" {
|
||||
t.Fatalf("health: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// bed is the script as the mesh writes it for one value of the setting, a stand-in transparent_hugepage
|
||||
// directory, the module's state, and a PATH whose journalctl records that it was asked to rotate.
|
||||
type bed struct {
|
||||
script, sys, state, rotated, path, bin string
|
||||
}
|
||||
|
||||
func newBed(t *testing.T, want, found string) bed {
|
||||
return newBedIn(t, t.TempDir(), want, found)
|
||||
}
|
||||
|
||||
// newBedIn writes the script for want into dir; a second bed in the same dir shares the machine and the
|
||||
// state, as a reload with a new value does.
|
||||
func newBedIn(t *testing.T, dir, want, found string) bed {
|
||||
t.Helper()
|
||||
b := bed{script: filepath.Join(dir, "huge-pages"), sys: filepath.Join(dir, "thp"), state: filepath.Join(dir, "state"),
|
||||
rotated: filepath.Join(dir, "rotated")}
|
||||
content := manifest(t).resource(t, "huge-pages")["content"].(string)
|
||||
content = strings.ReplaceAll(content, "${setting:transparent-huge-pages}", want)
|
||||
content = strings.ReplaceAll(content, "${dir:state}", b.state)
|
||||
if strings.Contains(content, "${") {
|
||||
t.Fatalf("a substitution the test does not make is left in the script")
|
||||
}
|
||||
bin := filepath.Join(dir, "bin")
|
||||
for _, d := range []string{b.sys, b.state, bin} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(t, b.script, content, 0o755)
|
||||
if found != "" {
|
||||
write(t, filepath.Join(b.sys, "enabled"), found, 0o644)
|
||||
}
|
||||
write(t, filepath.Join(bin, "journalctl"), "#!/bin/sh\necho \"$@\" >>"+b.rotated+"\n", 0o755)
|
||||
b.bin = bin
|
||||
b.booted(t, "running")
|
||||
b.path = bin + string(os.PathListSeparator) + os.Getenv("PATH")
|
||||
return b
|
||||
}
|
||||
|
||||
// booted makes the service manager say state for is-system-running.
|
||||
func (b bed) booted(t *testing.T, state string) {
|
||||
t.Helper()
|
||||
write(t, filepath.Join(b.bin, "systemctl"), "#!/bin/sh\n[ \"$1\" = is-system-running ] && echo "+state+"\n", 0o755)
|
||||
}
|
||||
|
||||
func write(t *testing.T, p, content string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(p, []byte(content), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b bed) run(t *testing.T, verb string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("sh", b.script, verb, b.sys, b.state)
|
||||
cmd.Env = append(os.Environ(), "PATH="+b.path)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func (b bed) file(t *testing.T, p string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestApplySetsTheModeRotatesTheJournalOnceAndRestoreGivesBackWhatWasFound(t *testing.T) {
|
||||
// novox's file on 2026-10-14, read through node-reads.
|
||||
b := newBed(t, "madvise", "[always] madvise never\n")
|
||||
out, err := b.run(t, "apply")
|
||||
if err != nil || !strings.Contains(out, "always -> madvise") {
|
||||
t.Fatalf("apply: %v %s", err, out)
|
||||
}
|
||||
if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "madvise\n" {
|
||||
t.Fatalf("written: %q", got)
|
||||
}
|
||||
if got := b.file(t, filepath.Join(b.state, "found")); got != "always\n" {
|
||||
t.Fatalf("found: %q", got)
|
||||
}
|
||||
if got := b.file(t, b.rotated); got != "--rotate\n" {
|
||||
t.Fatalf("rotation: %q", got)
|
||||
}
|
||||
// Applied again (a restart with the same value): nothing written, nothing rotated, found kept.
|
||||
if out, err := b.run(t, "apply"); err != nil || !strings.Contains(out, "madvise, as set") {
|
||||
t.Fatalf("again: %v %s", err, out)
|
||||
}
|
||||
if got := b.file(t, b.rotated); got != "--rotate\n" {
|
||||
t.Fatalf("rotated again: %q", got)
|
||||
}
|
||||
if out, err := b.run(t, "restore"); err != nil || !strings.Contains(out, "back to always") {
|
||||
t.Fatalf("restore: %v %s", err, out)
|
||||
}
|
||||
if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "always\n" {
|
||||
t.Fatalf("restored: %q", got)
|
||||
}
|
||||
// Nothing of the module is kept once it is stopped.
|
||||
if _, err := os.Stat(filepath.Join(b.state, "found")); !os.IsNotExist(err) {
|
||||
t.Fatalf("found kept after restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReloadWithANewValueGoesStraightThereAndNeverRewritesFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := newBedIn(t, dir, "madvise", "[always] madvise never\n")
|
||||
if out, err := b.run(t, "apply"); err != nil {
|
||||
t.Fatalf("apply: %v %s", err, out)
|
||||
}
|
||||
// The setting changes to never: the mesh rewrites the script and reloads the unit, which applies again.
|
||||
n := newBedIn(t, dir, "never", "")
|
||||
out, err := n.run(t, "apply")
|
||||
if err != nil || !strings.Contains(out, "madvise -> never") {
|
||||
t.Fatalf("reload: %v %s", err, out)
|
||||
}
|
||||
if got := n.file(t, filepath.Join(n.state, "found")); got != "always\n" {
|
||||
t.Fatalf("found rewritten by a reload: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoRotationWhileTheMachineIsStarting(t *testing.T) {
|
||||
for _, state := range []string{"initializing", "starting"} {
|
||||
b := newBed(t, "madvise", "[always] madvise never\n")
|
||||
b.booted(t, state)
|
||||
out, err := b.run(t, "apply")
|
||||
if err != nil || !strings.Contains(out, "still starting") {
|
||||
t.Fatalf("%s: %v %s", state, err, out)
|
||||
}
|
||||
if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "madvise\n" {
|
||||
t.Fatalf("%s: not set: %q", state, got)
|
||||
}
|
||||
if got := b.file(t, b.rotated); got != "" {
|
||||
t.Fatalf("%s: rotated at boot: %q", state, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlwaysIsSetWithoutRotating(t *testing.T) {
|
||||
b := newBed(t, "always", "always [madvise] never\n")
|
||||
if out, err := b.run(t, "apply"); err != nil || !strings.Contains(out, "madvise -> always") {
|
||||
t.Fatalf("apply: %v %s", err, out)
|
||||
}
|
||||
if got := b.file(t, b.rotated); got != "" {
|
||||
t.Fatalf("rotated for always: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAValueTheKernelDoesNotTakeIsRefusedBeforeAnythingIsWritten(t *testing.T) {
|
||||
b := newBed(t, "sometimes", "[always] madvise never\n")
|
||||
out, err := b.run(t, "apply")
|
||||
if err == nil || !strings.Contains(out, "takes always, madvise or never") {
|
||||
t.Fatalf("accepted: %v %s", err, out)
|
||||
}
|
||||
if got := b.file(t, filepath.Join(b.sys, "enabled")); got != "[always] madvise never\n" {
|
||||
t.Fatalf("written anyway: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWriteThatDoesNotTakeFailsTheUnit(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("root writes a read-only file")
|
||||
}
|
||||
b := newBed(t, "madvise", "[always] madvise never\n")
|
||||
if err := os.Chmod(filepath.Join(b.sys, "enabled"), 0o444); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := b.run(t, "apply"); err == nil {
|
||||
t.Fatalf("a refused write passed: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKernelWithoutHugePagesIsSaid(t *testing.T) {
|
||||
b := newBed(t, "madvise", "")
|
||||
if out, err := b.run(t, "apply"); err == nil || !strings.Contains(out, "built without transparent huge pages") {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type resource map[string]any
|
||||
|
||||
type manifestShape struct {
|
||||
Module string `json:"module"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Settings map[string]map[string]any `json:"settings"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []resource `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func manifest(t *testing.T) manifestShape {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifestShape
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifestShape) resource(t *testing.T, id string) resource {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the
|
||||
// bundle to the shape the builder compiles and the runtime loads.
|
||||
func TestToolsAreTheManifests(t *testing.T) {
|
||||
m := manifest(t)
|
||||
names := map[string]bool{}
|
||||
for _, tool := range tools(&Machine{Root: t.TempDir()}) {
|
||||
if names[tool.Name] {
|
||||
t.Errorf("%s is served twice", tool.Name)
|
||||
}
|
||||
names[tool.Name] = true
|
||||
}
|
||||
for _, want := range m.Tools {
|
||||
if !names[want] {
|
||||
t.Errorf("the manifest lists %s and the bundle does not serve it", want)
|
||||
}
|
||||
delete(names, want)
|
||||
}
|
||||
if len(names) != 0 {
|
||||
t.Errorf("served and not listed: %v", names)
|
||||
}
|
||||
var tools map[string]any
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["name"] == "tools" {
|
||||
tools = a
|
||||
}
|
||||
}
|
||||
if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" ||
|
||||
tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName {
|
||||
t.Fatalf("the tools artifact: %v", tools)
|
||||
}
|
||||
if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName {
|
||||
t.Fatalf("loads: %v", tools["loads"])
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
module kernel
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -1,2 +0,0 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -1,71 +0,0 @@
|
||||
{
|
||||
"module": "kernel",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"service-manager"
|
||||
],
|
||||
"settings": {
|
||||
"transparent-huge-pages": {
|
||||
"kind": "preference",
|
||||
"default": "always",
|
||||
"why": "always is what the distribution's kernel boots with, so assigning the module changes nothing until a machine sets another value; madvise gives huge pages only to programs that ask for them, which stops a small write to a mapped file (the journal) dirtying and writing back a whole 2 MiB page on a machine whose disks are slow (novox/hq issue 746)"
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
"kernel_huge_pages"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "huge-pages",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/huge-pages",
|
||||
"mode": "0755",
|
||||
"trusted": true,
|
||||
"content": "#!/bin/sh\n# Transparent huge pages as the mesh sets them on this machine (module kernel, novox/hq issue 746).\n# Written by the mesh from this machine's setting transparent-huge-pages and run by\n# mesh-kernel-huge-pages.service: `apply` at boot and whenever the setting changes, `restore` when the\n# unit stops (the module unassigned, or the machine shutting down). Replaced on every push; change the\n# setting, never this file. A change of the setting reloads the unit, which runs `apply` again: the mode\n# moves straight to the new value and never passes back through the one the machine was found with.\n#\n# huge-pages apply|restore [<the kernel's transparent_hugepage directory> [<this module's state>]]\n#\n# The two directories are arguments only so the module's tests can run this against a stand-in tree.\nset -eu\n\nwant='${setting:transparent-huge-pages}'\nsys=/sys/kernel/mm/transparent_hugepage\nstate='${dir:state}'\n[ $# -ge 2 ] && sys=$2\n[ $# -ge 3 ] && state=$3\n\ncase \"$want\" in\nalways | madvise | never) ;;\n*)\n\techo \"the setting transparent-huge-pages is '$want'; the kernel takes always, madvise or never\" >&2\n\texit 2\n\t;;\nesac\n[ -f \"$sys/enabled\" ] || {\n\techo \"this kernel has no $sys/enabled: it was built without transparent huge pages, so there is nothing to set\" >&2\n\texit 1\n}\n\n# The mode in force is the word the kernel brackets: \"always [madvise] never\". A file holding one bare\n# word (the stand-in tree of the tests) is read as that word.\nmode() { sed -n -e 's/.*\\[\\([a-z]*\\)\\].*/\\1/p' -e t -e 's/^ *\\([a-z]*\\) *$/\\1/p' \"$sys/enabled\"; }\n\n[ $# -ge 1 ] || set -- usage\ncase \"$1\" in\napply)\n\tnow=$(mode)\n\t# What the machine had before the mesh first set it, kept once, so `restore` gives it back.\n\t[ -s \"$state/found\" ] || printf '%s\\n' \"$now\" >\"$state/found\"\n\tif [ \"$now\" = \"$want\" ]; then\n\t\techo \"transparent huge pages: $want, as set\"\n\t\texit 0\n\tfi\n\tprintf '%s\\n' \"$want\" >\"$sys/enabled\"\n\tgot=$(mode)\n\t[ \"$got\" = \"$want\" ] || {\n\t\techo \"wrote $want to $sys/enabled and the kernel says $got\" >&2\n\t\texit 1\n\t}\n\techo \"transparent huge pages: $now -> $want\"\n\t# A file folio already in the page cache keeps its size: the active journal files, mapped by journald\n\t# and by every reader, stay in 2 MiB folios until they are archived (issue 746). Rotating starts new\n\t# files that are mapped under the new mode. A failed rotation is said and does not fail the setting.\n\t# At boot nothing is in huge folios yet, and the persistent journal opens only after this unit: no rotation\n\t# while the service manager is still bringing the machine up.\n\tbooting=$(systemctl is-system-running 2>/dev/null || true)\n\tcase \"$booting\" in\n\tinitializing | starting) echo \"the machine is still starting: nothing to rotate yet\" ;;\n\t*)\n\t\tif [ \"$want\" != always ] && command -v journalctl >/dev/null 2>&1; then\n\t\t\tjournalctl --rotate || echo \"the journal could not be rotated: its active files keep their huge pages until they are archived\" >&2\n\t\tfi\n\t\t;;\n\tesac\n\t;;\nrestore)\n\tfound=$(cat \"$state/found\" 2>/dev/null || true)\n\tcase \"$found\" in\n\talways | madvise | never)\n\t\tprintf '%s\\n' \"$found\" >\"$sys/enabled\"\n\t\techo \"transparent huge pages: back to $found, as the machine had them\"\n\t\t;;\n\t*) echo \"transparent huge pages: nothing recorded as found, left at $(mode)\" ;;\n\tesac\n\t# Unassigned or shut down: nothing of the module is kept; the next start records what it finds again.\n\trm -f \"$state/found\"\n\t;;\n*)\n\techo \"usage: huge-pages apply|restore [<transparent_hugepage directory> [<state directory>]]\" >&2\n\texit 2\n\t;;\nesac\n"
|
||||
},
|
||||
{
|
||||
"id": "unit",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/mesh-kernel-huge-pages.service",
|
||||
"mode": "0644",
|
||||
"content": "[Unit]\nDescription=Transparent huge pages as the mesh sets them on this machine\n# Module kernel, novox/hq issue 746. Starting this unit sets the mode from the setting\n# transparent-huge-pages; reloading it (the setting changed) sets the new mode; stopping it gives back the\n# mode the machine had before. It runs early in boot, before the services that fill the page cache.\nDefaultDependencies=no\nAfter=local-fs.target\nBefore=sysinit.target shutdown.target\nConflicts=shutdown.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/huge-pages apply\nExecReload=${dir:state}/huge-pages apply\nExecStop=${dir:state}/huge-pages restore\n# It writes one file under /sys and its own state, and asks the journal daemon to rotate over its socket.\nProtectSystem=strict\nReadWritePaths=${dir:state}\nProtectHome=yes\nPrivateNetwork=yes\nNoNewPrivileges=yes\n\n[Install]\nWantedBy=sysinit.target\n"
|
||||
},
|
||||
{
|
||||
"id": "apply",
|
||||
"type": "service",
|
||||
"unit": "mesh-kernel-huge-pages.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"unit"
|
||||
],
|
||||
"reload-on": [
|
||||
"huge-pages"
|
||||
],
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/kernel-tools",
|
||||
"binary": "kernel-tools",
|
||||
"loads": [
|
||||
"kernel-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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;/);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { installPlan, installStep, NPM_REPOSITORIES, outcomeOf, readStatus, REPOSITORIES, script } from "../tools/runs.ts";
|
||||
import { installPlan, installStep, NPM_REPOSITORIES, 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
|
||||
// mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its
|
||||
@@ -381,108 +381,3 @@ test("a forge setting that is not http(s) trusts no origin of its own", async ()
|
||||
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");
|
||||
});
|
||||
|
||||
+12
-51
@@ -9,8 +9,7 @@ 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,
|
||||
stop, tail, writeCredential,
|
||||
} from "./runs.js";
|
||||
|
||||
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
@@ -60,18 +59,12 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
// **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
|
||||
// installs from.
|
||||
// A credential a stopped, crashed or rebooted run or rebuild left behind is removed, and said; one still in
|
||||
// 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");
|
||||
// A credential a stopped, crashed or rebooted run left behind is removed, and said; one still in use
|
||||
// by a running run is not.
|
||||
const busy = running(work);
|
||||
const left = cleanCredentials(work, busy ? `${work}/.${busy.id}.git-credentials` : undefined);
|
||||
const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run left behind: ${left.join(", ")}` : "";
|
||||
const output = [`${check.stdout}${check.stderr}`.trim(), removed, ...leftovers, ...problems].filter(Boolean).join("\n");
|
||||
return { ok: check.status === 0 && problems.length === 0, output };
|
||||
},
|
||||
},
|
||||
@@ -103,43 +96,11 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
|
||||
}
|
||||
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(", ")}` };
|
||||
// **One job at a time, not on a base image past its bound, nor while it is rebuilt** (novox/hq issue 698,
|
||||
// review 256): the registry asked first, then the check and the start with nothing awaited between.
|
||||
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 };
|
||||
const busy = running(work);
|
||||
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
|
||||
const registry = await registryOf(forge);
|
||||
return { started: true, run: start(work, forge, tests, refs, credentialOf(forge), registry.registry),
|
||||
registry: registry.registry, ...(registry.problem ? { problem: registry.problem } : {}) };
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+9
-364
@@ -6,7 +6,7 @@
|
||||
// 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.
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -223,18 +223,16 @@ 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.
|
||||
* cleanCredentials removes every run's credential file left in the work directory but the one 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[] {
|
||||
export function cleanCredentials(work: string, keep?: 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;
|
||||
if (!/^\.run-.*\.git-credentials$/.test(name)) continue;
|
||||
const path = join(work, name);
|
||||
if (kept.has(path)) continue;
|
||||
if (path === keep) continue;
|
||||
rmSync(path, { force: true });
|
||||
removed.push(path);
|
||||
}
|
||||
@@ -288,117 +286,6 @@ export interface RunStatus {
|
||||
commits?: Record<string, string>;
|
||||
exit?: 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 {
|
||||
@@ -413,23 +300,16 @@ export function readStatus(work: string, id: string): RunStatus | undefined {
|
||||
try {
|
||||
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
|
||||
// the machine restarted under it. Said, rather than left reading as running for ever. One that never
|
||||
// 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)) {
|
||||
// the machine restarted under it. Said, rather than left reading as running for ever.
|
||||
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && s.pid && !alive(s.pid)) {
|
||||
s.state = "lost";
|
||||
}
|
||||
return settled(work, s);
|
||||
return s;
|
||||
} catch {
|
||||
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 {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
@@ -566,238 +446,3 @@ export function tail(work: string, id: string, lines: number): string {
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
// Whether mail flows through mailu, as the node-engine asks it every interval (novox/hq ADR 0240, kind
|
||||
// `tool`; issue 690). Every container read healthy on 2026-10-14 while postfix could not resolve a name:
|
||||
// inbound mail was refused with 450 and outbound mail sat in the deferred queue for days. The parts'
|
||||
// checks judge parts; this judges the flow, from inside the postfix container, the way postfix sees it:
|
||||
//
|
||||
// 1. postfix runs (`postfix status`, standing in for the image's own check, `! /usr/libexec/postfix/master
|
||||
// -t`, which this one replaces);
|
||||
// 2. the deferred queue (`postqueue -j`) shows no fault of this side: a recipient deferred longer than the
|
||||
// bound because a lookup failed here, or overdue mail to three or more domains. One or two far domains
|
||||
// that do not answer are theirs, and can stay deferred for days: said, never unhealthy, so one dead
|
||||
// recipient does not page the operator for days;
|
||||
// 3. a public mail host's name resolves through the resolver postfix is given: a resolver that
|
||||
// SERVFAILs refuses inbound mail before anything reaches the queue.
|
||||
//
|
||||
// The answer is the health answer every health tool gives (mesh-sdk go/health): `state`, `healthy` beside
|
||||
// it for an older reader, and `why` in words — counts, the oldest message's age and its first reason —
|
||||
// never a bare false.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
/** How long a message may wait in the deferred queue before mail is said not to flow. */
|
||||
export const DEFERRED_BOUND_SECONDS = 30 * 60;
|
||||
|
||||
/** The public name resolved through postfix's resolver: gmail's mail exchanger, which mail needs. */
|
||||
export const PUBLIC_NAME = "gmail-smtp-in.l.google.com";
|
||||
|
||||
// The lookup postfix's own would make, through the resolver the container is given; a failure is said on one
|
||||
// line ("gaierror: [Errno -3] Try again" is what a SERVFAIL reads as), never as a traceback.
|
||||
const RESOLVE = [
|
||||
"import socket, sys",
|
||||
"try:",
|
||||
` socket.getaddrinfo(${JSON.stringify(PUBLIC_NAME)}, 25)`,
|
||||
"except Exception as e:",
|
||||
" sys.exit(type(e).__name__ + ': ' + str(e))",
|
||||
].join("\n");
|
||||
|
||||
/** Overdue mail to this many recipient domains or more is our side's fault, not one far server's. */
|
||||
export const SPREAD_DOMAINS = 3;
|
||||
|
||||
/**
|
||||
* What postfix writes when its own lookups fail, as on 2026-10-14 (issue 687): "Host or domain name not
|
||||
* found. Name service error for name=gmail.com type=MX: Host not found, try again", or "Temporary lookup
|
||||
* failure". A far server that is down reads otherwise ("Connection timed out", "Connection refused").
|
||||
*/
|
||||
export const LOCAL_LOOKUP_FAILURE =
|
||||
/Name service error|Temporary lookup failure|Host or domain name not found.*try again/is;
|
||||
|
||||
/** The domain of an address, lower-cased; "(no recipient)" when there is none. */
|
||||
function domainOf(address?: string): string {
|
||||
const at = address?.lastIndexOf("@") ?? -1;
|
||||
return address && at >= 0 ? address.slice(at + 1).toLowerCase() : "(no recipient)";
|
||||
}
|
||||
|
||||
/** Runs a command in a container: its stdout, or a rejection carrying stderr. */
|
||||
export type Exec = (container: string, command: string[]) => Promise<string>;
|
||||
|
||||
/** The health answer (mesh-sdk go/health): healthy, or unhealthy with why. */
|
||||
export interface HealthAnswer {
|
||||
state: "healthy" | "unhealthy";
|
||||
healthy: boolean;
|
||||
why: string;
|
||||
queue?: {
|
||||
messages: number;
|
||||
deferred: number;
|
||||
overdue: number;
|
||||
oldestDeferredSeconds?: number;
|
||||
overdueDomains?: string[];
|
||||
nameServiceFailures?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** One message as `postqueue -j` prints it, one JSON object a line (postfix 3.1 and later). */
|
||||
interface Queued {
|
||||
queue_name?: string;
|
||||
queue_id?: string;
|
||||
arrival_time?: number;
|
||||
recipients?: Array<{ address?: string; delay_reason?: string }>;
|
||||
}
|
||||
|
||||
/** docker exec, bounded: a hung container is an answer too. */
|
||||
export function dockerExec(timeoutMs = 10_000): Exec {
|
||||
return async (container, command) =>
|
||||
(await run("docker", ["exec", container, ...command], { timeout: timeoutMs })).stdout;
|
||||
}
|
||||
|
||||
/** What a failed exec said, on one line: its stderr when it has one, else its message. */
|
||||
function said(e: unknown): string {
|
||||
const err = e as { stderr?: string; message?: string; killed?: boolean; signal?: string };
|
||||
if (err?.killed || err?.signal === "SIGTERM") return "it did not answer in time";
|
||||
const text = (err?.stderr ?? "").trim() || (err?.message ?? String(e)).trim();
|
||||
return text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "it failed and said nothing";
|
||||
}
|
||||
|
||||
/** An age in words: "2 days 3 hours", "45 minutes". */
|
||||
export function age(seconds: number): string {
|
||||
const s = Math.max(0, Math.floor(seconds));
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
||||
const unit = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
||||
if (d > 0) return h > 0 ? `${unit(d, "day")} ${unit(h, "hour")}` : unit(d, "day");
|
||||
if (h > 0) return m > 0 ? `${unit(h, "hour")} ${unit(m, "minute")}` : unit(h, "hour");
|
||||
return unit(m, "minute");
|
||||
}
|
||||
|
||||
function parseQueue(output: string): Queued[] {
|
||||
const queued: Queued[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const text = line.trim();
|
||||
if (!text.startsWith("{")) continue; // "Mail queue is empty" and the like
|
||||
queued.push(JSON.parse(text) as Queued);
|
||||
}
|
||||
return queued;
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge whether mail flows. `now` is in seconds since the epoch. Every part is asked even when one
|
||||
* fails, so the answer says everything that is wrong at once.
|
||||
*/
|
||||
export async function mailFlows(
|
||||
exec: Exec,
|
||||
container: string,
|
||||
now: number = Date.now() / 1000,
|
||||
boundSeconds: number = DEFERRED_BOUND_SECONDS,
|
||||
): Promise<HealthAnswer> {
|
||||
const faults: string[] = [];
|
||||
const [status, queue, resolve] = await Promise.allSettled([
|
||||
exec(container, ["postfix", "status"]),
|
||||
exec(container, ["postqueue", "-j"]),
|
||||
exec(container, ["python3", "-c", RESOLVE]),
|
||||
]);
|
||||
|
||||
if (status.status === "rejected") {
|
||||
faults.push(`postfix in ${container} does not run: ${said(status.reason)}.`);
|
||||
}
|
||||
|
||||
let summary: HealthAnswer["queue"];
|
||||
const notes: string[] = [];
|
||||
if (queue.status === "rejected") {
|
||||
faults.push(`postfix's queue in ${container} could not be read: ${said(queue.reason)}.`);
|
||||
} else {
|
||||
let queued: Queued[] = [];
|
||||
try {
|
||||
queued = parseQueue(queue.value);
|
||||
} catch (e) {
|
||||
faults.push(`postfix's queue in ${container} printed a line that is not JSON: ${said(e)}.`);
|
||||
}
|
||||
const deferred = queued.filter((q) => q.queue_name === "deferred");
|
||||
const overdue = deferred
|
||||
.filter((q) => typeof q.arrival_time === "number" && now - q.arrival_time > boundSeconds)
|
||||
.sort((a, b) => (a.arrival_time ?? 0) - (b.arrival_time ?? 0));
|
||||
const oldest = deferred.reduce<number | undefined>(
|
||||
(o, q) => (typeof q.arrival_time === "number" && (o === undefined || q.arrival_time < o) ? q.arrival_time : o),
|
||||
undefined,
|
||||
);
|
||||
// Each overdue recipient, oldest message first: what our side is judged by, not one far host.
|
||||
const late = overdue.flatMap((q) =>
|
||||
(q.recipients?.length ? q.recipients : [{}]).map((r) => ({
|
||||
to: r.address,
|
||||
domain: domainOf(r.address),
|
||||
reason: r.delay_reason?.trim() || "postfix gave no reason",
|
||||
waited: now - (q.arrival_time ?? now),
|
||||
})),
|
||||
);
|
||||
const lookups = late.filter((r) => LOCAL_LOOKUP_FAILURE.test(r.reason));
|
||||
const domains = [...new Set(late.map((r) => r.domain))];
|
||||
summary = {
|
||||
messages: queued.length,
|
||||
deferred: deferred.length,
|
||||
overdue: overdue.length,
|
||||
...(oldest !== undefined ? { oldestDeferredSeconds: Math.max(0, Math.floor(now - oldest)) } : {}),
|
||||
...(domains.length > 0 ? { overdueDomains: domains } : {}),
|
||||
...(lookups.length > 0 ? { nameServiceFailures: lookups.length } : {}),
|
||||
};
|
||||
const one = (r: (typeof late)[number]) =>
|
||||
`the oldest, ${age(r.waited)} old${r.to ? ` to ${r.to}` : ""}, waits because: ${r.reason}`;
|
||||
const bound = age(boundSeconds);
|
||||
if (lookups.length > 0) {
|
||||
faults.push(
|
||||
`${lookups.length} deferred ${lookups.length === 1 ? "recipient has" : "recipients have"} waited longer ` +
|
||||
`than ${bound} on a name-service failure, which is this server's resolver; ${one(lookups[0])}.`,
|
||||
);
|
||||
} else if (domains.length >= SPREAD_DOMAINS) {
|
||||
faults.push(
|
||||
`${overdue.length} of ${deferred.length} deferred messages have waited longer than ${bound}, to ` +
|
||||
`${domains.length} domains (${domains.join(", ")}): more than one far server's fault; ${one(late[0])}.`,
|
||||
);
|
||||
} else if (late.length > 0) {
|
||||
notes.push(
|
||||
`${overdue.length} deferred ${overdue.length === 1 ? "message has" : "messages have"} waited longer than ` +
|
||||
`${bound}, only to ${domains.join(" and ")}, which reads as the far side's: ${one(late[0])}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolve.status === "rejected") {
|
||||
faults.push(`${container} cannot resolve ${PUBLIC_NAME} through its resolver: ${said(resolve.reason)}.`);
|
||||
}
|
||||
|
||||
if (faults.length > 0) {
|
||||
const why = [`Mail does not flow.`, ...faults, ...notes].join(" ");
|
||||
return { state: "unhealthy", healthy: false, why, queue: summary };
|
||||
}
|
||||
const waiting = !summary || summary.deferred === 0
|
||||
? " The deferred queue is empty."
|
||||
: notes.length > 0
|
||||
? ` ${notes.join(" ")}`
|
||||
: ` ${summary.deferred} deferred, the oldest ${age(summary.oldestDeferredSeconds ?? 0)} old, within ${age(boundSeconds)}.`;
|
||||
return {
|
||||
state: "healthy",
|
||||
healthy: true,
|
||||
why: `Mail flows: postfix runs, ${PUBLIC_NAME} resolves.${waiting}`,
|
||||
queue: summary,
|
||||
};
|
||||
}
|
||||
@@ -5,10 +5,6 @@
|
||||
"policy": "record",
|
||||
"why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)"
|
||||
},
|
||||
"unhealthy": {
|
||||
"severity": "urgent",
|
||||
"why": "while mailu is broken, mail is refused or stuck for everyone with a mailbox here (hq ADR 0355, issue 687)"
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
@@ -60,19 +56,6 @@
|
||||
"api-token": "${dir:state}/api-token.secret"
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
"mailu_list_users",
|
||||
"mailu_create_user",
|
||||
"mailu_change_password",
|
||||
"mailu_delete_user",
|
||||
"mailu_list_aliases",
|
||||
"mailu_create_alias",
|
||||
"mailu_delete_alias",
|
||||
"mailu_list_domains",
|
||||
"mailu_read_mail",
|
||||
"mailu_search_mail",
|
||||
"mailu_health"
|
||||
],
|
||||
"emits": [
|
||||
"user.created",
|
||||
"user.deleted",
|
||||
@@ -388,7 +371,7 @@
|
||||
"id": "resolver",
|
||||
"type": "container",
|
||||
"name": "mailu-resolver",
|
||||
"image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e",
|
||||
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
|
||||
"health": {
|
||||
"kind": "runtime"
|
||||
},
|
||||
@@ -460,12 +443,7 @@
|
||||
"name": "mailu-smtp",
|
||||
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "mailu_health",
|
||||
"interval": "60s",
|
||||
"timeout": "30s",
|
||||
"looks": 3,
|
||||
"grace": "120s"
|
||||
"kind": "runtime"
|
||||
},
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
@@ -582,12 +560,7 @@
|
||||
"name": "mailu-front",
|
||||
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
|
||||
"health": {
|
||||
"kind": "exec",
|
||||
"command": "curl -m3 -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 $(cat /run/dovecot/master.pid) && python3 -c \"import smtplib; s = smtplib.SMTP('127.0.0.1', 25, timeout=5); s.ehlo('health'); s.quit()\" && openssl x509 -checkend 1209600 -noout -in /certs/letsencrypt/live/mailu/nginx-chain.pem",
|
||||
"interval": "60s",
|
||||
"timeout": "15s",
|
||||
"looks": 3,
|
||||
"grace": "120s"
|
||||
"kind": "runtime"
|
||||
},
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
@@ -664,7 +637,6 @@
|
||||
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
|
||||
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
|
||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||
"MESH_MAILU_SMTP_CONTAINER": "mailu-smtp",
|
||||
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
}
|
||||
@@ -701,7 +673,7 @@
|
||||
"jails": [
|
||||
{
|
||||
"name": "mailu-front",
|
||||
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)\n ^.*client login failed: \"AUTH not supported\" while in http auth state, client: <HOST>, server: \\S+:25,",
|
||||
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)",
|
||||
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// Whether mail flows (novox/hq issue 690): the health tool is unhealthy, in words, when a message waits in
|
||||
// postfix's deferred queue longer than the bound, when postfix does not run, when a public name does not
|
||||
// resolve, and when the container cannot be reached — and healthy otherwise. Run against the compiled
|
||||
// module, the way the runtime loads it.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { mailFlows, DEFERRED_BOUND_SECONDS } from "../dist/health.js";
|
||||
|
||||
const NOW = 1_800_000_000;
|
||||
|
||||
type Answers = { status?: string | Error; queue?: string | Error; resolve?: string | Error };
|
||||
|
||||
/** A container that answers each command as told; an Error is a failed exec, with its stderr. */
|
||||
function container(a: Answers) {
|
||||
const asked: string[][] = [];
|
||||
const exec = async (_name: string, command: string[]) => {
|
||||
asked.push(command);
|
||||
const answer = command[0] === "postfix" ? a.status ?? "" : command[0] === "postqueue" ? a.queue ?? "" : a.resolve ?? "";
|
||||
if (answer instanceof Error) throw answer;
|
||||
return answer;
|
||||
};
|
||||
return { exec, asked };
|
||||
}
|
||||
|
||||
function failed(stderr: string): Error {
|
||||
return Object.assign(new Error("Command failed: docker exec"), { stderr });
|
||||
}
|
||||
|
||||
function deferred(id: string, ageSeconds: number, reason: string, to = "friend@example.com"): string {
|
||||
return JSON.stringify({
|
||||
queue_name: "deferred",
|
||||
queue_id: id,
|
||||
arrival_time: NOW - ageSeconds,
|
||||
message_size: 1234,
|
||||
sender: "someone@example.org",
|
||||
recipients: [{ address: to, delay_reason: reason }],
|
||||
});
|
||||
}
|
||||
|
||||
test("an empty queue is healthy, and says so", async () => {
|
||||
const c = container({ queue: "" });
|
||||
const a = await mailFlows(c.exec, "mailu-smtp", NOW);
|
||||
assert.equal(a.state, "healthy");
|
||||
assert.equal(a.healthy, true);
|
||||
assert.match(a.why, /deferred queue is empty/);
|
||||
assert.deepEqual(a.queue, { messages: 0, deferred: 0, overdue: 0 });
|
||||
assert.equal(c.asked.length, 3, "postfix, its queue and a lookup were each asked");
|
||||
});
|
||||
|
||||
test("a recipient overdue on a name-service failure is unhealthy, with counts, its age and its reason", async () => {
|
||||
const reason = "Host or domain name not found. Name service error for name=gmail.com type=MX: Host not found, try again";
|
||||
const queue = [
|
||||
deferred("AAA111", 3 * 86400 + 2 * 3600, reason),
|
||||
deferred("BBB222", 5 * 60, "connect to mx.example.net[192.0.2.1]:25: Connection timed out"),
|
||||
JSON.stringify({ queue_name: "active", queue_id: "CCC333", arrival_time: NOW - 10, recipients: [] }),
|
||||
].join("\n");
|
||||
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
|
||||
assert.equal(a.state, "unhealthy");
|
||||
assert.equal(a.healthy, false);
|
||||
assert.match(a.why, /^Mail does not flow\./);
|
||||
assert.match(a.why, /1 deferred recipient has waited longer than 30 minutes on a name-service failure/);
|
||||
assert.match(a.why, /3 days 2 hours old to friend@example\.com/);
|
||||
assert.ok(a.why.includes(reason), "the reason is said");
|
||||
assert.deepEqual(a.queue, {
|
||||
messages: 3, deferred: 2, overdue: 1, oldestDeferredSeconds: 3 * 86400 + 2 * 3600,
|
||||
overdueDomains: ["example.com"], nameServiceFailures: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("one dead far domain overdue is healthy, and said", async () => {
|
||||
const queue = [
|
||||
deferred("AAA111", 2 * 86400, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "a@dead.example"),
|
||||
deferred("BBB222", 3600, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "b@dead.example"),
|
||||
].join("\n");
|
||||
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
|
||||
assert.equal(a.state, "healthy");
|
||||
assert.equal(a.healthy, true);
|
||||
assert.match(a.why, /2 deferred messages have waited longer than 30 minutes, only to dead\.example, which reads as the far side's/);
|
||||
assert.match(a.why, /2 days old to a@dead\.example, waits because: connect to mx\.dead\.example/);
|
||||
assert.deepEqual(a.queue?.overdueDomains, ["dead.example"]);
|
||||
});
|
||||
|
||||
test("overdue mail to three domains is unhealthy, whatever the reason", async () => {
|
||||
const timedOut = "connect to mx[192.0.2.1]:25: Connection timed out";
|
||||
const queue = [
|
||||
deferred("AAA111", 7200, timedOut, "a@one.example"),
|
||||
deferred("BBB222", 3600, timedOut, "b@two.example"),
|
||||
deferred("CCC333", 3600, timedOut, "c@three.example"),
|
||||
].join("\n");
|
||||
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
|
||||
assert.equal(a.state, "unhealthy");
|
||||
assert.match(a.why, /3 of 3 deferred messages have waited longer than 30 minutes, to 3 domains \(one\.example, two\.example, three\.example\)/);
|
||||
assert.match(a.why, /the oldest, 2 hours old to a@one\.example/);
|
||||
});
|
||||
|
||||
test("a deferred message younger than the bound is healthy, and is said", async () => {
|
||||
const queue = deferred("BBB222", DEFERRED_BOUND_SECONDS - 60, "connect to mx.example.net: Connection timed out");
|
||||
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
|
||||
assert.equal(a.state, "healthy");
|
||||
assert.equal(a.healthy, true);
|
||||
assert.match(a.why, /1 deferred, the oldest 29 minutes old, within 30 minutes/);
|
||||
});
|
||||
|
||||
test("a container that cannot be reached is unhealthy, and says what docker said", async () => {
|
||||
const gone = failed("Error response from daemon: No such container: mailu-smtp\n");
|
||||
const a = await mailFlows(container({ status: gone, queue: gone, resolve: gone }).exec, "mailu-smtp", NOW);
|
||||
assert.equal(a.state, "unhealthy");
|
||||
assert.equal(a.healthy, false);
|
||||
assert.match(a.why, /postfix in mailu-smtp does not run: Error response from daemon: No such container: mailu-smtp\./);
|
||||
assert.match(a.why, /postfix's queue in mailu-smtp could not be read: Error response from daemon: No such container/);
|
||||
assert.equal(a.queue, undefined);
|
||||
});
|
||||
|
||||
test("a name that does not resolve is unhealthy, though the queue is empty", async () => {
|
||||
const a = await mailFlows(
|
||||
container({ queue: "", resolve: failed("gaierror: [Errno -3] Try again\n") }).exec,
|
||||
"mailu-smtp",
|
||||
NOW,
|
||||
);
|
||||
assert.equal(a.state, "unhealthy");
|
||||
assert.match(a.why, /mailu-smtp cannot resolve gmail-smtp-in\.l\.google\.com through its resolver: gaierror: \[Errno -3\] Try again\./);
|
||||
});
|
||||
|
||||
test("a container that does not answer in time is said so", async () => {
|
||||
const hung = Object.assign(new Error("Command failed"), { killed: true, signal: "SIGTERM" });
|
||||
const a = await mailFlows(container({ queue: hung }).exec, "mailu-smtp", NOW);
|
||||
assert.equal(a.state, "unhealthy");
|
||||
assert.match(a.why, /postfix's queue in mailu-smtp could not be read: it did not answer in time\./);
|
||||
});
|
||||
|
||||
test("the health tool is served without the admin API, on the smtp container it is told", async () => {
|
||||
const { collectTools } = await import("@novox/mesh-sdk/tools");
|
||||
await import("../dist/tools/index.js");
|
||||
const mailu = collectTools({ MESH_MAILU_SMTP_CONTAINER: "mailu-smtp" }).find((m) => m.module === "mailu");
|
||||
const names = (mailu?.tools ?? []).map((t) => t.name);
|
||||
assert.deepEqual(names, ["mailu_health"]);
|
||||
assert.match(mailu!.tools[0].description, /inside mailu-smtp/);
|
||||
});
|
||||
@@ -4,27 +4,6 @@
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { MailuClient } from "../client.js";
|
||||
import { dockerExec, mailFlows, DEFERRED_BOUND_SECONDS, PUBLIC_NAME, SPREAD_DOMAINS } from "../health.js";
|
||||
|
||||
/**
|
||||
* Whether mail flows (novox/hq ADR 0240, issue 690): the tool the node-engine asks as the smtp container's
|
||||
* check. It needs no admin API, only the postfix container, so it is served even when the API is not
|
||||
* configured — a check that vanished with the API would say unhealthy for the wrong reason.
|
||||
*/
|
||||
export function getHealthTool(env: NodeJS.ProcessEnv): ToolDefinition {
|
||||
const container = env.MESH_MAILU_SMTP_CONTAINER || "mailu-smtp";
|
||||
return {
|
||||
name: "mailu_health",
|
||||
description:
|
||||
`Whether mail flows through mailu, asked inside ${container}: postfix runs; no recipient has waited ` +
|
||||
`deferred longer than ${DEFERRED_BOUND_SECONDS / 60} minutes on a name-service failure here, and overdue ` +
|
||||
`deferred mail does not reach ${SPREAD_DOMAINS} or more domains (one or two far domains are said, not ` +
|
||||
`unhealthy); and ${PUBLIC_NAME} resolves through the resolver postfix uses. Says counts, domains, the ` +
|
||||
"oldest message's age and its reason (novox/hq ADR 0240, issue 690).",
|
||||
input: {},
|
||||
run: async () => mailFlows(dockerExec(), container),
|
||||
};
|
||||
}
|
||||
|
||||
export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
|
||||
return [
|
||||
@@ -164,14 +143,12 @@ export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
|
||||
];
|
||||
}
|
||||
|
||||
// The admin tools exist only when the admin API is configured; without it, mailu contributes only its
|
||||
// health tool rather than failing the whole runtime.
|
||||
// The tools exist only when the admin API is configured; without it, mailu contributes none rather
|
||||
// than failing the whole runtime.
|
||||
registerModuleTools("mailu", (env) => {
|
||||
let tools: ToolDefinition[] = [];
|
||||
try {
|
||||
tools = getMailuTools(MailuClient.fromEnv(env));
|
||||
return getMailuTools(MailuClient.fromEnv(env));
|
||||
} catch {
|
||||
tools = [];
|
||||
return [];
|
||||
}
|
||||
return [...tools, getHealthTool(env)];
|
||||
});
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "health.ts", "index.ts", "tools/index.ts"]
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
// What the catalogue makes of a build outcome it hears (novox/hq issue 712).
|
||||
//
|
||||
// Kept apart from index.ts, which subscribes at its top level, so the decision can be tested without a bus.
|
||||
|
||||
import type { Made } from "./store.js";
|
||||
|
||||
/** What the build seat says when it has built something, and what the control plane replays. */
|
||||
export interface Built {
|
||||
module?: string;
|
||||
commit?: string;
|
||||
repository?: string;
|
||||
path?: string;
|
||||
ref?: string;
|
||||
manifest?: unknown;
|
||||
/** What this build published, each pinned as anything else would name it. */
|
||||
made?: Made[];
|
||||
/**
|
||||
* Every artifact this was built on top of, so the edge is derived rather than declared.
|
||||
*
|
||||
* References, not module names: the builder sees a pinned image and cannot see which module
|
||||
* produced it. Turning that into an edge between module-versions is this module's job.
|
||||
*/
|
||||
against?: string[];
|
||||
/**
|
||||
* This is history, not news — the mesh re-announcing a build this catalogue was not there for.
|
||||
*
|
||||
* Registered exactly as any other, and announced as nothing. A module that moved months ago is
|
||||
* not something anything should act on now: emitting `upgraded` would have the control plane
|
||||
* decide about a rollout, and `rebuild-needed` would ask for builds of things that are already
|
||||
* current.
|
||||
*/
|
||||
replay?: boolean;
|
||||
/** Why the build failed; a failed build made no module-version. */
|
||||
failed?: string;
|
||||
/** A dry run is looked at, never taken in (novox/hq issue 240). */
|
||||
"dry-run"?: boolean;
|
||||
/** A pull request's merge check or a branch tried before one (to-be 45 §9, issue 686): nothing merged. */
|
||||
"check-outcome"?: unknown;
|
||||
check?: unknown;
|
||||
/** The repository's default branch as the build seat read the forge at the build (ADR 0238); empty from a seat older than the rule. */
|
||||
trunk?: string;
|
||||
/** Whether the commit is on `trunk`. */
|
||||
"on-trunk"?: boolean;
|
||||
/** The branches the commit is on. */
|
||||
branches?: string[];
|
||||
}
|
||||
|
||||
/** The events a build outcome reaches this module on: the build seat's, as it happens, and the control plane's replay. */
|
||||
export const BUILD_EVENTS = [
|
||||
// **The build seat that builds now** (novox/hq ADR 0190). Until issue 712 the catalogue heard only the
|
||||
// retired seat below, so every build after the handover reached the graph only through the replay this
|
||||
// module asks for when it starts: a merge built and pushed stayed out of the catalogue until its next start.
|
||||
"node-build-agent.built",
|
||||
// The retired build role, while a machine still serves it (ADR 0190's handover). Goes with the seat row.
|
||||
"mesh-build-machine.built",
|
||||
// What the mesh already held, when this module asks what it missed (novox/hq ADR 0134).
|
||||
"mesh-controller.built-before",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Whether a build outcome is a module-version to place in the graph, and why not when it is not.
|
||||
*
|
||||
* It mirrors two of the rules the controller keeps before it registers a build (mesh-controller
|
||||
* cmd/mesh-controller/main.go `builds.Built`, build.go `publishable`): a merge check, a try and a dry run are
|
||||
* never published, and **only a commit on its repository's trunk is** (ADR 0238) — a branch built through
|
||||
* the `build` verb is checked, never a module's version. Where the seat names no trunk (one older than the
|
||||
* rule) it is placed, as the controller lets it through. A failure made nothing and is passed over too.
|
||||
*
|
||||
* Not mirrored, because the catalogue cannot see what they read: the branch a module follows when it is not
|
||||
* the default one (the controller's `follows`; such a module's builds off the default branch are passed over
|
||||
* here and reach the graph by the replay), ADR 0266's rule that a module registers only from the repository
|
||||
* it is built from, a superseded request, a build in the scratch namespace, one that failed its gate, and a
|
||||
* build whose source is unchanged. Each needs the controller's records. This is safe for what the mesh
|
||||
* sends, which is chosen from the controller's own module table and never from this graph; that the graph
|
||||
* can still hold a build the controller refused under those rules is a follow-up (hq issue 712).
|
||||
*/
|
||||
export function placeable(body: Built):
|
||||
| { placed: "yes"; module: string; commit: string }
|
||||
| { placed: "no"; why: string; fault: boolean } {
|
||||
if (body["check-outcome"] != null || body.check != null) {
|
||||
return { placed: "no", why: "a merge check or a try: nothing of it merged", fault: false };
|
||||
}
|
||||
if (body["dry-run"]) {
|
||||
return { placed: "no", why: "a dry run is looked at, never taken in", fault: false };
|
||||
}
|
||||
if (body.failed) {
|
||||
return { placed: "no", why: "a failed build made no module-version", fault: false };
|
||||
}
|
||||
if (body.trunk && !body["on-trunk"] && !(body.branches ?? []).includes(body.trunk)) {
|
||||
return { placed: "no", why: `the commit is not on its repository's trunk ${body.trunk}: checked, never published (ADR 0238)`, fault: false };
|
||||
}
|
||||
if (!body.module || !body.commit) {
|
||||
return { placed: "no", why: "it named no module or no commit", fault: true };
|
||||
}
|
||||
return { placed: "yes", module: body.module, commit: body.commit };
|
||||
}
|
||||
@@ -10,8 +10,7 @@
|
||||
// diamond need no special handling, and nothing holds a plan.
|
||||
|
||||
import { on, emit } from "@novox/mesh-sdk/events";
|
||||
import { Graph } from "./store.js";
|
||||
import { BUILD_EVENTS, placeable, type Built } from "./heard.js";
|
||||
import { Graph, type Made } from "./store.js";
|
||||
|
||||
const graph = Graph.fromEnv();
|
||||
|
||||
@@ -22,29 +21,54 @@ const graph = Graph.fromEnv();
|
||||
// would block the very apply that brings the overlay up — stopped being true when a step's failure
|
||||
// became this module's business and not the machine's (ADR 0136).
|
||||
|
||||
/** What the builder says when it has built something. */
|
||||
interface Built {
|
||||
module?: string;
|
||||
commit?: string;
|
||||
repository?: string;
|
||||
path?: string;
|
||||
ref?: string;
|
||||
manifest?: unknown;
|
||||
/** What this build published, each pinned as anything else would name it. */
|
||||
made?: Made[];
|
||||
/**
|
||||
* Every artifact this was built on top of, so the edge is derived rather than declared.
|
||||
*
|
||||
* References, not module names: the builder sees a pinned image and cannot see which module
|
||||
* produced it. Turning that into an edge between module-versions is this module's job.
|
||||
*/
|
||||
against?: string[];
|
||||
/**
|
||||
* This is history, not news — the mesh re-announcing a build this catalogue was not there for.
|
||||
*
|
||||
* Registered exactly as any other, and announced as nothing. A module that moved months ago is
|
||||
* not something anything should act on now: emitting `upgraded` would have the control plane
|
||||
* decide about a rollout, and `rebuild-needed` would ask for builds of things that are already
|
||||
* current.
|
||||
*/
|
||||
replay?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a build means for the graph, wherever it came from.
|
||||
*
|
||||
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
|
||||
* and the control plane says what it already held when this module asks what it missed
|
||||
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
|
||||
* months ago — see `replay` in heard.ts.
|
||||
* months ago — see `replay` above.
|
||||
*/
|
||||
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||
const body = (event.body ?? {}) as Built;
|
||||
const placing = placeable(body);
|
||||
if (placing.placed === "no") {
|
||||
// A check, a try, a dry run and a failure are the build seat's ordinary outcomes: passed over. One
|
||||
// that names no module or commit is a fault in the builder, said rather than dropped, so it does
|
||||
// not look like a missing event.
|
||||
if (placing.fault) console.error(`mesh-catalog: a build event was not placed: ${placing.why}`, body);
|
||||
if (!body.module || !body.commit) {
|
||||
// Said rather than dropped: a build that announced itself without saying what it built is a
|
||||
// fault in the builder, and a silent discard here would make it look like a missing event.
|
||||
console.error("mesh-catalog: a build event named no module or no commit; ignored", body);
|
||||
return;
|
||||
}
|
||||
const { module, commit } = placing;
|
||||
|
||||
const { upgraded, previous } = await graph.register({
|
||||
module,
|
||||
commit,
|
||||
module: body.module,
|
||||
commit: body.commit,
|
||||
repository: body.repository ?? "",
|
||||
path: body.path ?? "",
|
||||
ref: body.ref ?? "",
|
||||
@@ -56,7 +80,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||
if (body.replay) return;
|
||||
|
||||
await emit("registered", {
|
||||
module, commit, upgraded,
|
||||
module: body.module, commit: body.commit, upgraded,
|
||||
});
|
||||
|
||||
// **A rebuild that changed nothing is not an upgrade.** Announcing it would ripple outward
|
||||
@@ -64,7 +88,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||
if (!upgraded) return;
|
||||
|
||||
await emit("upgraded", {
|
||||
module, commit, previous,
|
||||
module: body.module, commit: body.commit, previous,
|
||||
});
|
||||
|
||||
// What can be built now — stale, and waiting on nothing that is itself stale.
|
||||
@@ -77,9 +101,9 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
// As it happens — from the build seat that builds now, and the retired one while it still answers — and
|
||||
// what the mesh already held when this module asked what it missed (novox/hq issue 712).
|
||||
for (const heard of BUILD_EVENTS) await on(heard, placeTheBuild);
|
||||
// As it happens, and what the mesh already held when this module asked what it missed.
|
||||
await on("mesh-build-machine.built", placeTheBuild);
|
||||
await on("mesh-controller.built-before", placeTheBuild);
|
||||
|
||||
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
|
||||
//
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"postgres-database": "${dir:state}/database.secret"
|
||||
},
|
||||
"consumes": [
|
||||
"node-build-agent.built",
|
||||
"mesh-build-machine.built",
|
||||
"mesh-controller.built-before"
|
||||
],
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { BUILD_EVENTS, placeable } from "../heard.js";
|
||||
|
||||
// A walk's build as the build seat says it (mesh-controller's link.BuildResult), here mailu's of novox/hq issue 712.
|
||||
const walkBuild = {
|
||||
id: "build-1791997333800041716",
|
||||
repository: "http://novox.internal:20000/novox/mesh-catalog.git",
|
||||
path: "modules/mailu",
|
||||
ref: "main",
|
||||
on: "shanks",
|
||||
trunk: "main",
|
||||
"on-trunk": true,
|
||||
branches: ["main"],
|
||||
module: "mailu",
|
||||
commit: "e1c8965ddbf1cf9af9cf1eed7a72ae9ffa6d5a87",
|
||||
manifest: { module: "mailu", unhealthy: { severity: "urgent", why: "mail stops" } },
|
||||
made: [{ name: "code", kind: "bundle", reference: "artifact-store://mailu/code/blobs/sha256:d041" }],
|
||||
};
|
||||
|
||||
test("the catalogue hears the build seat that builds now, not only the retired one (issue 712)", async () => {
|
||||
assert.ok(BUILD_EVENTS.includes("node-build-agent.built"));
|
||||
assert.ok(BUILD_EVENTS.includes("mesh-controller.built-before"));
|
||||
// Subscribing is not enough: the mesh grants and routes only what the manifest consumes.
|
||||
const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8"));
|
||||
for (const heard of BUILD_EVENTS) assert.ok(manifest.consumes.includes(heard), `${heard} is not consumed`);
|
||||
});
|
||||
|
||||
test("a walk's build is placed, under its module and commit", () => {
|
||||
assert.deepEqual(placeable(walkBuild), { placed: "yes", module: "mailu", commit: walkBuild.commit });
|
||||
// A walk that took the check's build of the same key says so, and is placed all the same (ADR 0350 §2).
|
||||
assert.equal(placeable({ ...walkBuild, reused: "the check's build" } as never).placed, "yes");
|
||||
});
|
||||
|
||||
test("a merge check, a try, a dry run and a failure are passed over without a fault", () => {
|
||||
for (const [body, why] of [
|
||||
[{ ...walkBuild, "check-outcome": { verdict: "passed" } }, "merge check"],
|
||||
[{ ...walkBuild, check: { try: true } }, "merge check or a try"],
|
||||
[{ ...walkBuild, "dry-run": true }, "dry run"],
|
||||
[{ ...walkBuild, failed: "exit status 1" }, "failed"],
|
||||
] as const) {
|
||||
const placing = placeable(body);
|
||||
assert.equal(placing.placed, "no");
|
||||
if (placing.placed === "no") {
|
||||
assert.match(placing.why, new RegExp(why));
|
||||
assert.equal(placing.fault, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("an outcome that names no module or commit is said as the builder's fault", () => {
|
||||
const placing = placeable({ repository: walkBuild.repository, commit: walkBuild.commit });
|
||||
assert.equal(placing.placed, "no");
|
||||
if (placing.placed === "no") assert.equal(placing.fault, true);
|
||||
});
|
||||
|
||||
test("a branch built through the build verb is passed over; a commit on the trunk, or from a seat that names none, is placed (ADR 0238)", () => {
|
||||
const branch = { ...walkBuild, ref: "feature", "on-trunk": false, branches: ["feature"] };
|
||||
const placing = placeable(branch);
|
||||
assert.equal(placing.placed, "no");
|
||||
if (placing.placed === "no") {
|
||||
assert.match(placing.why, /not on its repository's trunk main/);
|
||||
assert.equal(placing.fault, false);
|
||||
}
|
||||
// On the trunk by its branches although the seat did not set on-trunk.
|
||||
assert.equal(placeable({ ...branch, branches: ["feature", "main"] }).placed, "yes");
|
||||
// A seat older than the rule says no trunk: placed, as the controller lets it through.
|
||||
const { trunk, "on-trunk": _on, branches, ...older } = walkBuild;
|
||||
assert.equal(placeable(older).placed, "yes");
|
||||
});
|
||||
@@ -11,7 +11,6 @@
|
||||
"include": [
|
||||
"pg.d.ts",
|
||||
"store.ts",
|
||||
"heard.ts",
|
||||
"index.ts",
|
||||
"tools/index.ts",
|
||||
"prepare/index.ts"
|
||||
|
||||
@@ -144,9 +144,7 @@ func (h *Holder) accounted(w Walk) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// held is whether a live delivery follows the walk, or a live delivery it answers follows a newer one. A walk whose
|
||||
// word only stops that lapsed give (lapsedOn, novox/hq issue 753) is not held: it stays under the orphans' timer, so
|
||||
// past the grace without its word it is listed by `stalled`, saying why it was not given.
|
||||
// held is whether a live delivery follows the walk, or a live delivery it answers follows a newer one.
|
||||
func (h *Holder) heldWalk(w Walk) bool {
|
||||
for _, d := range h.deliveries {
|
||||
if d.State.Final() {
|
||||
@@ -294,28 +292,12 @@ func (h *Holder) orphanLines(now time.Time) []StalledLine {
|
||||
continue
|
||||
}
|
||||
w := s.walk
|
||||
if lapsed, behind := h.lapsedOf(w); len(lapsed) > 0 || len(behind) > 0 {
|
||||
// Owed its word by stops that lapsed, and not given it past the grace (review of issue 753): said, why.
|
||||
says := fmt.Sprintf("the walk %s of %s at %s waits for this owner's word, which the stopped deliveries whose "+
|
||||
"merge it answers owe it (novox/hq issue 753), and it has not started", w.ID, w.Repository, shortOf(w.Commit, 8))
|
||||
if why := h.lapsedSaid(w); why != "" {
|
||||
says += ": " + why
|
||||
}
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: w.ID,
|
||||
For: now.Sub(first).Round(time.Second).String(), Bound: matchGrace.String(),
|
||||
H2: "none: its word is given at its turn — the operator's past the bound", Says: says})
|
||||
continue
|
||||
}
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Unheld, Walk: w.ID,
|
||||
For: now.Sub(first).Round(time.Second).String(), Bound: matchGrace.String(),
|
||||
H2: "none: no delivery holds the walk, so nothing gives it its word — the operator's",
|
||||
Says: fmt.Sprintf("the walk %s of %s at %s waits for this owner's word, and no delivery that has not ended "+
|
||||
"follows it or is answered by it: `mesh-delivery.show` for the deliveries of %s, and `plans go %s --why …` "+
|
||||
"starts it by hand", w.ID, w.Repository, shortOf(w.Commit, 8), w.Repository, w.ID)})
|
||||
out[len(out)-1].Says += h.endedSaid(w)
|
||||
if hold := h.coverHeld(id); hold != "" {
|
||||
out[len(out)-1].Says += "; it is held back meanwhile: " + hold
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -362,7 +344,6 @@ func (h *Holder) forgetWalks(now time.Time) {
|
||||
}
|
||||
case !w.Open() && now.Sub(s.at) > keptWalkFor:
|
||||
delete(h.walks, id)
|
||||
delete(h.deliverErr, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,168 +356,6 @@ type turnWant struct {
|
||||
whys []string
|
||||
}
|
||||
|
||||
// endedSaid names the ended deliveries a waiting walk answers or that follow it, with their states, so a walk no live
|
||||
// delivery holds says what it carries instead of only that nothing holds it (novox/hq issue 753); empty for none.
|
||||
func (h *Holder) endedSaid(w Walk) string {
|
||||
var ended []string
|
||||
for _, d := range h.deliveries {
|
||||
if !d.State.Final() {
|
||||
continue
|
||||
}
|
||||
_, answers := walkAnswers(w, d)
|
||||
if answers || d.Walk != nil && d.Walk.ID == w.ID {
|
||||
ended = append(ended, fmt.Sprintf("%s (%s)", d.ID, d.State))
|
||||
}
|
||||
}
|
||||
if len(ended) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(ended)
|
||||
return "; every delivery it carries ended: " + strings.Join(ended, ", ")
|
||||
}
|
||||
|
||||
// lapsedOn are the deliveries, by id, whose word an open walk waiting for this owner's word takes from a stop that
|
||||
// lapsed (novox/hq issue 753): each was stopped by its group for a member delivered since, so its own change waits for
|
||||
// a later walk of its repository that contains its merge (issue 747), and this walk answers that merge — it names it,
|
||||
// or is made at it — and has not left it out. Such a walk is the one that delivery waits for: it gives it its word, so
|
||||
// a walk whose every other delivery ended is never left without one. A walk at the merge of a delivery stopped for
|
||||
// itself, or by a person, gets nothing from it. Under the lock.
|
||||
//
|
||||
// In its group's order (review of issue 753): a member gives its word only once every member ordered before it reached
|
||||
// the machines, or is itself a stop that lapsed whose merge this same walk answers — the walk then delivers both, in
|
||||
// the group's order among its tiers, as members sharing one walk do. A member behind one still undelivered gives
|
||||
// nothing; lapsedBehind names that one.
|
||||
func (h *Holder) lapsedOn(w Walk) []string {
|
||||
out, _ := h.lapsedOf(w)
|
||||
return out
|
||||
}
|
||||
|
||||
// lapsedOf is lapsedOn, and, by delivery, the member before it in its group that holds back the word of a stop that
|
||||
// lapsed whose merge the walk answers. Under the lock.
|
||||
func (h *Holder) lapsedOf(w Walk) ([]string, map[string]string) {
|
||||
if w.Release != nil || w.IsBatch() || !w.Open() || !w.Seen().Waits {
|
||||
return nil, nil
|
||||
}
|
||||
var out []string
|
||||
behind := map[string]string{}
|
||||
for _, d := range h.deliveries {
|
||||
if !h.lapsedAnswered(w, d) {
|
||||
continue
|
||||
}
|
||||
if before := h.lapsedBehind(w, d); before != "" {
|
||||
behind[d.ID] = before
|
||||
continue
|
||||
}
|
||||
out = append(out, d.ID)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, behind
|
||||
}
|
||||
|
||||
// lapsedAnswered is whether a delivery's stop lapsed and the walk answers its merge, not left out.
|
||||
func (h *Holder) lapsedAnswered(w Walk, d *Delivery) bool {
|
||||
if d.MergedAs == "" || !h.stopLapsed(d) {
|
||||
return false
|
||||
}
|
||||
if _, ok := walkAnswers(w, d); !ok {
|
||||
return false
|
||||
}
|
||||
_, left := w.leftOut(d.Repository, d.MergedAs)
|
||||
return !left
|
||||
}
|
||||
|
||||
// lapsedBehind is the member ordered before a delivery in its group that has not reached the machines and that this
|
||||
// walk does not deliver with it, with its state; empty for none.
|
||||
func (h *Holder) lapsedBehind(w Walk, d *Delivery) string {
|
||||
g := h.groups[d.Group]
|
||||
if d.Group == "" || g == nil {
|
||||
return ""
|
||||
}
|
||||
members := h.membersOf(g)
|
||||
byID := map[string]*Delivery{}
|
||||
for _, m := range members {
|
||||
byID[m.ID] = m
|
||||
}
|
||||
for _, m := range h.inOrder(g, members) {
|
||||
if m.ID == d.ID {
|
||||
return ""
|
||||
}
|
||||
o := byID[m.ID]
|
||||
if o == nil || memberReached(o) || h.lapsedAnswered(w, o) {
|
||||
continue
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", o.ID, o.State)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// lapsedSaid is why a walk whose word stops that lapsed give still waits: the members of their groups not yet
|
||||
// delivered before them, what the last tick held it for, or the controller's refusal of the word. Under the lock.
|
||||
func (h *Holder) lapsedSaid(w Walk) string {
|
||||
var parts []string
|
||||
_, behind := h.lapsedOf(w)
|
||||
ids := make([]string, 0, len(behind))
|
||||
for id := range behind {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
parts = append(parts, fmt.Sprintf("%s waits for %s, ordered before it in its group", id, behind[id]))
|
||||
}
|
||||
if why := h.waitsWhy[w.ID]; why != "" {
|
||||
parts = append(parts, "not let go yet: "+why)
|
||||
}
|
||||
if err := h.deliverErr[w.ID]; err != "" {
|
||||
parts = append(parts, "the controller refused its word: "+err)
|
||||
}
|
||||
lapsed, _ := h.lapsedOf(w)
|
||||
for _, id := range lapsed {
|
||||
if d := h.deliveries[id]; d != nil && strings.Contains(d.LetGoWhy, lapsedWord(w.ID)) {
|
||||
parts = append(parts, fmt.Sprintf("its word was given for %s at %s, and the controller has not started it",
|
||||
id, d.LetGo.Local().Format("15:04:05")))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// lapsedOwed is whether a walk's word is owed by stops that lapsed: given now, or once a member before one is delivered.
|
||||
func (h *Holder) lapsedOwed(w Walk) bool {
|
||||
lapsed, behind := h.lapsedOf(w)
|
||||
return len(lapsed) > 0 || len(behind) > 0
|
||||
}
|
||||
|
||||
// lapsedTurns registers, for every open walk waiting for this owner's word, the turn of each delivery whose stop
|
||||
// lapsed and whose merge it answers (lapsedOn, novox/hq issue 753). The word is then given as for any walk: once,
|
||||
// when every live delivery it carries is at its turn too and it carries no stopped change still held back (words).
|
||||
// Given once per walk: the walk's id is in the delivery's LetGoWhy once its word was given. Under the lock.
|
||||
func (h *Holder) lapsedTurns() {
|
||||
for id, s := range h.walks {
|
||||
for _, did := range h.lapsedOn(s.walk) {
|
||||
d := h.deliveries[did]
|
||||
if strings.Contains(d.LetGoWhy, lapsedWord(id)) {
|
||||
continue
|
||||
}
|
||||
if h.turning == nil {
|
||||
h.turning = map[string]*turnWant{}
|
||||
}
|
||||
want := h.turning[id]
|
||||
if want == nil {
|
||||
want = &turnWant{}
|
||||
h.turning[id] = want
|
||||
}
|
||||
if !containsString(want.ids, d.ID) {
|
||||
want.ids = append(want.ids, d.ID)
|
||||
want.whys = append(want.whys, fmt.Sprintf("%s: %s its merge %s, stopped by its group for %s, whose change "+
|
||||
"was delivered since; it waits for a later walk of %s that contains its merge, and this is that "+
|
||||
"walk (novox/hq issue 753)", d.ID, lapsedWord(id), shortOf(d.MergedAs, 8), d.stoppedFor(), d.Repository))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lapsedWord is how the word a lapsed stop gives a walk names that walk.
|
||||
func lapsedWord(walk string) string { return "the walk " + walk + " answers" }
|
||||
|
||||
// words are the asks of the word of every walk whose turn came for every live delivery it carries: one ask per
|
||||
// walk, however many deliveries it carries, and none while one of them is not at its turn (ADR 0276 decision 5).
|
||||
// Under the lock.
|
||||
@@ -569,9 +388,6 @@ func (h *Holder) words() []func() {
|
||||
if stopped, _ := h.carriesStopped(walk); len(stopped) > 0 {
|
||||
h.waitsWhy[walk] = "it carries " + strings.Join(stopped, ", ") + ", whose delivery was stopped: it is " +
|
||||
"asked to leave that merge out first"
|
||||
if hold := h.coverHeld(walk); hold != "" {
|
||||
h.waitsWhy[walk] = hold
|
||||
}
|
||||
h.Logf("[mesh-delivery] the walk %s is not let go: it carries %s, whose delivery was stopped", walk,
|
||||
strings.Join(stopped, ", "))
|
||||
continue
|
||||
@@ -587,14 +403,9 @@ func (h *Holder) words() []func() {
|
||||
defer h.mu.Unlock()
|
||||
delete(h.asking, walk)
|
||||
if err != nil {
|
||||
if h.deliverErr == nil {
|
||||
h.deliverErr = map[string]string{}
|
||||
}
|
||||
h.deliverErr[walk] = err.Error() // said by `stalled` past the grace (review of novox/hq issue 753)
|
||||
h.Logf("[mesh-delivery] the walk %s could not be let go yet: %v", walk, err)
|
||||
return
|
||||
}
|
||||
delete(h.deliverErr, walk)
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
@@ -668,28 +479,9 @@ func (h *Holder) WalksWaiting() []StalledLine {
|
||||
switch {
|
||||
case len(held) > 0:
|
||||
out = append(out, held...)
|
||||
case h.coverHeld(id) != "":
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
|
||||
Bound: coverAskBound.String(), H2: "none: it is asked to leave the merge out past the bound",
|
||||
Says: "held back: " + h.coverHeld(id)})
|
||||
case h.waitsWhy[id] != "":
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
|
||||
Bound: "0s", H2: "none: it goes at its turn", Says: "not let go yet: " + h.waitsWhy[id]})
|
||||
case !live && h.lapsedOwed(w):
|
||||
// Its word comes from the stopped deliveries whose merge it answers (novox/hq issue 753): said, never
|
||||
// unheld, while the controller has not taken it, with what holds it.
|
||||
lapsed, _ := h.lapsedOf(w)
|
||||
says := "its word is owed by the stopped deliveries whose merge it answers, each stopped for a member " +
|
||||
"delivered since (novox/hq issue 753)"
|
||||
if len(lapsed) > 0 {
|
||||
says = "its word is given for " + strings.Join(lapsed, ", ") + ", stopped for a member delivered since, " +
|
||||
"whose merge it answers (novox/hq issue 753)"
|
||||
}
|
||||
if why := h.lapsedSaid(w); why != "" {
|
||||
says += "; " + why
|
||||
}
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
|
||||
Bound: "0s", H2: "none: it goes at its turn", Says: says})
|
||||
case !live:
|
||||
says := "no delivery that has not ended follows it or is answered by it, so nothing gives it its word: " +
|
||||
"`mesh-delivery.show` for the deliveries of " + w.Repository
|
||||
@@ -700,7 +492,7 @@ func (h *Holder) WalksWaiting() []StalledLine {
|
||||
"), yet the walk moves " + strings.Join(walkModules(w), ", ") + " — nothing gives it its word"
|
||||
}
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Unheld, Walk: id, For: waited,
|
||||
Bound: "0s", H2: "none: no delivery holds the walk — the operator's", Says: says + h.endedSaid(w)})
|
||||
Bound: "0s", H2: "none: no delivery holds the walk — the operator's", Says: says})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
|
||||
@@ -150,9 +150,6 @@ func statusOf(d *Delivery) (string, string) {
|
||||
case Delivered:
|
||||
return "success", "delivered"
|
||||
case Failed:
|
||||
if d.coveredStop() {
|
||||
return "success", clip(d.coveredStopSaid(), 140) // its change reached the machines (novox/hq issue 747)
|
||||
}
|
||||
return "failure", clip("failed: "+why, 140)
|
||||
case Superseded:
|
||||
return "warning", clip("superseded: "+why, 140)
|
||||
@@ -181,7 +178,7 @@ func ViewBody(d *Delivery, g *Group) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s\n**Delivery** `%s` — **%s** since %s\n\n", viewMarker, d.ID, d.State, d.Since.UTC().Format(time.RFC3339))
|
||||
if d.coveredStop() {
|
||||
fmt.Fprintf(&b, "**Covered**: %s\n\n", d.coveredStopSaid())
|
||||
fmt.Fprintf(&b, "**Covered** after its stop: a later merge that contains it was delivered: %s\n\n", d.CoveredBy)
|
||||
}
|
||||
if d.HeldWhy != "" && d.State == Held {
|
||||
fmt.Fprintf(&b, "Held for a person: %s — `mesh-delivery.release` with why lets it go on.\n\n", d.HeldWhy)
|
||||
|
||||
@@ -106,8 +106,6 @@ type fakeController struct {
|
||||
moves map[string][]string
|
||||
plans int
|
||||
down bool
|
||||
// refuseDeliver refuses every word, as a controller that will not take it does (review of novox/hq issue 753).
|
||||
refuseDeliver bool
|
||||
// onCheck runs as a check is asked, before it is answered: what the forge held at that moment.
|
||||
onCheck func()
|
||||
// planned is what Plan answers instead of "builds app", when set.
|
||||
@@ -184,9 +182,6 @@ func (c *fakeController) Deliver(walk, why string) error {
|
||||
if c.down {
|
||||
return errors.New("down")
|
||||
}
|
||||
if c.refuseDeliver {
|
||||
return errors.New("the controller refuses the word")
|
||||
}
|
||||
w, known := c.walks[walk]
|
||||
if !known || w.Delivery == nil || w.Delivery.Go != nil {
|
||||
return fmt.Errorf("%s waits for nobody", walk)
|
||||
@@ -401,9 +396,6 @@ type fakeForge struct {
|
||||
// open is what OpenPulls answers; openDown a forge that cannot say.
|
||||
open []OpenPull
|
||||
openDown bool
|
||||
// merges, comments and unlabels are what was asked of the forge for ready marks (novox/hq issue 722).
|
||||
merges, comments, unlabels []string
|
||||
refuse error
|
||||
}
|
||||
|
||||
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
|
||||
@@ -657,25 +649,3 @@ func aWalk(id, repo, commit string, waits bool) Walk {
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// Merge, Comment and Unlabel record what was asked (novox/hq issue 722); Merge answers refuse when set.
|
||||
func (f *fakeForge) Merge(owner, repo string, number int, head string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.merges = append(f.merges, fmt.Sprintf("%s/%s#%d@%s", owner, repo, number, head))
|
||||
return f.refuse
|
||||
}
|
||||
|
||||
func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.comments = append(f.comments, fmt.Sprintf("%s/%s#%d: %s", owner, repo, number, body))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeForge) Unlabel(owner, repo string, number, label int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.unlabels = append(f.unlabels, fmt.Sprintf("%s/%s#%d-%d", owner, repo, number, label))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -60,8 +60,6 @@ type Holder struct {
|
||||
waitsWhy map[string]string
|
||||
// asking are the walks whose word is being asked right now, outside the lock: not asked twice meanwhile.
|
||||
asking map[string]bool
|
||||
// deliverErr is, by walk, the controller's last refusal of its word, until it is given (review of issue 753).
|
||||
deliverErr map[string]string
|
||||
// leaving are, by walk, the merges a stop gathered to leave out of it, asked once (novox/hq ADR 0299).
|
||||
leaving map[string][]string
|
||||
// leaveAsked is when a walk was last asked to leave out the stopped merges it carries (leaveStopped).
|
||||
@@ -69,17 +67,8 @@ type Holder struct {
|
||||
// leaveRefused is, by walk, what the controller answered when it refused both to leave a stopped merge out and
|
||||
// to stop the walk whole.
|
||||
leaveRefused map[string]string
|
||||
// coverAsked are the deliveries that failed or were stopped whose cover by a later finished walk waits for the
|
||||
// forge's word on containment, worked out each pass (novox/hq issue 747): a walk carrying a merge stopped for one of
|
||||
// them is held back meanwhile, never asked to leave it out before the answer.
|
||||
coverAsked map[string]bool
|
||||
// coverAskedSince is, by delivery, when the forge was first asked about its cover and has not answered: past
|
||||
// coverAskBound the walk is no longer held back for it, and is asked to leave the merge out (review of issue 747).
|
||||
coverAskedSince map[string]time.Time
|
||||
// tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407).
|
||||
tried owedTries
|
||||
// marks are the open pull requests read with a ready mark, by owner/repo#number (novox/hq issue 722, ready.go).
|
||||
marks map[string]*marked
|
||||
}
|
||||
|
||||
// owedTries is how many owed effects were tried since this owner started, and when the last was: three numbers
|
||||
@@ -131,7 +120,6 @@ func (h *Holder) init() {
|
||||
h.askTried = map[string]time.Time{}
|
||||
h.walkAsked = map[string]time.Time{}
|
||||
h.unchecked = map[string]unchecked{}
|
||||
h.marks = map[string]*marked{}
|
||||
h.walks = map[string]seenWalk{}
|
||||
h.orphans = map[string]time.Time{}
|
||||
h.asking = map[string]bool{}
|
||||
@@ -1008,12 +996,6 @@ func (h *Holder) Tick() {
|
||||
for _, ask := range h.leaveStopped() {
|
||||
ask()
|
||||
}
|
||||
h.mu.Lock()
|
||||
asks := h.readyAsks(h.Now())
|
||||
h.mu.Unlock()
|
||||
for _, ask := range asks {
|
||||
ask()
|
||||
}
|
||||
}
|
||||
|
||||
// plan works out, under the lock, what to ask; each ask applies its own answer.
|
||||
@@ -1121,48 +1103,6 @@ func (h *Holder) plan() []func() {
|
||||
// A published delivery whose walk ended done before its word is read the same way (novox/hq issue 474): what
|
||||
// that walk did not build, a later delivered merge may have; and when the cover test says none did, past the
|
||||
// grace, it is stopped saying so — never left waiting for a start that cannot come.
|
||||
// A delivery that failed or was stopped is read for a later finished walk of its repository that delivered its
|
||||
// change after all: a walk that failed in an outage and was resumed, or the walk of a later merge that contains it
|
||||
// (novox/hq issue 747). Read each tick, from what the walks and the kept deliveries say, so one stuck before this
|
||||
// was built clears by itself after a restart.
|
||||
h.coverAsked = map[string]bool{}
|
||||
if h.coverAskedSince == nil {
|
||||
h.coverAskedSince = map[string]time.Time{}
|
||||
}
|
||||
pending := map[string]bool{}
|
||||
for _, d := range h.deliveries {
|
||||
if d.State != Failed && d.State != Stopped || d.coveredStop() {
|
||||
continue
|
||||
}
|
||||
by, ask := h.walkCover(d)
|
||||
if len(ask) > 0 && by == "" {
|
||||
pending[d.ID] = true
|
||||
since, asked := h.coverAskedSince[d.ID]
|
||||
if !asked {
|
||||
since = now
|
||||
h.coverAskedSince[d.ID] = now
|
||||
}
|
||||
// Held back only within the bound: a forge that keeps erroring never holds a walk for good.
|
||||
h.coverAsked[d.ID] = now.Sub(since) < coverAskBound
|
||||
}
|
||||
if by != "" {
|
||||
d.DeliveredBy = by
|
||||
h.tellCovered(d, now)
|
||||
continue
|
||||
}
|
||||
for _, in := range ask {
|
||||
if a := h.askContains(d, in, now); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for id := range h.coverAskedSince {
|
||||
if !pending[id] {
|
||||
delete(h.coverAskedSince, id)
|
||||
}
|
||||
}
|
||||
|
||||
for _, d := range h.deliveries {
|
||||
ended := d.endedUnstarted()
|
||||
if d.coveredStop() && !d.CoverTold {
|
||||
@@ -1246,8 +1186,6 @@ func (h *Holder) plan() []func() {
|
||||
}
|
||||
h.turn(d, "its turn: a delivery in no group")
|
||||
}
|
||||
// A walk that answers the merge of a delivery whose stop lapsed: that delivery's turn (novox/hq issue 753).
|
||||
h.lapsedTurns()
|
||||
// The word, once per walk, when every live delivery it carries is at its turn.
|
||||
asks = append(asks, h.words()...)
|
||||
|
||||
@@ -1326,7 +1264,7 @@ func (h *Holder) laterOnItsWay(d *Delivery) string {
|
||||
// stays final, and the bus's transition event is not said again.
|
||||
func (h *Holder) tellCovered(d *Delivery, now time.Time) {
|
||||
d.CoverTold = true
|
||||
line := oneLine(fmt.Sprintf("%s delivery %s: %s, %s", now.UTC().Format(time.RFC3339), d.ID, d.State, d.coveredStopSaid()))
|
||||
line := oneLine(fmt.Sprintf("%s delivery %s: stopped, %s", now.UTC().Format(time.RFC3339), d.ID, d.coveredStopSaid()))
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Line: line, Since: now})
|
||||
if d.MergedAs != "" && !sameCommit(d.MergedAs, d.Commit) {
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.MergedAs, Line: line, Since: now})
|
||||
@@ -1336,7 +1274,7 @@ func (h *Holder) tellCovered(d *Delivery, now time.Time) {
|
||||
state, desc := statusOf(d)
|
||||
d.owe(Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/delivery", State: state, Line: desc, Since: now})
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s: %s, %s", d.ID, d.State, d.coveredStopSaid())
|
||||
h.Logf("[mesh-delivery] %s: stopped, %s", d.ID, d.coveredStopSaid())
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
@@ -1347,104 +1285,6 @@ func strandedUncovered(d *Delivery) bool {
|
||||
return d.State == Stopped && d.Stranded != "" && d.CoveredBy == ""
|
||||
}
|
||||
|
||||
// walkCover says which later finished walk delivered the change of a delivery that failed or was stopped, or
|
||||
// nothing, and which walk commits the forge must still be asked about (novox/hq issue 747). A walk covers it when it
|
||||
// is of its repository and trunk, done, made after its merge, its commit containing its merge — the merge itself, or
|
||||
// a later one the forge says contains it — and it built every module the delivery's plan moves, each it did not
|
||||
// build being one the delivery's own walk said unchanged (issue 611). This is the containment "one walk answers every
|
||||
// merge it contains" uses (issue 362, ADR 0276), read after the fact: a failed walk resumed to done answers its own
|
||||
// merge, and a later merge's walk answers every merge it contains. A walk that only said a module unchanged for its
|
||||
// own merges says nothing of this one's, so it covers no module that way. Walks are read from the controller's records
|
||||
// this owner holds and from every delivery's kept walk, so a walk long ended still counts after a restart.
|
||||
func (h *Holder) walkCover(d *Delivery) (string, []string) {
|
||||
if d.State != Failed && d.State != Stopped || d.MergedAs == "" || d.Plan == nil || len(d.Plan.Moved) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
var ask []string
|
||||
for _, c := range h.doneWalksOf(d.Repository) {
|
||||
w := c.walk
|
||||
if !sameBranch(w.Branch, d.Base) || !w.Created.IsZero() && w.Created.Before(d.MergedAt.Add(-time.Minute)) {
|
||||
continue
|
||||
}
|
||||
var missing []string
|
||||
for _, m := range d.Plan.Moved {
|
||||
unchanged := false
|
||||
if d.Walk != nil {
|
||||
_, unchanged = d.Walk.Unchanged[m]
|
||||
}
|
||||
if !containsString(w.Moved, m) && !unchanged {
|
||||
missing = append(missing, m)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
continue
|
||||
}
|
||||
if !sameCommit(c.commit, d.MergedAs) {
|
||||
yes, known := d.ContainedIn[c.commit]
|
||||
if !known && !containsString(ask, c.commit) {
|
||||
ask = append(ask, c.commit)
|
||||
}
|
||||
if !yes {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s (%s at %s into %s, done, contains its merge %s)", w.ID, d.Repository, shortOf(c.commit, 8),
|
||||
orNone(d.Base), shortOf(d.MergedAs, 8)), nil
|
||||
}
|
||||
return "", ask
|
||||
}
|
||||
|
||||
// doneWalk is a finished walk and the commit of its repository it carried.
|
||||
type doneWalk struct {
|
||||
walk *WalkSeen
|
||||
commit string
|
||||
}
|
||||
|
||||
// doneWalksOf are the finished walks of a repository this owner knows, oldest first: the controller's records it
|
||||
// holds, and each delivery's kept walk with the commit of its repository that walk carried for it.
|
||||
func (h *Holder) doneWalksOf(repository string) []doneWalk {
|
||||
byID := map[string]doneWalk{}
|
||||
for id, s := range h.walks {
|
||||
w := s.walk
|
||||
if w.Release != nil || w.IsBatch() || w.State != walkDone || !strings.EqualFold(w.Repository, repository) {
|
||||
continue
|
||||
}
|
||||
byID[id] = doneWalk{walk: w.Seen(), commit: w.Commit}
|
||||
}
|
||||
for _, o := range h.deliveries {
|
||||
if o.Walk == nil || o.Walk.State != walkDone || !strings.EqualFold(o.Repository, repository) {
|
||||
continue
|
||||
}
|
||||
if _, known := byID[o.Walk.ID]; known {
|
||||
continue
|
||||
}
|
||||
commit := o.AnsweredBy
|
||||
if commit == "" && o.MergedAs != "" && sameCommit(o.Walk.Commit, o.MergedAs) {
|
||||
commit = o.MergedAs
|
||||
}
|
||||
if commit == "" {
|
||||
continue
|
||||
}
|
||||
byID[o.Walk.ID] = doneWalk{walk: o.Walk, commit: commit}
|
||||
}
|
||||
out := make([]doneWalk, 0, len(byID))
|
||||
for _, c := range byID {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if !out[i].walk.Created.Equal(out[j].walk.Created) {
|
||||
return out[i].walk.Created.Before(out[j].walk.Created)
|
||||
}
|
||||
return out[i].walk.ID < out[j].walk.ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// stopLapsed is whether a member its group stopped for another is stopped for nothing any more (see stopLapsed).
|
||||
func (h *Holder) stopLapsed(d *Delivery) bool {
|
||||
return stopLapsed(d, func(id string) *Delivery { return h.deliveries[id] })
|
||||
}
|
||||
|
||||
// unwalkedPast is whether a merged delivery has gone without a walk past the bound one opens within, read
|
||||
// again after it: what holds it for a person, unless something already ends it.
|
||||
func (h *Holder) unwalkedPast(d *Delivery, now time.Time) bool {
|
||||
@@ -1887,8 +1727,7 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa
|
||||
// A superseded member a later delivered walk carried counts as reached, as in GroupState: a group counts a
|
||||
// covered member as delivered, so the member after it gets its turn (ADR 0270 decision 2, novox/hq
|
||||
// issues 442 and 445).
|
||||
// A stop or failure a later walk delivered since counts as reached too (novox/hq issues 480 and 747).
|
||||
if memberReached(d) {
|
||||
if d.State.Reached() || d.State == Superseded && d.CoveredBy != "" {
|
||||
continue
|
||||
}
|
||||
if sharing != "" && (d.Walk == nil || d.Walk.ID != sharing) {
|
||||
@@ -1913,7 +1752,7 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa
|
||||
}
|
||||
if w.why != "" {
|
||||
for _, later := range ordered[i+1:] {
|
||||
if a := h.stopInGroup(byID[later.ID], d.ID, fmt.Sprintf("its group %s stopped: %s superseded, and %s", g.ID,
|
||||
if a := h.stopInGroup(byID[later.ID], fmt.Sprintf("its group %s stopped: %s superseded, and %s", g.ID,
|
||||
d.ID, w.why)); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
@@ -1940,13 +1779,9 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa
|
||||
continue
|
||||
}
|
||||
return asks
|
||||
case h.stopLapsed(d):
|
||||
// Stopped for a member whose change reached the machines since: it stops nothing more. Its own change is
|
||||
// delivered by a later walk of its repository that contains it, which then covers it (novox/hq issue 747).
|
||||
continue
|
||||
case d.State == Failed || d.State == Stopped:
|
||||
for _, later := range ordered[i+1:] {
|
||||
if a := h.stopInGroup(byID[later.ID], d.ID, fmt.Sprintf("its group %s stopped: %s %s", g.ID, d.ID, d.State)); a != nil {
|
||||
if a := h.stopInGroup(byID[later.ID], fmt.Sprintf("its group %s stopped: %s %s", g.ID, d.ID, d.State)); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
}
|
||||
@@ -2184,7 +2019,7 @@ func mayCarry(c, d *Delivery, module string) bool {
|
||||
// controller ends the walk and walks the other merges again without it, and without each later merge of its
|
||||
// repository, which contains it. Every member a pass stops on one walk is gathered first, and the walk is asked
|
||||
// once, by the first member's ask, after the pass: a member is never walked again because its stop came second.
|
||||
func (h *Holder) stopInGroup(d *Delivery, cause, why string) func() {
|
||||
func (h *Holder) stopInGroup(d *Delivery, why string) func() {
|
||||
if d == nil || d.State.Final() {
|
||||
return nil
|
||||
}
|
||||
@@ -2193,7 +2028,6 @@ func (h *Holder) stopInGroup(d *Delivery, cause, why string) func() {
|
||||
h.refuse(err)
|
||||
return nil
|
||||
}
|
||||
d.StoppedFor = cause // the member it was stopped for: once that one is delivered, the stop lapses (novox/hq issue 747)
|
||||
h.moved(d, []Transition{t})
|
||||
// Its open walk; or, its walk stopped for another merge, the batch or walk that walks its merge again (review
|
||||
// of #221): a merge walked again and then stopped by its group is left out there too.
|
||||
@@ -2275,12 +2109,6 @@ func (h *Holder) carriesStopped(walk string) ([]string, string) {
|
||||
if last := d.Transitions[len(d.Transitions)-1]; last.Event != EvStop || last.By == "" {
|
||||
continue
|
||||
}
|
||||
// A stop whose change a later walk delivered since, or one its group made for a member delivered since,
|
||||
// holds nothing back: the group stops a walk only for a member whose change is still undelivered (novox/hq
|
||||
// issue 747).
|
||||
if d.coveredStop() || h.stopLapsed(d) {
|
||||
continue
|
||||
}
|
||||
// By the merges it names alone: its own commit may be another's merge it no longer answers.
|
||||
if !slices.ContainsFunc(s.walk.Delivery.Merges, func(m WalkMerge) bool {
|
||||
return strings.EqualFold(m.Repository, d.Repository) && sameCommit(m.Commit, d.MergedAs)
|
||||
@@ -2298,51 +2126,6 @@ func (h *Holder) carriesStopped(walk string) ([]string, string) {
|
||||
return out, why
|
||||
}
|
||||
|
||||
// coverAwaited is whether a stopped merge a walk carries, given as `<repository>@<commit>`, waits for the forge's
|
||||
// word on whether a later finished walk delivered it or the member its group stopped it for (novox/hq issue 747).
|
||||
// It says since when the forge was first asked, the earliest of them. Held only within coverAskBound of the first
|
||||
// ask: past it the forge's silence or error lets the walk be asked to leave the merge out. Under the lock.
|
||||
func (h *Holder) coverAwaited(merges []string) (time.Time, bool) {
|
||||
var since time.Time
|
||||
awaited := false
|
||||
for _, d := range h.deliveries {
|
||||
if d.State != Stopped || d.MergedAs == "" || !containsString(merges, d.Repository+"@"+d.MergedAs) {
|
||||
continue
|
||||
}
|
||||
for _, id := range []string{d.ID, d.stoppedFor()} {
|
||||
if id == "" || !h.coverAsked[id] {
|
||||
continue
|
||||
}
|
||||
if at := h.coverAskedSince[id]; !awaited || at.Before(since) {
|
||||
since = at
|
||||
}
|
||||
awaited = true
|
||||
}
|
||||
}
|
||||
return since, awaited
|
||||
}
|
||||
|
||||
// coverHeld says why a walk is held back while the forge is asked whether a later finished walk delivered a stopped
|
||||
// merge it carries (novox/hq issue 747), or nothing when it is not. Said by `waits`, `stalled` and `stalled walks`, so
|
||||
// a forge that cannot answer is loud. Under the lock.
|
||||
func (h *Holder) coverHeld(walk string) string {
|
||||
without, _ := h.carriesStopped(walk)
|
||||
if len(without) == 0 {
|
||||
return ""
|
||||
}
|
||||
since, awaited := h.coverAwaited(without)
|
||||
if !awaited {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("it carries %s, whose delivery was stopped, and the forge is asked since %s whether a later "+
|
||||
"finished walk delivered it; it is asked to leave the merge out if no answer comes by %s (novox/hq issue 747)",
|
||||
strings.Join(without, ", "), since.Local().Format("15:04:05"), since.Add(coverAskBound).Local().Format("15:04:05"))
|
||||
}
|
||||
|
||||
// coverAskBound is how long a walk carrying a stopped merge is held back while the forge is asked whether a later
|
||||
// finished walk delivered it (novox/hq issue 747); past it, the walk is asked to leave the merge out.
|
||||
const coverAskBound = 10 * time.Minute
|
||||
|
||||
// lastWhyOf is the reason of a delivery's last transition.
|
||||
func lastWhyOf(d *Delivery) string {
|
||||
if len(d.Transitions) == 0 {
|
||||
@@ -2378,9 +2161,6 @@ func (h *Holder) leaveStopped() []func() {
|
||||
if without, _ := h.carriesStopped(id); len(without) > 0 {
|
||||
said := fmt.Sprintf("its walk %s is not let go: it carries %s, whose delivery was stopped, and is asked to "+
|
||||
"leave it out (novox/hq ADR 0299)", id, strings.Join(without, ", "))
|
||||
if hold := h.coverHeld(id); hold != "" {
|
||||
said = "its walk " + id + " is held back: " + hold
|
||||
}
|
||||
if r := h.leaveRefused[id]; r != "" {
|
||||
said += "; the controller refused: " + r
|
||||
}
|
||||
@@ -2404,9 +2184,6 @@ func (h *Holder) leaveStopped() []func() {
|
||||
delete(h.leaveRefused, id)
|
||||
continue
|
||||
}
|
||||
if _, awaited := h.coverAwaited(without); awaited {
|
||||
continue // held back, not stopped, until the forge says whether a later walk delivered it (issue 747)
|
||||
}
|
||||
// Once per record of it: the record that answers an ask is newer. Asked again after five minutes, should
|
||||
// the ask have gone nowhere.
|
||||
if a, asked := h.leaveAsked[id]; asked && seen.walk.Revision <= a.revision && now.Sub(a.at) < 5*time.Minute {
|
||||
@@ -2528,28 +2305,18 @@ func againWords(a StopAnswer) string {
|
||||
func GroupState(g *Group, members []*Delivery) (string, string) {
|
||||
count := map[State]int{}
|
||||
reached, ended := 0, 0
|
||||
var uncovered, lapsed []string
|
||||
byID := map[string]*Delivery{}
|
||||
for _, d := range members {
|
||||
byID[d.ID] = d
|
||||
}
|
||||
var uncovered []string
|
||||
for _, d := range members {
|
||||
count[d.State]++
|
||||
if d.State.Final() {
|
||||
ended++
|
||||
}
|
||||
switch {
|
||||
case memberReached(d):
|
||||
// Reached, whatever its final state: a failure or stop a later walk delivered since is no failure of the
|
||||
// group (novox/hq issue 747).
|
||||
case d.State.Reached(), d.State == Superseded && d.CoveredBy != "":
|
||||
reached++
|
||||
continue
|
||||
case stopLapsed(d, func(id string) *Delivery { return byID[id] }):
|
||||
lapsed = append(lapsed, d.ID)
|
||||
continue
|
||||
case d.State == Superseded:
|
||||
uncovered = append(uncovered, d.ID)
|
||||
}
|
||||
count[d.State]++
|
||||
}
|
||||
n := len(members)
|
||||
switch {
|
||||
@@ -2559,11 +2326,6 @@ func GroupState(g *Group, members []*Delivery) (string, string) {
|
||||
return "stopped", "a member was stopped"
|
||||
case n > 0 && reached == n:
|
||||
return "delivered", "every member is delivered"
|
||||
case g.Closed && len(lapsed) > 0 && reached+len(lapsed) == n:
|
||||
// Stopped for a member delivered since: its own change waits for a later walk of its repository, which then
|
||||
// covers it (novox/hq issue 747).
|
||||
return "delivering", fmt.Sprintf("%d of %d delivered; %s, stopped for a member delivered since, waits for a "+
|
||||
"later walk of its repository", reached, n, strings.Join(lapsed, ", "))
|
||||
case g.Closed && n > 0 && ended == n:
|
||||
return "stopped", supersededWhy + strings.Join(uncovered, ", ")
|
||||
case g.Closed && g.Check != nil && (g.Check.OutOfDate != "" || g.Check.Verdict == "" ||
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 747: in the group fix/a-module-moves-only-when-its-program-changes, the controller's member #336
|
||||
// (merged as 5d22d7df) failed at 22:32 during an outage of the forge's tools. The walk of the controller's next merge,
|
||||
// #339 at 60cc9ec3, which contains 5d22d7df, failed with it and was resumed to done by a rebuild. mesh-delivery still
|
||||
// held #336 failed, so its group stopped every later walk of the catalogue that carried the catalogue member's merge
|
||||
// 2edef5a0: the catalogue could deliver nothing. A failed or stopped delivery is covered once a later finished walk of
|
||||
// its repository that contains its merge built its modules, and a group stops a walk only for a member whose change is
|
||||
// still undelivered.
|
||||
//
|
||||
// The shape is the live state of 2026-10-14 23:07, as mesh-delivery.show gave it, read back after a restart: the
|
||||
// controller holds none of the walks of the night any more, only the walk of the catalogue's next merge.
|
||||
|
||||
const (
|
||||
g747 = "fix/a-module-moves-only-when-its-program-changes"
|
||||
ctl747, cat747 = "novox/mesh-controller", "novox/mesh-catalog"
|
||||
head336, merge336 = "b34055f580d4e2a4", "5d22d7df86c73452"
|
||||
head339, merge339 = "e4acab32d6a20ade", "60cc9ec363dfa7c6"
|
||||
head301, merge301 = "d94764f00168471f", "2edef5a00ae05605"
|
||||
merge307 = "0f76b6e2aaaaaaaa"
|
||||
)
|
||||
|
||||
var (
|
||||
at747 = time.Date(2026, 10, 14, 20, 21, 6, 0, time.UTC)
|
||||
id336, id339, id301 = IDOf(ctl747, head336), IDOf(ctl747, head339), IDOf(cat747, head301)
|
||||
unchangedRouteProxy747 = map[string]string{"route-proxy": "its build source holds none of the changed files"}
|
||||
)
|
||||
|
||||
// night747 is the stuck state, with the forge saying, or not, that 60cc9ec3 contains 5d22d7df.
|
||||
func night747(t *testing.T, contained bool) *world { return night747With(t, contained, nil) }
|
||||
|
||||
// night747With is night747 with #339's walk having built only the modules given, saying the others unchanged.
|
||||
func night747With(t *testing.T, contained bool, built []string) *world {
|
||||
t.Helper()
|
||||
return night747Over(t, func(f *fakeForge) { f.contains[merge336+" in "+merge339] = contained }, built)
|
||||
}
|
||||
|
||||
// night747Over is night747With with the forge set as given: one that is not told the pair cannot say.
|
||||
func night747Over(t *testing.T, set func(*fakeForge), built []string) *world {
|
||||
t.Helper()
|
||||
at := at747
|
||||
failedAt := at.Add(11 * time.Minute)
|
||||
store := newMemStore()
|
||||
moved339, unchanged339 := []string{"build-agent", "mesh-controller"}, unchangedRouteProxy747
|
||||
if built != nil {
|
||||
moved339, unchanged339 = built, map[string]string{}
|
||||
for _, m := range []string{"build-agent", "mesh-controller", "route-proxy"} {
|
||||
if !containsString(built, m) {
|
||||
unchanged339[m] = "its build source holds none of the files #339 changed"
|
||||
}
|
||||
}
|
||||
}
|
||||
m336 := &Delivery{ID: id336, Repository: ctl747, Commit: head336, Number: 336, Base: "main", Branch: g747, Group: g747,
|
||||
State: Failed, Since: failedAt, Created: at.Add(-time.Hour), MergedAs: merge336, MergedAt: at,
|
||||
AnsweredBy: merge336, Check: &Verdict{Gate: "pass"},
|
||||
Plan: planMoving(ctl747, "build-agent", "mesh-controller", "route-proxy"),
|
||||
Walk: &WalkSeen{ID: "plan-336", Commit: merge336, State: walkFailed, Branch: "main", MovedSaid: true,
|
||||
Note: "mesh-controller failed to build in tier 0", Created: at.Add(7 * time.Minute), Tiers: 2,
|
||||
Unchanged: unchangedRouteProxy747},
|
||||
Transitions: []Transition{{At: failedAt, From: Delivering, To: Failed, Event: EvFailed,
|
||||
Why: "its walk failed, carrying its own commit"}}}
|
||||
m339 := &Delivery{ID: id339, Repository: ctl747, Commit: head339, Number: 339, Base: "main", Branch: "fix/726",
|
||||
State: Failed, Since: failedAt, Created: at.Add(-10 * time.Minute), MergedAs: merge339, MergedAt: at.Add(7 * time.Minute),
|
||||
AnsweredBy: merge339, Check: &Verdict{Gate: "pass"}, Plan: planMoving(ctl747, "mesh-controller"),
|
||||
// Its walk failed with #336's, and a rebuild resumed it to done: the record it keeps says so.
|
||||
Walk: &WalkSeen{ID: "plan-339", Commit: merge339, State: walkDone, Branch: "main", MovedSaid: true,
|
||||
Moved: moved339, Unchanged: unchanged339,
|
||||
Created: at.Add(11 * time.Minute), Tiers: 2, Tier: 2},
|
||||
Transitions: []Transition{{At: failedAt, From: Delivering, To: Failed, Event: EvFailed,
|
||||
Why: "its walk failed, carrying its own commit"}}}
|
||||
m301 := &Delivery{ID: id301, Repository: cat747, Commit: head301, Number: 301, Base: "main", Branch: g747, Group: g747,
|
||||
State: Stopped, Since: failedAt, Created: at.Add(-2 * time.Hour), MergedAs: merge301, MergedAt: at.Add(7 * time.Second),
|
||||
Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "build-agent", "route-proxy"),
|
||||
Transitions: []Transition{{At: failedAt, From: Ready, To: Stopped, Event: EvStop, By: byOwner,
|
||||
Why: "its group " + g747 + " stopped: " + id336 + " failed"}}}
|
||||
for _, d := range []*Delivery{m336, m339, m301} {
|
||||
if err := store.PutDelivery(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
g := &Group{ID: g747, Members: []string{id301, id336}, Closed: true, Order: []string{id336, id301}, Said: "failed",
|
||||
OrderOf: commitsOf([]*Delivery{m301, m336}), Check: &GroupCheck{Asked: "check-1", At: at.Add(-time.Minute),
|
||||
Verdict: "pass", Summary: "both compose", Commits: commitsOf([]*Delivery{m301, m336})}}
|
||||
if err := store.PutGroup(g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctlr := newFakeController()
|
||||
// The catalogue's next merge: its walk carries 2edef5a0, merged before it and never walked, and waits for its word.
|
||||
ctlr.put(Walk{ID: "plan-cat", Repository: cat747, Branch: "main", Commit: merge307, Created: at.Add(46 * time.Minute),
|
||||
State: walkBuilding, Tiers: [][]string{{"gitea"}}, Modules: map[string]*WalkModule{"gitea": {}},
|
||||
Delivery: &WalkDelivery{Awaits: "mesh-delivery", Merges: []WalkMerge{
|
||||
{Repository: cat747, Commit: merge301, Carried: merge307}, {Repository: cat747, Commit: merge307}}}})
|
||||
ctlr.mergedAt[merge301], ctlr.mergedAt[merge307] = m301.MergedAt, at.Add(46*time.Minute)
|
||||
forge := newFakeForge()
|
||||
set(forge)
|
||||
w := newWorldOver(t, store, ctlr, forge, at.Add(47*time.Minute))
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
return w
|
||||
}
|
||||
|
||||
func groupState747(w *world) (string, string) {
|
||||
g := w.h.groups[g747]
|
||||
return GroupState(g, w.h.membersOf(g))
|
||||
}
|
||||
|
||||
// **A failed member is covered by a later finished walk that contains it**: #339's walk, done at 60cc9ec3, which the
|
||||
// forge says contains 5d22d7df, built build-agent and mesh-controller, and #336's own walk said route-proxy unchanged.
|
||||
// #336 stays failed, reads covered by that walk on its pull request, and its group no longer reads failed. #339, whose
|
||||
// own walk was resumed to done, is covered by it too.
|
||||
func TestAFailedMemberIsCoveredByALaterDoneWalkThatContainsIt(t *testing.T) {
|
||||
w := night747(t, true)
|
||||
d := w.delivery(id336)
|
||||
if state, desc := statusOf(d); d.State != Failed || state != "success" || !strings.Contains(desc, "covered after its failure") ||
|
||||
!strings.Contains(desc, "plan-339") {
|
||||
t.Fatalf("#336's status reads %s %q", state, desc)
|
||||
}
|
||||
if s := statusOn(w, ctl747, head336); !strings.HasPrefix(s, "success") {
|
||||
t.Fatalf("the forge was told %q of #336", s)
|
||||
}
|
||||
if state, desc := statusOf(w.delivery(id339)); state != "success" || !strings.Contains(desc, "plan-339") {
|
||||
t.Fatalf("#339 reads %s %q; want it covered by its own walk, resumed to done", state, desc)
|
||||
}
|
||||
if state, why := groupState747(w); state == "failed" || state == "stopped" {
|
||||
t.Fatalf("the group reads %s: %s", state, why)
|
||||
}
|
||||
// Told once, and kept: a restart tells nothing again.
|
||||
notes := len(w.forge.notesOn(merge336))
|
||||
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Minute))
|
||||
again.settleAll()
|
||||
if state, _ := statusOf(again.delivery(id336)); len(w.forge.notesOn(merge336)) != notes || state != "success" {
|
||||
t.Fatalf("a restart told it again or forgot it: %d notes, was %d", len(w.forge.notesOn(merge336)), notes)
|
||||
}
|
||||
}
|
||||
|
||||
// **The group then no longer stops the other repository's walk**: the catalogue member was stopped for #336 alone,
|
||||
// so the walk of the catalogue's next merge, which carries its merge, is neither stopped nor held back, and the
|
||||
// group waits for that walk to deliver the catalogue member's change.
|
||||
func TestACoveredMemberNoLongerStopsTheOtherRepositorysWalk(t *testing.T) {
|
||||
w := night747(t, true)
|
||||
if len(w.ctl.withouts) > 0 || containsString(w.ctl.stops, "plan-cat") {
|
||||
t.Fatalf("the catalogue's walk was stopped: without %v, stops %v", w.ctl.withouts, w.ctl.stops)
|
||||
}
|
||||
if stopped, _ := w.h.carriesStopped("plan-cat"); len(stopped) > 0 {
|
||||
t.Fatalf("the catalogue's walk is held back for %v", stopped)
|
||||
}
|
||||
if state, why := groupState747(w); state != "delivering" || !strings.Contains(why, id301) {
|
||||
t.Fatalf("the group reads %s: %s; want delivering, the catalogue member waiting for a later walk", state, why)
|
||||
}
|
||||
// Once a later walk of the catalogue that contains its merge built its modules, it is covered, and the group is
|
||||
// delivered.
|
||||
w.forge.contains[merge301+" in "+merge307] = true
|
||||
r := w.ctl.walk("plan-cat")
|
||||
sent := w.now
|
||||
r.Modules = map[string]*WalkModule{}
|
||||
for _, m := range []string{"build-agent", "route-proxy", "gitea"} {
|
||||
r.Modules[m] = &WalkModule{State: "built", SentAt: &sent}
|
||||
}
|
||||
r.State = walkDone
|
||||
w.h.WalkMoved(w.ctl.put(r))
|
||||
w.later(time.Minute)
|
||||
w.settleAll()
|
||||
if state, desc := statusOf(w.delivery(id301)); state != "success" || !strings.Contains(desc, "plan-cat") {
|
||||
t.Fatalf("the catalogue member reads %s %q", state, desc)
|
||||
}
|
||||
if state, why := groupState747(w); state != "delivered" {
|
||||
t.Fatalf("the group reads %s: %s", state, why)
|
||||
}
|
||||
}
|
||||
|
||||
// **A failed member not contained still stops it**: the forge says 60cc9ec3 does not contain 5d22d7df, so #336's change
|
||||
// never reached the machines; it stays failed and uncovered, its group failed, and the catalogue's walk that carries
|
||||
// the stopped member's merge is asked to leave it out.
|
||||
func TestAFailedMemberNotContainedStillStopsTheOtherRepositorysWalk(t *testing.T) {
|
||||
w := night747(t, false)
|
||||
d := w.delivery(id336)
|
||||
if state, desc := statusOf(d); d.State != Failed || state != "failure" {
|
||||
t.Fatalf("#336 is %s and reads %s %q", d.State, state, desc)
|
||||
}
|
||||
if state, _ := groupState747(w); state != "failed" {
|
||||
t.Fatalf("the group reads %s", state)
|
||||
}
|
||||
if len(w.ctl.withouts) == 0 || !strings.HasPrefix(w.ctl.withouts[0], "plan-cat without "+cat747+"@"+merge301) {
|
||||
t.Fatalf("the catalogue's walk was not asked to leave the stopped merge out: %v", w.ctl.withouts)
|
||||
}
|
||||
}
|
||||
|
||||
// **A later walk that only said a module unchanged covers nothing of an earlier merge that moves it**: #339's walk
|
||||
// built mesh-controller alone and said build-agent unchanged for #339's own files, which says nothing of #336's; only
|
||||
// #336's own walk says that of its change (route-proxy). #336 stays failed and its group still stops the catalogue.
|
||||
func TestALaterWalksUnchangedCoversNoModuleOfAnEarlierMerge(t *testing.T) {
|
||||
w := night747With(t, true, []string{"mesh-controller"})
|
||||
if state, desc := statusOf(w.delivery(id336)); state != "failure" {
|
||||
t.Fatalf("#336 reads %s %q, though no walk built build-agent for it", state, desc)
|
||||
}
|
||||
if state, _ := groupState747(w); state != "failed" {
|
||||
t.Fatalf("the group reads %s", state)
|
||||
}
|
||||
if len(w.ctl.withouts) == 0 {
|
||||
t.Fatal("the catalogue's walk was not asked to leave the stopped merge out")
|
||||
}
|
||||
}
|
||||
|
||||
// **A forge that cannot say holds the walk only within the bound** (review of issue 747): while the forge errors on
|
||||
// whether 60cc9ec3 contains 5d22d7df, the catalogue's walk is held back, loudly, neither stopped nor asked to leave the
|
||||
// merge out, and #336 still reads failed. Past the bound the forge's silence holds nothing back any more: the walk
|
||||
// is asked to leave the stopped merge out, as with no later walk at all.
|
||||
func TestAForgeThatCannotSayHoldsTheWalkOnlyWithinTheBound(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
set func(*fakeForge)
|
||||
}{
|
||||
{"erroring", func(f *fakeForge) { f.containsDown = true }},
|
||||
{"not answering for the pair", func(*fakeForge) {}},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := night747Over(t, c.set, nil)
|
||||
if len(w.ctl.withouts) > 0 || len(w.ctl.stops) > 0 {
|
||||
t.Fatalf("within the bound the walk was not held: without %v, stops %v", w.ctl.withouts, w.ctl.stops)
|
||||
}
|
||||
if state, desc := statusOf(w.delivery(id336)); state != "failure" {
|
||||
t.Fatalf("#336 reads %s %q while the forge cannot say", state, desc)
|
||||
}
|
||||
said := ""
|
||||
for _, l := range w.h.WalksWaiting() {
|
||||
if l.Walk == "plan-cat" {
|
||||
said += l.Says
|
||||
}
|
||||
}
|
||||
w.later(3 * time.Minute) // past the grace an unheld walk is given, so `stalled` lists it
|
||||
w.settleAll()
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.Walk == "plan-cat" {
|
||||
said += " | " + l.Says
|
||||
}
|
||||
}
|
||||
if strings.Count(said, "the forge is asked since") != 2 {
|
||||
t.Fatalf("the hold is not said by `stalled walks` and `stalled`: %q", said)
|
||||
}
|
||||
if len(w.ctl.withouts) > 0 || len(w.ctl.stops) > 0 {
|
||||
t.Fatalf("within the bound the walk was not held: without %v, stops %v", w.ctl.withouts, w.ctl.stops)
|
||||
}
|
||||
w.later(coverAskBound + time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.ctl.withouts) == 0 || !strings.HasPrefix(w.ctl.withouts[0], "plan-cat without "+cat747+"@"+merge301) {
|
||||
t.Fatalf("past the bound the walk was not asked to leave the stopped merge out: %v", w.ctl.withouts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 753: once issue 747's fix was live (2026-10-14 23:35), the catalogue's walk plan-1792012015539062661 at
|
||||
// 0f76b6e2 (#307's merge; build-agent, gitea, route-proxy to all four machines) was resumed and said "published; its
|
||||
// walk waits for mesh-delivery's word: no delivery that has not ended follows it or is answered by it". Every delivery
|
||||
// it carried had ended: #301 stopped by its group for the controller's #336, delivered since, and waiting for "a later
|
||||
// walk of its repository that contains its merge"; #304 stopped; #307 delivered. The only walk that contained #301's
|
||||
// merge waited for #301's word: a deadlock, released by a hand `plans go`. A delivery whose stop lapsed gives its word
|
||||
// to the walk that answers its merge, and a walk with no living delivery behind it says what it carries.
|
||||
//
|
||||
// The shape is the live state as `mesh-delivery.show` and `mesh-controller.plans` gave it, read back after a restart.
|
||||
|
||||
const (
|
||||
merge304, head304, head307 = "4c0ffee0bbbbbbbb", "a304a304a304a304", "b307b307b307b307"
|
||||
plan753 = "plan-1792012015539062661"
|
||||
)
|
||||
|
||||
var id304, id307 = IDOf(cat747, head304), IDOf(cat747, head307)
|
||||
|
||||
// night753 is the stuck walk: #336 delivered (its change reached the machines), #301 stopped by the given act, #304
|
||||
// stopped by its group, #307 delivered, and the catalogue's walk at #307's merge naming all three merges, waiting.
|
||||
func night753(t *testing.T, stop Transition) *world { return night753With(t, stop) }
|
||||
|
||||
// night753With is night753 with more deliveries kept: one in #301's group is ordered after #336 and before #301.
|
||||
func night753With(t *testing.T, stop Transition, more ...*Delivery) *world {
|
||||
t.Helper()
|
||||
return night753Over(t, stop, nil, nil, more...)
|
||||
}
|
||||
|
||||
// night753Over is night753With with more merges named by the walk and the controller set as given before the first
|
||||
// tick.
|
||||
func night753Over(t *testing.T, stop Transition, merges []WalkMerge, set func(*fakeController), more ...*Delivery) *world {
|
||||
t.Helper()
|
||||
at := at747
|
||||
store := newMemStore()
|
||||
m336 := &Delivery{ID: id336, Repository: ctl747, Commit: head336, Number: 336, Base: "main", Branch: g747, Group: g747,
|
||||
State: Delivered, Since: at.Add(70 * time.Minute), Created: at.Add(-time.Hour), MergedAs: merge336, MergedAt: at,
|
||||
AnsweredBy: merge336, Check: &Verdict{Gate: "pass"}, Plan: planMoving(ctl747, "build-agent", "mesh-controller"),
|
||||
Walk: &WalkSeen{ID: "plan-336", Commit: merge336, State: walkDone, Branch: "main", MovedSaid: true,
|
||||
Moved: []string{"build-agent", "mesh-controller"}, Created: at.Add(7 * time.Minute), Tiers: 2, Tier: 2}}
|
||||
m301 := &Delivery{ID: id301, Repository: cat747, Commit: head301, Number: 301, Base: "main", Branch: g747, Group: g747,
|
||||
State: Stopped, Since: stop.At, Created: at.Add(-2 * time.Hour), MergedAs: merge301, MergedAt: at.Add(7 * time.Second),
|
||||
Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "build-agent", "route-proxy"), Transitions: []Transition{stop}}
|
||||
if stop.By == byOwner {
|
||||
m301.StoppedFor = id336
|
||||
}
|
||||
m304 := &Delivery{ID: id304, Repository: cat747, Commit: head304, Number: 304, Base: "main", Branch: "fix/forge-events",
|
||||
State: Stopped, Since: at.Add(80 * time.Minute), Created: at.Add(-time.Hour), MergedAs: merge304,
|
||||
MergedAt: at.Add(30 * time.Minute), Check: &Verdict{Gate: "pass"},
|
||||
Plan: planMoving(cat747, "build-agent", "gitea", "route-proxy"),
|
||||
// Stopped with its walk, which the walk of #307 took over, and covered since by the walk of #306 (issue 747).
|
||||
DeliveredBy: "plan-1792011830935225963 (novox/mesh-catalog at 0361c44a into main, done, contains its merge 4c0ffee0)",
|
||||
CoverTold: true,
|
||||
Walk: &WalkSeen{ID: "plan-1792009301770286799", Commit: merge304, State: walkSuperseded, Branch: "main",
|
||||
TakenOverBy: plan753, Waits: true, WaitedFor: "mesh-delivery", Created: at.Add(31 * time.Minute)},
|
||||
Transitions: []Transition{{At: at.Add(80 * time.Minute), From: Published, To: Stopped, Event: EvStop,
|
||||
Why: "its walk plan-1792009301770286799 was stopped by mesh-delivery"}}}
|
||||
m307 := &Delivery{ID: id307, Repository: cat747, Commit: head307, Number: 307, Base: "main", Branch: "fix/ts-check",
|
||||
State: Delivered, Since: at.Add(90 * time.Minute), Created: at.Add(-time.Hour), MergedAs: merge307,
|
||||
MergedAt: at.Add(46 * time.Minute), Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "build-agent")}
|
||||
for _, d := range append([]*Delivery{m336, m301, m304, m307}, more...) {
|
||||
if err := store.PutDelivery(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
inGroup, order := []*Delivery{m301, m336}, []string{id336}
|
||||
for _, d := range more {
|
||||
if d.Group == g747 {
|
||||
inGroup, order = append(inGroup, d), append(order, d.ID)
|
||||
}
|
||||
}
|
||||
order = append(order, id301)
|
||||
var ids []string
|
||||
for _, d := range inGroup {
|
||||
ids = append(ids, d.ID)
|
||||
}
|
||||
g := &Group{ID: g747, Members: ids, Closed: true, Order: order, Said: "delivering",
|
||||
OrderOf: commitsOf(inGroup), Check: &GroupCheck{Asked: "check-1", At: at.Add(-time.Minute),
|
||||
Verdict: "pass", Summary: "all compose", Commits: commitsOf(inGroup)}}
|
||||
if err := store.PutGroup(g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctlr := newFakeController()
|
||||
modules := map[string]*WalkModule{"build-agent": {}, "gitea": {}, "route-proxy": {}}
|
||||
ctlr.put(Walk{ID: plan753, Repository: cat747, Branch: "main", Commit: merge307, Created: at.Add(46 * time.Minute),
|
||||
State: walkBuilding, Tiers: [][]string{{"build-agent", "gitea", "route-proxy"}}, Modules: modules,
|
||||
Delivery: &WalkDelivery{Awaits: "mesh-delivery", Merges: []WalkMerge{
|
||||
{Repository: cat747, Commit: merge301, Carried: merge307}, {Repository: cat747, Commit: merge304, Carried: merge307},
|
||||
{Repository: cat747, Commit: merge307}}}})
|
||||
if len(merges) > 0 {
|
||||
r := ctlr.walk(plan753)
|
||||
r.Delivery.Merges = append(merges, r.Delivery.Merges...)
|
||||
ctlr.put(r)
|
||||
}
|
||||
if set != nil {
|
||||
set(ctlr)
|
||||
}
|
||||
ctlr.mergedAt[merge301], ctlr.mergedAt[merge304], ctlr.mergedAt[merge307] = m301.MergedAt, m304.MergedAt, m307.MergedAt
|
||||
w := newWorldOver(t, store, ctlr, newFakeForge(), at.Add(100*time.Minute))
|
||||
if err := w.h.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
return w
|
||||
}
|
||||
|
||||
// groupStop753 is #301's stop as its group made it: for #336, whose change was delivered since.
|
||||
func groupStop753() Transition {
|
||||
return Transition{At: at747.Add(11 * time.Minute), From: Ready, To: Stopped, Event: EvStop, By: byOwner,
|
||||
Why: "its group " + g747 + " stopped: " + id336 + " failed"}
|
||||
}
|
||||
|
||||
func saidOf(lines []StalledLine, walk string) (string, State) {
|
||||
said, state := "", State("")
|
||||
for _, l := range lines {
|
||||
if l.Walk == walk {
|
||||
said, state = said+l.Says, l.State
|
||||
}
|
||||
}
|
||||
return said, state
|
||||
}
|
||||
|
||||
// **A stopped delivery gives its word to the later walk it waits for**: #301, stopped for #336, delivered since, waits for
|
||||
// a later walk of the catalogue that contains its merge; plan-1792012015539062661 names that merge and waits for this
|
||||
// owner's word. #301 gives it, once, saying why, and `stalled walks` never calls the walk unheld.
|
||||
func TestAStoppedDeliveryGivesItsWordToTheWalkItWaitsFor(t *testing.T) {
|
||||
w := night753(t, groupStop753())
|
||||
if n := strings.Count(strings.Join(w.ctl.delivers, " "), plan753); n != 1 {
|
||||
t.Fatalf("the walk was let go %d times (%v); want once", n, w.ctl.delivers)
|
||||
}
|
||||
d := w.delivery(id301)
|
||||
if d.State != Stopped || !strings.Contains(d.LetGoWhy, lapsedWord(plan753)) || !strings.Contains(d.LetGoWhy, "issue 753") {
|
||||
t.Fatalf("#301 is %s, its word given for %q", d.State, d.LetGoWhy)
|
||||
}
|
||||
if said, state := saidOf(w.h.WalksWaiting(), plan753); state == Unheld {
|
||||
t.Fatalf("the walk reads unheld: %q", said)
|
||||
}
|
||||
if len(w.ctl.withouts) > 0 || len(w.ctl.stops) > 0 {
|
||||
t.Fatalf("the walk was stopped: without %v, stops %v", w.ctl.withouts, w.ctl.stops)
|
||||
}
|
||||
// Once that walk built build-agent and route-proxy and ended done, #301 is covered by it (issue 747).
|
||||
w.forge.contains[merge301+" in "+merge307] = true
|
||||
r := w.ctl.walk(plan753)
|
||||
sent := w.now
|
||||
for _, m := range []string{"build-agent", "gitea", "route-proxy"} {
|
||||
r.Modules[m] = &WalkModule{State: "built", SentAt: &sent}
|
||||
}
|
||||
r.State = walkDone
|
||||
w.h.WalkMoved(w.ctl.put(r))
|
||||
w.later(time.Minute)
|
||||
w.settleAll()
|
||||
if state, desc := statusOf(w.delivery(id301)); state != "success" || !strings.Contains(desc, plan753) {
|
||||
t.Fatalf("#301 reads %s %q", state, desc)
|
||||
}
|
||||
}
|
||||
|
||||
// **A walk still holding a live delivery not at its turn waits for it**: the stopped delivery's word is one turn among
|
||||
// the walk's, never a way past a live delivery it carries.
|
||||
func TestALapsedStopDoesNotLetGoPastALiveDelivery(t *testing.T) {
|
||||
at := at747
|
||||
held := &Delivery{ID: IDOf(cat747, "c308c308c308c308"), Repository: cat747, Commit: "c308c308c308c308", Number: 308,
|
||||
Base: "main", Branch: "fix/held", State: Held, HeldWhy: "a person's", Since: at, Created: at.Add(-time.Hour),
|
||||
MergedAs: "c308aaaaaaaaaaaa", MergedAt: at.Add(40 * time.Minute), Check: &Verdict{Gate: "pass"},
|
||||
Plan: planMoving(cat747, "gitea"), Walk: &WalkSeen{ID: plan753, Commit: merge307, State: walkBuilding, Branch: "main",
|
||||
Waits: true, Created: at.Add(46 * time.Minute)}}
|
||||
w := night753With(t, groupStop753(), held)
|
||||
if containsString(w.ctl.delivers, plan753) {
|
||||
t.Fatalf("the walk was let go past a held delivery it carries: %v", w.ctl.delivers)
|
||||
}
|
||||
if said, state := saidOf(w.h.WalksWaiting(), plan753); state != Held {
|
||||
t.Fatalf("`stalled walks` reads %s %q; want the held delivery", state, said)
|
||||
}
|
||||
}
|
||||
|
||||
// **A stop by a person gives no word**: #301 stopped by hand holds its change back; the walk is asked to leave it out,
|
||||
// never let go for it.
|
||||
func TestAPersonsStopGivesNoWord(t *testing.T) {
|
||||
w := night753(t, Transition{At: at747.Add(11 * time.Minute), From: Ready, To: Stopped, Event: EvStop, By: "jochen",
|
||||
Why: "stopped by hand"})
|
||||
if containsString(w.ctl.delivers, plan753) {
|
||||
t.Fatalf("the walk was let go for a person's stop: %v", w.ctl.delivers)
|
||||
}
|
||||
if len(w.ctl.withouts) == 0 && len(w.ctl.stops) == 0 {
|
||||
t.Fatal("the walk carrying a person's stop was neither left without it nor stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// **A walk no living delivery holds says what it carries** (the issue's third expectation): with nothing to give it
|
||||
// its word, `stalled walks` and, past the grace, `stalled` name every ended delivery it carries and its state, never
|
||||
// only that nothing holds it.
|
||||
func TestAWalkWithNoLivingDeliverySaysWhatItCarries(t *testing.T) {
|
||||
w := night753(t, groupStop753())
|
||||
// The walk read again as waiting, naming only #307's merge, which ended delivered: no delivery whose stop lapsed
|
||||
// gives it its word, and no live one holds it.
|
||||
r := w.ctl.walk(plan753)
|
||||
r.Delivery.Go = nil
|
||||
r.Delivery.Merges = []WalkMerge{{Repository: cat747, Commit: merge307}}
|
||||
w.h.WalkMoved(w.ctl.put(r))
|
||||
said, state := saidOf(w.h.WalksWaiting(), plan753)
|
||||
if state != Unheld || !strings.Contains(said, id307+" (delivered)") {
|
||||
t.Fatalf("`stalled walks` reads %s %q; want unheld, naming %s (delivered)", state, said, id307)
|
||||
}
|
||||
w.settleAll()
|
||||
w.later(matchGrace + time.Minute)
|
||||
w.settleAll()
|
||||
said, _ = saidOf(w.h.Stalled(), plan753)
|
||||
if !strings.Contains(said, "every delivery it carries ended: ") || !strings.Contains(said, id307+" (delivered)") {
|
||||
t.Fatalf("`stalled` reads %q", said)
|
||||
}
|
||||
}
|
||||
|
||||
// m299 is a member of #301's group ordered before it, stopped for #336 as #301 was, its merge on another walk.
|
||||
func m299() *Delivery {
|
||||
at := at747
|
||||
return &Delivery{ID: IDOf(cat747, "e299e299e299e299"), Repository: cat747, Commit: "e299e299e299e299", Number: 299,
|
||||
Base: "main", Branch: g747, Group: g747, State: Stopped, StoppedFor: id336, Since: at.Add(11 * time.Minute),
|
||||
Created: at.Add(-3 * time.Hour), MergedAs: "e299aaaaaaaaaaaa", MergedAt: at.Add(5 * time.Second),
|
||||
Check: &Verdict{Gate: "pass"}, Plan: planMoving(cat747, "gitea"),
|
||||
Transitions: []Transition{{At: at.Add(11 * time.Minute), From: Ready, To: Stopped, Event: EvStop, By: byOwner,
|
||||
Why: "its group " + g747 + " stopped: " + id336 + " failed"}}}
|
||||
}
|
||||
|
||||
// **A stopped member gives its word only in its group's order** (review of issue 753): #299, ordered before #301 and
|
||||
// stopped for #336 too, is not delivered and this walk does not carry its merge, so #301 gives no word — it would land
|
||||
// before #299 — and `stalled walks` names #299. When the walk names #299's merge too, it delivers both, in order, and
|
||||
// is let go.
|
||||
func TestAStoppedMemberGivesItsWordOnlyInItsGroupsOrder(t *testing.T) {
|
||||
w := night753With(t, groupStop753(), m299())
|
||||
if containsString(w.ctl.delivers, plan753) {
|
||||
t.Fatalf("the walk was let go for #301 before #299, ordered before it: %v", w.ctl.delivers)
|
||||
}
|
||||
said, state := saidOf(w.h.WalksWaiting(), plan753)
|
||||
if state == Unheld || !strings.Contains(said, m299().ID+" (stopped)") {
|
||||
t.Fatalf("`stalled walks` reads %s %q; want #299 named", state, said)
|
||||
}
|
||||
w = night753Over(t, groupStop753(), []WalkMerge{{Repository: cat747, Commit: "e299aaaaaaaaaaaa", Carried: merge307}}, nil,
|
||||
m299())
|
||||
if !containsString(w.ctl.delivers, plan753) {
|
||||
t.Fatalf("the walk carrying both members was not let go: %v", w.ctl.delivers)
|
||||
}
|
||||
}
|
||||
|
||||
// **A word the controller refuses is never silent** (review of issue 753): the controller keeps refusing it, and past
|
||||
// the grace `stalled` lists the walk, with the refusal.
|
||||
func TestARefusedWordIsListedByStalled(t *testing.T) {
|
||||
w := night753Over(t, groupStop753(), nil, func(c *fakeController) { c.refuseDeliver = true })
|
||||
if containsString(w.ctl.delivers, plan753) {
|
||||
t.Fatalf("the walk was let go: %v", w.ctl.delivers)
|
||||
}
|
||||
w.later(matchGrace + time.Minute)
|
||||
w.settleAll()
|
||||
said, state := saidOf(w.h.Stalled(), plan753)
|
||||
if state != Published || !strings.Contains(said, "the controller refused its word: the controller refuses the word") {
|
||||
t.Fatalf("`stalled` reads %s %q", state, said)
|
||||
}
|
||||
// Once the controller takes it, the walk is let go.
|
||||
w.ctl.refuseDeliver = false
|
||||
w.settleAll()
|
||||
if !containsString(w.ctl.delivers, plan753) {
|
||||
t.Fatalf("the walk was not let go once the controller took the word: %v", w.ctl.delivers)
|
||||
}
|
||||
}
|
||||
|
||||
// **A walk held back for another stopped change is never silent** (review of issue 753): #304 was stopped by a person,
|
||||
// and the controller refuses to leave its merge out or to stop the walk, so the word #301 owes is never sent; past the
|
||||
// grace `stalled` lists the walk, saying why.
|
||||
func TestAWalkHeldForAPersonsStopIsListedByStalled(t *testing.T) {
|
||||
at := at747
|
||||
m305 := &Delivery{ID: IDOf(cat747, "f305f305f305f305"), Repository: cat747, Commit: "f305f305f305f305", Number: 305,
|
||||
Base: "main", Branch: "fix/other", State: Stopped, Since: at.Add(80 * time.Minute), Created: at.Add(-time.Hour),
|
||||
MergedAs: "f305aaaaaaaaaaaa", MergedAt: at.Add(35 * time.Minute), Check: &Verdict{Gate: "pass"},
|
||||
Plan: planMoving(cat747, "gitea"), Transitions: []Transition{{At: at.Add(80 * time.Minute), From: Ready,
|
||||
To: Stopped, Event: EvStop, By: "jochen", Why: "stopped by hand"}}}
|
||||
w := night753Over(t, groupStop753(), []WalkMerge{{Repository: cat747, Commit: m305.MergedAs, Carried: merge307}},
|
||||
func(c *fakeController) { c.refuseWithout, c.refuseStop = true, true }, m305)
|
||||
if containsString(w.ctl.delivers, plan753) {
|
||||
t.Fatalf("the walk carrying a person's stop was let go: %v", w.ctl.delivers)
|
||||
}
|
||||
w.later(matchGrace + time.Minute)
|
||||
w.settleAll()
|
||||
said, _ := saidOf(w.h.Stalled(), plan753)
|
||||
if !strings.Contains(said, "owe it (novox/hq issue 753)") || !strings.Contains(said, "f305aaaaaaaaaaaa") ||
|
||||
!strings.Contains(said, "whose delivery was stopped") {
|
||||
t.Fatalf("`stalled` reads %q", said)
|
||||
}
|
||||
}
|
||||
@@ -76,9 +76,8 @@ func run(h *Holder, l *listening) {
|
||||
logf("[mesh-delivery] the controller's walks cannot be read: %v", err)
|
||||
}
|
||||
}
|
||||
// Every open pull request, from the forge itself, every minute (novox/hq issues 347, 722): what says a
|
||||
// head nobody checks, and the ready marks a merge is asked on.
|
||||
if now.Sub(looked) >= time.Minute {
|
||||
// Every open pull request, from the forge itself, every five minutes (novox/hq issue 347).
|
||||
if now.Sub(looked) >= 5*time.Minute {
|
||||
looked = now
|
||||
h.SeeOpenPulls()
|
||||
}
|
||||
|
||||
@@ -65,9 +65,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
want = append(want, "seat:"+ControllerSeat+"."+v)
|
||||
}
|
||||
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
|
||||
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls",
|
||||
// The merge on a ready mark (novox/hq issue 722): the merge tool, a comment, a label taken off.
|
||||
"gitea.gitea_merge_pull_request", "gitea.gitea_add_comment", "gitea.gitea_api")
|
||||
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls")
|
||||
if !reflect.DeepEqual(m.Invokes, want) {
|
||||
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
|
||||
}
|
||||
|
||||
@@ -83,14 +83,6 @@ type Delivery struct {
|
||||
// CoverTold is whether the forge was told that such a stop was covered since: its status, its view and its note
|
||||
// (novox/hq issue 480). Kept, so a restart tells it once, and a stop covered before this was built is told too.
|
||||
CoverTold bool `json:"cover_told,omitempty"`
|
||||
// DeliveredBy says, for a delivery that failed or was stopped, the later finished walk of its repository whose
|
||||
// commit contains its merge and which built every module it moves — each it did not build, its own walk said
|
||||
// unchanged: its change reached the machines after all (novox/hq issue 747). Its state stays final, as a covered
|
||||
// stop's does (issue 480), and it reads covered: its group counts it as delivered.
|
||||
DeliveredBy string `json:"delivered_by,omitempty"`
|
||||
// StoppedFor is, for a member its group stopped, the member whose end stopped it (novox/hq issue 747): once that
|
||||
// member's change reached the machines, the stop has lapsed, and holds no walk of its repository back.
|
||||
StoppedFor string `json:"stopped_for,omitempty"`
|
||||
// TurnAwaits says, for a group member whose turn waits for a later merge to carry a superseded member before it,
|
||||
// which merges and until when; TurnAwaitsUntil is that moment, which bounds the wait (ADR 0276 decision 9,
|
||||
// novox/hq issue 445). `show` shows both, and `stalled` says them past that moment.
|
||||
@@ -118,9 +110,6 @@ type Delivery struct {
|
||||
LetGo time.Time `json:"let_go"`
|
||||
LetGoWhy string `json:"let_go_why,omitempty"`
|
||||
|
||||
// Ready is what this owner did with its pull request's ready mark (novox/hq issue 722, ready.go).
|
||||
Ready *ReadyRun `json:"ready,omitempty"`
|
||||
|
||||
Transitions []Transition `json:"transitions,omitempty"`
|
||||
// Owed are the forge's and the bus's effects of its transitions not yet done: said only after the
|
||||
// delivery is kept, retried until done.
|
||||
@@ -311,77 +300,18 @@ func (d *Delivery) FromForgesWord() bool {
|
||||
|
||||
// shownOnItsPullRequest is whether its view and status are kept on its pull request: not for history, whose
|
||||
// pull request the forge may no longer hold — or holds under its number another one.
|
||||
// coveredStop is whether a delivery that ended stopped or failed was covered since, and reads covered. A stop
|
||||
// because its walk ended done before its word without building its modules (novox/hq issue 474) is covered when a
|
||||
// later merge into its trunk that contains it was delivered, carrying every module it moves to every machine running
|
||||
// it (ADR 0270's cover test, kept as CoveredBy; issue 480). Any stop or failure is covered when a later finished walk
|
||||
// of its repository whose commit contains its merge built its modules (DeliveredBy, novox/hq issue 747). Its state
|
||||
// stays final, as a covered superseded member stays superseded (issue 442), and it reads covered, by that merge or
|
||||
// walk. A person's stop with a cover found only while it was held keeps its error.
|
||||
// coveredStop is whether a delivery stopped because its walk ended done before its word without building its modules
|
||||
// (novox/hq issue 474) was covered since: a later merge into its trunk that contains it was delivered, carrying every
|
||||
// module it moves to every machine running it (ADR 0270's cover test, kept as CoveredBy). Its state stays stopped —
|
||||
// final, as a covered superseded member stays superseded (issue 442) — and it reads covered, by that merge (novox/hq
|
||||
// issue 480). A stop for any other reason never reads covered.
|
||||
func (d *Delivery) coveredStop() bool {
|
||||
switch d.State {
|
||||
case Stopped:
|
||||
return d.Stranded != "" && d.CoveredBy != "" || d.DeliveredBy != ""
|
||||
case Failed:
|
||||
return d.DeliveredBy != ""
|
||||
}
|
||||
return false
|
||||
return d.State == Stopped && d.Stranded != "" && d.CoveredBy != ""
|
||||
}
|
||||
|
||||
// coveredStopSaid is how a covered stop or failure reads: on the forge's status, in its view and note, and in `show`.
|
||||
// coveredStopSaid is how a covered stop reads: on the forge's status, in its view and note, and in `show`.
|
||||
func (d *Delivery) coveredStopSaid() string {
|
||||
ended := "its stop"
|
||||
if d.State == Failed {
|
||||
ended = "its failure"
|
||||
}
|
||||
if d.Stranded != "" && d.CoveredBy != "" {
|
||||
return "covered after " + ended + ": a later merge that contains it was delivered: " + d.CoveredBy
|
||||
}
|
||||
return "covered after " + ended + ": a later walk that contains it was delivered: " + d.DeliveredBy
|
||||
}
|
||||
|
||||
// memberReached is whether a group counts a member as delivered: its change reached the machines, or had nothing to
|
||||
// reach them with — delivered, history or covered, a superseded member a later delivered walk carried (issue 442),
|
||||
// or a stop or failure covered since (issues 480 and 747).
|
||||
func memberReached(d *Delivery) bool {
|
||||
return d.State.Reached() || d.State == Superseded && d.CoveredBy != "" || d.coveredStop()
|
||||
}
|
||||
|
||||
// stoppedFor is the member whose end stopped this one in its group: kept since novox/hq issue 747, and read from the
|
||||
// stop's reason for a stop made before ("its group <id> stopped: <member> …").
|
||||
func (d *Delivery) stoppedFor() string {
|
||||
if d.StoppedFor != "" || d.State != Stopped || d.Group == "" {
|
||||
return d.StoppedFor
|
||||
}
|
||||
prefix := "its group " + d.Group + " stopped: "
|
||||
for i := len(d.Transitions) - 1; i >= 0; i-- {
|
||||
t := d.Transitions[i]
|
||||
if t.Event != EvStop {
|
||||
continue
|
||||
}
|
||||
if t.By != byOwner || !strings.HasPrefix(t.Why, prefix) {
|
||||
return ""
|
||||
}
|
||||
member, _, _ := strings.Cut(strings.TrimPrefix(t.Why, prefix), " ")
|
||||
return strings.TrimRight(member, ",;:")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stopLapsed is whether a member its group stopped for another member is stopped for nothing any more: that member's
|
||||
// change reached the machines since — delivered, or its failure covered by a later walk (novox/hq issue 747). Its own
|
||||
// change is still to deliver, by a later walk of its repository that contains it; its stop holds none back. find
|
||||
// gives a delivery by its id.
|
||||
func stopLapsed(d *Delivery, find func(string) *Delivery) bool {
|
||||
if d.State != Stopped || d.coveredStop() {
|
||||
return false
|
||||
}
|
||||
by := d.stoppedFor()
|
||||
if by == "" || by == d.ID {
|
||||
return false
|
||||
}
|
||||
o := find(by)
|
||||
return o != nil && memberReached(o)
|
||||
return "covered after its stop: a later merge that contains it was delivered: " + d.CoveredBy
|
||||
}
|
||||
|
||||
func (d *Delivery) shownOnItsPullRequest() bool {
|
||||
|
||||
@@ -91,13 +91,6 @@ type Forge interface {
|
||||
// OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue
|
||||
// 347). Reads only.
|
||||
OpenPulls() ([]OpenPull, error)
|
||||
// Merge asks the forge's merge tool to merge a pull request at the head named (gitea_merge_pull_request, novox/hq
|
||||
// issue 722): the one path every merge takes. Its refusal is the error, in its words.
|
||||
Merge(owner, repo string, number int, head string) error
|
||||
// Comment adds a comment to a pull request.
|
||||
Comment(owner, repo string, number int, body string) error
|
||||
// Unlabel takes a label off a pull request, by the label's id.
|
||||
Unlabel(owner, repo string, number, label int) error
|
||||
}
|
||||
|
||||
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
|
||||
@@ -399,19 +392,6 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (f toolForge) Merge(owner, repo string, number int, head string) error {
|
||||
return f.tool("gitea_merge_pull_request", map[string]any{"owner": owner, "repo": repo, "number": number, "head": head})
|
||||
}
|
||||
|
||||
func (f toolForge) Comment(owner, repo string, number int, body string) error {
|
||||
return f.tool("gitea_add_comment", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body})
|
||||
}
|
||||
|
||||
func (f toolForge) Unlabel(owner, repo string, number, label int) error {
|
||||
return f.tool("gitea_api", map[string]any{"method": "DELETE",
|
||||
"path": fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%d", owner, repo, number, label)})
|
||||
}
|
||||
|
||||
func (f toolForge) OpenPulls() ([]OpenPull, error) {
|
||||
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
|
||||
if err != nil {
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Merge on ready (novox/hq issue 722).
|
||||
//
|
||||
// The operator, 2026-10-14: "the reviewer task should merge the request or mark the MR as ready to merge in any case".
|
||||
// Before, a reviewed pull request with green checks waited until a session called the forge's merge tool, and again
|
||||
// after every check the tool asked because the trunk had moved. Now the independent reviewer marks the pull request
|
||||
// ready at the head it approved — the forge label `mesh/ready` and a comment "ready at <full head sha>" (approving
|
||||
// through the forge is the operator's) — and this owner merges it, through the forge's own merge tool
|
||||
// (gitea_merge_pull_request), the one path every merge takes:
|
||||
//
|
||||
// - only at the head the mark names, and only while that is the pull request's head: a push after the mark clears
|
||||
// it — the label taken off, and said on the pull request — and the new head needs a mark of its own;
|
||||
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is judged
|
||||
// again by the merge tool, which asks the check against the trunk's head itself (novox/hq ADR 0323 §5); the merge
|
||||
// is asked again once a minute, and lands on the passing verdict;
|
||||
// - a group member only at its turn in the group's order, and only once the group's composed check passed for its
|
||||
// members' heads as they are;
|
||||
// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the
|
||||
// controller raises as the condition `delivery.<id>.stalled`. So is a mark the merge tool refused for an hour.
|
||||
//
|
||||
// What this owner does with a mark is kept on the delivery (Ready), said in its log, and the merge's transitions name
|
||||
// it as their author.
|
||||
|
||||
// ReadyMark is a pull request's ready mark as the forge reads it (the gitea module's gitea_open_pulls): the head the
|
||||
// newest "ready at" comment names, who wrote it and when, and the label's id. Head is empty when the label is on and
|
||||
// no comment names a head.
|
||||
type ReadyMark struct {
|
||||
Head string `json:"head"`
|
||||
By string `json:"by,omitempty"`
|
||||
At string `json:"at,omitempty"`
|
||||
LabelID int `json:"label_id"`
|
||||
}
|
||||
|
||||
// ReadyLabel is the label that marks a pull request ready.
|
||||
const ReadyLabel = "mesh/ready"
|
||||
|
||||
// readyTryEvery is how often a marked head's merge is asked while the merge tool refuses it: a check it asked takes
|
||||
// minutes, and a refusal costs the forge a few reads.
|
||||
const readyTryEvery = time.Minute
|
||||
|
||||
// readyRefusedFor is how long the merge tool may refuse a marked head before it is said as stalled: longer than a
|
||||
// check takes (the controller's checkTimeout is 45 minutes), so a check on its way is never said.
|
||||
const readyRefusedFor = time.Hour
|
||||
|
||||
// ReadyRun is what this owner did with a delivery's ready mark: kept on the delivery and shown whole by `show`.
|
||||
type ReadyRun struct {
|
||||
// Head, By and MarkedAt are the mark: the head it names, who marked it, when.
|
||||
Head string `json:"head"`
|
||||
By string `json:"by,omitempty"`
|
||||
MarkedAt string `json:"marked_at,omitempty"`
|
||||
// Seen is when this owner first read the mark.
|
||||
Seen time.Time `json:"seen"`
|
||||
// Waits is what the merge waits on, when it waits: a verdict, the group's turn or its composed check.
|
||||
Waits string `json:"waits,omitempty"`
|
||||
// Tries is how many times the merge was asked; LastTry when, and Answer what the merge tool last answered.
|
||||
Tries int `json:"tries,omitempty"`
|
||||
LastTry time.Time `json:"last_try"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
// RefusedSince is when the merge tool began refusing it without a break.
|
||||
RefusedSince *time.Time `json:"refused_since,omitempty"`
|
||||
// Merged is when the merge tool merged it.
|
||||
Merged *time.Time `json:"merged,omitempty"`
|
||||
// Failed is why it is not merged and will not be on this head: its check failed. Said once on the pull request.
|
||||
Failed string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// mergedBy is who a merge this owner made on a ready mark is said to be by, in its transitions.
|
||||
func (r *ReadyRun) mergedBy() string {
|
||||
if r == nil || r.Merged == nil {
|
||||
return ""
|
||||
}
|
||||
by := byOwner + ", on the ready mark at " + shortOf(r.Head, 12)
|
||||
if r.By != "" {
|
||||
by += " by " + r.By
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
// marked is a pull request read with a ready mark.
|
||||
type marked struct {
|
||||
pull OpenPull
|
||||
// cleared is when the mark was cleared, the label taken off: not cleared again within readyTryEvery.
|
||||
cleared time.Time
|
||||
}
|
||||
|
||||
// pullKey is a pull request as a key: owner/repo#number.
|
||||
func pullKey(p OpenPull) string {
|
||||
return strings.ToLower(p.Owner+"/"+p.Repo) + fmt.Sprintf("#%d", p.Number)
|
||||
}
|
||||
|
||||
// seeMarks keeps the open pull requests that carry a ready mark; under the lock. A pull request no longer open or
|
||||
// no longer marked drops out.
|
||||
func (h *Holder) seeMarks(open []OpenPull) {
|
||||
seen := map[string]*marked{}
|
||||
for _, p := range open {
|
||||
if p.Ready == nil || p.HeadSHA == "" {
|
||||
continue
|
||||
}
|
||||
k := pullKey(p)
|
||||
m := &marked{pull: p}
|
||||
if was := h.marks[k]; was != nil && was.pull.Ready != nil && was.pull.Ready.Head == p.Ready.Head &&
|
||||
was.pull.HeadSHA == p.HeadSHA {
|
||||
m.cleared = was.cleared
|
||||
}
|
||||
seen[k] = m
|
||||
}
|
||||
h.marks = seen
|
||||
}
|
||||
|
||||
// sameFull is whether a mark names a head: the whole sha, or — read from an older forge module — at least twelve
|
||||
// characters of it.
|
||||
func sameFull(mark, head string) bool {
|
||||
mark, head = strings.ToLower(mark), strings.ToLower(head)
|
||||
return len(mark) >= 12 && strings.HasPrefix(head, mark)
|
||||
}
|
||||
|
||||
// readyAsks works out, under the lock, what each ready mark needs: a mark cleared, a merge asked, a failure said.
|
||||
// The asking happens outside the lock.
|
||||
func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
var asks []func()
|
||||
for k, m := range h.marks {
|
||||
p, mark := m.pull, *m.pull.Ready
|
||||
repository := strings.ToLower(p.Owner + "/" + p.Repo)
|
||||
if !sameFull(mark.Head, p.HeadSHA) {
|
||||
if !m.cleared.IsZero() && now.Sub(m.cleared) < readyTryEvery {
|
||||
continue
|
||||
}
|
||||
m.cleared = now
|
||||
asks = append(asks, h.clearMark(k, p, mark))
|
||||
continue
|
||||
}
|
||||
d := h.deliveries[IDOf(repository, p.HeadSHA)]
|
||||
if d == nil || d.State.Final() || d.MergedAs != "" {
|
||||
continue // not announced yet, or past its merge
|
||||
}
|
||||
if d.Ready == nil || d.Ready.Head != mark.Head {
|
||||
d.Ready = &ReadyRun{Head: mark.Head, By: mark.By, MarkedAt: mark.At, Seen: now}
|
||||
h.Logf("[mesh-delivery] %s is marked ready at %s by %s: merged once its check passes against its trunk's head",
|
||||
d.ID, shortOf(mark.Head, 12), orNone(mark.By))
|
||||
h.keep(d)
|
||||
}
|
||||
r := d.Ready
|
||||
if r.Merged != nil {
|
||||
continue
|
||||
}
|
||||
if d.State == Rejected {
|
||||
if r.Failed == "" {
|
||||
r.Failed = "its check failed"
|
||||
if d.Check != nil {
|
||||
r.Failed += ": " + oneLine(d.Check.Summary)
|
||||
if j := d.Check.Judged(); j != "" {
|
||||
r.Failed += " (" + j + ")"
|
||||
}
|
||||
}
|
||||
r.Waits = ""
|
||||
h.Logf("[mesh-delivery] %s is marked ready and not merged: %s", d.ID, r.Failed)
|
||||
h.keep(d)
|
||||
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request is marked "+
|
||||
"ready at %s, and %s. A new head, marked ready once reviewed, or a recheck that passes, merges it.",
|
||||
mark.Head, r.Failed)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.Failed = ""
|
||||
waits := h.readyWaits(d)
|
||||
if waits != r.Waits {
|
||||
r.Waits = waits
|
||||
if waits != "" {
|
||||
h.Logf("[mesh-delivery] %s is marked ready and waits: %s", d.ID, waits)
|
||||
}
|
||||
h.keep(d)
|
||||
}
|
||||
if waits != "" || (!r.LastTry.IsZero() && now.Sub(r.LastTry) < readyTryEvery) {
|
||||
continue
|
||||
}
|
||||
r.Tries++
|
||||
r.LastTry = now
|
||||
h.keep(d)
|
||||
asks = append(asks, h.askMerge(d.ID, p, mark.Head))
|
||||
}
|
||||
return asks
|
||||
}
|
||||
|
||||
// readyWaits is what a marked delivery's merge waits on; empty when it is asked now. A ready delivery is asked; so is
|
||||
// a proposed one whose verdict went out of date and whose check nobody asked again — the merge tool asks it.
|
||||
func (h *Holder) readyWaits(d *Delivery) string {
|
||||
switch {
|
||||
case d.State == Ready:
|
||||
case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil:
|
||||
case d.State == Proposed && d.AskedAgain != nil:
|
||||
return "its check against the trunk's head is asked and has not answered"
|
||||
default:
|
||||
return "its check has not passed yet (" + string(d.State) + ")"
|
||||
}
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return h.groupTurnToMerge(d)
|
||||
}
|
||||
|
||||
// groupTurnToMerge is why a group member may not merge yet; empty at its turn: the group's composed check passed for
|
||||
// its members' heads as they are and is not out of date, and every member before it in the group's order merged.
|
||||
func (h *Holder) groupTurnToMerge(d *Delivery) string {
|
||||
g := h.groups[d.Group]
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
members := h.membersOf(g)
|
||||
if state, why := GroupState(g, members); state != "ready" && state != "delivering" {
|
||||
return fmt.Sprintf("its group %s is %s: %s", g.ID, state, why)
|
||||
}
|
||||
if c := g.Check; c == nil || (c.Verdict != "pass" && c.Verdict != "warning") || c.OutOfDate != "" ||
|
||||
!sameMembers(c.Commits, commitsOf(members)) {
|
||||
return "its group " + g.ID + "'s composed check has not passed for its members' heads"
|
||||
}
|
||||
if !containsString(g.Order, d.ID) {
|
||||
return "its group " + g.ID + "'s order is not worked out for it yet"
|
||||
}
|
||||
for _, id := range g.Order {
|
||||
if id == d.ID {
|
||||
return ""
|
||||
}
|
||||
if o := h.deliveries[id]; o != nil && o.MergedAs == "" && !o.State.Final() {
|
||||
return "its group " + g.ID + "'s order merges " + id + " first"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// askMerge asks the forge's merge tool to merge a marked head, and keeps its answer.
|
||||
func (h *Holder) askMerge(id string, p OpenPull, head string) func() {
|
||||
return func() {
|
||||
err := h.Forge.Merge(p.Owner, p.Repo, p.Number, head)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.Ready == nil {
|
||||
return
|
||||
}
|
||||
r, now := d.Ready, h.Now()
|
||||
if err == nil {
|
||||
r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil
|
||||
h.Logf("[mesh-delivery] %s merged on its ready mark at %s, its check passed against its trunk's head", id,
|
||||
shortOf(head, 12))
|
||||
h.keep(d)
|
||||
return
|
||||
}
|
||||
answer := oneLine(err.Error())
|
||||
if r.RefusedSince == nil {
|
||||
r.RefusedSince = &now
|
||||
}
|
||||
if answer != r.Answer {
|
||||
h.Logf("[mesh-delivery] %s is marked ready, and the merge tool did not merge it (asked again in %s): %s", id,
|
||||
readyTryEvery, answer)
|
||||
}
|
||||
r.Answer = answer
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
|
||||
// clearMark takes a mark off a pull request whose head moved after it, and says so on it.
|
||||
func (h *Holder) clearMark(k string, p OpenPull, mark ReadyMark) func() {
|
||||
return func() {
|
||||
err := h.Forge.Unlabel(p.Owner, p.Repo, p.Number, mark.LabelID)
|
||||
if err == nil {
|
||||
err = h.Forge.Comment(p.Owner, p.Repo, p.Number, fmt.Sprintf("mesh-delivery: the ready mark at %s is cleared: "+
|
||||
"the head is now %s, pushed after it. Mark the new head ready once it is reviewed.", mark.Head, p.HeadSHA))
|
||||
}
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's ready mark at %s names an older head than %s, and could not be cleared yet: %v",
|
||||
k, shortOf(mark.Head, 12), shortOf(p.HeadSHA, 12), err)
|
||||
return
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s's ready mark at %s cleared: its head is now %s", k, shortOf(mark.Head, 12),
|
||||
shortOf(p.HeadSHA, 12))
|
||||
h.mu.Lock()
|
||||
delete(h.marks, k)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// sayOn comments on a delivery's pull request.
|
||||
func (h *Holder) sayOn(d *Delivery, body string) func() {
|
||||
owner, repo, number, id := d.Owner(), d.Repo(), d.Number, d.ID
|
||||
return func() {
|
||||
if err := h.Forge.Comment(owner, repo, number, body); err != nil {
|
||||
h.Logf("[mesh-delivery] %s: could not say on its pull request: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readyLines are the marked heads that will not merge: a check that failed at once, a merge refused past its bound;
|
||||
// as stalled lines, which the controller raises as conditions. Under the lock.
|
||||
func (h *Holder) readyLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
if r == nil || r.Merged != nil || d.MergedAs != "" || d.State.Final() {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case r.Failed != "" && d.State == Rejected:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
|
||||
Bound: "0s", H2: "none: a new head marked ready, or a recheck that passes — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d is marked ready at %s and not merged: %s", d.Repository, d.Number,
|
||||
shortOf(r.Head, 12), r.Failed)})
|
||||
case r.RefusedSince != nil && now.Sub(*r.RefusedSince) >= readyRefusedFor:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: readyRefusedFor.String(),
|
||||
H2: "none: the merge is asked again each minute — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d is marked ready at %s, and the merge tool has refused it since %s: %s", d.Repository,
|
||||
d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A pull request marked ready is merged by this owner** (novox/hq issue 722): before, a reviewed pull request with
|
||||
// green checks waited until a session called the merge tool, and again after every check the tool asked because the
|
||||
// trunk had moved. Each test below fails on a holder without the ready mark: nothing merged it.
|
||||
|
||||
const (
|
||||
readyHead = "1111111111111111111111111111111111111111"
|
||||
pushed = "2222222222222222222222222222222222222222"
|
||||
)
|
||||
|
||||
// markedPull is an open pull request as gitea_open_pulls reads it, labelled mesh/ready at the head named.
|
||||
func markedPull(repo string, number int, head, markedAt string) OpenPull {
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
return OpenPull{Owner: owner, Repo: name, Number: number, Base: "main", HeadSHA: head, Labels: []string{ReadyLabel},
|
||||
Ready: &ReadyMark{Head: markedAt, By: "reviewer", At: "2026-10-14T19:00:00Z", LabelID: 31}}
|
||||
}
|
||||
|
||||
func judgedAt(repo string, number int, sha, gate, trunk string) CheckedEvent {
|
||||
c := verdict(repo, number, sha, gate)
|
||||
c.Trunk = trunk
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAReadyPullRequestMarkedAtItsHeadIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("an unmarked pull request was merged: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
|
||||
t.Fatalf("a ready, marked pull request was merged %v; wanted once, at the marked head", w.forge.merges)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
if d.Ready == nil || d.Ready.Merged == nil || d.Ready.By != "reviewer" || d.Ready.Tries != 1 {
|
||||
t.Fatalf("what was done with the mark is not kept: %+v", d.Ready)
|
||||
}
|
||||
// Its merge's transition names who merged it.
|
||||
w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-7", "novox/app", "m7m7m7m7m7m7", true)))
|
||||
w.settleAll()
|
||||
d = w.delivery(id)
|
||||
var last Transition
|
||||
for _, tr := range d.Transitions {
|
||||
if tr.Event == EvMerged {
|
||||
last = tr
|
||||
}
|
||||
}
|
||||
if !strings.Contains(last.By, "ready mark at "+readyHead[:12]+" by reviewer") {
|
||||
t.Fatalf("the merge's transition does not name the mark: %+v", last)
|
||||
}
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("merged again: %v", w.forge.merges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPushAfterTheMarkClearsItAndNothingIsMerged(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, pushed, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a head pushed after the mark was merged: %v", w.forge.merges)
|
||||
}
|
||||
if len(w.forge.unlabels) != 1 || w.forge.unlabels[0] != "novox/app#7-31" {
|
||||
t.Fatalf("the stale mark's label was not taken off: %v", w.forge.unlabels)
|
||||
}
|
||||
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "cleared") ||
|
||||
!strings.Contains(w.forge.comments[0], pushed) {
|
||||
t.Fatalf("the cleared mark is not said on the pull request: %v", w.forge.comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMovedTrunkIsJudgedAgainByTheMergeToolAndMergedOnItsVerdict(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
// Another merge moves the trunk: its verdict is out of date, and it goes back to proposed.
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
if w.state(id) != Proposed {
|
||||
t.Fatalf("out of date, it is %s", w.state(id))
|
||||
}
|
||||
// The merge tool asks the check against the trunk's head and refuses: nothing merged.
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: The check of head merged into trunk was asked")
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the merge tool was not asked once to judge it again: %v", w.forge.merges)
|
||||
}
|
||||
// The tool's ask is the head announced again; it waits for that verdict, asking nothing.
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.later(5 * time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || !strings.Contains(w.delivery(id).Ready.Waits, "asked") {
|
||||
t.Fatalf("asked again while its check was on its way: %v (%+v)", w.forge.merges, w.delivery(id).Ready)
|
||||
}
|
||||
// It passes against the trunk's head: merged, without anyone asking.
|
||||
w.forge.refuse = nil
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
w.settleAll()
|
||||
if w.state(id) != Ready || len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil {
|
||||
t.Fatalf("on its passing verdict it was not merged: %s %v %+v", w.state(id), w.forge.merges, w.delivery(id).Ready)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMarkedHeadWhoseCheckFailedIsNotMergedAndIsSaid(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.later(10 * time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a head whose check failed was merged: %v", w.forge.merges)
|
||||
}
|
||||
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "not merged") {
|
||||
t.Fatalf("the failure is not said once on the pull request: %v", w.forge.comments)
|
||||
}
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].ID != id || lines[0].State != Rejected || !strings.Contains(lines[0].Says, "marked ready") {
|
||||
t.Fatalf("the failure is not a stalled line, which the controller raises as a condition: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMarkRefusedForAnHourIsSaid(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check is pending")
|
||||
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.later(30 * time.Second)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("asked again within the minute: %v", w.forge.merges)
|
||||
}
|
||||
w.later(readyRefusedFor)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("not asked again after a minute: %v", w.forge.merges)
|
||||
}
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "refused it since") {
|
||||
t.Fatalf("a mark refused for an hour is not said: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupMergesItsMarkedMembersInOrderOnlyOnceComposed(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
||||
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
|
||||
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctl, cat}} }
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
||||
w.forge.open = []OpenPull{markedPull("novox/mesh-controller", 1, ctlHead, ctlHead),
|
||||
markedPull("novox/mesh-catalog", 2, catHead, catHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a member merged before its group's composed check passed: %v", w.forge.merges)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/mesh-controller#1@"+ctlHead {
|
||||
t.Fatalf("composed, the first member in the group's order was not merged alone: %v", w.forge.merges)
|
||||
}
|
||||
if waits := w.delivery(cat).Ready.Waits; !strings.Contains(waits, "merges "+ctl+" first") {
|
||||
t.Fatalf("the second member does not say it waits its turn: %q", waits)
|
||||
}
|
||||
w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1"))
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 || w.forge.merges[1] != "novox/mesh-catalog#2@"+catHead {
|
||||
t.Fatalf("after the first member merged, the next was not: %v", w.forge.merges)
|
||||
}
|
||||
}
|
||||
@@ -498,12 +498,7 @@ func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) {
|
||||
if why == "" {
|
||||
why = r.Guard
|
||||
}
|
||||
by := f.By
|
||||
if by == "" && (ev == EvMerged || ev == EvDone) && d.MergedAs != "" {
|
||||
// A merge this owner made on a ready mark names it as its author (novox/hq issue 722).
|
||||
by = d.Ready.mergedBy()
|
||||
}
|
||||
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: by}
|
||||
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: f.By}
|
||||
d.State, d.Since = r.To, f.Now
|
||||
d.Transitions = append(d.Transitions, t)
|
||||
if len(d.Transitions) > keptTransitions {
|
||||
|
||||
@@ -72,9 +72,6 @@ type OpenPull struct {
|
||||
// than that says none: the head is then read by Statuses alone, as before, and a pending check is not told
|
||||
// from an answered one.
|
||||
Checks map[string]HeadCheck `json:"checks,omitempty"`
|
||||
// Labels are its labels by name, and Ready its ready mark, when it carries one (novox/hq issue 722).
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
Ready *ReadyMark `json:"ready,omitempty"`
|
||||
}
|
||||
|
||||
// HeadCheck is one status on a head as the forge keeps it: pending, success, failure, error or warning.
|
||||
@@ -204,7 +201,6 @@ func (h *Holder) SeeOpenPulls() {
|
||||
seen[id] = u
|
||||
}
|
||||
h.unchecked = seen
|
||||
h.seeMarks(open)
|
||||
}
|
||||
|
||||
// started is whether the head's check started, rather than was never asked: it has any merge check, or a delivery
|
||||
|
||||
@@ -72,10 +72,6 @@ func (h *Holder) waitsFor(d *Delivery) string {
|
||||
return "its walk: " + clip(d.Walk.Note, 120)
|
||||
}
|
||||
case Stopped:
|
||||
if h.stopLapsed(d) {
|
||||
return "a later walk of its repository that contains its merge: its group stopped it for " + d.stoppedFor() +
|
||||
", whose change was delivered since (novox/hq issue 747)"
|
||||
}
|
||||
if strandedUncovered(d) {
|
||||
return "a later delivered merge to carry " + strings.Join(d.notMovedBy(), ", ") + ": it is stopped, and " +
|
||||
"listed by stalled until one does (novox/hq issue 480)"
|
||||
@@ -253,7 +249,6 @@ func (h *Holder) Stalled() []StalledLine {
|
||||
}
|
||||
out = append(out, h.withChecks(h.uncheckedLines(now))...)
|
||||
out = append(out, h.orphanLines(now)...)
|
||||
out = append(out, h.readyLines(now)...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -47,10 +47,7 @@
|
||||
"gitea.gitea_commit_status",
|
||||
"gitea.gitea_commit_statuses",
|
||||
"gitea.gitea_contains",
|
||||
"gitea.gitea_open_pulls",
|
||||
"gitea.gitea_merge_pull_request",
|
||||
"gitea.gitea_add_comment",
|
||||
"gitea.gitea_api"
|
||||
"gitea.gitea_open_pulls"
|
||||
],
|
||||
"state": [
|
||||
"deliveries",
|
||||
|
||||
@@ -121,12 +121,6 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
|
||||
!reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool}) {
|
||||
t.Fatalf("watch %v, tools %v", watch, m.Tools)
|
||||
}
|
||||
// The watch's verdict is whether the database serves, so its check names the provision it exercises: while the
|
||||
// database's provider is unhealthy, this finding is held under the provider's condition (hq ADR 0240 rule 5,
|
||||
// issue 145).
|
||||
if h["needs"] != "postgres-database" {
|
||||
t.Fatalf("watch's check needs %v; it exercises postgres-database", h["needs"])
|
||||
}
|
||||
if watch["env"].(map[string]any)["MESH_KNOWLEDGE_STATUS"] != m.Build.Artifacts[0].Env["MESH_KNOWLEDGE_STATUS"] {
|
||||
t.Fatal("watch writes another status file than the health tool reads")
|
||||
}
|
||||
|
||||
@@ -156,7 +156,6 @@
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "knowledge_health",
|
||||
"needs": "postgres-database",
|
||||
"interval": "60s",
|
||||
"timeout": "10s",
|
||||
"looks": 2,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# mesh-vault
|
||||
|
||||
Holds the `mesh-vault` seat: the mesh's shared secrets, each kept sealed, handed out only on request and
|
||||
reply, sealed to whoever asked (novox/hq ADR 0085, ADR 0113, ADR 0349 §7 and §11). Go, one binary
|
||||
(`cmd/mesh-vault`), launched by its node's runtime and speaking MCP to it through the Go SDK.
|
||||
|
||||
## Where the secrets are kept, and why there
|
||||
|
||||
In the vault's own store: one file per secret in `${dir:store}` on the vault's own disk, mode 0600 in a
|
||||
0700 directory. Not a database, as most Go modules keep their data, because:
|
||||
|
||||
- the vault is installed before any other module built on the runtime base (ADR 0113 §14), and a database
|
||||
login is itself a shared secret the vault makes — the vault cannot need one to start;
|
||||
- every value in the store is ciphertext anyway, so a database would add nothing a file does not;
|
||||
- no secret is in a bucket, sealed or not (ADR 0201 §13), so the module declares no `state`.
|
||||
|
||||
Each record holds the value twice, never in the clear:
|
||||
|
||||
- **at rest**, sealed to the vault's own key (the sealed box of ADR 0206, `mesh-sdk/go/seal`). The key is
|
||||
an X25519 pair the vault makes once in `${dir:state}/vault-key.json`; its private half never leaves it.
|
||||
The key is made only for an empty store: with the key file missing and secrets in the store, the vault
|
||||
does not start and says so, rather than make a key that opens none of them.
|
||||
- **for the operator**, sealed to the operator key (ADR 0113 §22), so nothing exists only inside the
|
||||
vault. A secret is refused, loudly, while the vault has no operator key.
|
||||
|
||||
Every file is written whole, synced, renamed into place and its directory synced.
|
||||
|
||||
### What the encryption at rest protects against, and what not
|
||||
|
||||
The vault's private key lives in plaintext in `${dir:state}`, on the same disk as the store, readable by the
|
||||
vault's account alone. So:
|
||||
|
||||
- **It protects** against a copy of the store directory alone — a `store` backup, a file sent somewhere,
|
||||
a listing of `${dir:store}` by a tool that reads files — holding anything usable, and against a value
|
||||
being found by searching the disk for it.
|
||||
- **It does not protect** against anyone who can read the vault's state directory too: root on the
|
||||
vault's node, the vault's own account, or a backup that holds both `state` and `store` (both are
|
||||
class `valuable`, so the node's backup holds both). Against those, at-rest encryption adds nothing; the
|
||||
protection is the node's own security. Keeping the key elsewhere (another directory class, a key the
|
||||
operator unlocks) is for V3's review, before real secrets move in.
|
||||
|
||||
## Where the operator key comes from
|
||||
|
||||
From the export the mesh writes to the vault's root directory (`${dir:root}/export.json`, the manifest's
|
||||
`keeps`): its `operator-key` is the public half `operator key set` recorded in the controller. The vault
|
||||
checks it every minute; a new key makes the vault seal every operator copy again to it — it can, because
|
||||
it opens its own copy. Each record names the key its copy is sealed to, so a failure partway is finished on
|
||||
the next minute's pass, and nothing is left sealed to an earlier key for good.
|
||||
|
||||
The vault takes an operator key only as SPKI PEM (`SetOperatorKey`). A PEM private key is refused, and so
|
||||
is a raw 32-byte key, because a raw private key looks the same. The one raw key it reads is the export's,
|
||||
which the controller writes from what it recorded; the vault wraps it as SPKI before taking it. It keeps
|
||||
the key raw in base64, the form of the operator's NaCl box. When the secrets move to the vault (design 57's
|
||||
V3), the operator key moves with them.
|
||||
|
||||
### How the operator opens a copy
|
||||
|
||||
`mesh-vault.secret_vault_export` answers the vault's copies in the shape `secret export` writes. Saved to a
|
||||
file, one is opened with the operator's private key by
|
||||
|
||||
nox secret recover vault mesh-vault <name> --key <operator key file> --from-export <file> --out <file>
|
||||
|
||||
(`--out` is needed: a name holds `/`). `TestTheOperatorRecoversAVaultSecretFromItsExport` reads the file
|
||||
as that command does and opens it as it does. `secret_export` still answers the controller's export, as
|
||||
before; until V3 the controller's secrets are there.
|
||||
|
||||
## The boxes and the key formats — settled
|
||||
|
||||
Two boxes on the mesh today, both over X25519:
|
||||
|
||||
| | format | keys | who uses it |
|
||||
|---|---|---|---|
|
||||
| NaCl anonymous box | XSalsa20-Poly1305, `box.SealAnonymous` | raw 32 bytes, base64 | the controller sealing into declarations; the node-engine's `sealing.key` (mesh-host `internal/identity/sealing.go`); the operator key and `secret recover --from-export` |
|
||||
| the sealed box (ADR 0206) | X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}` | PEM: SPKI public, PKCS #8 private | the licence manager and `claude-code`; `mesh-sdk/go/seal` |
|
||||
|
||||
What the vault uses:
|
||||
|
||||
- **Its answers to `get` are the ADR 0206 box, for every asker, a node included.** The asker sends its
|
||||
public key as SPKI PEM. A node's provisioner client (V4) needs no second key: its node's sealing key is
|
||||
X25519 already, so it sends that key's public half wrapped as SPKI and opens the answer with the private
|
||||
half wrapped as PKCS #8 (`TestANodesSealingKeyOpensTheAnswer` does exactly this). A raw 32-byte key is
|
||||
refused: a raw private key looks the same, and the vault takes no private key.
|
||||
- **Its operator copies are the NaCl anonymous box**, to the operator key as it is made today, so the
|
||||
operator's recovery opens them with the key and the command it already has.
|
||||
- **The at-rest copy is the ADR 0206 box**, to the vault's own key.
|
||||
|
||||
The NaCl box stays on the node-engine only for what the controller seals into declarations, until the last
|
||||
module has moved (ADR 0349 §17). For the core's own credentials that travel in a declaration sealed by the
|
||||
vault (ADR 0349 §16), V4 adds opening the ADR 0206 box with the same sealing key to the node-engine.
|
||||
|
||||
## The seat's verb
|
||||
|
||||
`mesh-vault.get` — `name`, `public_key` (SPKI PEM). It answers `{name, generation, changed-at, sealed}`,
|
||||
`sealed` being the secret sealed to that key, and only to an asker allowed for that secret, the asker being
|
||||
the bus user the runtime proved — never an argument.
|
||||
|
||||
Every answer and every refusal is recorded in the vault's own store (`handed.jsonl`: who, which secret,
|
||||
which generation, when, and why a refusal), synced before the answer is given — as ADR 0277 §25 records
|
||||
asks; a secret whose hand-out cannot be recorded is not handed out. A refusal is recorded only for a caller
|
||||
of a bus user's shape and a well-formed secret name, so an arbitrary argument fills nothing. The record is
|
||||
bounded: rolled at 1 MiB into `handed.jsonl.1` … `.3`, the oldest dropped, each roll said loudly in the
|
||||
journal and counted in `vault_status`. `secret_handed` reads it.
|
||||
|
||||
`vault_status` says what needs a person: operator copies still sealed to an earlier key, an operator key in
|
||||
the export the vault refused, and how often the record was rolled. **It is not yet a condition**: a health
|
||||
check is declared on a long-running resource (ADR 0240), and the vault is a bundle the tool runner loads, so
|
||||
nothing asks it on its own; until it runs as a resource of its own (V3's review) these reach a person
|
||||
through the journal and this tool only.
|
||||
|
||||
**Who may ask, today: nobody.** The rule (ADR 0349 §11) is a node's provisioner client under a bus account
|
||||
of its own, for what its node's declaration names, and a module under its own account (issue 569). Neither
|
||||
exists yet, so the running vault refuses every asker; the tests set a stand-in list. The real rule goes
|
||||
behind the `Askers` interface in `askers.go` (design 57's V4 and P3).
|
||||
|
||||
## Taking values in: `accept`, `secret-ask`, `issued`, `rotate`
|
||||
|
||||
Design 57's V2 (issue 675; ADR 0349 clauses 8 to 10, ADR 0277 §22–§27 and ADR 0305 with the vault in the
|
||||
controller's place). `intake.go`.
|
||||
|
||||
**Two steps of one verb, from the controller alone.** The first step names the secret and answers a `call` and
|
||||
`seal_to`, the public half of an X25519 key the vault made for that one call (raw, base64 — the NaCl anonymous box
|
||||
the desk's prompt and a provider's tool already seal to). The second step sends `call` and `sealed`, and the vault
|
||||
opens it with the private half, which lives in this process only and is forgotten once used or after two minutes.
|
||||
A value sealed to any other key is refused, and a call is answered once.
|
||||
|
||||
**Why the controller carries it.** A desk's prompt (`node-launcher.secret`) and a provider's tool answer the
|
||||
controller alone: the bus lets only the controller ask them, on the subject that names its caller (issue 365). The
|
||||
vault cannot call in a name of its own while it runs in its machine's tool runner, whose one bus account carries
|
||||
that machine's agents' calls too (issue 569, ADR 0305 §3). So the controller asks them with the vault's key and
|
||||
passes the sealed answer on; it cannot open it. The value opens in the vault and nowhere else. The vault refuses
|
||||
these steps from any caller but `controller`, as the runtime proved it.
|
||||
|
||||
- `accept` — node, module, name (a module's own secret) or provider, provision (a provision's one credential),
|
||||
and `by`. The controller's terminal seals the value it read to the call's key before it leaves the terminal.
|
||||
Kept with origin `accepted`; an accepted provision credential is never remade (`rotate` refuses it); a value a
|
||||
provider's software issued is not taken by `accept`.
|
||||
- `secret-ask` — node, module, name, desk and `asked_by` (who asked the controller, as the bus named its caller).
|
||||
The ask is recorded in `asks.jsonl` before anything else, and refused while another ask for the same secret is
|
||||
open (two minutes at most) or when three were made in the last hour. The answer names who asked as the record
|
||||
has it, which the prompt shows. The second step carries what the prompt sealed, or `outcome` (dismissed,
|
||||
timed-out, empty, refused, failed); how each ask ended is recorded. `secret_asks` reads the record. The hand-act
|
||||
log stays the controller's: it records the act once the vault has taken the value.
|
||||
- `issued` — provision, provider (machine), module. The controller asks the provider's tool with the call's key,
|
||||
as it asks it today; the value is kept as the provision's one credential with origin `issued`. The same value
|
||||
again writes nothing, says nothing and keeps the generation (ADR 0305 §5).
|
||||
- `rotate` — a module's own secret or a provision's credential, made anew. Refused for an `issued` value (the
|
||||
software rotates it, ADR 0305 §9) and an accepted provision credential (clause 8). Who asked is the proven caller,
|
||||
or the `by` the controller names when it carries the act; both are kept (`changed-by`, `changed-via`).
|
||||
|
||||
Names in the store: `own/<node>/<module>/<name>` and `provision/<provider node>/<provision>`. V3 copies the
|
||||
controller's rows in under these names.
|
||||
|
||||
**Which modules reach the vault this way.** The controller sends a module's verbs here only when its secrets are
|
||||
filled the new way (its machine asks the vault for them, V4); every other module keeps the controller's path, so
|
||||
no value lands where its machine never looks (ADR 0349 clause 17; the release lead's decision on issue 675). Until
|
||||
V4 that is no module: these verbs are served and tested, and nothing calls them yet.
|
||||
|
||||
**What the issuing provider's test is here.** `TestAnIssuingProviderAnswersOnlyTheControllersCallSealedToTheVault`
|
||||
uses a stand-in provider that refuses every caller but the controller and answers only sealed to the call's key.
|
||||
plex's own tool already answers the controller alone; whether it moves with the media catalogue is for that move,
|
||||
after release 57.
|
||||
|
||||
## Events
|
||||
|
||||
- `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value
|
||||
or a sealed copy.
|
||||
- `provisioned`, `rotated` — the custody ledger's, by fingerprint, as before.
|
||||
- `provisioner.retirement` (ADR 0230 §35) — `waiting`, `settled`, `approved`, `rejected`, `retired`,
|
||||
`reenabled`, `deleted`, naming holders, never a value.
|
||||
|
||||
## The tools
|
||||
|
||||
`secret_export`, `secret_holders`, `secret_verify` answer as the TypeScript vault's did, and its custody
|
||||
ledger (`${dir:ledger}`, the same JSON files) is kept by a pass over the contributions file every minute.
|
||||
`secret_handed` and `secret_vault_export` read the vault's own store. None returns a value.
|
||||
|
||||
**The ledger follows ADR 0230.** A contributions file that does not say who is asked for (unreadable, not
|
||||
JSON, no `given` list or a null one, or for another requirement) is not "nobody": the pass records nothing,
|
||||
retires nobody and starts its count again. A holder no longer asked for is retired — a mark with when and
|
||||
why in its own ledger file, its history kept — only after the same set has gone unasked in five passes and
|
||||
ten minutes; more than three at once, or more than half of those held, waits for a person
|
||||
(`provisioner_retire_approve`, `provisioner_retire_reject`); asked for again it is re-enabled; it is deleted
|
||||
only by `provisioner_delete`, a person's act with a why. `provisioner_retirement` shows it all. The vault is
|
||||
mark-only: it holds no access to switch off.
|
||||
|
||||
The words are the controller's: the `provisioner.retirement` event is mesh-controller's `link.Retirement`
|
||||
(`provider-node`, `consumers` as objects) and the `provisioner_retirement` answer its `link.RetirementState`
|
||||
(`held`, `bound`, `retires_by: mark-only`), so `retire approve`, `retire reject` and `cleanup list` read the
|
||||
vault and a set waiting for a person raises the controller's urgent condition. The acts take what the SDK's
|
||||
harness takes: approve and reject repeat the set in `consumers` and refuse any other set; delete repeats the
|
||||
name in `confirm`; each records the `by` and `via` the controller sends. `testdata/retirement-*.json` are
|
||||
those types marshalled by the controller (feat/57 at dc36536), and the tests hold the vault's words to them.
|
||||
|
||||
What the TypeScript vault did and this one does not: it consumed its own events only to print an audit line,
|
||||
which the pass now prints itself. Its retirement ran on the SDK's harness, which this one follows: mark-only,
|
||||
and a holder's ledger file removed only through a person's delete, as here. The difference is where the mark
|
||||
lives: the harness kept it in its process, so a restart forgot it (ADR 0230 §13); here it is in the ledger
|
||||
file.
|
||||
|
||||
## What comes later
|
||||
|
||||
Taking values in is built (above); the controller's secrets move in with V3; nodes ask with V4, which also turns
|
||||
the controller's switch on for each module it moves; pair credentials are minted here with V5. Until V3 the
|
||||
vault's store is empty and the controller still serves every secret.
|
||||
|
||||
## Tests
|
||||
|
||||
go test ./...
|
||||
|
||||
`vault_test.go` is ADR 0349's vault test list as far as this package goes: no bucket holds a secret; no
|
||||
private key is accepted; every secret has an operator-sealed copy; `get` answers only an allowed asker,
|
||||
sealed to its key; `secret-changed` carries no value. `intake_test.go` is ADR 0349's `secret-ask` test as far as
|
||||
the vault answers it (recorded before the prompt, one open ask per secret and three an hour, the asker named as
|
||||
the bus named it, the typed value opened only here), the issuing provider's test with a stand-in provider (ADR 0305
|
||||
§13's checks that belong to the vault: nothing rewritten for a value held, `rotate` refused, no other way in), and
|
||||
accept and rotate. `followups_test.go` holds V1's review follow-ups.
|
||||
@@ -1,172 +0,0 @@
|
||||
// mesh-vault's ledger — vault's own code, living in the module (novox/hq ADR 0039). The provisioner and
|
||||
// the tools both import it, and nothing outside vault does.
|
||||
//
|
||||
// **The vault holds no value.** A `secret` is an ordinary pair credential: the controller mints it,
|
||||
// seals it to the consumer's node and to this one, and the host unseals this node's copy into the
|
||||
// file the contribution names (ADR 0048). That file is already on this machine, readable by nothing
|
||||
// but the vault's runtime, and it is the only copy the vault ever sees. Writing a second copy —
|
||||
// plain, or sealed to a key the vault keeps — would put back exactly the single place that can open
|
||||
// everything, which is what sealing to the machine was built to remove (ADR 0085's open question is
|
||||
// how to recover WITHOUT that; the answer is not "keep one anyway").
|
||||
//
|
||||
// So what the vault keeps is what makes a secret *owned* rather than merely delivered: who holds
|
||||
// one, since when, its fingerprint, and every time it changed. Enough to say "this holder's value is
|
||||
// the one the mesh last delivered" and "it has been rotated twice, last on Tuesday" — and never
|
||||
// enough to say what it is. The fingerprint is the only thing a tool may take or return, which is
|
||||
// the rule the source mesh's secret tools were built on: the secret is never an argument.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** One holder of a secret this vault provides — everything the vault knows, and no value. */
|
||||
export interface Held {
|
||||
/** The login the mesh derived for the consumer — `<node>-<module>`, so it names the holder. */
|
||||
readonly as: string;
|
||||
/** The consumer's node. */
|
||||
readonly consumer: string;
|
||||
/** sha256 of the value the mesh last delivered, `sha256:<hex>`. Compared, never inverted. */
|
||||
readonly fingerprint: string;
|
||||
/** Length of the value, so a holder can tell a truncated file from a wrong one. */
|
||||
readonly length: number;
|
||||
/** When this holder was first granted a secret. */
|
||||
readonly since: string;
|
||||
/** When the value last changed — equal to `since` until the first rotation. */
|
||||
readonly changed: string;
|
||||
/** How many times the value has changed since `since`. */
|
||||
readonly rotations: number;
|
||||
/** Every earlier fingerprint, oldest first: the audit trail a rotation leaves. */
|
||||
readonly history: readonly { readonly fingerprint: string; readonly until: string }[];
|
||||
}
|
||||
|
||||
/** What recording a delivery found: a new holder, a changed value, or nothing new. */
|
||||
export type Outcome = "granted" | "rotated" | "unchanged";
|
||||
|
||||
/** sha256 of a value, as `sha256:<hex>`. The one thing about a secret that may be spoken. */
|
||||
export function fingerprint(value: string): string {
|
||||
return "sha256:" + createHash("sha256").update(value, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export class Ledger {
|
||||
private readonly dir: string;
|
||||
|
||||
constructor(dir: string) {
|
||||
this.dir = dir;
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
/** Build from the module's resolved environment: $MESH_VAULT_LEDGER is where holders are kept. */
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): Ledger {
|
||||
const dir = env.MESH_VAULT_LEDGER;
|
||||
if (!dir) {
|
||||
throw new Error("MESH_VAULT_LEDGER is not set — the vault has nowhere to keep its ledger");
|
||||
}
|
||||
return new Ledger(dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that the mesh delivered `value` for `as`. Idempotent: the same value again changes
|
||||
* nothing, a different value is a rotation and is remembered as one. The value is fingerprinted
|
||||
* here and goes no further.
|
||||
*/
|
||||
record(as: string, consumer: string, value: string, now = new Date()): { held: Held; outcome: Outcome } {
|
||||
const fp = fingerprint(value);
|
||||
const at = now.toISOString();
|
||||
const before = this.get(as);
|
||||
if (!before) {
|
||||
const held: Held = {
|
||||
as, consumer, fingerprint: fp, length: value.length,
|
||||
since: at, changed: at, rotations: 0, history: [],
|
||||
};
|
||||
this.write(held);
|
||||
return { held, outcome: "granted" };
|
||||
}
|
||||
if (before.fingerprint === fp && before.length === value.length) {
|
||||
return { held: before, outcome: "unchanged" };
|
||||
}
|
||||
const held: Held = {
|
||||
...before, consumer, fingerprint: fp, length: value.length, changed: at,
|
||||
rotations: before.rotations + 1,
|
||||
history: [...before.history, { fingerprint: before.fingerprint, until: at }],
|
||||
};
|
||||
this.write(held);
|
||||
return { held, outcome: "rotated" };
|
||||
}
|
||||
|
||||
/** Forget a holder the mesh withdrew. Returns whether there was one to forget. */
|
||||
withdraw(as: string): boolean {
|
||||
try {
|
||||
unlinkSync(this.pathOf(as));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
get(as: string): Held | undefined {
|
||||
try {
|
||||
return JSON.parse(readFileSync(this.pathOf(as), "utf8")) as Held;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every holder, by login. */
|
||||
list(): Held[] {
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(this.dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names
|
||||
.filter((n) => n.endsWith(".json"))
|
||||
.map((n) => this.get(n.slice(0, -".json".length)))
|
||||
.filter((h): h is Held => h !== undefined)
|
||||
.sort((a, b) => a.as.localeCompare(b.as));
|
||||
}
|
||||
|
||||
private pathOf(as: string): string {
|
||||
if (!/^[a-z0-9][a-z0-9_.-]*$/.test(as)) {
|
||||
throw new Error(`a login is a name, not a path: ${JSON.stringify(as)}`);
|
||||
}
|
||||
return join(this.dir, `${as}.json`);
|
||||
}
|
||||
|
||||
/** Written whole and renamed into place, so a reader never sees half a record. */
|
||||
private write(held: Held): void {
|
||||
const final = this.pathOf(held.as);
|
||||
const tmp = `${final}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(held, null, 2) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, final);
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry of the mesh's contributions file, as the vault reads it for its tools. */
|
||||
export interface Contribution {
|
||||
readonly from?: string;
|
||||
readonly node?: string;
|
||||
readonly as: string;
|
||||
readonly secret: string;
|
||||
}
|
||||
|
||||
/** The consumers the mesh currently asks this vault to serve — the `receives` file, read plainly. */
|
||||
export function contributions(receives: string): Contribution[] {
|
||||
let doc: { given?: Contribution[] };
|
||||
try {
|
||||
doc = JSON.parse(readFileSync(receives, "utf8")) as { given?: Contribution[] };
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return (doc.given ?? []).filter((g) => g.as && g.secret);
|
||||
}
|
||||
|
||||
/** Fingerprint of the value the host currently holds for one contribution, or why it could not. */
|
||||
export function deliveredFingerprint(c: Contribution): { fingerprint: string; length: number } | { error: string } {
|
||||
try {
|
||||
const value = readFileSync(c.secret, "utf8").replace(/\n$/, "");
|
||||
return { fingerprint: fingerprint(value), length: value.length };
|
||||
} catch (err) {
|
||||
return { error: `the delivered secret is not readable: ${err}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// Who may ask `get` for which secret: the seam the real rule fills (novox/hq ADR 0349 §11).
|
||||
//
|
||||
// The rule is: a node's provisioner client, running as a service under a bus account of its own, for a
|
||||
// secret that node's declaration names; or a module, for a secret its manifest declares, only once its
|
||||
// code runs under a bus account of its own (issue 569). Neither exists yet — the client and its account are
|
||||
// design 57's P3, the vault's answer to a node V4 — so the vault that runs allows nobody (NoAsker), and the
|
||||
// tests set a stand-in list (AllowList). V4 and P3 put the real rule behind this interface.
|
||||
|
||||
// Askers decides whether caller (the bus user the runtime proved) may have the secret name, and if not,
|
||||
// why not.
|
||||
type Askers interface {
|
||||
May(caller, name string) (bool, string)
|
||||
}
|
||||
|
||||
// NoAsker allows nobody: the vault's rule until who may ask is decided by a declaration and an account.
|
||||
type NoAsker struct{}
|
||||
|
||||
// May refuses every caller.
|
||||
func (NoAsker) May(string, string) (bool, string) {
|
||||
return false, "the vault hands a secret to no asker yet: a node's provisioner client under its own account, " +
|
||||
"for what its declaration names, is the first it will answer (novox/hq ADR 0349 §11, issue 647)"
|
||||
}
|
||||
|
||||
// AllowList is a stand-in rule: each caller, and the names it may have.
|
||||
type AllowList map[string][]string
|
||||
|
||||
// May allows a caller only the names listed for it.
|
||||
func (l AllowList) May(caller, name string) (bool, string) {
|
||||
for _, n := range l[caller] {
|
||||
if n == name {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, "not on the list of who may ask for it"
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
// The four follow-ups V1's third review (review 263 on mesh-catalog#286) left for this package: the tool layer
|
||||
// passes an act's words through; a rolled record of hand-outs is said in the journal; the export's fingerprint
|
||||
// and vault_status are what they say; and an act's unproven `by` is kept beside the proven caller.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
func anyOf(list []string) []any {
|
||||
out := make([]any, len(list))
|
||||
for i, s := range list {
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// (1) and (4): the retirement tools pass consumers, why, by, via and confirm through to the custody ledger, and
|
||||
// a `by` naming somebody other than the proven caller is kept beside it.
|
||||
func TestTheRetirementToolsPassTheActThrough(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
c.ask(apps(5))
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(5), 1))
|
||||
c.stable()
|
||||
set := waitingNames(c.Retirement())
|
||||
sort.Strings(set)
|
||||
defs := definitions(&Env{Custody: c.Custody})
|
||||
approve := toolNamed(t, defs, "provisioner_retire_approve")
|
||||
if _, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set[:2]), "why": "x"}); err == nil {
|
||||
t.Error("the tool took a set other than the one waiting")
|
||||
}
|
||||
got, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set), "why": "the apps are gone",
|
||||
"by": "person.jochen", "via": "telegram"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if done := got.(map[string]any)["retired"].([]string); len(done) != 4 {
|
||||
t.Errorf("retired %v", done)
|
||||
}
|
||||
last := c.bus.events[len(c.bus.events)-5]["body"].(map[string]any)
|
||||
for _, e := range c.bus.events {
|
||||
if b := e["body"].(map[string]any); b["change"] == "approved" {
|
||||
last = b
|
||||
}
|
||||
}
|
||||
if last["why"] != "the apps are gone" || last["by"] != "person.jochen" || last["via"] != "telegram, called by controller" {
|
||||
t.Errorf("the approval said %v", last)
|
||||
}
|
||||
|
||||
// Delete: consumer and confirm through, the act's words with them.
|
||||
r := retiredC(t)
|
||||
del := toolNamed(t, definitions(&Env{Custody: r.Custody}), "provisioner_delete")
|
||||
if _, err := del.runAs("person.jochen", map[string]any{"consumer": "d-app", "confirm": "c-app", "why": "x"}); err == nil {
|
||||
t.Error("a confirm naming another was taken through the tool")
|
||||
}
|
||||
if _, err := del.runAs("person.jochen", map[string]any{"consumer": "d-app", "confirm": "d-app", "why": "gone"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := r.bus.events[len(r.bus.events)-1]["body"].(map[string]any)
|
||||
if body["change"] != "deleted" || body["by"] != "person.jochen" || body["via"] != nil || body["why"] != "gone" {
|
||||
t.Errorf("the deletion said %v", body)
|
||||
}
|
||||
|
||||
// Reject, by the proven caller alone: nothing more is added to via.
|
||||
k := newCustody(t)
|
||||
k.ask(apps(5))
|
||||
k.passes(1)
|
||||
k.ask(keepOnly(apps(5), 1))
|
||||
k.stable()
|
||||
waiting := waitingNames(k.Retirement())
|
||||
if _, err := toolNamed(t, definitions(&Env{Custody: k.Custody}), "provisioner_retire_reject").runAs("person.jochen",
|
||||
map[string]any{"consumers": anyOf(waiting), "why": "they come back"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body := k.bus.events[len(k.bus.events)-1]["body"].(map[string]any); body["by"] != "person.jochen" || body["via"] != nil {
|
||||
t.Errorf("the rejection said %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// (2): a roll of the record of hand-outs is said in the journal, loudly.
|
||||
func TestARolledRecordOfHandOutsIsSaid(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
var mu sync.Mutex
|
||||
var said []string
|
||||
v.Log = func(format string, args ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
said = append(said, fmt.Sprintf(format, args...))
|
||||
}
|
||||
v.Store.HandedMax = 600
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
for i := 0; i < 20; i++ {
|
||||
_, _ = v.Get("g14.flood", "pair/postgres/gitea", asker.PublicKey)
|
||||
}
|
||||
rolled := 0
|
||||
for _, s := range said {
|
||||
if strings.Contains(s, "THE RECORD OF HAND-OUTS WAS ROLLED") {
|
||||
rolled++
|
||||
}
|
||||
}
|
||||
if rolled == 0 || rolled != v.Store.Rolled() {
|
||||
t.Errorf("rolled %d times, said %d times", v.Store.Rolled(), rolled)
|
||||
}
|
||||
}
|
||||
|
||||
// (3): the vault's export names the operator key's fingerprint as the controller's secrets.Fingerprint says it,
|
||||
// and vault_status says what needs a person.
|
||||
func TestTheExportsFingerprintAndVaultStatus(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := answer.(map[string]any)
|
||||
// sha256 of the key, its first eight bytes in hex: the controller's secrets.Fingerprint.
|
||||
if fp := doc["fingerprint"].(string); !strings.HasPrefix(fp, "sha256:") || len(fp) != len("sha256:")+16 ||
|
||||
fp != keyFingerprint(v.operatorPublic) || doc["operator-key"] != v.operatorPublic {
|
||||
t.Errorf("the export says %v", doc)
|
||||
}
|
||||
if keyFingerprint("") != "" {
|
||||
t.Error("no key has a fingerprint")
|
||||
}
|
||||
|
||||
env := &Env{Vault: v.Vault}
|
||||
status := func() map[string]any {
|
||||
got, err := toolNamed(t, definitions(env), "vault_status").Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got.(map[string]any)
|
||||
}
|
||||
if s := status(); s["ok"] != true || s["copies_on_an_earlier_key"] != 0 || s["operator_key"] != v.operatorPublic {
|
||||
t.Errorf("a vault in order says %v", s)
|
||||
}
|
||||
env.refuseKey("a private key was offered")
|
||||
if s := status(); s["ok"] != false || s["operator_key_refused"] != "a private key was offered" {
|
||||
t.Errorf("a refused key says %v", s)
|
||||
}
|
||||
env.refuseKey("")
|
||||
// A new operator key whose resealing fails leaves copies on the earlier key, and the status says so.
|
||||
pub, _ := operatorKey(t)
|
||||
v.Store.failWrite = func(Record) error { return fmt.Errorf("disk full") }
|
||||
_, _ = v.SetOperatorKey(asPEM(t, pub), "the test")
|
||||
v.Store.failWrite = nil
|
||||
if s := status(); s["ok"] != false || s["copies_on_an_earlier_key"] != 1 {
|
||||
t.Errorf("a copy on an earlier key says %v", s)
|
||||
}
|
||||
if s, _ := toolNamed(t, definitions(&Env{}), "vault_status").Run(map[string]any{}); s.(map[string]any)["ready"] != false {
|
||||
t.Errorf("no vault says %v", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package main
|
||||
|
||||
// What the vault takes in, and how it rotates (novox/hq ADR 0349 clauses 8 to 10; ADR 0277 §22–§27 and ADR 0305
|
||||
// with the vault in the controller's place; design 57's V2, issue 675).
|
||||
//
|
||||
// Four verbs of the seat: `accept` (a value given at the controller's terminal), `secret-ask` (a value the
|
||||
// operator types at a desk), `issued` (a value a provider's software issued) and `rotate`.
|
||||
//
|
||||
// **A value comes in sealed to a key the vault made for that one call, and opens only here.** The first three
|
||||
// are two steps of one verb. The first names what is taken and answers a call and the public half of a key made
|
||||
// for it (`seal_to`, a raw X25519 key in base64, the NaCl anonymous box the desk's prompt and a provider's tool
|
||||
// already seal to); the second hands back what was sealed to that key, and the vault opens it with the private
|
||||
// half, which never leaves this process and is forgotten once used or after two minutes.
|
||||
//
|
||||
// **The controller carries the call, and cannot open what it carries.** The desk's prompt and a provider's tool
|
||||
// answer the controller alone: the bus lets only the controller ask them, on the subject that names its caller
|
||||
// (issue 365). The vault cannot call in a name of its own while it runs in its machine's tool runner, whose one
|
||||
// bus account carries that machine's agents' calls too (issue 569, ADR 0305 §3). So the controller asks them on
|
||||
// the vault's behalf with the vault's key, and passes on the sealed answer: the value is opened in the vault and
|
||||
// nowhere else (ADR 0349 clause 9, ADR 0277 §23), and the vault takes these steps from the controller alone.
|
||||
//
|
||||
// Which secret each one is, by name in the vault's store:
|
||||
//
|
||||
// own/<node>/<module>/<name> a module's own secret on one machine
|
||||
// provision/<provider node>/<provision> the one credential of a provider assignment and provision (clause 8)
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// ControllerCaller is the controller's bus user: the one caller that carries a value in (broker.ControllerName).
|
||||
const ControllerCaller = "controller"
|
||||
|
||||
// Where a value came from.
|
||||
const (
|
||||
OriginMade = "made"
|
||||
OriginAccepted = "accepted"
|
||||
OriginTyped = "typed"
|
||||
OriginIssued = "issued"
|
||||
)
|
||||
|
||||
// CallOpenFor is how long a call's key waits for its sealed value: longer than any prompt or tool waits.
|
||||
const CallOpenFor = 2 * time.Minute
|
||||
|
||||
// The bounds of asking for one secret (ADR 0277 §24, the controller's inventory.SecretAsk* until now).
|
||||
const (
|
||||
// SecretAskOpenFor is how long an ask that has not ended counts as open.
|
||||
SecretAskOpenFor = 2 * time.Minute
|
||||
// SecretAsksPerHour is how many asks for one secret an hour takes.
|
||||
SecretAsksPerHour = 3
|
||||
)
|
||||
|
||||
var namePart = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
|
||||
|
||||
// OwnSecret is the vault's name for a module's own secret on one machine.
|
||||
func OwnSecret(node, module, name string) (string, error) {
|
||||
for what, v := range map[string]string{"the machine": node, "the module": module, "the secret": name} {
|
||||
if !namePart.MatchString(v) {
|
||||
return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v)
|
||||
}
|
||||
}
|
||||
return "own/" + node + "/" + module + "/" + name, nil
|
||||
}
|
||||
|
||||
// ProvisionSecret is the vault's name for the one credential of a provider assignment and provision.
|
||||
func ProvisionSecret(provider, provision string) (string, error) {
|
||||
for what, v := range map[string]string{"the provider's machine": provider, "the provision": provision} {
|
||||
if !namePart.MatchString(v) {
|
||||
return "", fmt.Errorf("%s %q is not a name the mesh writes", what, v)
|
||||
}
|
||||
}
|
||||
return "provision/" + provider + "/" + provision, nil
|
||||
}
|
||||
|
||||
// callKey is one call's key and what the call takes in. Never written anywhere.
|
||||
type callKey struct {
|
||||
verb, secret, by string
|
||||
// ask is the ask's record for `secret-ask`, which the call's id is.
|
||||
ask *AskRecord
|
||||
public [32]byte
|
||||
private [32]byte
|
||||
until time.Time
|
||||
}
|
||||
|
||||
// Opened is a first step's answer: the call, and the key to seal its value to. Never a value.
|
||||
type Opened struct {
|
||||
Call string `json:"call"`
|
||||
SealTo string `json:"seal_to"`
|
||||
Secret string `json:"secret"`
|
||||
AskedBy string `json:"asked_by,omitempty"`
|
||||
}
|
||||
|
||||
// fromTheController refuses every caller but the controller: only it reaches a desk's prompt and a provider's
|
||||
// tool, and only it carries a value given at its terminal.
|
||||
func fromTheController(caller, verb string) error {
|
||||
if caller != ControllerCaller {
|
||||
if caller == "" {
|
||||
caller = stdio.Unproven
|
||||
}
|
||||
return fmt.Errorf("%s takes a value from the controller alone, which carries it sealed to this vault's key "+
|
||||
"for one call; %s is not the controller", verb, caller)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openCall makes a key for one call and keeps its private half here until the call is taken or two minutes
|
||||
// have passed.
|
||||
func (v *Vault) openCall(verb, secret, by string, ask *AskRecord) (string, string, error) {
|
||||
pub, priv, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
idRaw := make([]byte, 12)
|
||||
if _, err := rand.Read(idRaw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
id := hex.EncodeToString(idRaw)
|
||||
if ask != nil {
|
||||
id = ask.ID
|
||||
}
|
||||
v.callsMu.Lock()
|
||||
defer v.callsMu.Unlock()
|
||||
if v.pending == nil {
|
||||
v.pending = map[string]*callKey{}
|
||||
}
|
||||
now := v.Now()
|
||||
for k, c := range v.pending {
|
||||
if now.After(c.until) {
|
||||
delete(v.pending, k)
|
||||
}
|
||||
}
|
||||
v.pending[id] = &callKey{verb: verb, secret: secret, by: by, ask: ask, public: *pub, private: *priv,
|
||||
until: now.Add(CallOpenFor)}
|
||||
return id, base64.StdEncoding.EncodeToString(pub[:]), nil
|
||||
}
|
||||
|
||||
// takeCall ends a call: its key is forgotten whatever follows.
|
||||
func (v *Vault) takeCall(verb, id string) (*callKey, error) {
|
||||
v.callsMu.Lock()
|
||||
defer v.callsMu.Unlock()
|
||||
c, ok := v.pending[id]
|
||||
if !ok || c.verb != verb {
|
||||
return nil, fmt.Errorf("no %s call %q is open in the vault: it ended, it was never opened, or the vault "+
|
||||
"started again; open it anew", verb, id)
|
||||
}
|
||||
delete(v.pending, id)
|
||||
if v.Now().After(c.until) {
|
||||
return nil, fmt.Errorf("the %s call %q waited longer than %s and is closed; open it anew", verb, id, CallOpenFor)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// open opens what was sealed to the call's key, and forgets the key.
|
||||
func (c *callKey) open(sealed string) ([]byte, error) {
|
||||
defer func() { c.private = [32]byte{} }()
|
||||
blob, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sealed))
|
||||
if err != nil {
|
||||
return nil, errors.New("the value is not sealed to this call; nothing was taken")
|
||||
}
|
||||
out, ok := box.OpenAnonymous(nil, blob, &c.public, &c.private)
|
||||
if !ok {
|
||||
// Never the value, never what failed to open: only that it was not sealed to this call.
|
||||
return nil, errors.New("the value is not sealed to this call; nothing was taken")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func zero(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---- accept -------------------------------------------------------------------------------------------------
|
||||
|
||||
// AcceptOpen opens a call to take a value given at the controller's terminal for secret, which the terminal
|
||||
// seals to the call's key before it leaves it (ADR 0349 clause 8: the vault alone opens an accepted value).
|
||||
func (v *Vault) AcceptOpen(caller, secret, by string) (Opened, error) {
|
||||
if err := fromTheController(caller, "accept"); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
if err := validName(secret); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
if rec, held, err := v.Store.Read(secret); err != nil {
|
||||
return Opened{}, err
|
||||
} else if held && rec.Origin == OriginIssued {
|
||||
return Opened{}, fmt.Errorf("%s is issued by its provider's software, and `secret issued` is the only way "+
|
||||
"it enters the vault (ADR 0305 §10); nothing is taken", secret)
|
||||
}
|
||||
id, key, err := v.openCall("accept", secret, by, nil)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
return Opened{Call: id, SealTo: key, Secret: secret}, nil
|
||||
}
|
||||
|
||||
// AcceptTake opens the value sealed to the call's key and keeps it, accepted: never remade by the vault.
|
||||
func (v *Vault) AcceptTake(caller, call, sealed string) (Changed, error) {
|
||||
if err := fromTheController(caller, "accept"); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
c, err := v.takeCall("accept", call)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
value, err := c.open(sealed)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
defer zero(value)
|
||||
if strings.TrimSpace(string(value)) == "" {
|
||||
return Changed{}, errors.New("the value given is empty; nothing was taken")
|
||||
}
|
||||
return v.keep(c.secret, string(value), someoneOr(c.by, caller), caller, OriginAccepted)
|
||||
}
|
||||
|
||||
// ---- secret-ask ---------------------------------------------------------------------------------------------
|
||||
|
||||
// AskOf is what an ask is for, and where.
|
||||
type AskOf struct {
|
||||
Node, Module, Name, Desk string
|
||||
// AskedBy is who asked the controller, as the bus named its caller; the controller's word, taken from the
|
||||
// controller alone.
|
||||
AskedBy string
|
||||
}
|
||||
|
||||
// AskOpen records an ask for a module's own secret before any prompt opens, holding its bounds — one open ask
|
||||
// per secret, three an hour (ADR 0277 §24, §25) — and answers the key the desk's prompt seals what is typed to,
|
||||
// and who asked as the record says it, which the prompt names (§26).
|
||||
func (v *Vault) AskOpen(caller string, a AskOf) (Opened, error) {
|
||||
if err := fromTheController(caller, "secret-ask"); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
secret, err := OwnSecret(a.Node, a.Module, a.Name)
|
||||
if err != nil {
|
||||
return Opened{}, fmt.Errorf("nobody was asked to type anything: %w", err)
|
||||
}
|
||||
if !namePart.MatchString(a.Desk) {
|
||||
return Opened{}, fmt.Errorf("nobody was asked to type anything: the desk %q is not a machine's name", a.Desk)
|
||||
}
|
||||
askedBy := askedByName(a.AskedBy)
|
||||
v.Store.asksMu.Lock()
|
||||
defer v.Store.asksMu.Unlock()
|
||||
now := v.Now().UTC()
|
||||
recent, err := v.Store.AsksSince(now.Add(-time.Hour))
|
||||
if err != nil {
|
||||
return Opened{}, fmt.Errorf("nobody was asked to type anything: the record of asks cannot be read: %w", err)
|
||||
}
|
||||
ended := map[string]bool{}
|
||||
for _, r := range recent {
|
||||
if r.Event == AskEnded {
|
||||
ended[r.ID] = true
|
||||
}
|
||||
}
|
||||
lastHour, openBy := 0, ""
|
||||
for _, r := range recent {
|
||||
if r.Event != AskOpened || r.Secret != secret {
|
||||
continue
|
||||
}
|
||||
lastHour++
|
||||
if !ended[r.ID] && r.At.After(now.Add(-SecretAskOpenFor)) {
|
||||
openBy = r.AskedBy
|
||||
}
|
||||
}
|
||||
if openBy != "" {
|
||||
return Opened{}, fmt.Errorf("an ask for %s of %s on %s is still open (asked by %s): one prompt at a time for a "+
|
||||
"secret, and this one ends within %s", a.Name, a.Module, a.Node, openBy, SecretAskOpenFor)
|
||||
}
|
||||
if lastHour >= SecretAsksPerHour {
|
||||
return Opened{}, fmt.Errorf("%s of %s on %s was asked for %d times in the last hour, and an hour takes %d asks "+
|
||||
"for one secret: the operator is not kept at a prompt", a.Name, a.Module, a.Node, lastHour, SecretAsksPerHour)
|
||||
}
|
||||
idRaw := make([]byte, 12)
|
||||
if _, err := rand.Read(idRaw); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
rec := AskRecord{ID: hex.EncodeToString(idRaw), At: now, Event: AskOpened, Secret: secret, Node: a.Node,
|
||||
Module: a.Module, Name: a.Name, Desk: a.Desk, AskedBy: askedBy, Caller: caller}
|
||||
if err := v.recordAsk(rec); err != nil {
|
||||
return Opened{}, fmt.Errorf("nobody was asked to type anything: the ask could not be recorded: %w", err)
|
||||
}
|
||||
id, key, err := v.openCall("secret-ask", secret, askedBy, &rec)
|
||||
if err != nil {
|
||||
_ = v.recordAsk(AskRecord{ID: rec.ID, At: v.Now().UTC(), Event: AskEnded, Secret: secret, Outcome: "failed"})
|
||||
return Opened{}, err
|
||||
}
|
||||
return Opened{Call: id, SealTo: key, Secret: secret, AskedBy: askedBy}, nil
|
||||
}
|
||||
|
||||
// The outcomes an ask ends with, as the controller's record had them.
|
||||
var askOutcomes = map[string]bool{"dismissed": true, "timed-out": true, "empty": true, "refused": true, "failed": true}
|
||||
|
||||
// AskEnd ends an ask: with what the desk sealed to its key, which the vault opens and keeps as typed, or with
|
||||
// the outcome the desk answered instead. How it ended is recorded whatever happens.
|
||||
func (v *Vault) AskEnd(caller, call, sealed, outcome string) (Changed, error) {
|
||||
if err := fromTheController(caller, "secret-ask"); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
if sealed == "" && !askOutcomes[outcome] {
|
||||
return Changed{}, fmt.Errorf("an ask ends with what was sealed to its key, or with one of dismissed, " +
|
||||
"timed-out, empty, refused or failed")
|
||||
}
|
||||
c, err := v.takeCall("secret-ask", call)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
end := func(how string) {
|
||||
if err := v.recordAsk(AskRecord{ID: c.ask.ID, At: v.Now().UTC(), Event: AskEnded, Secret: c.secret,
|
||||
Outcome: how}); err != nil {
|
||||
v.log("the end of ask %s (%s) could not be recorded: %v", c.ask.ID, how, err)
|
||||
}
|
||||
}
|
||||
if sealed == "" {
|
||||
end(outcome)
|
||||
return Changed{Name: c.secret, Unchanged: true}, nil
|
||||
}
|
||||
opened, err := c.open(sealed)
|
||||
if err != nil {
|
||||
end("failed")
|
||||
return Changed{}, err
|
||||
}
|
||||
value := strings.TrimRight(string(opened), "\r\n")
|
||||
zero(opened)
|
||||
if strings.TrimSpace(value) == "" {
|
||||
end("empty")
|
||||
return Changed{}, fmt.Errorf("nothing was given: the prompt on %s was answered empty", c.ask.Desk)
|
||||
}
|
||||
changed, err := v.keep(c.secret, value, c.ask.AskedBy, caller, OriginTyped)
|
||||
value = ""
|
||||
if err != nil {
|
||||
end("failed")
|
||||
return Changed{}, err
|
||||
}
|
||||
end("given")
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (v *Vault) recordAsk(r AskRecord) error {
|
||||
rolled, err := v.Store.RecordAsk(r)
|
||||
if rolled {
|
||||
v.log("THE RECORD OF ASKS WAS ROLLED: it reached its bound of %d bytes; the oldest of %d kept files is "+
|
||||
"dropped. Many asks in little time is worth a look: secret_asks", v.Store.HandedBound(), HandedKept)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// askedByName is who asked as the prompt and the record name it: the first clause of what the bus said, in the
|
||||
// characters a name has, at most 80 (the controller's own rule, ADR 0277 §26), so no words of the caller's own
|
||||
// reach the prompt.
|
||||
func askedByName(caller string) string {
|
||||
first, _, _ := strings.Cut(caller, ",")
|
||||
var b strings.Builder
|
||||
for _, r := range strings.TrimSpace(first) {
|
||||
if b.Len() >= 80 {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '/', r == '@',
|
||||
r == '-', r == ' ':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
name := strings.TrimSpace(b.String())
|
||||
if name == "" || strings.HasPrefix(name, "-") {
|
||||
return "an unnamed caller"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// ---- issued -------------------------------------------------------------------------------------------------
|
||||
|
||||
// IssuedOpen opens a call to take the credential a provider's software issued for a provision (ADR 0349 clause
|
||||
// 10, ADR 0305 with the vault in the controller's place): the controller asks the provider's tool, on the
|
||||
// provider's machine, with the call's key, and passes on what it answered.
|
||||
func (v *Vault) IssuedOpen(caller, provision, provider, module string) (Opened, error) {
|
||||
if err := fromTheController(caller, "issued"); err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
secret, err := ProvisionSecret(provider, provision)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
if !namePart.MatchString(module) {
|
||||
return Opened{}, fmt.Errorf("the provider's module %q is not a name the mesh writes", module)
|
||||
}
|
||||
id, key, err := v.openCall("issued", secret, module+" on "+provider, nil)
|
||||
if err != nil {
|
||||
return Opened{}, err
|
||||
}
|
||||
return Opened{Call: id, SealTo: key, Secret: secret}, nil
|
||||
}
|
||||
|
||||
// IssuedTake opens what the provider's tool sealed to the call's key and keeps it as the provision's one
|
||||
// credential. The same value again writes nothing (ADR 0305 §5): every holder that has it is left as it is.
|
||||
func (v *Vault) IssuedTake(caller, call, sealed string) (Changed, error) {
|
||||
if err := fromTheController(caller, "issued"); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
c, err := v.takeCall("issued", call)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
opened, err := c.open(sealed)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
value := strings.TrimSpace(string(opened))
|
||||
zero(opened)
|
||||
if value == "" {
|
||||
return Changed{}, fmt.Errorf("%s answered an empty value; nothing was taken", c.by)
|
||||
}
|
||||
return v.keep(c.secret, value, c.by, caller, OriginIssued)
|
||||
}
|
||||
|
||||
// ---- rotate -------------------------------------------------------------------------------------------------
|
||||
|
||||
// Rotate makes a secret anew (ADR 0114, ADR 0228). Refused: a secret the vault does not hold; a value a
|
||||
// provider's software issued, which the software rotates (ADR 0305 §9); and a provision's accepted credential,
|
||||
// which the vault does not remake (ADR 0349 clause 8, ADR 0158 §8). A module's own value given by hand is
|
||||
// rotated like a made one (ADR 0228). Who asked is the caller the runtime proved, or the person the controller
|
||||
// names when the controller carries the act — both are kept.
|
||||
func (v *Vault) Rotate(caller, secret, by string) (Changed, error) {
|
||||
if caller == "" || caller == stdio.Unproven || !busUser.MatchString(caller) {
|
||||
return Changed{}, errors.New("the vault rotates only for a caller the runtime proved")
|
||||
}
|
||||
if err := validName(secret); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
rec, held, err := v.Store.Read(secret)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
switch {
|
||||
case !held:
|
||||
return Changed{}, fmt.Errorf("not rotated: the vault holds no secret named %s", secret)
|
||||
case rec.Origin == OriginIssued:
|
||||
return Changed{}, fmt.Errorf("not rotated: %s is issued by its provider's software, which the mesh cannot "+
|
||||
"make or rotate (ADR 0305 §9): change it in that software, and `secret issued` takes the value it holds now",
|
||||
secret)
|
||||
case rec.Origin == OriginAccepted && strings.HasPrefix(secret, "provision/"):
|
||||
return Changed{}, fmt.Errorf("not rotated: %s was accepted, and an accepted credential is not remade by the "+
|
||||
"vault (ADR 0349 clause 8): accept its new value instead", secret)
|
||||
}
|
||||
if caller != ControllerCaller || by == "" {
|
||||
by = caller
|
||||
}
|
||||
raw := make([]byte, 30)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
return v.keep(secret, base64.RawURLEncoding.EncodeToString(raw), by, caller, OriginMade)
|
||||
}
|
||||
|
||||
func someoneOr(by, caller string) string {
|
||||
if strings.TrimSpace(by) == "" {
|
||||
return caller
|
||||
}
|
||||
return by
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package main
|
||||
|
||||
// ADR 0349's tests for what the vault takes in (design 57's V2, issue 675): the vault's `secret-ask` test, the
|
||||
// issuing provider's test with a stand-in provider (plex's own tool moves with the media catalogue, after
|
||||
// release 57), an accepted value opened in the vault alone and never remade, and rotate.
|
||||
//
|
||||
// In each, "the controller" is a test function that holds no private key: it only carries what the desk or the
|
||||
// provider sealed to the key the vault made for the call, as the controller does on the bus.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// sealToCall seals value to a call's key as the desk's prompt and a provider's tool do: the NaCl anonymous box.
|
||||
func sealToCall(t *testing.T, sealTo, value string) string {
|
||||
t.Helper()
|
||||
raw, err := base64.StdEncoding.DecodeString(sealTo)
|
||||
if err != nil || len(raw) != 32 {
|
||||
t.Fatalf("seal_to %q is not a key", sealTo)
|
||||
}
|
||||
var key [32]byte
|
||||
copy(key[:], raw)
|
||||
sealed, err := box.SealAnonymous(nil, []byte(value), &key, rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sealed)
|
||||
}
|
||||
|
||||
// handedTo opens what `get` hands an allowed asker: the value the vault holds.
|
||||
func handedTo(t *testing.T, v testVault, asker, name string) string {
|
||||
t.Helper()
|
||||
key, _ := seal.GenerateKeyPair()
|
||||
a, err := v.Get(asker, name, key.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := seal.Open(a.Sealed, key.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// everything is all the vault said and wrote during a test, as text.
|
||||
func everything(t *testing.T, v testVault, more ...any) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
for _, said := range v.bus.raw {
|
||||
b.WriteString(said)
|
||||
}
|
||||
for _, content := range filesUnder(t, v.dir) {
|
||||
b.WriteString(content)
|
||||
}
|
||||
for _, m := range more {
|
||||
raw, _ := json.Marshal(m)
|
||||
b.Write(raw)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
const typedAtTheDesk = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g"
|
||||
|
||||
func telegramAsk(by string) AskOf {
|
||||
return AskOf{Node: "anchor", Module: "telegram", Name: "telegram-token", Desk: "laptop", AskedBy: by}
|
||||
}
|
||||
|
||||
// ---- the vault's secret-ask test (ADR 0349 "How it is checked") ---------------------------------------------
|
||||
|
||||
// An ask is recorded in the vault's store before the prompt opens; its prompt names the asker as the bus named
|
||||
// it; the typed value opens only in the vault; the controller carries ciphertext only.
|
||||
func TestAnAskIsRecordedBeforeThePromptAndTheTypedValueOpensOnlyInTheVault(t *testing.T) {
|
||||
v := newVault(t, AllowList{"anchor.node-provisioner": {"own/anchor/telegram/telegram-token"}})
|
||||
opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14/claude-code, via the console"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Recorded before any prompt opens: the record holds the ask now, with no end.
|
||||
asks, _ := v.Store.Asks(10)
|
||||
if len(asks) != 1 || asks[0].Event != AskOpened || asks[0].ID != opened.Call || asks[0].AskedBy != "g14/claude-code" ||
|
||||
asks[0].Caller != ControllerCaller || asks[0].Desk != "laptop" || asks[0].Secret != "own/anchor/telegram/telegram-token" {
|
||||
t.Fatalf("the record before the prompt: %+v", asks)
|
||||
}
|
||||
// The prompt names the asker as the bus named it, cut to a name: what the vault answers is what it recorded.
|
||||
if opened.AskedBy != "g14/claude-code" {
|
||||
t.Errorf("the prompt would name %q", opened.AskedBy)
|
||||
}
|
||||
// The desk seals what is typed to the call's key; the controller passes it on.
|
||||
changed, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, typedAtTheDesk+"\n"), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.Generation != 1 || changed.By != "g14/claude-code" {
|
||||
t.Errorf("kept as %+v", changed)
|
||||
}
|
||||
if got := handedTo(t, v, "anchor.node-provisioner", "own/anchor/telegram/telegram-token"); got != typedAtTheDesk {
|
||||
t.Errorf("the vault holds %q, not what was typed with its line ending taken off", got)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("own/anchor/telegram/telegram-token")
|
||||
if rec.Origin != OriginTyped || rec.ChangedBy != "g14/claude-code" || rec.ChangedVia != ControllerCaller {
|
||||
t.Errorf("the record says origin %q, by %q, via %q", rec.Origin, rec.ChangedBy, rec.ChangedVia)
|
||||
}
|
||||
asks, _ = v.Store.Asks(10)
|
||||
if len(asks) != 2 || asks[0].Event != AskEnded || asks[0].Outcome != "given" || asks[0].ID != opened.Call {
|
||||
t.Errorf("how it ended: %+v", asks)
|
||||
}
|
||||
if strings.Contains(everything(t, v, opened, changed, asks), typedAtTheDesk) {
|
||||
t.Fatal("the typed value is in something the vault said, answered or wrote in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
// One open ask per secret, and three an hour (ADR 0277 §24), read from the vault's own record.
|
||||
func TestAnAskIsBoundedToOneOpenPerSecretAndThreeAnHour(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
first, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.AskOpen(ControllerCaller, telegramAsk("ace.claude-code")); err == nil ||
|
||||
!strings.Contains(err.Error(), "still open (asked by g14.claude-code)") {
|
||||
t.Fatalf("a second ask while one is open: %v", err)
|
||||
}
|
||||
// Another secret is not held back by it.
|
||||
other := telegramAsk("ace.claude-code")
|
||||
other.Name = "webhook-secret"
|
||||
if _, err := v.AskOpen(ControllerCaller, other); err != nil {
|
||||
t.Fatalf("another secret's ask was held back: %v", err)
|
||||
}
|
||||
if _, err := v.AskEnd(ControllerCaller, first.Call, "", "dismissed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
o, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
|
||||
if err != nil {
|
||||
t.Fatalf("ask %d of the hour: %v", i+2, err)
|
||||
}
|
||||
if _, err := v.AskEnd(ControllerCaller, o.Call, "", "timed-out"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err == nil ||
|
||||
!strings.Contains(err.Error(), "3 times in the last hour") {
|
||||
t.Fatalf("a fourth ask in the hour: %v", err)
|
||||
}
|
||||
asks, _ := v.Store.Asks(50)
|
||||
outcomes := map[string]int{}
|
||||
for _, a := range asks {
|
||||
outcomes[a.Outcome]++
|
||||
}
|
||||
if outcomes["dismissed"] != 1 || outcomes["timed-out"] != 2 {
|
||||
t.Errorf("the record's outcomes: %v", outcomes)
|
||||
}
|
||||
// An ask left open counts as open for SecretAskOpenFor only: a prompt that died does not hold the secret.
|
||||
w := newVault(t, nil)
|
||||
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
|
||||
w.Now = func() time.Time { return clock }
|
||||
if _, err := w.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock = clock.Add(SecretAskOpenFor + time.Second)
|
||||
if _, err := w.AskOpen(ControllerCaller, telegramAsk("g14.claude-code")); err != nil {
|
||||
t.Errorf("an ask left open past its time still held the secret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the controller reaches a desk's prompt, so only it opens and ends an ask; a value not sealed to the
|
||||
// call's key, or a call that waited too long, keeps nothing.
|
||||
func TestAnAskIsTakenFromTheControllerAloneAndOnlySealedToItsCall(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
for _, caller := range []string{"g14.node-tools", "unproven", "", "person.jochen"} {
|
||||
if _, err := v.AskOpen(caller, telegramAsk("x")); err == nil {
|
||||
t.Errorf("%q opened an ask", caller)
|
||||
}
|
||||
}
|
||||
if asks, _ := v.Store.Asks(10); len(asks) != 0 {
|
||||
t.Fatalf("a refused caller filled the record: %+v", asks)
|
||||
}
|
||||
opened, err := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.AskEnd("g14.node-tools", opened.Call, sealToCall(t, opened.SealTo, "x"), ""); err == nil {
|
||||
t.Error("another caller ended the ask")
|
||||
}
|
||||
other, _, _ := box.GenerateKey(rand.Reader)
|
||||
if _, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, base64.StdEncoding.EncodeToString(other[:]), "x"), ""); err == nil ||
|
||||
!strings.Contains(err.Error(), "not sealed to this call") {
|
||||
t.Errorf("a value sealed to another key: %v", err)
|
||||
}
|
||||
if _, held, _ := v.Store.Read("own/anchor/telegram/telegram-token"); held {
|
||||
t.Error("a value not sealed to the call was kept")
|
||||
}
|
||||
// The call's key is forgotten once used: the same call cannot be answered twice.
|
||||
if _, err := v.AskEnd(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, "x"), ""); err == nil {
|
||||
t.Error("a call was answered twice")
|
||||
}
|
||||
asks, _ := v.Store.Asks(1)
|
||||
if asks[0].Outcome != "failed" {
|
||||
t.Errorf("the ask that failed ended %+v", asks[0])
|
||||
}
|
||||
// A call that waited longer than its time is closed.
|
||||
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
|
||||
v.Now = func() time.Time { return clock }
|
||||
late, err := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock = clock.Add(CallOpenFor + time.Second)
|
||||
if _, err := v.IssuedTake(ControllerCaller, late.Call, sealToCall(t, late.SealTo, "x")); err == nil {
|
||||
t.Error("a call that waited too long was taken")
|
||||
}
|
||||
// An empty answer gives nothing and says so.
|
||||
clock = clock.Add(time.Hour)
|
||||
empty, _ := v.AskOpen(ControllerCaller, telegramAsk("g14.claude-code"))
|
||||
if _, err := v.AskEnd(ControllerCaller, empty.Call, sealToCall(t, empty.SealTo, " \n"), ""); err == nil {
|
||||
t.Error("an empty answer was kept")
|
||||
}
|
||||
if asks, _ := v.Store.Asks(1); asks[0].Outcome != "empty" {
|
||||
t.Errorf("an empty answer ended %+v", asks[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the issuing provider's test, with a stand-in provider -------------------------------------------------
|
||||
|
||||
// standInProvider is a provider's tool for an `issued-by: software` offer, as plex's must answer (ADR 0305 §2
|
||||
// with the vault in the controller's place): it refuses every caller but the controller — the one caller the
|
||||
// bus lets ask it, carrying the vault's call — and answers only the software's value sealed to the call's key.
|
||||
func standInProvider(software *string) func(caller string, args map[string]any) (map[string]any, error) {
|
||||
return func(caller string, args map[string]any) (map[string]any, error) {
|
||||
if caller != ControllerCaller {
|
||||
return nil, errors.New("this tool answers the controller alone")
|
||||
}
|
||||
key, _ := args["seal_to"].(string)
|
||||
raw, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, errors.New("seal_to is not a sealing key")
|
||||
}
|
||||
var to [32]byte
|
||||
copy(to[:], raw)
|
||||
sealed, err := box.SealAnonymous(nil, []byte(*software), &to, rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"sealed": base64.StdEncoding.EncodeToString(sealed)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// issue is the controller's `secret issued` for one provider, carried as the controller carries it: open the
|
||||
// vault's call, ask the provider's tool with the call's key, pass on what it sealed.
|
||||
func issue(t *testing.T, v testVault, provider func(string, map[string]any) (map[string]any, error)) (Changed, error) {
|
||||
t.Helper()
|
||||
opened, err := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex")
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
answer, err := provider(ControllerCaller, map[string]any{"provision": "plex-api", "seal_to": opened.SealTo})
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
return v.IssuedTake(ControllerCaller, opened.Call, answer["sealed"].(string))
|
||||
}
|
||||
|
||||
func TestAnIssuingProviderAnswersOnlyTheControllersCallSealedToTheVault(t *testing.T) {
|
||||
v := newVault(t, AllowList{"anchor.node-provisioner": {"provision/anchor/plex-api"}})
|
||||
software := "plex-token-from-the-vendor"
|
||||
provider := standInProvider(&software)
|
||||
|
||||
// The provider's tool refuses every caller but the controller, whatever it asks.
|
||||
for _, caller := range []string{"anchor.node-tools", "g14.claude-code", "unproven", ""} {
|
||||
if _, err := provider(caller, map[string]any{"provision": "plex-api", "seal_to": "x"}); err == nil {
|
||||
t.Errorf("the provider's tool answered %q", caller)
|
||||
}
|
||||
}
|
||||
// The vault takes the steps from the controller alone.
|
||||
if _, err := v.IssuedOpen("anchor.node-tools", "plex-api", "anchor", "plex"); err == nil {
|
||||
t.Error("the vault opened an issued call for another caller")
|
||||
}
|
||||
|
||||
changed, err := issue(t, v, provider)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.Name != "provision/anchor/plex-api" || changed.Generation != 1 || changed.By != "plex on anchor" {
|
||||
t.Errorf("kept as %+v", changed)
|
||||
}
|
||||
if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software {
|
||||
t.Errorf("the vault holds %q", got)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("provision/anchor/plex-api")
|
||||
if rec.Origin != OriginIssued || rec.ChangedVia != ControllerCaller {
|
||||
t.Errorf("the record: origin %q via %q", rec.Origin, rec.ChangedVia)
|
||||
}
|
||||
|
||||
// Nothing rewritten for a value every holder has (ADR 0305 §5): no new generation, no write, nothing said.
|
||||
path := filepath.Join(v.dir, "store", "secrets", "provision%2Fanchor%2Fplex-api.json")
|
||||
before, _ := os.ReadFile(path)
|
||||
said := len(v.bus.raw)
|
||||
again, err := issue(t, v, provider)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if !again.Unchanged || again.Generation != 1 || string(before) != string(after) || len(v.bus.raw) != said {
|
||||
t.Errorf("the same value again rewrote something: %+v", again)
|
||||
}
|
||||
|
||||
// The software changes its value: the next ask keeps it as a new generation, and says so without it.
|
||||
software = "plex-token-renewed"
|
||||
renewed, err := issue(t, v, provider)
|
||||
if err != nil || renewed.Generation != 2 {
|
||||
t.Fatalf("%+v %v", renewed, err)
|
||||
}
|
||||
if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software {
|
||||
t.Errorf("after the change the vault holds %q", got)
|
||||
}
|
||||
|
||||
// A value sealed to any other key is refused, and nothing changes.
|
||||
opened, _ := v.IssuedOpen(ControllerCaller, "plex-api", "anchor", "plex")
|
||||
stranger, _, _ := box.GenerateKey(rand.Reader)
|
||||
if _, err := v.IssuedTake(ControllerCaller, opened.Call, sealToCall(t, base64.StdEncoding.EncodeToString(stranger[:]), "forged")); err == nil {
|
||||
t.Error("a value not sealed to the call was taken")
|
||||
}
|
||||
|
||||
// The software rotates it, not the mesh (ADR 0305 §9); and no other path puts it into the vault (§10).
|
||||
if _, err := v.Rotate(ControllerCaller, "provision/anchor/plex-api", "person.jochen"); err == nil ||
|
||||
!strings.Contains(err.Error(), "software") {
|
||||
t.Errorf("rotate of an issued value: %v", err)
|
||||
}
|
||||
if _, err := v.AcceptOpen(ControllerCaller, "provision/anchor/plex-api", "person.jochen"); err == nil {
|
||||
t.Error("an issued value was taken by accept")
|
||||
}
|
||||
if strings.Contains(everything(t, v), "plex-token") {
|
||||
t.Fatal("the issued value is in something the vault said or wrote in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- accept and rotate -------------------------------------------------------------------------------------
|
||||
|
||||
// A value given at the controller's terminal is sealed there to the vault's call and opened here alone; an
|
||||
// accepted provision's credential is never remade; a module's own given value rotates as a made one (ADR 0228).
|
||||
func TestAnAcceptedValueOpensInTheVaultAloneAndIsNotRemade(t *testing.T) {
|
||||
v := newVault(t, AllowList{"g14.node-provisioner": {"provision/novox/postgres", "own/g14/dunst/token"}})
|
||||
if _, err := v.AcceptOpen("g14.claude-code", "provision/novox/postgres", ""); err == nil {
|
||||
t.Error("accept was opened for a caller that is not the controller")
|
||||
}
|
||||
for name, value := range map[string]string{"provision/novox/postgres": "pw-of-last-year", "own/g14/dunst/token": "tok"} {
|
||||
opened, err := v.AcceptOpen(ControllerCaller, name, "jochen")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := v.AcceptTake(ControllerCaller, opened.Call, sealToCall(t, opened.SealTo, value))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.By != "jochen" {
|
||||
t.Errorf("%s accepted by %q", name, changed.By)
|
||||
}
|
||||
if got := handedTo(t, v, "g14.node-provisioner", name); got != value {
|
||||
t.Errorf("%s holds %q", name, got)
|
||||
}
|
||||
rec, _, _ := v.Store.Read(name)
|
||||
if rec.Origin != OriginAccepted || rec.ChangedVia != ControllerCaller {
|
||||
t.Errorf("%s: origin %q via %q", name, rec.Origin, rec.ChangedVia)
|
||||
}
|
||||
}
|
||||
if _, err := v.Rotate(ControllerCaller, "provision/novox/postgres", "jochen"); err == nil ||
|
||||
!strings.Contains(err.Error(), "not remade") {
|
||||
t.Errorf("rotate of an accepted credential: %v", err)
|
||||
}
|
||||
rotated, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen")
|
||||
if err != nil || rotated.Generation != 2 {
|
||||
t.Fatalf("rotate of a module's own given value: %+v %v", rotated, err)
|
||||
}
|
||||
if got := handedTo(t, v, "g14.node-provisioner", "own/g14/dunst/token"); got == "tok" || len(got) != 40 {
|
||||
t.Errorf("rotated to %q", got)
|
||||
}
|
||||
if strings.Contains(everything(t, v), "pw-of-last-year") {
|
||||
t.Fatal("an accepted value is in something the vault said or wrote in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate: refused for a secret not held and for an unproven caller; who asked is the proven caller, or the
|
||||
// person the controller names when it carries the act — both kept.
|
||||
func TestRotateKeepsWhoAskedAndWhoCarriedIt(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen"); err == nil {
|
||||
t.Error("a secret not held was rotated")
|
||||
}
|
||||
if _, err := v.Mint("own/g14/dunst/token", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Rotate("unproven", "own/g14/dunst/token", ""); err == nil {
|
||||
t.Error("an unproven caller rotated")
|
||||
}
|
||||
if _, err := v.Rotate(ControllerCaller, "own/g14/dunst/token", "jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("own/g14/dunst/token")
|
||||
if rec.ChangedBy != "jochen" || rec.ChangedVia != ControllerCaller || rec.Generation != 2 {
|
||||
t.Errorf("by %q via %q generation %d", rec.ChangedBy, rec.ChangedVia, rec.Generation)
|
||||
}
|
||||
// A `by` from any caller but the controller names nobody: the proven caller is who asked.
|
||||
if _, err := v.Rotate("g14.claude-code", "own/g14/dunst/token", "jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _, _ = v.Store.Read("own/g14/dunst/token")
|
||||
if rec.ChangedBy != "g14.claude-code" || rec.ChangedVia != "" {
|
||||
t.Errorf("by %q via %q", rec.ChangedBy, rec.ChangedVia)
|
||||
}
|
||||
}
|
||||
|
||||
// The names the vault keeps them under are names, never paths.
|
||||
func TestTheNamesOfWhatIsTakenIn(t *testing.T) {
|
||||
if n, err := OwnSecret("g14", "dunst", "token"); err != nil || n != "own/g14/dunst/token" {
|
||||
t.Errorf("%q %v", n, err)
|
||||
}
|
||||
if n, err := ProvisionSecret("novox", "postgres"); err != nil || n != "provision/novox/postgres" {
|
||||
t.Errorf("%q %v", n, err)
|
||||
}
|
||||
for _, bad := range [][3]string{{"..", "x", "y"}, {"g14", "a/b", "y"}, {"g14", "x", ""}, {"G14", "x", "y"}} {
|
||||
if _, err := OwnSecret(bad[0], bad[1], bad[2]); err == nil {
|
||||
t.Errorf("%v was taken", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the verbs as the runtime calls them ------------------------------------------------------------------
|
||||
|
||||
// Each verb takes its caller from the runtime, never from its arguments, and names its secret one way only.
|
||||
func TestTheVerbsTakeTheirCallerFromTheRuntime(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
env := &Env{Vault: v.Vault}
|
||||
defs := definitions(env)
|
||||
ask := toolNamed(t, defs, Seat+".secret-ask")
|
||||
args := map[string]any{"node": "anchor", "module": "telegram", "name": "telegram-token", "desk": "laptop",
|
||||
"asked_by": "g14.claude-code", "caller": ControllerCaller}
|
||||
if _, err := ask.Run(args); err == nil {
|
||||
t.Error("an ask opened for a caller named in the arguments")
|
||||
}
|
||||
answer, err := ask.runAs(ControllerCaller, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opened := answer.(Opened)
|
||||
if _, err := ask.runAs(ControllerCaller, map[string]any{"call": opened.Call, "outcome": "dismissed"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed, err := toolNamed(t, defs, "secret_asks").Run(map[string]any{})
|
||||
if err != nil || len(listed.([]AskRecord)) != 2 {
|
||||
t.Fatalf("secret_asks: %v %v", listed, err)
|
||||
}
|
||||
|
||||
accept := toolNamed(t, defs, Seat+".accept")
|
||||
if _, err := accept.runAs(ControllerCaller, map[string]any{"node": "g14", "provision": "postgres"}); err == nil {
|
||||
t.Error("accept took a name both ways")
|
||||
}
|
||||
answer, err = accept.runAs(ControllerCaller, map[string]any{"provider": "novox", "provision": "postgres", "by": "jochen"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := answer.(Opened)
|
||||
if _, err := accept.runAs(ControllerCaller, map[string]any{"call": o.Call, "sealed": sealToCall(t, o.SealTo, "pw")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotate := toolNamed(t, defs, Seat+".rotate")
|
||||
if _, err := rotate.runAs("g14.claude-code", map[string]any{"node": "g14", "module": "dunst", "name": "token"}); err == nil {
|
||||
t.Error("a secret not held was rotated through the verb")
|
||||
}
|
||||
|
||||
issued := toolNamed(t, defs, Seat+".issued")
|
||||
answer, err = issued.runAs(ControllerCaller, map[string]any{"provision": "plex-api", "provider": "anchor", "module": "plex"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o = answer.(Opened)
|
||||
if _, err := issued.runAs(ControllerCaller, map[string]any{"call": o.Call, "sealed": sealToCall(t, o.SealTo, "tok")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken := &Env{Broken: errors.New("no store")}
|
||||
if _, err := toolNamed(t, definitions(broken), Seat+".rotate").runAs(ControllerCaller, map[string]any{}); err == nil ||
|
||||
err.Error() != "no store" {
|
||||
t.Errorf("a broken vault said %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
// The keys the vault meets, and the one it holds (novox/hq ADR 0113 §2, ADR 0206).
|
||||
//
|
||||
// - Its own key: an X25519 pair made once in its state directory, the private half never leaving it. Every
|
||||
// secret is kept sealed to it at rest.
|
||||
// - An asker's key: the public half only, as SPKI PEM — the form of the ADR 0206 box (mesh-sdk go/seal).
|
||||
// A raw 32-byte key is refused, because a raw private key looks the same and the vault takes none.
|
||||
// - The operator key: the public half, taken as SPKI PEM only and kept raw in base64, the form `operator key
|
||||
// make` writes and the NaCl box the operator opens uses.
|
||||
//
|
||||
// No private key is ever accepted: not as a key to seal to, not as a secret to hold.
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// OwnKeyFile is where the vault's own key lives in its state directory.
|
||||
const OwnKeyFile = "vault-key.json"
|
||||
|
||||
// OwnKey is the vault's own key pair, made once in dir. Written whole and synced, then linked into place and
|
||||
// the directory synced, so a second process reads the first's key and never half of it.
|
||||
//
|
||||
// **A key is made only for an empty store.** When the key file is missing and the store holds secrets, the
|
||||
// key they are sealed to is lost or misplaced, and a new key would open none of them: the vault refuses to
|
||||
// start, loudly, rather than seal new secrets to a key beside old ones nothing opens. The operator copies
|
||||
// are what re-raise it (ADR 0113 §21).
|
||||
func OwnKey(dir string, store *Store) (seal.KeyPair, error) {
|
||||
if dir == "" {
|
||||
return seal.KeyPair{}, errors.New("the vault has no state directory: MESH_VAULT_STATE is not set")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return seal.KeyPair{}, err
|
||||
}
|
||||
file := filepath.Join(dir, OwnKeyFile)
|
||||
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
||||
if store != nil {
|
||||
empty, err := store.Empty()
|
||||
if err != nil {
|
||||
return seal.KeyPair{}, fmt.Errorf("the vault's key is missing and its store cannot be read: %w", err)
|
||||
}
|
||||
if !empty {
|
||||
return seal.KeyPair{}, fmt.Errorf("THE VAULT'S KEY IS MISSING (%s) WHILE ITS STORE HOLDS SECRETS SEALED TO IT: "+
|
||||
"the vault does not start and makes no new key, which would open none of them — put the key back, "+
|
||||
"or re-raise the vault from the operator copies (novox/hq ADR 0113 §21)", file)
|
||||
}
|
||||
}
|
||||
k, err := seal.GenerateKeyPair()
|
||||
if err != nil {
|
||||
return seal.KeyPair{}, err
|
||||
}
|
||||
raw, _ := json.Marshal(k)
|
||||
tmp := filepath.Join(dir, fmt.Sprintf(".vault-key.%d.json", os.Getpid()))
|
||||
if err := writeSynced(tmp, raw); err != nil {
|
||||
return seal.KeyPair{}, err
|
||||
}
|
||||
_ = os.Link(tmp, file) // fails when another made it first, which is right
|
||||
_ = os.Remove(tmp)
|
||||
if err := syncDir(dir); err != nil {
|
||||
return seal.KeyPair{}, err
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return seal.KeyPair{}, err
|
||||
}
|
||||
var k seal.KeyPair
|
||||
if err := json.Unmarshal(raw, &k); err != nil || k.PublicKey == "" || k.PrivateKey == "" {
|
||||
return seal.KeyPair{}, fmt.Errorf("%s is not the vault's key", file)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
var privateKeyPEM = regexp.MustCompile(`-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----`)
|
||||
|
||||
// privateKeyIn says whether text carries a private key.
|
||||
func privateKeyIn(text string) bool {
|
||||
return privateKeyPEM.MatchString(text) || strings.Contains(text, "PRIVATE KEY")
|
||||
}
|
||||
|
||||
// askerKey is an asker's public key, checked and as PEM: SPKI, X25519, and never a private key.
|
||||
func askerKey(key string) (string, error) {
|
||||
if privateKeyIn(key) {
|
||||
return "", errors.New("that is a private key, and a private key is never sent to the vault (ADR 0113 §2): send the public half")
|
||||
}
|
||||
block, rest := pem.Decode([]byte(strings.TrimSpace(key)))
|
||||
if block == nil || block.Type != "PUBLIC KEY" || strings.TrimSpace(string(rest)) != "" {
|
||||
return "", errors.New("the key to seal to is one X25519 public key as SPKI PEM (`-----BEGIN PUBLIC KEY-----`), the form of the sealed box (ADR 0206)")
|
||||
}
|
||||
k, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the key to seal to is not a public key: %w", err)
|
||||
}
|
||||
pub, ok := k.(*ecdh.PublicKey)
|
||||
if !ok || pub.Curve() != ecdh.X25519() {
|
||||
return "", errors.New("the key to seal to is not an X25519 key")
|
||||
}
|
||||
return string(pem.EncodeToMemory(block)), nil
|
||||
}
|
||||
|
||||
// checkOperatorKey takes the operator's public key as SPKI PEM — never a private key, and never a raw key,
|
||||
// which cannot be told from a private one — and gives it in the raw base64 form the operator's NaCl box and
|
||||
// `secret recover` use.
|
||||
func checkOperatorKey(key string) (string, error) {
|
||||
if privateKeyIn(key) {
|
||||
return "", errors.New("that is a private key: the operator key's private half never enters the mesh (ADR 0113 §2)")
|
||||
}
|
||||
block, rest := pem.Decode([]byte(strings.TrimSpace(key)))
|
||||
if block == nil || block.Type != "PUBLIC KEY" || strings.TrimSpace(string(rest)) != "" {
|
||||
return "", errors.New("the operator key is one X25519 public key as SPKI PEM (`-----BEGIN PUBLIC KEY-----`); " +
|
||||
"a raw key is refused, because a raw private key looks the same")
|
||||
}
|
||||
k, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the operator key is not a public key: %w", err)
|
||||
}
|
||||
pub, ok := k.(*ecdh.PublicKey)
|
||||
if !ok || pub.Curve() != ecdh.X25519() {
|
||||
return "", errors.New("the operator key is not an X25519 key")
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(pub.Bytes()), nil
|
||||
}
|
||||
|
||||
// publicPEM wraps a raw X25519 public key, base64, as SPKI PEM.
|
||||
func publicPEM(raw string) (string, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
|
||||
if err != nil || len(b) != 32 {
|
||||
return "", errors.New("not a raw X25519 key in base64")
|
||||
}
|
||||
pub, err := ecdh.X25519().NewPublicKey(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
der, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil
|
||||
}
|
||||
|
||||
// operatorSeal seals value to the operator key in the box the operator opens today.
|
||||
func operatorSeal(key string, value []byte) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return "", errors.New("the operator key is not usable")
|
||||
}
|
||||
var pub [32]byte
|
||||
copy(pub[:], raw)
|
||||
sealed, err := box.SealAnonymous(nil, value, &pub, rand.Reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package main
|
||||
|
||||
// The custody ledger (novox/hq ADR 0085, design 24), carried over from the TypeScript vault: for each pair
|
||||
// credential the controller delivers to this vault as the `secret` provider, who holds it, since when, its
|
||||
// fingerprint and every rotation — and never the value. The controller still mints and seals those until
|
||||
// the secrets move to the vault (design 57's V3, V4); the pass records what it delivered, so
|
||||
// `secret_holders` and `secret_verify` answer as before. Its files are the same JSON, read as they are.
|
||||
//
|
||||
// **A holder the mesh stops asking for is retired, never deleted by a pass** (novox/hq ADR 0230). The vault
|
||||
// is a mark-only provider: it has no access to switch off, so retiring writes the mark — when and why — into
|
||||
// the holder's own ledger file, which keeps its rotation history. A holder is retired only once the same set
|
||||
// has gone unasked in five consecutive passes that read the contributions file and for ten minutes; a set
|
||||
// over the bound (more than three, or more than half of those held) waits for a person; asked for again, a
|
||||
// retired holder is re-enabled at once; a holder is deleted only by `provisioner_delete`, a person's act
|
||||
// with a why. Because the mark is in the ledger file, a restart forgets nothing.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// isoMillis is JavaScript's toISOString, the form the ledger's times were first written in.
|
||||
const isoMillis = "2006-01-02T15:04:05.000Z"
|
||||
|
||||
// Held is one holder of a secret this vault provides: everything the vault knows of it, and no value.
|
||||
type Held struct {
|
||||
As string `json:"as"`
|
||||
Consumer string `json:"consumer"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Length int `json:"length"`
|
||||
Since string `json:"since"`
|
||||
Changed string `json:"changed"`
|
||||
Rotations int `json:"rotations"`
|
||||
History []Earlier `json:"history"`
|
||||
// Retired is the mark a retirement writes; nil while the holder is active.
|
||||
Retired *RetiredMark `json:"retired,omitempty"`
|
||||
}
|
||||
|
||||
// RetiredMark is when and why a holder was retired.
|
||||
type RetiredMark struct {
|
||||
At string `json:"at"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// Earlier is a fingerprint a rotation replaced, and when.
|
||||
type Earlier struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Until string `json:"until"`
|
||||
}
|
||||
|
||||
// Outcome is what recording a delivery found.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
Granted Outcome = "granted"
|
||||
Rotated Outcome = "rotated"
|
||||
Unchanged Outcome = "unchanged"
|
||||
)
|
||||
|
||||
// Fingerprint is sha256 of a value, as `sha256:<hex>`: the one thing about a secret that may be said.
|
||||
func Fingerprint(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Ledger is the directory of holders.
|
||||
type Ledger struct{ dir string }
|
||||
|
||||
// NewLedger opens (and makes) the ledger at dir.
|
||||
func NewLedger(dir string) (*Ledger, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("MESH_VAULT_LEDGER is not set: the vault has nowhere to keep its ledger")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Ledger{dir: dir}, nil
|
||||
}
|
||||
|
||||
var login = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
||||
|
||||
func (l *Ledger) pathOf(as string) (string, error) {
|
||||
if !login.MatchString(as) || strings.Contains(as, "..") {
|
||||
return "", fmt.Errorf("a login is a name, not a path: %q", as)
|
||||
}
|
||||
return filepath.Join(l.dir, as+".json"), nil
|
||||
}
|
||||
|
||||
// Record says the mesh delivered value for as: the same value again changes nothing, another is a rotation.
|
||||
// The value is fingerprinted here and goes no further. A retirement mark is left as it is.
|
||||
func (l *Ledger) Record(as, consumer, value string, now time.Time) (Held, Outcome, error) {
|
||||
path, err := l.pathOf(as)
|
||||
if err != nil {
|
||||
return Held{}, "", err
|
||||
}
|
||||
fp, at := Fingerprint(value), now.UTC().Format(isoMillis)
|
||||
before, ok := l.Get(as)
|
||||
if !ok {
|
||||
h := Held{As: as, Consumer: consumer, Fingerprint: fp, Length: len(value), Since: at, Changed: at, History: []Earlier{}}
|
||||
return h, Granted, l.write(path, h)
|
||||
}
|
||||
if before.Fingerprint == fp && before.Length == len(value) {
|
||||
return before, Unchanged, nil
|
||||
}
|
||||
h := before
|
||||
h.Consumer, h.Fingerprint, h.Length, h.Changed = consumer, fp, len(value), at
|
||||
h.Rotations++
|
||||
h.History = append(append([]Earlier{}, before.History...), Earlier{Fingerprint: before.Fingerprint, Until: at})
|
||||
return h, Rotated, l.write(path, h)
|
||||
}
|
||||
|
||||
// Mark writes or clears a holder's retirement mark.
|
||||
func (l *Ledger) Mark(as string, mark *RetiredMark) error {
|
||||
path, err := l.pathOf(as)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, ok := l.Get(as)
|
||||
if !ok {
|
||||
return fmt.Errorf("the ledger holds nothing for %s", as)
|
||||
}
|
||||
h.Retired = mark
|
||||
return l.write(path, h)
|
||||
}
|
||||
|
||||
// Delete forgets a holder, saying whether there was one. Only a person's `provisioner_delete` reaches it.
|
||||
func (l *Ledger) Delete(as string) bool {
|
||||
path, err := l.pathOf(as)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if os.Remove(path) != nil {
|
||||
return false
|
||||
}
|
||||
_ = syncDir(l.dir)
|
||||
return true
|
||||
}
|
||||
|
||||
// Get is one holder.
|
||||
func (l *Ledger) Get(as string) (Held, bool) {
|
||||
path, err := l.pathOf(as)
|
||||
if err != nil {
|
||||
return Held{}, false
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Held{}, false
|
||||
}
|
||||
var h Held
|
||||
if json.Unmarshal(raw, &h) != nil {
|
||||
return Held{}, false
|
||||
}
|
||||
return h, true
|
||||
}
|
||||
|
||||
// List is every holder, by login.
|
||||
func (l *Ledger) List() []Held {
|
||||
entries, err := os.ReadDir(l.dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []Held
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".json") {
|
||||
if h, ok := l.Get(strings.TrimSuffix(e.Name(), ".json")); ok {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].As < out[j].As })
|
||||
return out
|
||||
}
|
||||
|
||||
func (l *Ledger) write(path string, h Held) error {
|
||||
raw, err := json.MarshalIndent(h, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeWhole(path, append(raw, '\n'))
|
||||
}
|
||||
|
||||
// Contribution is one entry of the contributions file the mesh delivers to the vault.
|
||||
type Contribution struct {
|
||||
From string `json:"from,omitempty"`
|
||||
Node string `json:"node,omitempty"`
|
||||
As string `json:"as"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
// Contributions is what the mesh asks this vault to serve: the entries with a login and a secret file.
|
||||
//
|
||||
// **A file that does not say who is asked for is not "nobody".** One that is unreadable, not JSON, without a
|
||||
// `given` list (missing or null), or written for another requirement than `secret` is refused, so a pass
|
||||
// over it retires nobody. An empty `given` list is an answer: nobody is asked for.
|
||||
func Contributions(receives string) ([]Contribution, error) {
|
||||
raw, err := os.ReadFile(receives)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Requirement *string `json:"requirement"`
|
||||
Given json.RawMessage `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, fmt.Errorf("the contributions file is not JSON: %w", err)
|
||||
}
|
||||
if doc.Requirement != nil && *doc.Requirement != "secret" {
|
||||
return nil, fmt.Errorf("the contributions file is for %q, not for secret", *doc.Requirement)
|
||||
}
|
||||
if len(doc.Given) == 0 || string(doc.Given) == "null" {
|
||||
return nil, errors.New("the contributions file has no `given` list, so it says nothing of who is asked for")
|
||||
}
|
||||
var given []Contribution
|
||||
if err := json.Unmarshal(doc.Given, &given); err != nil {
|
||||
return nil, fmt.Errorf("the contributions file's `given` is not a list of contributions: %w", err)
|
||||
}
|
||||
var out []Contribution
|
||||
for _, g := range given {
|
||||
if g.As != "" && g.Secret != "" {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// delivered is the value the mesh delivered for c, without the newline a file may end with.
|
||||
func delivered(c Contribution) (string, error) {
|
||||
raw, err := os.ReadFile(c.Secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the delivered secret is not readable: %v", err)
|
||||
}
|
||||
return strings.TrimSuffix(string(raw), "\n"), nil
|
||||
}
|
||||
|
||||
// DeliveredFingerprint is the fingerprint and length of what the mesh currently delivers for c.
|
||||
func DeliveredFingerprint(c Contribution) (string, int, error) {
|
||||
v, err := delivered(c)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return Fingerprint(v), len(v), nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
// The custody ledger the TypeScript vault kept (its client.ts and test/ledger.test.ts), carried over whole:
|
||||
// who holds a secret the controller delivered for this vault, since when, and its fingerprint — never the
|
||||
// value. Its files are the same JSON, so the ledger on the vault's disk is read as it is.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAFirstDeliveryIsAGrantTheSameAgainNothingANewValueARotation(t *testing.T) {
|
||||
l, _ := NewLedger(t.TempDir())
|
||||
t0 := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
||||
t1 := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
held, outcome, err := l.Record("anchor-redis", "anchor", "first-value", t0)
|
||||
if err != nil || outcome != Granted || held.Rotations != 0 || held.Since != t0.Format(isoMillis) ||
|
||||
held.Fingerprint != Fingerprint("first-value") {
|
||||
t.Fatalf("%+v %s %v", held, outcome, err)
|
||||
}
|
||||
if _, outcome, _ := l.Record("anchor-redis", "anchor", "first-value", t1); outcome != Unchanged {
|
||||
t.Errorf("the same value again is %s", outcome)
|
||||
}
|
||||
held, outcome, _ = l.Record("anchor-redis", "anchor", "second-value", t1)
|
||||
if outcome != Rotated || held.Rotations != 1 || held.Since != t0.Format(isoMillis) || held.Changed != t1.Format(isoMillis) {
|
||||
t.Errorf("%+v %s", held, outcome)
|
||||
}
|
||||
if len(held.History) != 1 || held.History[0].Fingerprint != Fingerprint("first-value") || held.History[0].Until != t1.Format(isoMillis) {
|
||||
t.Errorf("history %+v", held.History)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLedgerHoldsFingerprintsNeverTheValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
l, _ := NewLedger(dir)
|
||||
_, _, _ = l.Record("anchor-redis", "anchor", "the-actual-password", time.Now())
|
||||
_, _, _ = l.Record("anchor-redis", "anchor", "the-rotated-password", time.Now())
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if strings.Contains(string(raw), "the-actual-password") || strings.Contains(string(raw), "the-rotated-password") {
|
||||
t.Errorf("%s holds a value", e.Name())
|
||||
}
|
||||
info, _ := e.Info()
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("%s is %v", e.Name(), info.Mode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletingForgetsAHolderAndListingIsByLogin(t *testing.T) {
|
||||
l, _ := NewLedger(t.TempDir())
|
||||
_, _, _ = l.Record("b-app", "b", "x", time.Now())
|
||||
_, _, _ = l.Record("a-app", "a", "y", time.Now())
|
||||
if got := logins(l.List()); got != "a-app,b-app" {
|
||||
t.Errorf("listed %s", got)
|
||||
}
|
||||
if !l.Delete("a-app") || l.Delete("a-app") {
|
||||
t.Error("deleting found the wrong thing")
|
||||
}
|
||||
if got := logins(l.List()); got != "b-app" {
|
||||
t.Errorf("listed %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALoginIsANameNotAPath(t *testing.T) {
|
||||
l, _ := NewLedger(t.TempDir())
|
||||
if _, _, err := l.Record("../etc/passwd", "n", "v", time.Now()); err == nil {
|
||||
t.Error("a path was taken for a login")
|
||||
}
|
||||
}
|
||||
|
||||
// A ledger the TypeScript vault wrote is read as it is.
|
||||
func TestALedgerTheTypeScriptVaultWroteIsRead(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
written := `{
|
||||
"as": "anchor-redis",
|
||||
"consumer": "anchor",
|
||||
"fingerprint": "sha256:abc",
|
||||
"length": 40,
|
||||
"since": "2026-09-20T10:00:00.000Z",
|
||||
"changed": "2026-09-21T10:00:00.000Z",
|
||||
"rotations": 1,
|
||||
"history": [{"fingerprint": "sha256:old", "until": "2026-09-21T10:00:00.000Z"}]
|
||||
}
|
||||
`
|
||||
_ = os.WriteFile(filepath.Join(dir, "anchor-redis.json"), []byte(written), 0o600)
|
||||
l, _ := NewLedger(dir)
|
||||
h, ok := l.Get("anchor-redis")
|
||||
if !ok || h.Rotations != 1 || h.Length != 40 || h.History[0].Fingerprint != "sha256:old" {
|
||||
t.Fatalf("%+v %v", h, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheMeshDeliversIsFingerprintedNeverReturned(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secret := filepath.Join(dir, "anchor.redis.secret")
|
||||
_ = os.WriteFile(secret, []byte("minted-value\n"), 0o600)
|
||||
receives := filepath.Join(dir, "mesh.json")
|
||||
doc, _ := json.Marshal(map[string]any{"requirement": "secret", "given": []map[string]string{
|
||||
{"from": "redis", "node": "anchor", "as": "anchor-redis", "secret": secret},
|
||||
{"from": "offer-only", "node": "anchor"},
|
||||
}})
|
||||
_ = os.WriteFile(receives, doc, 0o600)
|
||||
asked, err := Contributions(receives)
|
||||
if err != nil || len(asked) != 1 || asked[0].As != "anchor-redis" {
|
||||
t.Fatalf("%+v %v", asked, err)
|
||||
}
|
||||
fp, length, err := DeliveredFingerprint(asked[0])
|
||||
if err != nil || fp != Fingerprint("minted-value") || length != len("minted-value") {
|
||||
t.Errorf("%s %d %v", fp, length, err)
|
||||
}
|
||||
if _, _, err := DeliveredFingerprint(Contribution{As: "x", Secret: filepath.Join(dir, "missing")}); err == nil || !strings.Contains(err.Error(), "not readable") {
|
||||
t.Errorf("a missing file said %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func logins(hs []Held) string {
|
||||
var out []string
|
||||
for _, h := range hs {
|
||||
out = append(out, h.As)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// mesh-vault (novox/hq ADR 0085, ADR 0113, ADR 0349 §7 and §11): one binary, launched by its node's runtime
|
||||
// and speaking MCP to it over stdio through the Go SDK. It holds the `mesh-vault` seat: it keeps every
|
||||
// secret in its own store, sealed at rest and with a copy sealed to the operator key; it answers the seat's
|
||||
// verb `get` with a secret sealed to the asker's key; it raises `secret-changed` without a value. Beside
|
||||
// that it keeps the custody ledger of the pair credentials the controller still delivers to it.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func say(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-vault] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
func emit(event string, body map[string]any) error { return stdio.Emit(event, body) }
|
||||
|
||||
// build makes the vault from the module's words: directories, never values.
|
||||
func build() *Env {
|
||||
env := &Env{Receives: os.Getenv("MESH_RECEIVES"), Root: os.Getenv("MESH_VAULT_ROOT")}
|
||||
if l, err := NewLedger(os.Getenv("MESH_VAULT_LEDGER")); err == nil {
|
||||
env.Ledger = l
|
||||
} else {
|
||||
say("%v", err)
|
||||
}
|
||||
if env.Ledger != nil && env.Receives != "" {
|
||||
env.Custody = &Custody{Ledger: env.Ledger, Receives: env.Receives, Resource: "secret", Node: os.Getenv("MESH_NODE"),
|
||||
Emit: emit, Now: time.Now, Log: say}
|
||||
}
|
||||
store, err := OpenStore(os.Getenv("MESH_VAULT_STORE"))
|
||||
if err != nil {
|
||||
env.Broken = fmt.Errorf("the vault could not open its store: %w", err)
|
||||
say("%v", env.Broken)
|
||||
return env
|
||||
}
|
||||
own, err := OwnKey(os.Getenv("MESH_VAULT_STATE"), store)
|
||||
if err != nil {
|
||||
env.Broken = fmt.Errorf("the vault could not open its key: %w", err)
|
||||
say("%v", env.Broken)
|
||||
return env
|
||||
}
|
||||
// Askers is left nil: the vault that runs answers nobody until who may ask arrives (askers.go).
|
||||
env.Vault = &Vault{Store: store, Own: own, Emit: emit, Now: time.Now, Log: say}
|
||||
return env
|
||||
}
|
||||
|
||||
// adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what
|
||||
// `operator key set` recorded; the controller keeps only a public half there), and seals again any
|
||||
// operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished.
|
||||
func adoptOperatorKey(env *Env) {
|
||||
if env.Vault == nil {
|
||||
return
|
||||
}
|
||||
if raw := exportedOperatorKey(env.Root); env.Root != "" && raw != "" {
|
||||
key, err := publicPEM(raw)
|
||||
if err == nil {
|
||||
_, err = env.Vault.SetOperatorKey(key, "the mesh's export")
|
||||
}
|
||||
env.refuseKey("")
|
||||
if err != nil {
|
||||
env.refuseKey(err.Error())
|
||||
say("the operator key in the mesh's export is not taken, or not every copy is sealed to it yet: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := env.Vault.Reseal(); err != nil {
|
||||
say("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
env := build()
|
||||
go func() {
|
||||
tick := time.NewTicker(time.Minute)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
adoptOperatorKey(env)
|
||||
if env.Custody != nil {
|
||||
env.Custody.Pass()
|
||||
}
|
||||
<-tick.C
|
||||
}
|
||||
}()
|
||||
if err := stdio.Serve("", tools(env)); err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package main
|
||||
|
||||
// Retirement of a holder the mesh stops asking for (novox/hq ADR 0230), in the words the controller reads:
|
||||
// the `provisioner.retirement` event as mesh-controller's link.Retirement, the `provisioner_retirement`
|
||||
// answer as link.RetirementState, and the acts as the SDK's harness takes them (mesh-sdk
|
||||
// src/provisioner/index.ts) — the set repeated in `consumers`, a `why`, and the person's `by` and the
|
||||
// `via` the controller's verbs say. testdata/retirement-*.json are those types marshalled by the controller
|
||||
// at the commit its README names, and the tests hold this file's words to them.
|
||||
//
|
||||
// The vault is mark-only: it holds no access to switch off, so a retired holder keeps what it has until a
|
||||
// person deletes it, and the mark — when and why — is in the holder's own ledger file, so a restart forgets
|
||||
// nothing.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventRetirement is the one event a provider says about retirement (ADR 0230 §35).
|
||||
const EventRetirement = "provisioner.retirement"
|
||||
|
||||
// The rules of ADR 0230 §15 and §18.
|
||||
const (
|
||||
StablePasses = 5
|
||||
StableFor = 10 * time.Minute
|
||||
RetireAtOnce = 3
|
||||
RetireFraction = 0.5
|
||||
SayAgainEvery = time.Hour
|
||||
)
|
||||
|
||||
// RetiredConsumer is one holder in a retirement word or answer (link.RetiredConsumer).
|
||||
type RetiredConsumer struct {
|
||||
Consumer string `json:"consumer"`
|
||||
Node string `json:"node,omitempty"`
|
||||
RetiredAt string `json:"retired_at,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
SizeBytes *int64 `json:"size_bytes,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Access string `json:"access,omitempty"`
|
||||
}
|
||||
|
||||
// RetirementWord is the event's body (link.Retirement).
|
||||
type RetirementWord struct {
|
||||
Provider string `json:"provider"`
|
||||
ProviderNode string `json:"provider-node"`
|
||||
Change string `json:"change"`
|
||||
Consumers []RetiredConsumer `json:"consumers"`
|
||||
Held int `json:"held"`
|
||||
Bound string `json:"bound,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
Via string `json:"via,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Since *time.Time `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// RetirementWaiting, RetirementRejected and RetirementState are the answer to provisioner_retirement
|
||||
// (link.RetirementWaiting, link.RetirementRejected, link.RetirementState).
|
||||
type RetirementWaiting struct {
|
||||
Consumers []RetiredConsumer `json:"consumers"`
|
||||
Since string `json:"since"`
|
||||
Held int `json:"held"`
|
||||
}
|
||||
|
||||
type RetirementRejected struct {
|
||||
Consumers []RetiredConsumer `json:"consumers"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
type RetirementState struct {
|
||||
Resource string `json:"resource"`
|
||||
Node string `json:"node"`
|
||||
Held []string `json:"held"`
|
||||
StablePasses int `json:"stable_passes"`
|
||||
StableForSeconds int `json:"stable_for_seconds,omitempty"`
|
||||
RetiresBy string `json:"retires_by,omitempty"`
|
||||
Bound string `json:"bound"`
|
||||
Waiting *RetirementWaiting `json:"waiting"`
|
||||
Rejected *RetirementRejected `json:"rejected"`
|
||||
Retired []RetiredConsumer `json:"retired"`
|
||||
}
|
||||
|
||||
// Act is a person's act on retirement: why, who (the person the controller proved) and through what.
|
||||
type Act struct {
|
||||
Why, By, Via string
|
||||
}
|
||||
|
||||
// Custody is the pass over the contributions file that keeps the ledger, and what it remembers between
|
||||
// passes about the holders the mesh no longer asks for.
|
||||
type Custody struct {
|
||||
Ledger *Ledger
|
||||
Receives string
|
||||
// Resource is the provision the vault provides; Node the machine it runs on.
|
||||
Resource string
|
||||
Node string
|
||||
Emit func(event string, body map[string]any) error
|
||||
Now func() time.Time
|
||||
Log func(format string, args ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
key string
|
||||
count int
|
||||
firstSeen time.Time
|
||||
asked map[string]bool
|
||||
read bool
|
||||
waiting *heldSet
|
||||
rejected *heldSet
|
||||
}
|
||||
|
||||
type heldSet struct {
|
||||
set []string
|
||||
since time.Time
|
||||
saidAt time.Time
|
||||
held int
|
||||
by, why string
|
||||
}
|
||||
|
||||
func (c *Custody) resource() string {
|
||||
if c.Resource == "" {
|
||||
return "secret"
|
||||
}
|
||||
return c.Resource
|
||||
}
|
||||
|
||||
// Pass records every delivered value, re-enables a retired holder asked for again, and counts the holders
|
||||
// no longer asked for toward their retirement.
|
||||
func (c *Custody) Pass() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
asked, err := Contributions(c.Receives)
|
||||
if err != nil {
|
||||
c.Log("THE CONTRIBUTIONS FILE IS NOT USABLE (%s): %v — nothing is recorded or retired on this pass, and "+
|
||||
"the count toward retiring anyone starts again", c.Receives, err)
|
||||
c.key, c.count = "", 0
|
||||
return
|
||||
}
|
||||
now := c.Now()
|
||||
keep := map[string]bool{}
|
||||
for _, a := range asked {
|
||||
keep[a.As] = true
|
||||
value, err := delivered(a)
|
||||
if err != nil {
|
||||
c.Log("%s: %v", a.As, err)
|
||||
continue
|
||||
}
|
||||
held, outcome, err := c.Ledger.Record(a.As, a.Node, value, now)
|
||||
if err != nil {
|
||||
c.Log("%s: %v", a.As, err)
|
||||
continue
|
||||
}
|
||||
if held.Retired != nil {
|
||||
if err := c.Ledger.Mark(a.As, nil); err != nil {
|
||||
c.Log("%s: asked for again, and its retirement mark could not be cleared: %v", a.As, err)
|
||||
} else {
|
||||
c.Log("%s: asked for again — re-enabled, its retirement mark cleared", a.As)
|
||||
c.tell(RetirementWord{Change: "reenabled", Consumers: []RetiredConsumer{{Consumer: a.As, Node: a.Node}}}, now)
|
||||
}
|
||||
}
|
||||
if outcome == Unchanged {
|
||||
continue
|
||||
}
|
||||
event := "provisioned"
|
||||
if outcome == Rotated {
|
||||
event = "rotated"
|
||||
}
|
||||
c.Log("%s: %s (%s…, rotations %d)", held.As, outcome, held.Fingerprint[:19], held.Rotations)
|
||||
c.say(event, map[string]any{"consumer": held.Consumer, "as": held.As, "fingerprint": held.Fingerprint, "rotations": held.Rotations})
|
||||
}
|
||||
c.asked, c.read = keep, true
|
||||
c.countUnasked(now)
|
||||
}
|
||||
|
||||
func (c *Custody) active() []Held {
|
||||
var out []Held
|
||||
for _, h := range c.Ledger.List() {
|
||||
if h.Retired == nil {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Custody) countUnasked(now time.Time) {
|
||||
active := c.active()
|
||||
var unasked []string
|
||||
for _, h := range active {
|
||||
if !c.asked[h.As] {
|
||||
unasked = append(unasked, h.As)
|
||||
}
|
||||
}
|
||||
key := strings.Join(unasked, ",")
|
||||
if c.waiting != nil && strings.Join(c.waiting.set, ",") != key {
|
||||
c.settle(c.waiting, "the set the mesh no longer asks for changed", now)
|
||||
c.waiting = nil
|
||||
}
|
||||
if c.rejected != nil && strings.Join(c.rejected.set, ",") != key {
|
||||
c.settle(c.rejected, "the set the mesh no longer asks for changed", now)
|
||||
c.rejected = nil
|
||||
}
|
||||
switch {
|
||||
case len(unasked) == 0:
|
||||
c.key, c.count = "", 0
|
||||
return
|
||||
case key != c.key:
|
||||
c.key, c.count, c.firstSeen = key, 1, now
|
||||
c.Log("no longer asked for: %s; retired once the same set holds for %d passes and %s (novox/hq ADR 0230)",
|
||||
key, StablePasses, StableFor)
|
||||
default:
|
||||
c.count++
|
||||
}
|
||||
if c.rejected != nil || c.count < StablePasses || now.Sub(c.firstSeen) < StableFor {
|
||||
return
|
||||
}
|
||||
if overTheBound(len(unasked), len(active)) {
|
||||
c.wait(unasked, len(active), now)
|
||||
return
|
||||
}
|
||||
c.retire(unasked, len(active), fmt.Sprintf("the mesh stopped asking for it: the same answer in %d passes over %s, from %s",
|
||||
c.count, now.Sub(c.firstSeen).Round(time.Second), c.firstSeen.UTC().Format(time.RFC3339)), now)
|
||||
}
|
||||
|
||||
func overTheBound(n, held int) bool {
|
||||
return n > RetireAtOnce || (held > 1 && float64(n) > RetireFraction*float64(held))
|
||||
}
|
||||
|
||||
func bound(held int) string {
|
||||
return fmt.Sprintf("more than %d, or more than %d%% of the %d held", RetireAtOnce, int(RetireFraction*100), held)
|
||||
}
|
||||
|
||||
func (c *Custody) wait(set []string, held int, now time.Time) {
|
||||
if c.waiting == nil {
|
||||
c.waiting = &heldSet{set: set, since: now, held: held, saidAt: now}
|
||||
c.Log("RETIREMENT WAITS FOR A PERSON: the mesh no longer asks for %d of the %d holder(s) (%s), %s. "+
|
||||
"Nothing is retired until `retire approve` or `retire reject` (novox/hq ADR 0230)",
|
||||
len(set), held, strings.Join(set, ", "), bound(held))
|
||||
} else if now.Sub(c.waiting.saidAt) < SayAgainEvery {
|
||||
return
|
||||
} else {
|
||||
c.waiting.saidAt = now
|
||||
}
|
||||
since := c.waiting.since.UTC()
|
||||
c.tell(RetirementWord{Change: "waiting", Consumers: c.named(set), Held: c.waiting.held, Since: &since}, now)
|
||||
}
|
||||
|
||||
func (c *Custody) settle(s *heldSet, why string, now time.Time) {
|
||||
c.Log("the set waiting on retirement (%s) is settled, nothing retired: %s", strings.Join(s.set, ", "), why)
|
||||
c.tell(RetirementWord{Change: "settled", Consumers: c.named(s.set), Why: why}, now)
|
||||
}
|
||||
|
||||
// retire marks each holder in set retired: the ledger keeps it, and its history, until a person deletes it.
|
||||
func (c *Custody) retire(set []string, held int, why string, now time.Time) []string {
|
||||
at := now.UTC().Format(isoMillis)
|
||||
var done []string
|
||||
for _, as := range set {
|
||||
if err := c.Ledger.Mark(as, &RetiredMark{At: at, Why: why}); err != nil {
|
||||
c.Log("%s: could not be marked retired, asked again once its set is stable again: %v", as, err)
|
||||
continue
|
||||
}
|
||||
done = append(done, as)
|
||||
}
|
||||
c.key, c.count = "", 0
|
||||
if len(done) == 0 {
|
||||
return done
|
||||
}
|
||||
c.Log("%s: RETIRED, MARK ONLY — the vault holds no access to switch off, so each keeps what it holds until a "+
|
||||
"person deletes it with `cleanup delete` (novox/hq ADR 0230): %s", strings.Join(done, ", "), why)
|
||||
c.tell(RetirementWord{Change: "retired", Consumers: c.retiredOnes(done), Held: held, Why: why}, now)
|
||||
return done
|
||||
}
|
||||
|
||||
// named is each holder as the controller's words name it.
|
||||
func (c *Custody) named(set []string) []RetiredConsumer {
|
||||
out := make([]RetiredConsumer, 0, len(set))
|
||||
for _, as := range set {
|
||||
h, _ := c.Ledger.Get(as)
|
||||
out = append(out, RetiredConsumer{Consumer: as, Node: h.Consumer})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Custody) retiredOnes(set []string) []RetiredConsumer {
|
||||
out := make([]RetiredConsumer, 0, len(set))
|
||||
for _, as := range set {
|
||||
h, _ := c.Ledger.Get(as)
|
||||
out = append(out, retiredOne(h))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func retiredOne(h Held) RetiredConsumer {
|
||||
unknown := int64(-1)
|
||||
r := RetiredConsumer{Consumer: h.As, Node: h.Consumer, SizeBytes: &unknown, Kind: "consumer", Access: "kept"}
|
||||
if h.Retired != nil {
|
||||
r.RetiredAt, r.Why = h.Retired.At, h.Retired.Why
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func sameSet(a, b []string) bool {
|
||||
x, y := append([]string{}, a...), append([]string{}, b...)
|
||||
sort.Strings(x)
|
||||
sort.Strings(y)
|
||||
return strings.Join(x, ",") == strings.Join(y, ",")
|
||||
}
|
||||
|
||||
// Approve retires exactly the set waiting (or rejected) for a person, repeated in consumers; any other set
|
||||
// is refused (ADR 0230 §20).
|
||||
func (c *Custody) Approve(consumers []string, act Act) ([]string, error) {
|
||||
if strings.TrimSpace(act.Why) == "" {
|
||||
return nil, errors.New("approve: why is required")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
s := c.waiting
|
||||
if s == nil {
|
||||
s = c.rejected
|
||||
}
|
||||
if s == nil || !sameSet(s.set, consumers) {
|
||||
return nil, fmt.Errorf("approve: nothing waits with exactly that set (%s); waiting: %s; rejected: %s. Look again",
|
||||
strings.Join(consumers, ", "), setOf(c.waiting), setOf(c.rejected))
|
||||
}
|
||||
now := c.Now()
|
||||
held := len(c.active())
|
||||
c.Log("retirement of %s approved by %s: %s", strings.Join(s.set, ", "), someone(act.By), act.Why)
|
||||
c.tell(RetirementWord{Change: "approved", Consumers: c.named(s.set), Held: held, Why: act.Why, By: act.By, Via: act.Via}, now)
|
||||
set := s.set
|
||||
c.waiting, c.rejected = nil, nil
|
||||
return c.retire(set, held, "approved by "+someone(act.By)+": "+act.Why, now), nil
|
||||
}
|
||||
|
||||
// Reject keeps the waiting set active, repeated in consumers, and asks no more about it while it stays the
|
||||
// same set.
|
||||
func (c *Custody) Reject(consumers []string, act Act) ([]string, error) {
|
||||
if strings.TrimSpace(act.Why) == "" {
|
||||
return nil, errors.New("reject: why is required")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.waiting == nil || !sameSet(c.waiting.set, consumers) {
|
||||
return nil, fmt.Errorf("reject: nothing waits with exactly that set (%s); waiting: %s. Look again",
|
||||
strings.Join(consumers, ", "), setOf(c.waiting))
|
||||
}
|
||||
now := c.Now()
|
||||
c.rejected = &heldSet{set: c.waiting.set, since: now, by: act.By, why: act.Why}
|
||||
c.waiting = nil
|
||||
c.Log("retirement of %s rejected by %s: %s; kept active while the mesh says the same",
|
||||
strings.Join(c.rejected.set, ", "), someone(act.By), act.Why)
|
||||
c.tell(RetirementWord{Change: "rejected", Consumers: c.named(c.rejected.set), Why: act.Why, By: act.By, Via: act.Via}, now)
|
||||
return c.rejected.set, nil
|
||||
}
|
||||
|
||||
// Delete forgets one retired holder on a person's word, its name repeated in confirm. Refused: before a
|
||||
// pass has read the contributions file, a holder that is not retired, one the contributions file asks for
|
||||
// now, and any act without a why or a matching confirm.
|
||||
func (c *Custody) Delete(consumer, confirm string, act Act) error {
|
||||
if strings.TrimSpace(act.Why) == "" {
|
||||
return errors.New("delete: why is required")
|
||||
}
|
||||
if confirm != consumer {
|
||||
return errors.New("delete: confirm must repeat the consumer's name")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.read {
|
||||
return errors.New("delete: no pass has read the contributions file yet, so whether the mesh asks for it is not known; try again in a minute")
|
||||
}
|
||||
h, ok := c.Ledger.Get(consumer)
|
||||
if !ok {
|
||||
return fmt.Errorf("delete: %s is not held here", consumer)
|
||||
}
|
||||
if h.Retired == nil {
|
||||
return fmt.Errorf("delete: %s is active, not retired — only a retired holder is deleted", consumer)
|
||||
}
|
||||
asked, err := Contributions(c.Receives)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: the contributions file cannot say whether the mesh asks for %s: %v", consumer, err)
|
||||
}
|
||||
for _, a := range asked {
|
||||
if a.As == consumer {
|
||||
return fmt.Errorf("delete: the mesh asks for %s again; the next pass re-enables it", consumer)
|
||||
}
|
||||
}
|
||||
if !c.Ledger.Delete(consumer) {
|
||||
return fmt.Errorf("delete: %s could not be deleted", consumer)
|
||||
}
|
||||
c.Log("%s: DELETED from the ledger, asked by %s: %s", consumer, someone(act.By), act.Why)
|
||||
unknown := int64(-1)
|
||||
c.tell(RetirementWord{Change: "deleted", Consumers: []RetiredConsumer{{Consumer: consumer, Node: h.Consumer, SizeBytes: &unknown}},
|
||||
Why: act.Why, By: act.By, Via: act.Via}, c.Now())
|
||||
return nil
|
||||
}
|
||||
|
||||
func someone(by string) string {
|
||||
if by == "" {
|
||||
return "someone"
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
func setOf(s *heldSet) string {
|
||||
if s == nil || len(s.set) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(s.set, ", ")
|
||||
}
|
||||
|
||||
// Retirement is the answer to provisioner_retirement.
|
||||
func (c *Custody) Retirement() RetirementState {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
st := RetirementState{Resource: c.resource(), Node: c.Node, Held: []string{}, StablePasses: StablePasses,
|
||||
StableForSeconds: int(StableFor.Seconds()), RetiresBy: "mark-only", Retired: []RetiredConsumer{}}
|
||||
for _, h := range c.Ledger.List() {
|
||||
if h.Retired == nil {
|
||||
st.Held = append(st.Held, h.As)
|
||||
} else {
|
||||
st.Retired = append(st.Retired, retiredOne(h))
|
||||
}
|
||||
}
|
||||
st.Bound = bound(len(st.Held))
|
||||
if c.waiting != nil {
|
||||
st.Waiting = &RetirementWaiting{Consumers: c.named(c.waiting.set),
|
||||
Since: c.waiting.since.UTC().Format(time.RFC3339), Held: c.waiting.held}
|
||||
}
|
||||
if c.rejected != nil {
|
||||
st.Rejected = &RetirementRejected{Consumers: c.named(c.rejected.set), By: c.rejected.by, Why: c.rejected.why,
|
||||
At: c.rejected.since.UTC().Format(time.RFC3339)}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// tell says one retirement word, with the provider, its machine, the bound and the time filled in.
|
||||
func (c *Custody) tell(w RetirementWord, now time.Time) {
|
||||
w.Provider, w.ProviderNode, w.At = c.resource(), c.Node, now.UTC()
|
||||
if w.Bound == "" {
|
||||
w.Bound = bound(len(c.active()))
|
||||
}
|
||||
if w.Consumers == nil {
|
||||
w.Consumers = []RetiredConsumer{}
|
||||
}
|
||||
c.say(EventRetirement, wordBody(w))
|
||||
}
|
||||
|
||||
// wordBody is a word as the map the SDK's Emit takes, through its JSON, so the bus carries exactly the tags.
|
||||
func wordBody(w RetirementWord) map[string]any {
|
||||
raw, _ := json.Marshal(w)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func (c *Custody) say(event string, body map[string]any) {
|
||||
if c.Emit == nil {
|
||||
return
|
||||
}
|
||||
if err := c.Emit(event, body); err != nil {
|
||||
c.Log("%s could not be said: %v", event, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package main
|
||||
|
||||
// ADR 0230 in the vault's custody ledger, and its words held to the controller's: testdata/retirement-*.json
|
||||
// are mesh-controller's link.Retirement and link.RetirementState marshalled at dc36536 (feat/57, with #317).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// custodyAt is a custody pass over a contributions file in dir, on a clock the test moves.
|
||||
type custodyAt struct {
|
||||
*Custody
|
||||
bus *published
|
||||
receives string
|
||||
dir string
|
||||
clock *time.Time
|
||||
}
|
||||
|
||||
func newCustody(t *testing.T) custodyAt {
|
||||
dir := t.TempDir()
|
||||
l, _ := NewLedger(filepath.Join(dir, "ledger"))
|
||||
bus := &published{}
|
||||
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
|
||||
c := custodyAt{bus: bus, receives: filepath.Join(dir, "mesh.json"), dir: dir, clock: &clock}
|
||||
c.Custody = &Custody{Ledger: l, Receives: c.receives, Resource: "secret", Node: "novox", Emit: bus.emit,
|
||||
Now: func() time.Time { return *c.clock }, Log: t.Logf}
|
||||
return c
|
||||
}
|
||||
|
||||
// ask writes a contributions file asking for these logins, each with its value.
|
||||
func (c custodyAt) ask(values map[string]string) {
|
||||
given := []map[string]string{}
|
||||
for as, v := range values {
|
||||
f := filepath.Join(c.dir, as+".secret")
|
||||
_ = os.WriteFile(f, []byte(v), 0o600)
|
||||
given = append(given, map[string]string{"node": "anchor", "as": as, "secret": f})
|
||||
}
|
||||
doc, _ := json.Marshal(map[string]any{"requirement": "secret", "given": given})
|
||||
_ = os.WriteFile(c.receives, doc, 0o600)
|
||||
}
|
||||
|
||||
// apps is n holders named a-app, b-app, …
|
||||
func apps(n int) map[string]string {
|
||||
out := map[string]string{}
|
||||
for i := 0; i < n; i++ {
|
||||
out[fmt.Sprintf("%c-app", 'a'+i)] = fmt.Sprint(i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func keepOnly(all map[string]string, n int) map[string]string {
|
||||
names := make([]string, 0, len(all))
|
||||
for k := range all {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := map[string]string{}
|
||||
for _, k := range names[:n] {
|
||||
out[k] = all[k]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// passes runs n passes a minute apart.
|
||||
func (c custodyAt) passes(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
c.Pass()
|
||||
*c.clock = c.clock.Add(time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
// stableFor runs StablePasses passes over an hour: past both of ADR 0230's conditions.
|
||||
func (c custodyAt) stable() {
|
||||
for i := 0; i < StablePasses; i++ {
|
||||
c.Pass()
|
||||
*c.clock = c.clock.Add(15 * time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
func (c custodyAt) changes() []string {
|
||||
var out []string
|
||||
for _, e := range c.bus.events {
|
||||
if e["event"] == EventRetirement {
|
||||
out = append(out, e["body"].(map[string]any)["change"].(string))
|
||||
} else {
|
||||
out = append(out, e["event"].(string))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c custodyAt) retired(as string) bool {
|
||||
h, _ := c.Ledger.Get(as)
|
||||
return h.Retired != nil
|
||||
}
|
||||
|
||||
func waitingNames(st RetirementState) []string {
|
||||
if st.Waiting == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, w := range st.Waiting.Consumers {
|
||||
out = append(out, w.Consumer)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func act(why string) Act { return Act{Why: why, By: "person.jochen", Via: "mesh-controller"} }
|
||||
|
||||
// ---- the controller's words ----------------------------------------------------------------------------
|
||||
|
||||
// paths is every key path in a JSON document, arrays read through their first element.
|
||||
func paths(t *testing.T, raw []byte) map[string]bool {
|
||||
t.Helper()
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
var walk func(prefix string, v any)
|
||||
walk = func(prefix string, v any) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
for k, e := range x {
|
||||
out[prefix+k] = true
|
||||
walk(prefix+k+".", e)
|
||||
}
|
||||
case []any:
|
||||
if len(x) > 0 {
|
||||
walk(prefix, x[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("", v)
|
||||
return out
|
||||
}
|
||||
|
||||
// sameWords says every key the vault says is a key the controller's type has, and every key the controller
|
||||
// needs is said.
|
||||
func sameWords(t *testing.T, what string, ours []byte, golden string, needed ...string) {
|
||||
t.Helper()
|
||||
theirs, err := os.ReadFile(filepath.Join("testdata", golden))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := paths(t, theirs)
|
||||
got := paths(t, ours)
|
||||
for p := range got {
|
||||
if !want[p] {
|
||||
t.Errorf("%s says %q, which the controller's type has no field for", what, p)
|
||||
}
|
||||
}
|
||||
for _, p := range needed {
|
||||
if !got[p] {
|
||||
t.Errorf("%s does not say %q, which the controller reads", what, p)
|
||||
}
|
||||
}
|
||||
// And decoding strictly into the shape of the controller's golden document's own Go type would succeed:
|
||||
// the same kinds at the same keys.
|
||||
var g, o any
|
||||
_ = json.Unmarshal(theirs, &g)
|
||||
_ = json.Unmarshal(ours, &o)
|
||||
sameKinds(t, what, "", g, o)
|
||||
}
|
||||
|
||||
func sameKinds(t *testing.T, what, at string, want, got any) {
|
||||
switch w := want.(type) {
|
||||
case map[string]any:
|
||||
g, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
if got != nil {
|
||||
t.Errorf("%s: %q is %T, the controller's is an object", what, at, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
for k, v := range g {
|
||||
if wv, known := w[k]; known {
|
||||
sameKinds(t, what, at+"."+k, wv, v)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
g, ok := got.([]any)
|
||||
if !ok {
|
||||
if got != nil {
|
||||
t.Errorf("%s: %q is %T, the controller's is a list", what, at, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(w) > 0 {
|
||||
for _, e := range g {
|
||||
sameKinds(t, what, at+"[]", w[0], e)
|
||||
}
|
||||
}
|
||||
default:
|
||||
if got != nil && fmt.Sprintf("%T", want) != fmt.Sprintf("%T", got) {
|
||||
t.Errorf("%s: %q is %T, the controller's is %T", what, at, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every retirement word the vault says is one the controller's link.ReadRetirement reads: its machine
|
||||
// named, its change known, its consumers objects.
|
||||
func TestTheRetirementWordsAreTheControllers(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
all := apps(5)
|
||||
c.ask(all)
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(all, 1))
|
||||
c.stable() // waiting: four of five
|
||||
st := c.Retirement()
|
||||
if _, err := c.Reject(waitingNames(st), act("kept for now")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Approve(waitingNames(st), act("they are gone")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.passes(1)
|
||||
if err := c.Delete("b-app", "b-app", act("gone for good")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.ask(all)
|
||||
c.passes(1) // re-enabled
|
||||
seen := map[string]bool{}
|
||||
for i, e := range c.bus.events {
|
||||
if e["event"] != EventRetirement {
|
||||
continue
|
||||
}
|
||||
raw := []byte(c.bus.raw[i])
|
||||
var outer struct {
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &outer)
|
||||
var w struct {
|
||||
Change string `json:"change"`
|
||||
ProviderNode string `json:"provider-node"`
|
||||
Consumers []struct {
|
||||
Consumer string `json:"consumer"`
|
||||
} `json:"consumers"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(outer.Body))
|
||||
if err := dec.Decode(&w); err != nil {
|
||||
t.Fatalf("a retirement word the controller cannot read: %v: %s", err, outer.Body)
|
||||
}
|
||||
if w.ProviderNode != "novox" || len(w.Consumers) == 0 || w.Consumers[0].Consumer == "" {
|
||||
t.Errorf("a %s word names no machine or no consumer: %s", w.Change, outer.Body)
|
||||
}
|
||||
seen[w.Change] = true
|
||||
sameWords(t, "the "+w.Change+" word", outer.Body, "retirement-word.json",
|
||||
"provider", "provider-node", "change", "consumers", "consumers.consumer", "at")
|
||||
}
|
||||
for _, change := range []string{"waiting", "rejected", "approved", "retired", "deleted", "reenabled"} {
|
||||
if !seen[change] {
|
||||
t.Errorf("no %s word was said", change)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The answer to provisioner_retirement is the controller's RetirementState, so `retire approve`, `retire
|
||||
// reject` and `cleanup list` read the vault.
|
||||
func TestTheRetirementAnswerIsTheControllers(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
all := apps(5)
|
||||
c.ask(all)
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(all, 1))
|
||||
c.stable()
|
||||
raw, _ := json.Marshal(c.Retirement())
|
||||
sameWords(t, "the waiting answer", raw, "retirement-state.json", "resource", "node", "held", "stable_passes",
|
||||
"retires_by", "bound", "waiting", "waiting.consumers", "waiting.consumers.consumer", "waiting.since", "retired")
|
||||
if _, err := c.Approve(waitingNames(c.Retirement()), act("gone")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ = json.Marshal(c.Retirement())
|
||||
sameWords(t, "the retired answer", raw, "retirement-state.json", "retired.consumer", "retired.retired_at",
|
||||
"retired.why", "retired.access")
|
||||
st := c.Retirement()
|
||||
if st.RetiresBy != "mark-only" || st.Resource != "secret" || st.Node != "novox" || len(st.Retired) != 4 ||
|
||||
st.Retired[0].Access != "kept" {
|
||||
t.Errorf("the answer is %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the rules, one at a time ---------------------------------------------------------------------------
|
||||
|
||||
func TestTheCustodyPassRecordsAndRotates(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
c.ask(map[string]string{"anchor-redis": "v1"})
|
||||
c.passes(1)
|
||||
c.ask(map[string]string{"anchor-redis": "v2"})
|
||||
c.passes(1)
|
||||
if got := strings.Join(c.changes(), ","); got != "provisioned,rotated" {
|
||||
t.Errorf("announced %s", got)
|
||||
}
|
||||
for _, raw := range c.bus.raw {
|
||||
if strings.Contains(raw, `"v1"`) || strings.Contains(raw, `"v2"`) {
|
||||
t.Errorf("announced a value: %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A contributions file that does not say who is asked for is no answer: nobody is retired.
|
||||
func TestAFileThatSaysNothingRetiresNobody(t *testing.T) {
|
||||
for _, doc := range []string{"{not json", `{"requirement":"secret"}`, `{"requirement":"secret","given":null}`,
|
||||
`{"requirement":"redis-cache","given":[]}`} {
|
||||
c := newCustody(t)
|
||||
c.ask(map[string]string{"anchor-redis": "v1"})
|
||||
c.passes(1)
|
||||
_ = os.WriteFile(c.receives, []byte(doc), 0o600)
|
||||
c.passes(30)
|
||||
if h, ok := c.Ledger.Get("anchor-redis"); !ok || h.Retired != nil {
|
||||
t.Errorf("%s: the holder was retired or lost: %+v %v", doc, h, ok)
|
||||
}
|
||||
if _, err := Contributions(c.receives); err == nil {
|
||||
t.Errorf("%s was read as an answer", doc)
|
||||
}
|
||||
}
|
||||
if _, err := Contributions(filepath.Join(t.TempDir(), "missing.json")); err == nil {
|
||||
t.Error("a missing file was read as an answer")
|
||||
}
|
||||
}
|
||||
|
||||
// Five passes, not four, however much time has passed.
|
||||
func TestRetiringTakesFivePasses(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
c.ask(apps(3))
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(3), 2))
|
||||
for i := 0; i < StablePasses-1; i++ {
|
||||
c.Pass()
|
||||
*c.clock = c.clock.Add(time.Hour)
|
||||
}
|
||||
if c.retired("c-app") {
|
||||
t.Fatal("retired after four passes")
|
||||
}
|
||||
c.Pass()
|
||||
if !c.retired("c-app") {
|
||||
t.Fatal("not retired after five passes and hours")
|
||||
}
|
||||
}
|
||||
|
||||
// Ten minutes, not less, however many passes.
|
||||
func TestRetiringTakesTenMinutes(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
c.ask(apps(3))
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(3), 2))
|
||||
for i := 0; i < 20; i++ {
|
||||
c.Pass()
|
||||
*c.clock = c.clock.Add(25 * time.Second)
|
||||
}
|
||||
if c.retired("c-app") {
|
||||
t.Fatal("retired before ten minutes")
|
||||
}
|
||||
*c.clock = c.clock.Add(5 * time.Minute)
|
||||
c.Pass()
|
||||
if !c.retired("c-app") {
|
||||
t.Fatal("not retired after twenty passes and ten minutes")
|
||||
}
|
||||
}
|
||||
|
||||
// More than three at once waits, though under half: 4 of 10.
|
||||
func TestMoreThanThreeWaits(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
c.ask(apps(10))
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(10), 6))
|
||||
c.stable()
|
||||
if got := waitingNames(c.Retirement()); len(got) != 4 {
|
||||
t.Fatalf("four of ten did not wait: %v", got)
|
||||
}
|
||||
if c.retired("j-app") {
|
||||
t.Fatal("retired while waiting")
|
||||
}
|
||||
}
|
||||
|
||||
// More than half waits, though three or fewer: 2 of 3.
|
||||
func TestMoreThanHalfWaits(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
c.ask(apps(3))
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(3), 1))
|
||||
c.stable()
|
||||
if got := waitingNames(c.Retirement()); len(got) != 2 {
|
||||
t.Fatalf("two of three did not wait: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Within both bounds it retires without a person: 3 of 7.
|
||||
func TestWithinTheBoundRetires(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
c.ask(apps(7))
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(7), 4))
|
||||
c.stable()
|
||||
if c.Retirement().Waiting != nil || !c.retired("g-app") {
|
||||
t.Fatal("three of seven did not retire")
|
||||
}
|
||||
}
|
||||
|
||||
// A holder is retired, never deleted by a pass, keeps its history, and is re-enabled when asked again.
|
||||
func TestAHolderNoLongerAskedForIsRetiredNotDeleted(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
c.ask(apps(3))
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(3), 2))
|
||||
c.stable()
|
||||
h, ok := c.Ledger.Get("c-app")
|
||||
if !ok || h.Retired == nil || h.Retired.Why == "" || h.Fingerprint != Fingerprint("2") {
|
||||
t.Fatalf("%+v %v", h, ok)
|
||||
}
|
||||
c.ask(apps(3))
|
||||
c.Pass()
|
||||
if c.retired("c-app") {
|
||||
t.Error("asked for again, the holder stayed retired")
|
||||
}
|
||||
if got := strings.Join(c.changes(), ","); got != "provisioned,provisioned,provisioned,retired,reenabled" {
|
||||
t.Errorf("announced %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A file that says nothing starts the count again.
|
||||
func TestTheCountStartsAgain(t *testing.T) {
|
||||
c := newCustody(t)
|
||||
c.ask(apps(3))
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(3), 2))
|
||||
c.passes(4)
|
||||
_ = os.WriteFile(c.receives, []byte(`{"given":null}`), 0o600)
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(3), 2))
|
||||
c.passes(4)
|
||||
*c.clock = c.clock.Add(time.Hour)
|
||||
if c.retired("c-app") {
|
||||
t.Fatal("retired though the count was broken")
|
||||
}
|
||||
c.passes(1)
|
||||
if !c.retired("c-app") {
|
||||
t.Fatal("not retired after five unbroken passes and an hour")
|
||||
}
|
||||
}
|
||||
|
||||
// Approve and reject take exactly the set waiting, and refuse any other (ADR 0230 §20).
|
||||
func TestApproveAndRejectTakeExactlyTheSetWaiting(t *testing.T) {
|
||||
for _, decide := range []string{"approve", "reject"} {
|
||||
c := newCustody(t)
|
||||
c.ask(apps(5))
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(5), 1))
|
||||
c.stable()
|
||||
set := waitingNames(c.Retirement())
|
||||
for _, wrong := range [][]string{set[:3], append(append([]string{}, set...), "a-app"), {}} {
|
||||
if _, err := c.Approve(wrong, act("x")); err == nil {
|
||||
t.Errorf("approve took the set %v", wrong)
|
||||
}
|
||||
if _, err := c.Reject(wrong, act("x")); err == nil {
|
||||
t.Errorf("reject took the set %v", wrong)
|
||||
}
|
||||
}
|
||||
if _, err := c.Approve(set, Act{}); err == nil {
|
||||
t.Error("an approval without a why was taken")
|
||||
}
|
||||
reversed := append([]string{}, set...)
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(reversed)))
|
||||
if decide == "approve" {
|
||||
done, err := c.Approve(reversed, act("the four apps are gone"))
|
||||
if err != nil || len(done) != 4 || !c.retired("b-app") || c.retired("a-app") {
|
||||
t.Fatalf("%v %v", done, err)
|
||||
}
|
||||
} else {
|
||||
if _, err := c.Reject(reversed, act("they come back")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.passes(10)
|
||||
if c.retired("b-app") || c.Retirement().Waiting != nil {
|
||||
t.Error("a rejected set was retired or asked about again")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retiredC is a custody with c-app retired, d-app active.
|
||||
func retiredC(t *testing.T) custodyAt {
|
||||
c := newCustody(t)
|
||||
c.ask(apps(4))
|
||||
c.passes(1)
|
||||
c.ask(keepOnly(apps(4), 3))
|
||||
c.stable()
|
||||
if !c.retired("d-app") {
|
||||
t.Fatal("d-app was not retired")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Each refusal of a delete, one at a time.
|
||||
func TestEachDeleteRefusal(t *testing.T) {
|
||||
fresh := newCustody(t)
|
||||
fresh.ask(apps(1))
|
||||
if err := fresh.Delete("a-app", "a-app", act("x")); err == nil || !strings.Contains(err.Error(), "no pass") {
|
||||
t.Errorf("a delete before any pass read the file: %v", err)
|
||||
}
|
||||
c := retiredC(t)
|
||||
if err := c.Delete("a-app", "a-app", act("x")); err == nil || !strings.Contains(err.Error(), "active") {
|
||||
t.Errorf("an active holder: %v", err)
|
||||
}
|
||||
if err := c.Delete("d-app", "d-app", Act{By: "person.jochen"}); err == nil || !strings.Contains(err.Error(), "why") {
|
||||
t.Errorf("no why: %v", err)
|
||||
}
|
||||
if err := c.Delete("d-app", "c-app", act("x")); err == nil || !strings.Contains(err.Error(), "confirm") {
|
||||
t.Errorf("a confirm naming another: %v", err)
|
||||
}
|
||||
if err := c.Delete("nobody", "nobody", act("x")); err == nil {
|
||||
t.Error("a holder not held")
|
||||
}
|
||||
c.ask(apps(4)) // asked for again, no pass yet
|
||||
if err := c.Delete("d-app", "d-app", act("x")); err == nil || !strings.Contains(err.Error(), "asks for") {
|
||||
t.Errorf("a holder the file asks for again: %v", err)
|
||||
}
|
||||
c.ask(keepOnly(apps(4), 3))
|
||||
if err := c.Delete("d-app", "d-app", act("gone for good")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := c.Ledger.Get("d-app"); ok {
|
||||
t.Error("deleted, but still in the ledger")
|
||||
}
|
||||
last := c.bus.events[len(c.bus.events)-1]["body"].(map[string]any)
|
||||
if last["change"] != "deleted" || last["by"] != "person.jochen" || last["via"] != "mesh-controller" {
|
||||
t.Errorf("the deletion said %v", last)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package main
|
||||
|
||||
// The vault's own store (novox/hq ADR 0349 §7, ADR 0113 §22): one file per secret on the vault's own disk,
|
||||
// every value in it sealed — once to the vault's own key, the copy the vault opens to answer `get`, and once
|
||||
// to the operator key, the copy the operator opens when the vault is lost (ADR 0113 §21). No database and no
|
||||
// bucket: the vault is installed before any other module (ADR 0113 §14), a database login would be a
|
||||
// secret only the vault makes, and a bucket holds no secret, sealed or not (ADR 0201 §13).
|
||||
//
|
||||
// Each file is written whole and renamed into place, so a reader never sees half a record.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// OperatorBox names the box an operator copy is sealed in: the NaCl anonymous box the operator key is made
|
||||
// for today (`operator key make`) and `secret recover --from-export` opens.
|
||||
const OperatorBox = "nacl-anonymous-box"
|
||||
|
||||
// Record is one secret as the store keeps it: never its value.
|
||||
type Record struct {
|
||||
Name string `json:"name"`
|
||||
Generation int `json:"generation"`
|
||||
// AtRest is the value sealed to the vault's own key (the ADR 0206 box).
|
||||
AtRest seal.Box `json:"at-rest"`
|
||||
// Operator is the value sealed to the operator key.
|
||||
Operator OperatorCopy `json:"operator"`
|
||||
MadeAt time.Time `json:"made-at"`
|
||||
ChangedAt time.Time `json:"changed-at"`
|
||||
ChangedBy string `json:"changed-by"`
|
||||
// ChangedVia is the caller the runtime proved, when it is not who ChangedBy names: the controller
|
||||
// carrying a person's act (design 57's V2). Empty when they are the same.
|
||||
ChangedVia string `json:"changed-via,omitempty"`
|
||||
// Origin is where the value came from: made by the vault, accepted at the controller's terminal, typed at
|
||||
// a desk, or issued by a provider's software (ADR 0349 clauses 8 to 10). Empty is made, as V1 wrote it.
|
||||
Origin string `json:"origin,omitempty"`
|
||||
}
|
||||
|
||||
// OperatorCopy is a value sealed to the operator key named in Key.
|
||||
type OperatorCopy struct {
|
||||
Box string `json:"box"`
|
||||
Key string `json:"key"`
|
||||
Sealed string `json:"sealed"`
|
||||
}
|
||||
|
||||
// Store is the vault's directory of secrets.
|
||||
type Store struct {
|
||||
dir string
|
||||
// failWrite, when a test sets it, fails the write of a record: how a failure partway is proven.
|
||||
failWrite func(Record) error
|
||||
// HandedMax is the size at which the record of hand-outs is rolled (0: HandedMaxDefault).
|
||||
HandedMax int64
|
||||
|
||||
handedMu sync.Mutex
|
||||
rolled int
|
||||
// asksMu serialises the record of asks, so two asks for one secret at once do not both pass its bounds.
|
||||
asksMu sync.Mutex
|
||||
}
|
||||
|
||||
// The record of hand-outs is bounded: rolled at HandedMaxDefault into handed.jsonl.1 … .HandedKept, the
|
||||
// oldest dropped, so a flood of asks costs at most (HandedKept+1) × HandedMaxDefault on the vault's disk.
|
||||
const (
|
||||
HandedMaxDefault = 1 << 20
|
||||
HandedKept = 3
|
||||
)
|
||||
|
||||
// OpenStore opens (and makes) the store at dir.
|
||||
func OpenStore(dir string) (*Store, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("the vault's store has no directory: MESH_VAULT_STORE is not set")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "secrets"), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{dir: dir}, nil
|
||||
}
|
||||
|
||||
var secretName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$`)
|
||||
|
||||
// validName says why name is not a secret's name, or nil. A name is words joined by `/`, never a path.
|
||||
func validName(name string) error {
|
||||
if len(name) == 0 || len(name) > 200 || !secretName.MatchString(name) {
|
||||
return fmt.Errorf("%q is not a secret's name: lower-case words joined by /, at most 200 characters", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) pathOf(name string) string {
|
||||
return filepath.Join(s.dir, "secrets", url.PathEscape(name)+".json")
|
||||
}
|
||||
|
||||
// Read is the record of one secret, and whether there is one.
|
||||
func (s *Store) Read(name string) (Record, bool, error) {
|
||||
if err := validName(name); err != nil {
|
||||
return Record{}, false, err
|
||||
}
|
||||
raw, err := os.ReadFile(s.pathOf(name))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Record{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Record{}, false, err
|
||||
}
|
||||
var r Record
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return Record{}, false, fmt.Errorf("the record of %s is not readable: %w", name, err)
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
// Write keeps one record, replacing any earlier one.
|
||||
func (s *Store) Write(r Record) error {
|
||||
if err := validName(r.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.failWrite != nil {
|
||||
if err := s.failWrite(r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
raw, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeWhole(s.pathOf(r.Name), append(raw, '\n'))
|
||||
}
|
||||
|
||||
// Empty says whether the store holds no secret at all.
|
||||
func (s *Store) Empty() (bool, error) {
|
||||
all, err := s.List()
|
||||
return len(all) == 0, err
|
||||
}
|
||||
|
||||
// Handing is one answer to `get`, or one refusal, as the store records it (as ADR 0277 §25 records asks):
|
||||
// who asked, for what, when, and what it got — never the value.
|
||||
type Handing struct {
|
||||
At time.Time `json:"at"`
|
||||
To string `json:"to"`
|
||||
Name string `json:"name"`
|
||||
Generation int `json:"generation,omitempty"`
|
||||
Outcome string `json:"outcome"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
// RecordHanding appends one handing to the store's record, synced before it returns. It says whether the
|
||||
// record was rolled to make room.
|
||||
func (s *Store) RecordHanding(h Handing) (rolled bool, err error) {
|
||||
raw, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.handedMu.Lock()
|
||||
defer s.handedMu.Unlock()
|
||||
path := filepath.Join(s.dir, "handed.jsonl")
|
||||
max := s.HandedBound()
|
||||
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > max {
|
||||
if err := s.roll(path); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.rolled++
|
||||
rolled = true
|
||||
}
|
||||
return rolled, appendSynced(path, append(raw, '\n'))
|
||||
}
|
||||
|
||||
// roll moves handed.jsonl to .1, .1 to .2 and so on, dropping what falls past HandedKept.
|
||||
func (s *Store) roll(path string) error {
|
||||
_ = os.Remove(fmt.Sprintf("%s.%d", path, HandedKept))
|
||||
for i := HandedKept - 1; i >= 1; i-- {
|
||||
if err := os.Rename(fmt.Sprintf("%s.%d", path, i), fmt.Sprintf("%s.%d", path, i+1)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.Rename(path, path+".1"); err != nil {
|
||||
return err
|
||||
}
|
||||
return syncDir(s.dir)
|
||||
}
|
||||
|
||||
// Rolled is how many times the record of hand-outs was rolled since the vault started.
|
||||
func (s *Store) Rolled() int {
|
||||
s.handedMu.Lock()
|
||||
defer s.handedMu.Unlock()
|
||||
return s.rolled
|
||||
}
|
||||
|
||||
func appendSynced(path string, raw []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(raw); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// Handed is the latest handings, newest first, at most limit: the current record, then the rolled ones.
|
||||
func (s *Store) Handed(limit int) ([]Handing, error) {
|
||||
s.handedMu.Lock()
|
||||
defer s.handedMu.Unlock()
|
||||
path := filepath.Join(s.dir, "handed.jsonl")
|
||||
out := []Handing{}
|
||||
for i := 0; i <= HandedKept && len(out) < limit; i++ {
|
||||
file := path
|
||||
if i > 0 {
|
||||
file = fmt.Sprintf("%s.%d", path, i)
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
for j := len(lines) - 1; j >= 0 && len(out) < limit; j-- {
|
||||
var h Handing
|
||||
if json.Unmarshal([]byte(lines[j]), &h) == nil {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HandedBound is the size at which the record of hand-outs is rolled.
|
||||
func (s *Store) HandedBound() int64 {
|
||||
if s.HandedMax > 0 {
|
||||
return s.HandedMax
|
||||
}
|
||||
return HandedMaxDefault
|
||||
}
|
||||
|
||||
// List is every record, by name.
|
||||
func (s *Store) List() ([]Record, error) {
|
||||
entries, err := os.ReadDir(filepath.Join(s.dir, "secrets"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Record
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
name, err := url.PathUnescape(strings.TrimSuffix(e.Name(), ".json"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
r, ok, err := s.Read(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type operatorFile struct {
|
||||
Key string `json:"key"`
|
||||
Since time.Time `json:"since"`
|
||||
By string `json:"by"`
|
||||
}
|
||||
|
||||
// OperatorKey is the operator's public key the store seals to, or "" when it has none.
|
||||
func (s *Store) OperatorKey() (string, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(s.dir, "operator.json"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var f operatorFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return f.Key, nil
|
||||
}
|
||||
|
||||
func (s *Store) setOperatorKey(key, by string, at time.Time) error {
|
||||
raw, _ := json.MarshalIndent(operatorFile{Key: key, Since: at, By: by}, "", " ")
|
||||
return writeWhole(filepath.Join(s.dir, "operator.json"), append(raw, '\n'))
|
||||
}
|
||||
|
||||
// writeWhole writes a file readable by the vault alone, synced, then renames it into place and syncs the
|
||||
// directory, so a crash leaves the old file or the new one, never neither.
|
||||
func writeWhole(path string, raw []byte) error {
|
||||
tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
|
||||
if err := writeSynced(tmp, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return syncDir(filepath.Dir(path))
|
||||
}
|
||||
|
||||
// writeSynced writes a new file at 0600 and syncs it to disk.
|
||||
func writeSynced(path string, raw []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(raw); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// syncDir syncs a directory, so a rename or a link in it is on disk.
|
||||
func syncDir(dir string) error {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
|
||||
// The two lines an ask leaves in the record: when it opened, and how it ended.
|
||||
const (
|
||||
AskOpened = "opened"
|
||||
AskEnded = "ended"
|
||||
)
|
||||
|
||||
// AskRecord is one line of the record of asks for a module's own secret at a desk (ADR 0277 §25, in the vault's
|
||||
// store since ADR 0349 clause 9): who asked, as the bus named them, for what, at which desk, and how it ended.
|
||||
// Never a value.
|
||||
type AskRecord struct {
|
||||
ID string `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
Event string `json:"event"`
|
||||
Secret string `json:"secret"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Desk string `json:"desk,omitempty"`
|
||||
AskedBy string `json:"asked_by,omitempty"`
|
||||
Caller string `json:"caller,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
}
|
||||
|
||||
// RecordAsk appends one line to the record of asks, synced before it returns; rolled as the record of
|
||||
// hand-outs is, and it says whether it was.
|
||||
func (s *Store) RecordAsk(r AskRecord) (rolled bool, err error) {
|
||||
raw, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
path := filepath.Join(s.dir, "asks.jsonl")
|
||||
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > s.HandedBound() {
|
||||
if err := s.roll(path); err != nil {
|
||||
return false, err
|
||||
}
|
||||
rolled = true
|
||||
}
|
||||
return rolled, appendSynced(path, append(raw, '\n'))
|
||||
}
|
||||
|
||||
// asks is every line of the record of asks, oldest first, the rolled files before the current one.
|
||||
func (s *Store) asks() ([]AskRecord, error) {
|
||||
path := filepath.Join(s.dir, "asks.jsonl")
|
||||
var out []AskRecord
|
||||
for i := HandedKept; i >= 0; i-- {
|
||||
file := path
|
||||
if i > 0 {
|
||||
file = fmt.Sprintf("%s.%d", path, i)
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
||||
var r AskRecord
|
||||
if line != "" && json.Unmarshal([]byte(line), &r) == nil {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AsksSince is every line of the record of asks at or after since, oldest first.
|
||||
func (s *Store) AsksSince(since time.Time) ([]AskRecord, error) {
|
||||
all, err := s.asks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []AskRecord
|
||||
for _, r := range all {
|
||||
if !r.At.Before(since) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Asks is the latest lines of the record of asks, newest first, at most limit.
|
||||
func (s *Store) Asks(limit int) ([]AskRecord, error) {
|
||||
s.asksMu.Lock()
|
||||
defer s.asksMu.Unlock()
|
||||
all, err := s.asks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []AskRecord{}
|
||||
for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
out = append(out, all[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"resource": "secret",
|
||||
"node": "novox",
|
||||
"held": [
|
||||
"a"
|
||||
],
|
||||
"held_sizes": {
|
||||
"a": 1
|
||||
},
|
||||
"stable_passes": 5,
|
||||
"stable_for_seconds": 600,
|
||||
"retires_by": "mark-only",
|
||||
"bound": "b",
|
||||
"waiting": {
|
||||
"consumers": [
|
||||
{
|
||||
"consumer": "c",
|
||||
"node": "n",
|
||||
"retired_at": "t",
|
||||
"why": "w",
|
||||
"size_bytes": -1,
|
||||
"kind": "consumer",
|
||||
"access": "kept"
|
||||
}
|
||||
],
|
||||
"since": "t",
|
||||
"held": 1
|
||||
},
|
||||
"rejected": {
|
||||
"consumers": [
|
||||
{
|
||||
"consumer": "c",
|
||||
"node": "n",
|
||||
"retired_at": "t",
|
||||
"why": "w",
|
||||
"size_bytes": -1,
|
||||
"kind": "consumer",
|
||||
"access": "kept"
|
||||
}
|
||||
],
|
||||
"by": "b",
|
||||
"why": "w",
|
||||
"at": "t"
|
||||
},
|
||||
"retired": [
|
||||
{
|
||||
"consumer": "c",
|
||||
"node": "n",
|
||||
"retired_at": "t",
|
||||
"why": "w",
|
||||
"size_bytes": -1,
|
||||
"kind": "consumer",
|
||||
"access": "kept"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"provider": "secret",
|
||||
"provider-node": "novox",
|
||||
"change": "waiting",
|
||||
"consumers": [
|
||||
{
|
||||
"consumer": "c",
|
||||
"node": "n",
|
||||
"retired_at": "t",
|
||||
"why": "w",
|
||||
"size_bytes": -1,
|
||||
"kind": "consumer",
|
||||
"access": "kept"
|
||||
}
|
||||
],
|
||||
"held": 1,
|
||||
"bound": "b",
|
||||
"why": "w",
|
||||
"by": "person.x",
|
||||
"via": "mesh-controller",
|
||||
"at": "2026-10-14T09:00:00Z",
|
||||
"since": "2026-10-14T09:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
package main
|
||||
|
||||
// What the vault serves: the seat's verb `get`, and the three tools the TypeScript vault offered —
|
||||
// secret_export, secret_holders and secret_verify — answering as they did; beside them secret_handed and
|
||||
// secret_vault_export over the vault's own store, and ADR 0230's four retirement tools over the custody
|
||||
// ledger, and vault_status. None returns a value: `get` returns one sealed to the asker's key, the others
|
||||
// fingerprints and ciphertext only.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Env is what the tools work on. A part the module's words did not give is nil, and a tool that needs it
|
||||
// says so.
|
||||
type Env struct {
|
||||
Vault *Vault
|
||||
Ledger *Ledger
|
||||
Receives string
|
||||
Root string
|
||||
Custody *Custody
|
||||
// refused is why the operator key in the mesh's export was not taken, or empty.
|
||||
mu sync.Mutex
|
||||
refused string
|
||||
// Broken is why the vault could not be built, said by `get`.
|
||||
Broken error
|
||||
}
|
||||
|
||||
func (e *Env) keyRefused() string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.refused
|
||||
}
|
||||
|
||||
func (e *Env) refuseKey(why string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.refused = why
|
||||
}
|
||||
|
||||
// toolDef is a tool with its caller given apart from its arguments, so a test can be the runtime.
|
||||
type toolDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Input map[string]any
|
||||
run func(caller string, a map[string]any) (any, error)
|
||||
}
|
||||
|
||||
// Run answers a call the runtime made: the caller is the one it proved.
|
||||
func (t toolDef) Run(a map[string]any) (any, error) { return t.run(stdio.CallerOf(a), a) }
|
||||
|
||||
func (t toolDef) runAs(caller string, a map[string]any) (any, error) { return t.run(caller, a) }
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func definitions(env *Env) []toolDef {
|
||||
return []toolDef{
|
||||
{Name: Seat + ".get",
|
||||
Description: "One secret, sealed to the public key the asker sends (the sealed box of ADR 0206), with its " +
|
||||
"generation; answered only to an asker the vault allows, as the runtime proved it (ADR 0349 §11). " +
|
||||
"Never the value in the clear, and never to a private key.",
|
||||
Input: map[string]any{"name": str("the secret's name"),
|
||||
"public_key": str("the asker's X25519 public key as SPKI PEM — never a private key")},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
if env.Vault == nil {
|
||||
if env.Broken != nil {
|
||||
return nil, env.Broken
|
||||
}
|
||||
return nil, errors.New("the vault is not ready")
|
||||
}
|
||||
name, _ := a["name"].(string)
|
||||
key, _ := a["public_key"].(string)
|
||||
return env.Vault.Get(caller, strings.TrimSpace(name), key)
|
||||
}},
|
||||
{Name: Seat + ".accept",
|
||||
Description: "Take a value given at the controller's terminal (ADR 0349 clause 8), in two steps from the " +
|
||||
"controller alone: name the secret (node, module and name for a module's own; provider and provision " +
|
||||
"for a provision's one credential) and get a call and a key made for it; then send the call and the " +
|
||||
"value sealed to that key. The vault alone opens it, and never remakes an accepted credential.",
|
||||
Input: intakeInput(map[string]string{"node": "the machine, for a module's own secret",
|
||||
"module": "the module, for a module's own secret", "name": "the secret's name in the module",
|
||||
"provider": "the provider's machine, for a provision's credential", "provision": "the provision",
|
||||
"by": "who gave it at the terminal, as the controller names them"}),
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if call := arg(a, "call"); call != "" {
|
||||
return v.AcceptTake(caller, call, arg(a, "sealed"))
|
||||
}
|
||||
secret, err := secretNamed(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.AcceptOpen(caller, secret, arg(a, "by"))
|
||||
}},
|
||||
{Name: Seat + ".secret-ask",
|
||||
Description: "Ask the operator for a module's own secret at a desk (ADR 0349 clause 9, ADR 0277 §22–§27), in " +
|
||||
"two steps from the controller alone, which reaches the desk's prompt: node, module, name, desk and " +
|
||||
"asked_by open the ask — recorded before the prompt opens, one open ask per secret and three an " +
|
||||
"hour — and answer the key the prompt seals to and who asked as the record names them; then the call " +
|
||||
"with what the prompt sealed, or with how it ended (dismissed, timed-out, empty, refused, failed). " +
|
||||
"What is typed opens in the vault alone.",
|
||||
Input: intakeInput(map[string]string{"node": "the machine the secret is for", "module": "the module",
|
||||
"name": "the secret's name in the module", "desk": "the machine whose prompt asks",
|
||||
"asked_by": "who asked the controller, as the bus named its caller",
|
||||
"outcome": "instead of sealed: how the prompt ended — dismissed, timed-out, empty, refused or failed"}),
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if call := arg(a, "call"); call != "" {
|
||||
return v.AskEnd(caller, call, arg(a, "sealed"), arg(a, "outcome"))
|
||||
}
|
||||
return v.AskOpen(caller, AskOf{Node: arg(a, "node"), Module: arg(a, "module"), Name: arg(a, "name"),
|
||||
Desk: arg(a, "desk"), AskedBy: arg(a, "asked_by")})
|
||||
}},
|
||||
{Name: Seat + ".issued",
|
||||
Description: "Take the credential a provider's software issued for a provision (ADR 0349 clause 10, ADR 0305), " +
|
||||
"in two steps from the controller alone, which asks the provider's tool: provision, provider and module " +
|
||||
"open a call and answer the key the tool seals to; then the call with what the tool sealed. Kept as the " +
|
||||
"provision's one credential; the same value again writes nothing.",
|
||||
Input: intakeInput(map[string]string{"provision": "the provision", "provider": "the provider's machine",
|
||||
"module": "the provider's module, whose software issued it"}),
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if call := arg(a, "call"); call != "" {
|
||||
return v.IssuedTake(caller, call, arg(a, "sealed"))
|
||||
}
|
||||
return v.IssuedOpen(caller, arg(a, "provision"), arg(a, "provider"), arg(a, "module"))
|
||||
}},
|
||||
{Name: Seat + ".rotate",
|
||||
Description: "Make a secret anew (ADR 0114, ADR 0228): node, module and name for a module's own, or provider " +
|
||||
"and provision for a provision's credential. Refused for a value a provider's software issued, which " +
|
||||
"the software rotates, and for an accepted credential, which the vault does not remake. Never answers " +
|
||||
"the value.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"node": str("the machine, for a module's own secret"), "module": str("the module"),
|
||||
"name": str("the secret's name in the module"), "provider": str("the provider's machine"),
|
||||
"provision": str("the provision"),
|
||||
"by": str("who asked, as the controller names them when it carries the act")}},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := secretNamed(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.Rotate(caller, secret, arg(a, "by"))
|
||||
}},
|
||||
{Name: "secret_asks",
|
||||
Description: "The record of asks for a module's own secret at a desk, newest first: who asked as the bus named " +
|
||||
"them, for what, at which desk, when, and how each ended. Never a value.",
|
||||
Input: map[string]any{"limit": map[string]any{"type": "number", "description": "how many lines (default 50)"}},
|
||||
run: func(_ string, a map[string]any) (any, error) {
|
||||
v, err := env.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.Store.Asks(limitOf(a))
|
||||
}},
|
||||
{Name: "secret_export",
|
||||
Description: "The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " +
|
||||
"sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can open a line " +
|
||||
"of it; the operator, holding the private key off the mesh, recovers one with " +
|
||||
"`nox secret recover --from-export` on the control-node. Also lists what is NOT recoverable: secrets " +
|
||||
"made before the mesh had an operator key.",
|
||||
Input: map[string]any{"sealed": map[string]any{"type": "boolean",
|
||||
"description": "include the sealed blobs (default true); false lists holders and the key only"}},
|
||||
run: func(_ string, a map[string]any) (any, error) {
|
||||
return exportAnswer(env.Root, a["sealed"] != false), nil
|
||||
}},
|
||||
{Name: "secret_holders",
|
||||
Description: "Who holds a secret from this vault: each consumer's login, node and module, when it was " +
|
||||
"granted, how many times it has been rotated and when, and the fingerprint of the current value. " +
|
||||
"Fingerprints only — the value is never returned.",
|
||||
run: func(string, map[string]any) (any, error) {
|
||||
if env.Ledger == nil {
|
||||
return map[string]any{"holders": []any{}, "count": 0, "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}, nil
|
||||
}
|
||||
var asked []Contribution
|
||||
if env.Receives != "" {
|
||||
asked, _ = Contributions(env.Receives)
|
||||
}
|
||||
holders := []map[string]any{}
|
||||
for _, h := range env.Ledger.List() {
|
||||
row := map[string]any{}
|
||||
raw, _ := json.Marshal(h)
|
||||
_ = json.Unmarshal(raw, &row)
|
||||
row["module"], row["asked"] = nil, false
|
||||
for _, c := range asked {
|
||||
if c.As == h.As {
|
||||
row["module"], row["asked"] = c.From, true
|
||||
}
|
||||
}
|
||||
holders = append(holders, row)
|
||||
}
|
||||
return map[string]any{"holders": holders, "count": len(holders)}, nil
|
||||
}},
|
||||
{Name: "secret_verify",
|
||||
Description: "Check one holder's secret without seeing it: the fingerprint the vault recorded against the " +
|
||||
"fingerprint of the value the mesh currently delivers here, and optionally against a fingerprint " +
|
||||
"computed on the holder's own machine (sha256 of the file, as `sha256:<hex>`). Two ends agreeing " +
|
||||
"proves they agree, not that either works — the login itself is the test.",
|
||||
Input: map[string]any{"as": str("the holder's login, e.g. anchor-redis"),
|
||||
"fingerprint": str("optional: sha256:<hex> of the value as the holder reads it, computed there — never the value")},
|
||||
run: func(_ string, a map[string]any) (any, error) {
|
||||
as, _ := a["as"].(string)
|
||||
given, _ := a["fingerprint"].(string)
|
||||
return verifyAnswer(env, as, given), nil
|
||||
}},
|
||||
{Name: "secret_handed",
|
||||
Description: "Who was handed which secret by `get`, and when, and who was refused, newest first, as the " +
|
||||
"vault's own store records it. Never a value.",
|
||||
Input: map[string]any{"limit": map[string]any{"type": "number", "description": "how many (default 50)"}},
|
||||
run: func(_ string, a map[string]any) (any, error) {
|
||||
if env.Vault == nil {
|
||||
return nil, errors.New("the vault is not ready")
|
||||
}
|
||||
return env.Vault.Store.Handed(limitOf(a))
|
||||
}},
|
||||
{Name: "secret_vault_export",
|
||||
Description: "Every secret the vault itself holds, as its copy sealed to the operator key, in the shape of " +
|
||||
"`secret export`: saved to a file, `nox secret recover vault mesh-vault <name> --key <operator key file> " +
|
||||
"--from-export <file> --out <file>` opens one with the operator's private key. Ciphertext only.",
|
||||
run: func(string, map[string]any) (any, error) {
|
||||
if env.Vault == nil {
|
||||
return nil, errors.New("the vault is not ready")
|
||||
}
|
||||
return vaultExport(env.Vault)
|
||||
}},
|
||||
{Name: "provisioner_retirement",
|
||||
Description: "This provider's retirement (novox/hq ADR 0230): the holders it keeps active, a retirement waiting " +
|
||||
"for a person, one a person rejected, and every holder retired with when and why. Mark-only: a retired " +
|
||||
"holder keeps what it has until a person deletes it.",
|
||||
run: func(string, map[string]any) (any, error) {
|
||||
if env.Custody == nil {
|
||||
return nil, errors.New("the vault has no custody ledger")
|
||||
}
|
||||
return env.Custody.Retirement(), nil
|
||||
}},
|
||||
{Name: "provisioner_retire_approve",
|
||||
Description: "Retire exactly the set waiting for a person (or rejected earlier). Repeat the set in consumers; why is required.",
|
||||
Input: actInput(true, false),
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
if env.Custody == nil {
|
||||
return nil, errors.New("the vault has no custody ledger")
|
||||
}
|
||||
done, err := env.Custody.Approve(stringsOf(a["consumers"]), actOf(caller, a))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"retired": done}, nil
|
||||
}},
|
||||
{Name: "provisioner_retire_reject",
|
||||
Description: "Keep the set waiting for a person active, though the mesh no longer asks for it. Repeat the set in consumers; why is required.",
|
||||
Input: actInput(true, false),
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
if env.Custody == nil {
|
||||
return nil, errors.New("the vault has no custody ledger")
|
||||
}
|
||||
kept, err := env.Custody.Reject(stringsOf(a["consumers"]), actOf(caller, a))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"kept": kept}, nil
|
||||
}},
|
||||
{Name: "provisioner_delete",
|
||||
Description: "Delete one RETIRED holder from the custody ledger, its rotation history with it — never an active one. " +
|
||||
"Repeat its name in confirm; why is required.",
|
||||
Input: actInput(false, true),
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
if env.Custody == nil {
|
||||
return nil, errors.New("the vault has no custody ledger")
|
||||
}
|
||||
consumer, _ := a["consumer"].(string)
|
||||
confirm, _ := a["confirm"].(string)
|
||||
if err := env.Custody.Delete(consumer, confirm, actOf(caller, a)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"deleted": consumer, "freed_bytes": -1}, nil
|
||||
}},
|
||||
{Name: "vault_status",
|
||||
Description: "What needs a person's eye in the vault: operator copies still sealed to an earlier key, the operator " +
|
||||
"key the mesh's export holds if the vault refused it, and how often the record of hand-outs was rolled.",
|
||||
run: func(string, map[string]any) (any, error) {
|
||||
if env.Vault == nil {
|
||||
return map[string]any{"ready": false, "error": fmt.Sprint(env.Broken)}, nil
|
||||
}
|
||||
left, err := env.Vault.StillSealedToAnother()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, _ := env.Vault.Store.OperatorKey()
|
||||
return map[string]any{"ready": true, "operator_key": key, "copies_on_an_earlier_key": left,
|
||||
"operator_key_refused": env.keyRefused(), "handed_rolled": env.Vault.Store.Rolled(),
|
||||
"ok": left == 0 && env.keyRefused() == "" && key != ""}, nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// actInput is the arguments of an act on retirement, as the SDK's harness takes them.
|
||||
func actInput(set, one bool) map[string]any {
|
||||
text := map[string]any{"type": "string"}
|
||||
in := map[string]any{"why": text, "by": text, "via": text}
|
||||
if set {
|
||||
in["consumers"] = map[string]any{"type": "array", "items": text}
|
||||
}
|
||||
if one {
|
||||
in["consumer"], in["confirm"] = text, text
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// actOf is an act's why, by and via. `by` is the person the controller names; with none, the caller the
|
||||
// runtime proved. `by` is an argument, so it is not proven: when it names somebody other than the caller the
|
||||
// runtime proved, the proven caller is kept beside it in via, so the record says both.
|
||||
func actOf(caller string, a map[string]any) Act {
|
||||
act := Act{}
|
||||
act.Why, _ = a["why"].(string)
|
||||
act.By, _ = a["by"].(string)
|
||||
act.Via, _ = a["via"].(string)
|
||||
if act.By == "" {
|
||||
act.By = caller
|
||||
}
|
||||
if act.By != caller {
|
||||
proven := "called by " + caller
|
||||
if act.Via == "" {
|
||||
act.Via = proven
|
||||
} else {
|
||||
act.Via += ", " + proven
|
||||
}
|
||||
}
|
||||
return act
|
||||
}
|
||||
|
||||
// ready is the vault, or why there is none.
|
||||
func (e *Env) ready() (*Vault, error) {
|
||||
if e.Vault != nil {
|
||||
return e.Vault, nil
|
||||
}
|
||||
if e.Broken != nil {
|
||||
return nil, e.Broken
|
||||
}
|
||||
return nil, errors.New("the vault is not ready")
|
||||
}
|
||||
|
||||
func arg(a map[string]any, key string) string {
|
||||
s, _ := a[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// secretNamed is the secret a call names: a module's own (node, module, name), or a provision's one credential
|
||||
// (provider, provision) — one or the other.
|
||||
func secretNamed(a map[string]any) (string, error) {
|
||||
own := arg(a, "node") != "" || arg(a, "module") != "" || arg(a, "name") != ""
|
||||
pair := arg(a, "provider") != "" || arg(a, "provision") != ""
|
||||
switch {
|
||||
case own && !pair:
|
||||
return OwnSecret(arg(a, "node"), arg(a, "module"), arg(a, "name"))
|
||||
case pair && !own:
|
||||
return ProvisionSecret(arg(a, "provider"), arg(a, "provision"))
|
||||
}
|
||||
return "", errors.New("name a module's own secret (node, module, name) or a provision's credential " +
|
||||
"(provider, provision), one of the two")
|
||||
}
|
||||
|
||||
// intakeInput is a two-step verb's arguments: what its first step names, then the call and what was sealed.
|
||||
func intakeInput(first map[string]string) map[string]any {
|
||||
props := map[string]any{}
|
||||
for k, d := range first {
|
||||
props[k] = str(d)
|
||||
}
|
||||
props["call"] = str("the second step: the call the first step answered")
|
||||
props["sealed"] = str("the second step: the value sealed to the call's key (NaCl anonymous box, base64) — never the value")
|
||||
return map[string]any{"type": "object", "properties": props}
|
||||
}
|
||||
|
||||
func stringsOf(v any) []string {
|
||||
list, _ := v.([]any)
|
||||
out := []string{}
|
||||
for _, x := range list {
|
||||
if s, ok := x.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func limitOf(a map[string]any) int {
|
||||
if v, ok := a["limit"].(float64); ok && v > 0 {
|
||||
return int(v)
|
||||
}
|
||||
return 50
|
||||
}
|
||||
|
||||
// keyFingerprint is a key's fingerprint as the controller's secrets.Fingerprint says it: sha256 of the key,
|
||||
// its first eight bytes in hex.
|
||||
func keyFingerprint(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(key)))
|
||||
return "sha256:" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// vaultExport is the vault's operator copies in the shape `secret export` writes and `secret recover
|
||||
// --from-export` reads: each as node "vault", module "mesh-vault" and the secret's name.
|
||||
func vaultExport(v *Vault) (map[string]any, error) {
|
||||
key, err := v.Store.OperatorKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all, err := v.Store.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kept, earlier := []map[string]any{}, []map[string]any{}
|
||||
for _, rec := range all {
|
||||
k := map[string]any{"node": "vault", "module": Seat, "name": rec.Name, "kind": "own", "origin": "made",
|
||||
"sealed": rec.Operator.Sealed, "key": rec.Operator.Key, "made-at": rec.MadeAt}
|
||||
if rec.Operator.Key == key {
|
||||
kept = append(kept, k)
|
||||
} else {
|
||||
earlier = append(earlier, k)
|
||||
}
|
||||
}
|
||||
return map[string]any{"export": 1, "operator-key": key, "fingerprint": keyFingerprint(key), "kept": kept,
|
||||
"sealed-to-earlier-key": earlier}, nil
|
||||
}
|
||||
|
||||
func exportAnswer(root string, withBlobs bool) map[string]any {
|
||||
if root == "" {
|
||||
return map[string]any{"available": false, "error": "this vault keeps no root secrets (MESH_VAULT_ROOT is not set)"}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "export.json"))
|
||||
if err != nil {
|
||||
return map[string]any{"available": false, "error": "the mesh has not written an export here yet: " + err.Error()}
|
||||
}
|
||||
var doc struct {
|
||||
Export int `json:"export"`
|
||||
OperatorKey string `json:"operator-key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Kept []map[string]any `json:"kept"`
|
||||
Unrecoverable []map[string]any `json:"unrecoverable"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return map[string]any{"available": false, "error": "the export is not readable: " + err.Error()}
|
||||
}
|
||||
kept := []map[string]any{}
|
||||
for _, k := range doc.Kept {
|
||||
if !withBlobs {
|
||||
k = map[string]any{"node": k["node"], "module": k["module"], "name": k["name"], "origin": k["origin"], "made-at": k["made-at"]}
|
||||
}
|
||||
kept = append(kept, k)
|
||||
}
|
||||
if doc.Unrecoverable == nil {
|
||||
doc.Unrecoverable = []map[string]any{}
|
||||
}
|
||||
return map[string]any{"available": true, "export": doc.Export, "operator-key": doc.OperatorKey,
|
||||
"fingerprint": doc.Fingerprint, "count": len(kept), "kept": kept, "unrecoverable": doc.Unrecoverable}
|
||||
}
|
||||
|
||||
// exportedOperatorKey is the operator key in the export the mesh writes to the vault's root, or "".
|
||||
func exportedOperatorKey(root string) string {
|
||||
raw, err := os.ReadFile(filepath.Join(root, "export.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var doc struct {
|
||||
OperatorKey string `json:"operator-key"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &doc)
|
||||
return doc.OperatorKey
|
||||
}
|
||||
|
||||
func verifyAnswer(env *Env, as, given string) map[string]any {
|
||||
if env.Ledger == nil {
|
||||
return map[string]any{"as": as, "known": false, "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}
|
||||
}
|
||||
recorded, ok := env.Ledger.Get(as)
|
||||
if !ok {
|
||||
return map[string]any{"as": as, "known": false, "error": "this vault holds nothing for " + as}
|
||||
}
|
||||
var delivered, deliveredErr any
|
||||
var deliveredMatches any
|
||||
var asked *Contribution
|
||||
if env.Receives != "" {
|
||||
all, _ := Contributions(env.Receives)
|
||||
for i := range all {
|
||||
if all[i].As == as {
|
||||
asked = &all[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if asked == nil {
|
||||
deliveredErr = "the mesh does not currently ask this vault to serve that login"
|
||||
} else if fp, _, err := DeliveredFingerprint(*asked); err != nil {
|
||||
deliveredErr = err.Error()
|
||||
} else {
|
||||
delivered, deliveredMatches = fp, fp == recorded.Fingerprint
|
||||
}
|
||||
var givenAny, givenMatches, earlier any
|
||||
if given != "" {
|
||||
givenAny, givenMatches = given, given == recorded.Fingerprint
|
||||
was := false
|
||||
for _, h := range recorded.History {
|
||||
was = was || h.Fingerprint == given
|
||||
}
|
||||
earlier = was
|
||||
}
|
||||
return map[string]any{"as": recorded.As, "known": true, "recorded": recorded.Fingerprint,
|
||||
"rotations": recorded.Rotations, "changed": recorded.Changed,
|
||||
"delivered": delivered, "deliveredError": deliveredErr, "deliveredMatchesRecorded": deliveredMatches,
|
||||
"given": givenAny, "givenMatchesRecorded": givenMatches, "givenIsAnEarlierValue": earlier,
|
||||
"ok": deliveredMatches != false && givenMatches != false}
|
||||
}
|
||||
|
||||
// tools are the definitions as the SDK serves them.
|
||||
func tools(env *Env) []stdio.Tool {
|
||||
var out []stdio.Tool
|
||||
for _, d := range definitions(env) {
|
||||
out = append(out, stdio.Tool{Name: d.Name, Description: d.Description, Input: d.Input, Run: d.Run})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
// The tools the TypeScript vault offered, answering as they did: secret_export, secret_holders and
|
||||
// secret_verify — none returns a value. And the seat's verb `get`, reached the way the runtime calls it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
func toolNamed(t *testing.T, tools []toolDef, name string) toolDef {
|
||||
t.Helper()
|
||||
for _, tl := range tools {
|
||||
if tl.Name == name {
|
||||
return tl
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", name)
|
||||
return toolDef{}
|
||||
}
|
||||
|
||||
func TestTheManifestNamesWhatTheBundleServes(t *testing.T) {
|
||||
raw, _ := os.ReadFile("../../module.json")
|
||||
var m struct {
|
||||
Tools []string `json:"tools"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Emits []string `json:"emits"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tl := range definitions(&Env{}) {
|
||||
served[tl.Name] = true
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("the manifest names the tool %s, which the bundle does not serve", name)
|
||||
}
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
for _, verb := range c.Serves {
|
||||
if !served[c.Name+"."+verb] {
|
||||
t.Errorf("the claim of %s serves %s, which the bundle does not", c.Name, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range served {
|
||||
if !strings.HasPrefix(name, Seat+".") && !contains(m.Tools, name) {
|
||||
t.Errorf("the bundle serves %s, which the manifest does not name", name)
|
||||
}
|
||||
}
|
||||
if !contains(m.Emits, ChangedEvent) {
|
||||
t.Errorf("the manifest does not say the vault emits %s", ChangedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretExportAnswersTheMeshsExport(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
env := &Env{Root: root}
|
||||
export := toolNamed(t, definitions(env), "secret_export")
|
||||
got, _ := export.Run(map[string]any{})
|
||||
if got.(map[string]any)["available"] != false {
|
||||
t.Errorf("no export said %v", got)
|
||||
}
|
||||
doc := `{"export":1,"operator-key":"b3A=","fingerprint":"sha256:f","kept":[{"node":"g14","module":"dunst","name":"token","kind":"own","origin":"made","sealed":"c2VhbGVk","key":"b3A=","made-at":"2026-10-01T00:00:00Z"}],"unrecoverable":[{"node":"ace","module":"x","name":"y"}]}`
|
||||
_ = os.WriteFile(filepath.Join(root, "export.json"), []byte(doc), 0o600)
|
||||
got, _ = export.Run(map[string]any{})
|
||||
a := got.(map[string]any)
|
||||
if a["available"] != true || a["count"] != 1 || a["operator-key"] != "b3A=" {
|
||||
t.Errorf("the export said %v", a)
|
||||
}
|
||||
got, _ = export.Run(map[string]any{"sealed": false})
|
||||
kept := got.(map[string]any)["kept"].([]map[string]any)
|
||||
if _, ok := kept[0]["sealed"]; ok {
|
||||
t.Error("sealed=false still returned the blobs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretHoldersAndVerifySayFingerprintsOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
l, _ := NewLedger(filepath.Join(dir, "ledger"))
|
||||
secret := filepath.Join(dir, "s")
|
||||
_ = os.WriteFile(secret, []byte("the-value"), 0o600)
|
||||
receives := filepath.Join(dir, "mesh.json")
|
||||
doc, _ := json.Marshal(map[string]any{"given": []map[string]string{{"from": "redis", "node": "anchor", "as": "anchor-redis", "secret": secret}}})
|
||||
_ = os.WriteFile(receives, doc, 0o600)
|
||||
_, _, _ = l.Record("anchor-redis", "anchor", "the-value", time.Now())
|
||||
env := &Env{Ledger: l, Receives: receives}
|
||||
|
||||
holders, _ := toolNamed(t, definitions(env), "secret_holders").Run(map[string]any{})
|
||||
verify, _ := toolNamed(t, definitions(env), "secret_verify").Run(map[string]any{"as": "anchor-redis", "fingerprint": Fingerprint("the-value")})
|
||||
unknown, _ := toolNamed(t, definitions(env), "secret_verify").Run(map[string]any{"as": "nobody"})
|
||||
for _, answer := range []any{holders, verify, unknown} {
|
||||
raw, _ := json.Marshal(answer)
|
||||
if strings.Contains(string(raw), "the-value") {
|
||||
t.Errorf("a tool returned the value: %s", raw)
|
||||
}
|
||||
}
|
||||
v := verify.(map[string]any)
|
||||
if v["ok"] != true || v["deliveredMatchesRecorded"] != true || v["givenMatchesRecorded"] != true {
|
||||
t.Errorf("verify said %v", v)
|
||||
}
|
||||
h := holders.(map[string]any)
|
||||
if h["count"] != 1 {
|
||||
t.Errorf("holders said %v", h)
|
||||
}
|
||||
if unknown.(map[string]any)["known"] != false {
|
||||
t.Errorf("an unknown login said %v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// The seat's verb, called as the runtime calls it: the caller is the one the runtime proved, never an
|
||||
// argument.
|
||||
func TestTheGetVerbTakesItsCallerFromTheRuntime(t *testing.T) {
|
||||
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
|
||||
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
get := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".get")
|
||||
// No runtime proved a caller here, so the call is unproven whatever the arguments say.
|
||||
if _, err := get.Run(map[string]any{"name": "pair/postgres/gitea", "public_key": asker.PublicKey, "caller": "g14.node-provisioner"}); err == nil {
|
||||
t.Error("get answered a caller named in its arguments")
|
||||
}
|
||||
answer, err := get.runAs("g14.node-provisioner", map[string]any{"name": "pair/postgres/gitea", "public_key": asker.PublicKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := answer.(Answer)
|
||||
if got, err := seal.Open(a.Sealed, asker.PrivateKey); err != nil || got != "x" {
|
||||
t.Errorf("%q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package main
|
||||
|
||||
// The vault's rules (novox/hq ADR 0349 §7 and §11; ADR 0113 §1, §2, §22): it holds every secret sealed at
|
||||
// rest, with a copy sealed to the operator key; it hands one out only on request and reply, sealed to the
|
||||
// asker's key, and only to an asker it allows; it says a secret changed — who, when, never a value.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
const Seat = "mesh-vault"
|
||||
|
||||
// ChangedEvent is what the vault raises when a secret it holds changes: who, when, which, its generation.
|
||||
const ChangedEvent = "secret-changed"
|
||||
|
||||
// Vault holds the secrets.
|
||||
type Vault struct {
|
||||
Store *Store
|
||||
// Own is the vault's own key; the private half never leaves its state directory.
|
||||
Own seal.KeyPair
|
||||
// Askers decides who may ask `get` for what. Nil allows nobody.
|
||||
Askers Askers
|
||||
Emit func(event string, body map[string]any) error
|
||||
Now func() time.Time
|
||||
Log func(format string, args ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
// callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere.
|
||||
callsMu sync.Mutex
|
||||
pending map[string]*callKey
|
||||
}
|
||||
|
||||
// Changed is what one change to a secret was. Never its value.
|
||||
type Changed struct {
|
||||
Name string `json:"name"`
|
||||
Generation int `json:"generation"`
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by"`
|
||||
Unchanged bool `json:"unchanged,omitempty"`
|
||||
}
|
||||
|
||||
// Answer is `get`'s reply: the secret sealed to the asker's key, and which generation it is.
|
||||
type Answer struct {
|
||||
Name string `json:"name"`
|
||||
Generation int `json:"generation"`
|
||||
ChangedAt time.Time `json:"changed-at"`
|
||||
Sealed seal.Box `json:"sealed"`
|
||||
}
|
||||
|
||||
func (v *Vault) log(format string, args ...any) {
|
||||
if v.Log != nil {
|
||||
v.Log(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Put keeps value as the secret name: sealed to the vault's key and to the operator key, a new generation
|
||||
// when it differs from the one held, and `secret-changed` said. The same value again changes nothing.
|
||||
// Refused: a private key (it stays where it was made), and any value while the vault has no operator key —
|
||||
// a secret only the vault could open is what ADR 0113 §22 rules out.
|
||||
func (v *Vault) Put(name, value, by string) (Changed, error) {
|
||||
return v.keep(name, value, by, "", OriginMade)
|
||||
}
|
||||
|
||||
// keep is Put with where the value came from (origin) and the caller the runtime proved when it is not who
|
||||
// by names (via).
|
||||
func (v *Vault) keep(name, value, by, via, origin string) (Changed, error) {
|
||||
if err := validName(name); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
if value == "" {
|
||||
return Changed{}, fmt.Errorf("%s: a secret is not empty", name)
|
||||
}
|
||||
if privateKeyIn(value) {
|
||||
return Changed{}, fmt.Errorf("%s: that is a private key, and a private key stays where it was made and never reaches the vault (ADR 0113 §2)", name)
|
||||
}
|
||||
if by == "" {
|
||||
by = "the vault"
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
operator, err := v.Store.OperatorKey()
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
if operator == "" {
|
||||
return Changed{}, fmt.Errorf("%s is not kept: the vault has no operator key to seal its copy to (ADR 0113 §22) — the mesh writes it with the export once `operator key set` has been run", name)
|
||||
}
|
||||
before, held, err := v.Store.Read(name)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
if held {
|
||||
if old, err := seal.Open(before.AtRest, v.Own.PrivateKey); err == nil && old == value {
|
||||
return Changed{Name: name, Generation: before.Generation, At: before.ChangedAt, By: before.ChangedBy, Unchanged: true}, nil
|
||||
}
|
||||
}
|
||||
atRest, err := seal.Seal(value, v.Own.PublicKey)
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
forOperator, err := operatorSeal(operator, []byte(value))
|
||||
if err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
now := v.Now().UTC()
|
||||
if via == by {
|
||||
via = ""
|
||||
}
|
||||
rec := Record{Name: name, Generation: 1, AtRest: atRest,
|
||||
Operator: OperatorCopy{Box: OperatorBox, Key: operator, Sealed: forOperator},
|
||||
MadeAt: now, ChangedAt: now, ChangedBy: by, ChangedVia: via, Origin: origin}
|
||||
if held {
|
||||
rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt
|
||||
}
|
||||
if err := v.Store.Write(rec); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
c := Changed{Name: name, Generation: rec.Generation, At: now, By: by}
|
||||
v.announce(c)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Mint makes a fresh value for name and keeps it: 30 random bytes, base64url without padding — 40
|
||||
// characters, the controller's shape, which fits the tightest backend a minted password reaches.
|
||||
func (v *Vault) Mint(name, by string) (Changed, error) {
|
||||
raw := make([]byte, 30)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
return v.Put(name, base64.RawURLEncoding.EncodeToString(raw), by)
|
||||
}
|
||||
|
||||
// announce raises `secret-changed`: who, when, which and its generation — never a value or a sealed copy.
|
||||
func (v *Vault) announce(c Changed) {
|
||||
if v.Emit == nil {
|
||||
return
|
||||
}
|
||||
body := map[string]any{"name": c.Name, "by": c.By, "at": c.At.Format(time.RFC3339Nano), "generation": c.Generation}
|
||||
if err := v.Emit(ChangedEvent, body); err != nil {
|
||||
v.log("%s is kept at generation %d, but %s could not be said: %v", c.Name, c.Generation, ChangedEvent, err)
|
||||
}
|
||||
}
|
||||
|
||||
// recordHanding keeps who asked for what, and what they got, in the vault's own store, and says loudly
|
||||
// when the record had to be rolled to make room.
|
||||
func (v *Vault) recordHanding(h Handing) error {
|
||||
h.At = v.Now().UTC()
|
||||
rolled, err := v.Store.RecordHanding(h)
|
||||
if err != nil {
|
||||
v.log("the record of %s asking for %s could not be written: %v", h.To, h.Name, err)
|
||||
return err
|
||||
}
|
||||
if rolled {
|
||||
v.log("THE RECORD OF HAND-OUTS WAS ROLLED (%d times since the vault started): it reached its bound of %d bytes; "+
|
||||
"the oldest of %d kept files is dropped. Many asks in little time is worth a look: secret_handed and vault_status",
|
||||
v.Store.Rolled(), v.Store.HandedBound(), HandedKept)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// busUser is the shape of a caller the runtime proves (the SDK's): the one shape a refusal is recorded for.
|
||||
var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
|
||||
|
||||
// Get answers caller with the secret name sealed to publicKey, if caller may ask for it. The caller is the
|
||||
// bus user the runtime proved, never an argument; an unproven call is refused.
|
||||
func (v *Vault) Get(caller, name, publicKey string) (Answer, error) {
|
||||
if caller == "" || caller == stdio.Unproven {
|
||||
return Answer{}, errors.New("the vault answers only a caller the runtime proved")
|
||||
}
|
||||
askers := v.Askers
|
||||
if askers == nil {
|
||||
askers = NoAsker{}
|
||||
}
|
||||
if !busUser.MatchString(caller) || len(caller) > 200 {
|
||||
return Answer{}, errors.New("the vault answers only a caller the runtime proved")
|
||||
}
|
||||
// A name is judged before anything is recorded, so an arbitrary argument fills no record.
|
||||
if err := validName(name); err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
if ok, why := askers.May(caller, name); !ok {
|
||||
v.log("refused %s to %s: %s", name, caller, why)
|
||||
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
|
||||
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
|
||||
}
|
||||
key, err := askerKey(publicKey)
|
||||
if err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
rec, held, err := v.Store.Read(name)
|
||||
if err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
if !held {
|
||||
return Answer{}, fmt.Errorf("the vault holds no secret named %s", name)
|
||||
}
|
||||
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
|
||||
if err != nil {
|
||||
return Answer{}, fmt.Errorf("%s does not open with the vault's key: %w", name, err)
|
||||
}
|
||||
sealed, err := seal.Seal(value, key)
|
||||
if err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
if err := v.recordHanding(Handing{To: caller, Name: name, Generation: rec.Generation, Outcome: "handed"}); err != nil {
|
||||
return Answer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", name, err)
|
||||
}
|
||||
v.log("handed %s generation %d to %s, sealed to its key", name, rec.Generation, caller)
|
||||
return Answer{Name: name, Generation: rec.Generation, ChangedAt: rec.ChangedAt, Sealed: sealed}, nil
|
||||
}
|
||||
|
||||
// SetOperatorKey makes key (SPKI PEM) the operator key, then seals again to it every operator copy that is
|
||||
// not yet — see Reseal. It returns how many copies it sealed again.
|
||||
//
|
||||
// The key is recorded first and each record says which key its copy is sealed to, so that record is the
|
||||
// progress: a failure partway leaves the rest for the next call, and the same key given again (every minute
|
||||
// from the mesh's export) finishes them. Nothing returns early while a copy is sealed to another key.
|
||||
func (v *Vault) SetOperatorKey(key, by string) (int, error) {
|
||||
raw, err := checkOperatorKey(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
current, err := v.Store.OperatorKey()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if current != raw {
|
||||
if err := v.Store.setOperatorKey(raw, by, v.Now().UTC()); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v.log("the operator key is now %s (by %s); every operator copy is sealed again to it", raw, by)
|
||||
}
|
||||
return v.reseal(raw)
|
||||
}
|
||||
|
||||
// Reseal seals again to the current operator key every copy sealed to another one, and says how many it
|
||||
// did. Called every minute, so a copy a failure left behind is not left for ever.
|
||||
func (v *Vault) Reseal() (int, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
key, err := v.Store.OperatorKey()
|
||||
if err != nil || key == "" {
|
||||
return 0, err
|
||||
}
|
||||
return v.reseal(key)
|
||||
}
|
||||
|
||||
func (v *Vault) reseal(key string) (int, error) {
|
||||
all, err := v.Store.List()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
var failed []error
|
||||
for _, rec := range all {
|
||||
if rec.Operator.Key == key && rec.Operator.Box == OperatorBox {
|
||||
continue
|
||||
}
|
||||
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Errorf("%s does not open with the vault's key: %w", rec.Name, err))
|
||||
continue
|
||||
}
|
||||
sealed, err := operatorSeal(key, []byte(value))
|
||||
if err == nil {
|
||||
rec.Operator = OperatorCopy{Box: OperatorBox, Key: key, Sealed: sealed}
|
||||
err = v.Store.Write(rec)
|
||||
}
|
||||
if err != nil {
|
||||
failed = append(failed, fmt.Errorf("%s: %w", rec.Name, err))
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
err := fmt.Errorf("%d operator copies are still sealed to an earlier key, tried again on the next pass: %w",
|
||||
len(failed), errors.Join(failed...))
|
||||
v.log("%v", err)
|
||||
return n, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// StillSealedToAnother is how many operator copies are not yet sealed to the current operator key.
|
||||
func (v *Vault) StillSealedToAnother() (int, error) {
|
||||
key, err := v.Store.OperatorKey()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
all, err := v.Store.List()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, rec := range all {
|
||||
if rec.Operator.Key != key {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
package main
|
||||
|
||||
// ADR 0349's vault test list, the part this package (V1, issue 674) answers: no bucket holds a secret; no
|
||||
// private key is ever sent to the vault; every secret has a copy sealed to the operator key; `get` answers
|
||||
// only an allowed asker, sealed to its key; `secret-changed` carries no value. The rest of that list (a
|
||||
// consumer binding remakes the provider's credential, an accepted value is not remade, issued and typed
|
||||
// values) is V2's and V5's.
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// published is everything the vault said on the bus during a test, as it said it.
|
||||
type published struct {
|
||||
mu sync.Mutex
|
||||
events []map[string]any
|
||||
raw []string
|
||||
}
|
||||
|
||||
func (p *published) emit(event string, body map[string]any) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
b, _ := json.Marshal(map[string]any{"event": event, "body": body})
|
||||
p.raw = append(p.raw, string(b))
|
||||
p.events = append(p.events, map[string]any{"event": event, "body": body})
|
||||
return nil
|
||||
}
|
||||
|
||||
// operatorKey is an operator key the way the controller's `operator key make` makes one: NaCl, raw X25519,
|
||||
// base64 — the private half kept by the test, as the operator keeps it off the mesh.
|
||||
func operatorKey(t *testing.T) (public, private string) {
|
||||
t.Helper()
|
||||
pub, priv, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(pub[:]), base64.StdEncoding.EncodeToString(priv[:])
|
||||
}
|
||||
|
||||
// asPEM is a raw operator key as the SPKI PEM the vault takes.
|
||||
func asPEM(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
p, err := publicPEM(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// openOperator opens an operator copy the way `secret recover --from-export` does.
|
||||
func openOperator(t *testing.T, public, private, sealed string) string {
|
||||
t.Helper()
|
||||
var pub, priv [32]byte
|
||||
p, _ := base64.StdEncoding.DecodeString(public)
|
||||
q, _ := base64.StdEncoding.DecodeString(private)
|
||||
copy(pub[:], p)
|
||||
copy(priv[:], q)
|
||||
blob, err := base64.StdEncoding.DecodeString(sealed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, ok := box.OpenAnonymous(nil, blob, &pub, &priv)
|
||||
if !ok {
|
||||
t.Fatal("the operator copy does not open with the operator's key")
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
type testVault struct {
|
||||
*Vault
|
||||
dir string
|
||||
bus *published
|
||||
operatorPublic string
|
||||
operatorPrivate string
|
||||
}
|
||||
|
||||
func newVault(t *testing.T, askers Askers) testVault {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store, err := OpenStore(filepath.Join(dir, "store"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
own, err := OwnKey(filepath.Join(dir, "state"), store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bus := &published{}
|
||||
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
|
||||
v := &Vault{Store: store, Own: own, Askers: askers, Emit: bus.emit,
|
||||
Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Log: t.Logf}
|
||||
pub, priv := operatorKey(t)
|
||||
if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return testVault{Vault: v, dir: dir, bus: bus, operatorPublic: pub, operatorPrivate: priv}
|
||||
}
|
||||
|
||||
// filesUnder is every file's content under dir.
|
||||
func filesUnder(t *testing.T, dir string) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() {
|
||||
raw, _ := os.ReadFile(path)
|
||||
out[path] = string(raw)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- no bucket holds a secret ---------------------------------------------------------------------------
|
||||
|
||||
// The manifest declares no bucket, and the code opens none: the vault's only place for a secret is its own
|
||||
// store on its own disk (ADR 0349 §7, ADR 0201 §13).
|
||||
func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest map[string]any
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, ok := manifest["state"]; ok {
|
||||
t.Errorf("the manifest declares buckets %v: a secret is in no bucket, sealed or not (ADR 0201 §13)", s)
|
||||
}
|
||||
sources, _ := filepath.Glob("*.go")
|
||||
bucket := regexp.MustCompile(`stdio\.State\(|nats-io|jetstream|KeyValue`)
|
||||
for _, f := range sources {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
code, _ := os.ReadFile(f)
|
||||
if m := bucket.Find(code); m != nil {
|
||||
t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m)
|
||||
}
|
||||
}
|
||||
|
||||
// And what the vault publishes while it holds, changes and hands out secrets carries none of them, in
|
||||
// the clear or sealed.
|
||||
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
|
||||
value := "the-password-nobody-may-see"
|
||||
if _, err := v.Put("pair/postgres/gitea", value, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _, _ := v.Store.Read("pair/postgres/gitea")
|
||||
for _, said := range v.bus.raw {
|
||||
for _, secret := range []string{value, answer.Sealed.Ct, rec.AtRest.Ct, rec.Operator.Sealed} {
|
||||
if strings.Contains(said, secret) {
|
||||
t.Errorf("the vault published %s, which carries a secret or a sealed copy of one", said)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every secret is encrypted at rest: no file the vault writes holds a value in the clear, and each opens
|
||||
// only with the vault's own key.
|
||||
func TestEverySecretIsEncryptedAtRest(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
values := map[string]string{"own/g14/dunst/token": "tok-1-plain", "pair/postgres/gitea": "pw-2-plain"}
|
||||
for name, value := range values {
|
||||
if _, err := v.Put(name, value, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for path, content := range filesUnder(t, filepath.Join(v.dir, "store")) {
|
||||
for _, value := range values {
|
||||
if strings.Contains(content, value) {
|
||||
t.Errorf("%s holds a value in the clear", path)
|
||||
}
|
||||
}
|
||||
if info, _ := os.Stat(path); info.Mode().Perm()&0o077 != 0 {
|
||||
t.Errorf("%s is readable by others: %v", path, info.Mode())
|
||||
}
|
||||
}
|
||||
for name, value := range values {
|
||||
rec, ok, err := v.Store.Read(name)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("%s: %v %v", name, ok, err)
|
||||
}
|
||||
got, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
|
||||
if err != nil || got != value {
|
||||
t.Errorf("%s at rest opens to %q (%v), not its value", name, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- no private key is accepted -------------------------------------------------------------------------
|
||||
|
||||
func TestNoPrivateKeyIsAccepted(t *testing.T) {
|
||||
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
|
||||
keys, _ := seal.GenerateKeyPair()
|
||||
ssh := "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----\n"
|
||||
rsa := "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAK\n-----END RSA PRIVATE KEY-----\n"
|
||||
|
||||
// Not as a secret to hold: a private key stays where it was made (ADR 0113 §2).
|
||||
for _, value := range []string{keys.PrivateKey, ssh, rsa, "prefix " + keys.PrivateKey} {
|
||||
if _, err := v.Put("own/g14/sshd/host", value, "the test"); err == nil {
|
||||
t.Errorf("the vault took a private key as a secret: %.40q", value)
|
||||
}
|
||||
}
|
||||
if _, ok, _ := v.Store.Read("own/g14/sshd/host"); ok {
|
||||
t.Error("a refused private key was kept all the same")
|
||||
}
|
||||
|
||||
// Not as the key to seal an answer to.
|
||||
if _, err := v.Put("pair/postgres/gitea", "pw", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw32 := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
for _, key := range []string{keys.PrivateKey, ssh, raw32, "", "not a key"} {
|
||||
if _, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", key); err == nil {
|
||||
t.Errorf("get sealed an answer to %.40q, which is not a PEM public key", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Not as the operator key: neither a PEM private key nor a raw key, which looks the same as a raw
|
||||
// private one.
|
||||
rawPub, rawPriv := operatorKey(t)
|
||||
for _, key := range []string{keys.PrivateKey, rawPriv, rawPub} {
|
||||
if _, err := v.SetOperatorKey(key, "the test"); err == nil {
|
||||
t.Errorf("the vault took %.20q as the operator key", key)
|
||||
}
|
||||
}
|
||||
if k, _ := v.Store.OperatorKey(); k != v.operatorPublic {
|
||||
t.Error("a refused operator key replaced the operator key")
|
||||
}
|
||||
|
||||
// And nothing it publishes or writes names one that it refused.
|
||||
for _, said := range v.bus.raw {
|
||||
if strings.Contains(said, "PRIVATE KEY") {
|
||||
t.Errorf("the vault published a private key: %s", said)
|
||||
}
|
||||
}
|
||||
for path, content := range filesUnder(t, filepath.Join(v.dir, "store")) {
|
||||
if strings.Contains(content, "PRIVATE KEY") {
|
||||
t.Errorf("%s holds a private key", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- every secret has an operator-sealed copy -----------------------------------------------------------
|
||||
|
||||
func TestEverySecretHasAnOperatorSealedCopy(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
values := map[string]string{"own/g14/dunst/token": "a", "pair/postgres/gitea": "b"}
|
||||
for name, value := range values {
|
||||
if _, err := v.Put(name, value, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := v.Mint("pair/postgres/keycloak", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err := v.Store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("the store lists %d secrets, not 3", len(all))
|
||||
}
|
||||
for _, rec := range all {
|
||||
if rec.Operator.Key != v.operatorPublic || rec.Operator.Box != OperatorBox {
|
||||
t.Errorf("%s is sealed to %q in %q, not to the operator key", rec.Name, rec.Operator.Key, rec.Operator.Box)
|
||||
}
|
||||
got := openOperator(t, v.operatorPublic, v.operatorPrivate, rec.Operator.Sealed)
|
||||
want, _ := seal.Open(rec.AtRest, v.Own.PrivateKey)
|
||||
if got != want || got == "" {
|
||||
t.Errorf("%s: the operator copy opens to %q, the vault's to %q", rec.Name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A new operator key: every copy is sealed again to it, and none stays sealed to the old one.
|
||||
pub, priv := operatorKey(t)
|
||||
n, err := v.SetOperatorKey(asPEM(t, pub), "the test")
|
||||
if err != nil || n != 3 {
|
||||
t.Fatalf("a new operator key sealed %d copies again (%v), not 3", n, err)
|
||||
}
|
||||
all, _ = v.Store.List()
|
||||
for _, rec := range all {
|
||||
if rec.Operator.Key != pub {
|
||||
t.Errorf("%s is still sealed to the earlier operator key", rec.Name)
|
||||
}
|
||||
openOperator(t, pub, priv, rec.Operator.Sealed)
|
||||
}
|
||||
}
|
||||
|
||||
// Without an operator key the vault keeps nothing: a secret only it could open is what ADR 0113 §22 rules
|
||||
// out, so the refusal is loud rather than a copy quietly missing.
|
||||
func TestNoOperatorKeyKeepsNothing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, _ := OpenStore(filepath.Join(dir, "store"))
|
||||
own, _ := OwnKey(filepath.Join(dir, "state"), store)
|
||||
v := &Vault{Store: store, Own: own, Emit: (&published{}).emit, Now: time.Now, Log: t.Logf}
|
||||
if _, err := v.Put("own/g14/dunst/token", "x", "the test"); err == nil || !strings.Contains(err.Error(), "operator key") {
|
||||
t.Fatalf("a secret was kept with no operator key to seal its copy to: %v", err)
|
||||
}
|
||||
if all, _ := store.List(); len(all) != 0 {
|
||||
t.Fatalf("the store holds %d secrets", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- get answers only an allowed asker, sealed to its key -----------------------------------------------
|
||||
|
||||
func TestGetAnswersOnlyAnAllowedAskerSealedToItsKey(t *testing.T) {
|
||||
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
|
||||
if _, err := v.Put("pair/postgres/gitea", "the-value", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Put("own/ace/dunst/token", "another", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
|
||||
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := seal.Open(answer.Sealed, asker.PrivateKey); err != nil || got != "the-value" {
|
||||
t.Fatalf("the answer opens to %q (%v)", got, err)
|
||||
}
|
||||
other, _ := seal.GenerateKeyPair()
|
||||
if _, err := seal.Open(answer.Sealed, other.PrivateKey); err == nil {
|
||||
t.Error("the answer opens with a key it was not sealed to")
|
||||
}
|
||||
if _, err := seal.Open(answer.Sealed, v.Own.PrivateKey); err == nil {
|
||||
t.Error("the answer opens with the vault's own key")
|
||||
}
|
||||
if answer.Generation != 1 || answer.Name != "pair/postgres/gitea" {
|
||||
t.Errorf("the answer says %+v", answer)
|
||||
}
|
||||
|
||||
refusals := []struct{ caller, name, why string }{
|
||||
{"ace.node-provisioner", "pair/postgres/gitea", "an asker not on the list"},
|
||||
{"g14.node-provisioner", "own/ace/dunst/token", "a secret the asker is not allowed"},
|
||||
{stdio.Unproven, "pair/postgres/gitea", "a call whose caller the runtime did not prove"},
|
||||
{"", "pair/postgres/gitea", "no caller at all"},
|
||||
{"g14.node-provisioner", "pair/postgres/nothing", "a secret the vault does not hold"},
|
||||
}
|
||||
for _, r := range refusals {
|
||||
if a, err := v.Get(r.caller, r.name, asker.PublicKey); err == nil {
|
||||
t.Errorf("get answered %s: %+v", r.why, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Until who may ask is decided by a node's declaration and a module's own account (V4, P3), the vault
|
||||
// that runs answers nobody.
|
||||
func TestTheRunningVaultAnswersNobodyYet(t *testing.T) {
|
||||
v := newVault(t, nil) // what main builds: no list
|
||||
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
for _, caller := range []string{"g14.node-provisioner", "novox.mesh-provisioner", "controller", "person.jochen", "novox.node-tools"} {
|
||||
if _, err := v.Get(caller, "pair/postgres/gitea", asker.PublicKey); err == nil {
|
||||
t.Errorf("the running vault answered %s", caller)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The node-engine's sealing key (mesh-host internal/identity/sealing.go: raw X25519, base64, the NaCl
|
||||
// anonymous box) is the same curve as the ADR 0206 box. A node's client asks with that key's public half as
|
||||
// SPKI PEM, and opens the answer with its private half as PKCS #8 — no second key on the node, and no NaCl
|
||||
// in the vault's answers. This is the path V4 builds on.
|
||||
func TestANodesSealingKeyOpensTheAnswer(t *testing.T) {
|
||||
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
|
||||
if _, err := v.Put("pair/postgres/gitea", "for-the-node", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// As mesh-host's GenerateSealingKey makes it.
|
||||
priv, _ := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
nodePublic := base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes())
|
||||
nodePrivate := base64.StdEncoding.EncodeToString(priv.Bytes())
|
||||
|
||||
rawPub, _ := base64.StdEncoding.DecodeString(nodePublic)
|
||||
pubKey, _ := ecdh.X25519().NewPublicKey(rawPub)
|
||||
spki, _ := x509.MarshalPKIXPublicKey(pubKey)
|
||||
askWith := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spki}))
|
||||
|
||||
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", askWith)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawPriv, _ := base64.StdEncoding.DecodeString(nodePrivate)
|
||||
privKey, _ := ecdh.X25519().NewPrivateKey(rawPriv)
|
||||
pkcs8, _ := x509.MarshalPKCS8PrivateKey(privKey)
|
||||
openWith := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}))
|
||||
if got, err := seal.Open(answer.Sealed, openWith); err != nil || got != "for-the-node" {
|
||||
t.Fatalf("the node's own sealing key does not open the answer: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- secret-changed carries no value --------------------------------------------------------------------
|
||||
|
||||
func TestSecretChangedCarriesNoValue(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.Put("pair/postgres/gitea", "first-value", "g14.mesh-vault-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Put("pair/postgres/gitea", "second-value", "person.jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Mint("own/g14/dunst/token", "the vault"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var changed []map[string]any
|
||||
for _, e := range v.bus.events {
|
||||
if e["event"] == ChangedEvent {
|
||||
changed = append(changed, e["body"].(map[string]any))
|
||||
}
|
||||
}
|
||||
if len(changed) != 3 {
|
||||
t.Fatalf("%d secret-changed events, not 3: %v", len(changed), v.bus.events)
|
||||
}
|
||||
for i, body := range changed {
|
||||
keys := make([]string, 0, len(body))
|
||||
for k := range body {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
for _, k := range keys {
|
||||
switch k {
|
||||
case "name", "by", "at", "generation":
|
||||
default:
|
||||
t.Errorf("secret-changed carries %q, beyond who, when, which and its generation", k)
|
||||
}
|
||||
}
|
||||
if body["by"] == "" || body["at"] == "" {
|
||||
t.Errorf("secret-changed %d says not who or when: %v", i, body)
|
||||
}
|
||||
}
|
||||
if changed[1]["by"] != "person.jochen" || changed[1]["generation"] != 2 {
|
||||
t.Errorf("the second change says %v", changed[1])
|
||||
}
|
||||
minted, _, _ := v.Store.Read("own/g14/dunst/token")
|
||||
mintedValue, _ := seal.Open(minted.AtRest, v.Own.PrivateKey)
|
||||
for _, said := range v.bus.raw {
|
||||
for _, value := range []string{"first-value", "second-value", mintedValue} {
|
||||
if strings.Contains(said, value) {
|
||||
t.Errorf("an event carries a value: %s", said)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same value again is no change: no new generation, no event.
|
||||
func TestTheSameValueAgainIsNoChange(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := v.Put("pair/postgres/gitea", "same", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rec, _, _ := v.Store.Read("pair/postgres/gitea")
|
||||
if rec.Generation != 1 {
|
||||
t.Errorf("the same value twice is generation %d", rec.Generation)
|
||||
}
|
||||
n := 0
|
||||
for _, e := range v.bus.events {
|
||||
if e["event"] == ChangedEvent {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("%d secret-changed events for one value", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A secret's name is a name, not a path.
|
||||
func TestASecretsNameIsNotAPath(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
for _, name := range []string{"../etc/passwd", "/abs", "a//b", "A/b", "a/../b", "", strings.Repeat("a", 300)} {
|
||||
if _, err := v.Put(name, "x", "the test"); err == nil {
|
||||
t.Errorf("the vault kept a secret named %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The vault's own key is made once, kept beside it, and the same on the next start.
|
||||
func TestTheVaultsOwnKeyIsMadeOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, err := OwnKey(dir, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := OwnKey(dir, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a != b {
|
||||
t.Error("the vault's key changed between two starts")
|
||||
}
|
||||
info, _ := os.Stat(filepath.Join(dir, OwnKeyFile))
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the vault's key is %v", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
// The vault's key gone while its store holds secrets: no new key is made, and the vault does not start.
|
||||
func TestAMissingKeyBesideSecretsIsRefused(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(v.dir, "state", OwnKeyFile)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OwnKey(filepath.Join(v.dir, "state"), v.Store); err == nil || !strings.Contains(err.Error(), "MISSING") {
|
||||
t.Fatalf("a new key was made beside secrets sealed to the lost one: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.dir, "state", OwnKeyFile)); err == nil {
|
||||
t.Error("a key file was written all the same")
|
||||
}
|
||||
}
|
||||
|
||||
// A new operator key that fails partway leaves no copy sealed to the earlier key for good: the next call
|
||||
// with the same key — every minute, from the mesh's export — finishes the rest.
|
||||
func TestANewOperatorKeyFailingPartwayIsFinished(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
for _, name := range []string{"a/one", "b/two", "c/three"} {
|
||||
if _, err := v.Mint(name, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
pub, priv := operatorKey(t)
|
||||
v.Store.failWrite = func(r Record) error {
|
||||
if r.Name == "b/two" {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return nil
|
||||
}
|
||||
n, err := v.SetOperatorKey(asPEM(t, pub), "the test")
|
||||
if err == nil || n != 2 {
|
||||
t.Fatalf("a failure partway said %d sealed again, err %v", n, err)
|
||||
}
|
||||
if left, _ := v.StillSealedToAnother(); left != 1 {
|
||||
t.Fatalf("%d copies left on the earlier key, not 1", left)
|
||||
}
|
||||
v.Store.failWrite = nil
|
||||
// The path the minute takes while the export holds the key: the same key again.
|
||||
if n, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil || n != 1 {
|
||||
t.Fatalf("the same key again sealed %d again (%v), not the 1 left", n, err)
|
||||
}
|
||||
if n, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil || n != 0 {
|
||||
t.Fatalf("the same key a third time sealed %d (%v)", n, err)
|
||||
}
|
||||
all, _ := v.Store.List()
|
||||
for _, rec := range all {
|
||||
if rec.Operator.Key != pub {
|
||||
t.Errorf("%s is still sealed to an earlier key", rec.Name)
|
||||
}
|
||||
openOperator(t, pub, priv, rec.Operator.Sealed)
|
||||
}
|
||||
}
|
||||
|
||||
// Who was handed which secret, and when, and who was refused, is in the vault's own store.
|
||||
func TestWhoWasHandedWhatIsRecordedInTheStore(t *testing.T) {
|
||||
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
|
||||
if _, err := v.Put("pair/postgres/gitea", "the-value", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
if _, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = v.Get("ace.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
|
||||
handed, err := v.Store.Handed(10)
|
||||
if err != nil || len(handed) != 2 {
|
||||
t.Fatalf("%v %v", handed, err)
|
||||
}
|
||||
if handed[1].To != "g14.node-provisioner" || handed[1].Outcome != "handed" || handed[1].Generation != 1 || handed[1].At.IsZero() {
|
||||
t.Errorf("the hand-out is recorded as %+v", handed[1])
|
||||
}
|
||||
if handed[0].To != "ace.node-provisioner" || handed[0].Outcome != "refused" || handed[0].Why == "" {
|
||||
t.Errorf("the refusal is recorded as %+v", handed[0])
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(v.dir, "store", "handed.jsonl"))
|
||||
if strings.Contains(string(raw), "the-value") {
|
||||
t.Error("the record of hand-outs holds a value")
|
||||
}
|
||||
}
|
||||
|
||||
// The vault's operator copies reach the operator's recovery: secret_vault_export is a document
|
||||
// `secret recover --from-export` reads, found by node "vault", module "mesh-vault" and the secret's name,
|
||||
// and opened with the operator's private key as that command opens it (mesh-controller
|
||||
// cmd/mesh-controller/secret.go keptFromExport, internal/secrets Open).
|
||||
func TestTheOperatorRecoversAVaultSecretFromItsExport(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.Put("pair/postgres/gitea", "recover-me", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "vault-export.json")
|
||||
raw, _ := json.Marshal(answer)
|
||||
_ = os.WriteFile(file, raw, 0o600)
|
||||
|
||||
// What `secret recover vault mesh-vault pair/postgres/gitea --from-export <file>` reads.
|
||||
var e struct {
|
||||
Kept []struct {
|
||||
Node, Module, Name, Sealed, Key string
|
||||
} `json:"kept"`
|
||||
}
|
||||
read, _ := os.ReadFile(file)
|
||||
if err := json.Unmarshal(read, &e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sealed string
|
||||
for _, k := range e.Kept {
|
||||
if k.Node == "vault" && k.Module == "mesh-vault" && k.Name == "pair/postgres/gitea" {
|
||||
sealed = k.Sealed
|
||||
if k.Key != v.operatorPublic {
|
||||
t.Errorf("the export names key %s, not the operator's", k.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sealed == "" {
|
||||
t.Fatalf("the export holds no copy of the secret: %s", read)
|
||||
}
|
||||
// secrets.Open: the public half derived from the private key, then the anonymous box.
|
||||
privRaw, _ := base64.StdEncoding.DecodeString(v.operatorPrivate)
|
||||
key, _ := ecdh.X25519().NewPrivateKey(privRaw)
|
||||
if got := openOperator(t, base64.StdEncoding.EncodeToString(key.PublicKey().Bytes()), v.operatorPrivate, sealed); got != "recover-me" {
|
||||
t.Fatalf("recovered %q", got)
|
||||
}
|
||||
if strings.Contains(string(read), "recover-me") {
|
||||
t.Error("the export holds the value in the clear")
|
||||
}
|
||||
}
|
||||
|
||||
// With no key in the export the minute calls Reseal, which finishes what is pending too.
|
||||
func TestResealFinishesWhatIsPending(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
if _, err := v.Mint("a/one", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, _ := operatorKey(t)
|
||||
v.Store.failWrite = func(Record) error { return os.ErrPermission }
|
||||
if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err == nil {
|
||||
t.Fatal("a failing write was not said")
|
||||
}
|
||||
v.Store.failWrite = nil
|
||||
if n, err := v.Reseal(); err != nil || n != 1 {
|
||||
t.Fatalf("%d %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A secret is not handed out when who was handed it cannot be recorded.
|
||||
func TestNoHandOutWithoutItsRecord(t *testing.T) {
|
||||
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
|
||||
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(v.dir, "store", "handed.jsonl"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
if a, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey); err == nil {
|
||||
t.Fatalf("handed out with no record of it: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// What an asker cannot shape fills no record: a name that is no secret's name, a caller that is no bus user.
|
||||
func TestAnArbitraryAskFillsNoRecord(t *testing.T) {
|
||||
v := newVault(t, nil)
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
for _, ask := range [][2]string{{"g14.x", strings.Repeat("junk ", 1000)}, {"g14.x", "../../etc"},
|
||||
{"not a bus user!", "pair/a"}, {strings.Repeat("a", 300), "pair/a"}} {
|
||||
_, _ = v.Get(ask[0], ask[1], asker.PublicKey)
|
||||
}
|
||||
if h, _ := v.Store.Handed(10); len(h) != 0 {
|
||||
t.Errorf("recorded %d arbitrary asks: %+v", len(h), h)
|
||||
}
|
||||
}
|
||||
|
||||
// The record of hand-outs is bounded: rolled at its size into a fixed number of files, the oldest dropped.
|
||||
func TestTheRecordOfHandOutsIsBounded(t *testing.T) {
|
||||
v := newVault(t, nil) // refuses everyone, as the running vault does
|
||||
v.Store.HandedMax = 2000
|
||||
asker, _ := seal.GenerateKeyPair()
|
||||
for i := 0; i < 500; i++ {
|
||||
_, _ = v.Get("g14.flood", "pair/postgres/gitea", asker.PublicKey)
|
||||
}
|
||||
var total int64
|
||||
files, _ := filepath.Glob(filepath.Join(v.dir, "store", "handed.jsonl*"))
|
||||
if len(files) != HandedKept+1 {
|
||||
t.Errorf("%d files of the record, not %d: %v", len(files), HandedKept+1, files)
|
||||
}
|
||||
for _, f := range files {
|
||||
info, _ := os.Stat(f)
|
||||
if info.Size() > 2000 {
|
||||
t.Errorf("%s is %d bytes, over its bound", f, info.Size())
|
||||
}
|
||||
total += info.Size()
|
||||
}
|
||||
if total > int64(HandedKept+1)*2000 {
|
||||
t.Errorf("the record holds %d bytes", total)
|
||||
}
|
||||
if v.Store.Rolled() == 0 {
|
||||
t.Error("rolling was not counted")
|
||||
}
|
||||
if h, _ := v.Store.Handed(5); len(h) != 5 || h[0].To != "g14.flood" {
|
||||
t.Errorf("the newest are not read back: %+v", h)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
module mesh-vault
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009
|
||||
golang.org/x/crypto v0.50.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.43.0 // indirect
|
||||
@@ -0,0 +1,6 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
@@ -1,31 +0,0 @@
|
||||
// mesh-vault's events entrypoint, loaded by the per-node tool host (the provisioner runs in the same
|
||||
// process — ADR 0052). The lifecycle events are EMITTED from the provisioner, where custody
|
||||
// actually changes (novox/hq ADR 0041/0042):
|
||||
// mesh-vault.provisioned — a consumer was granted a secret
|
||||
// mesh-vault.rotated — that consumer's value changed (`rotate secret`)
|
||||
// mesh-vault.deprovisioned — the consumer went away and its secret was withdrawn
|
||||
// Here the vault reacts to them, keeping a lightweight audit line of who holds what and when it
|
||||
// moved — the audit an owner of secrets is best placed to log. Fingerprints, never values.
|
||||
|
||||
import { on } from "@novox/mesh-sdk/events";
|
||||
|
||||
interface SecretEvent {
|
||||
as: string;
|
||||
consumer?: string;
|
||||
fingerprint?: string;
|
||||
rotations?: number;
|
||||
}
|
||||
|
||||
await on<SecretEvent>("provisioned", async (e) => {
|
||||
console.log(`[mesh-vault] secret provisioned for ${e.body?.as} on ${e.body?.consumer} (${e.body?.fingerprint})`);
|
||||
});
|
||||
|
||||
await on<SecretEvent>("rotated", async (e) => {
|
||||
console.log(`[mesh-vault] secret rotated for ${e.body?.as} — rotation ${e.body?.rotations} (${e.body?.fingerprint})`);
|
||||
});
|
||||
|
||||
await on<SecretEvent>("deprovisioned", async (e) => {
|
||||
console.log(`[mesh-vault] secret withdrawn from ${e.body?.as}`);
|
||||
});
|
||||
|
||||
console.log("[mesh-vault] auditing secret lifecycle events");
|
||||
@@ -10,18 +10,10 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"provisioned",
|
||||
"rotated",
|
||||
"deprovisioned"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-vault.provisioned",
|
||||
"mesh-vault.rotated",
|
||||
"mesh-vault.deprovisioned"
|
||||
"secret-changed"
|
||||
],
|
||||
"receives": {
|
||||
"secret": "${dir:grants}/mesh.json"
|
||||
@@ -49,6 +41,12 @@
|
||||
"path": "${dir:root}",
|
||||
"class": "valuable",
|
||||
"why": "the vault's root material"
|
||||
},
|
||||
{
|
||||
"id": "store",
|
||||
"path": "${dir:store}",
|
||||
"class": "valuable",
|
||||
"why": "every secret the vault holds, sealed to its own key and to the operator key"
|
||||
}
|
||||
],
|
||||
"consumers": {
|
||||
@@ -80,6 +78,11 @@
|
||||
"id": "root",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "store",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -87,29 +90,47 @@
|
||||
{
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-vault",
|
||||
"binary": "mesh-vault",
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
"mesh-vault"
|
||||
],
|
||||
"env": {
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||
"MESH_VAULT_LEDGER": "${dir:ledger}",
|
||||
"MESH_VAULT_ROOT": "${dir:root}"
|
||||
"MESH_VAULT_ROOT": "${dir:root}",
|
||||
"MESH_VAULT_STORE": "${dir:store}",
|
||||
"MESH_VAULT_STATE": "${dir:state}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"tools": [
|
||||
"secret_export",
|
||||
"secret_holders",
|
||||
"secret_verify",
|
||||
"secret_handed",
|
||||
"secret_asks",
|
||||
"secret_vault_export",
|
||||
"provisioner_retirement",
|
||||
"provisioner_retire_approve",
|
||||
"provisioner_retire_reject",
|
||||
"provisioner_delete",
|
||||
"vault_status"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-vault",
|
||||
"scope": "mesh"
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"get",
|
||||
"accept",
|
||||
"secret-ask",
|
||||
"issued",
|
||||
"rotate"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "@novox/module-mesh-vault",
|
||||
"version": "0.1.0",
|
||||
"description": "mesh-vault — provides the mesh `secret` interface: a module's own secret as an ordinary pair credential, held, audited and rotated like any other (novox/hq ADR 0085). Its ledger, provisioner, tools and events live here (ADR 0039).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// mesh-vault's provisioner — the adapter that makes vault a provider of the mesh `secret` interface. The
|
||||
// reconcile loop, the contributions file, and reading the mesh's minted value are the sdk harness's;
|
||||
// this writes only the per-service half (novox/hq ADR 0039/0040/0048) — and for a vault that half is
|
||||
// taking custody, not creating anything.
|
||||
//
|
||||
// The `secret` interface (ADR 0085, design 24): a consumer requires a value for its own use — the
|
||||
// password of a store it runs privately, an internal token — and reads it from the file the mesh
|
||||
// writes on its machine. There is no server to create a login on. **The value is the pair
|
||||
// credential itself**: the controller minted it, sealed it to both nodes, and delivered each its
|
||||
// copy. What makes it *owned* is this: the vault records who holds it and its fingerprint, notices
|
||||
// when `rotate secret` delivers a different one, and says so on the mesh. Rotation is not new
|
||||
// machinery — it is the machinery that already moves a database password, pointed at a secret the
|
||||
// vault provides (design 13).
|
||||
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { Ledger } from "../client.js";
|
||||
|
||||
const ledger = Ledger.fromEnv();
|
||||
|
||||
/** Emit a lifecycle event without letting a broker hiccup fail the custody itself. */
|
||||
async function announce(type: string, body: Record<string, string | number>): Promise<void> {
|
||||
try {
|
||||
await emit(type, body);
|
||||
} catch (err) {
|
||||
console.error(`[provisioner:secret] emit ${type} failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
runProvisioner("secret", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
const { held, outcome } = ledger.record(p.as, p.consumer ?? "", p.password);
|
||||
if (outcome === "unchanged") return; // the harness re-runs create on restart; nothing happened
|
||||
console.log(`[mesh-vault] ${outcome}: ${held.as} (${held.fingerprint.slice(0, 19)}…, rotations ${held.rotations})`);
|
||||
await announce(`module.mesh-vault.secret.${outcome === "granted" ? "provisioned" : "rotated"}`, {
|
||||
consumer: held.consumer,
|
||||
as: held.as,
|
||||
fingerprint: held.fingerprint,
|
||||
rotations: held.rotations,
|
||||
});
|
||||
},
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
if (!ledger.withdraw(p.as)) return;
|
||||
console.log(`[mesh-vault] withdrawn: ${p.as}`);
|
||||
await announce("deprovisioned", { as: p.as });
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user