From 4964ad1037b9692a1877d672d775ccc7a56d0c96 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 14:18:30 +0100 Subject: [PATCH 1/2] Remove route-proxy from the controller's repository, which it left for mesh-networking The group's check composed route-proxy twice, here and in mesh-networking; the move is one delivery group (issue 1126), so it leaves here in the same group. The tests that read its directory go with it. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- internal/gosource/gosource_test.go | 35 - internal/ownmanifest/image_place_test.go | 22 - internal/ownmanifest/public_issuer_test.go | 160 --- modules/route-proxy/README.md | 130 --- .../route-proxy/cmd/route-provider/main.go | 45 - .../cmd/route-provider/provider.go | 38 - .../cmd/route-provider/provider_test.go | 159 --- .../route-proxy/cmd/route-provider/tools.go | 296 ------ .../cmd/route-provider/tools_test.go | 99 -- .../cmd/route-proxy/authority_test.go | 75 -- modules/route-proxy/cmd/route-proxy/bus.go | 194 ---- .../cmd/route-proxy/challenge_test.go | 109 -- .../cmd/route-proxy/forwarded_test.go | 29 - modules/route-proxy/cmd/route-proxy/ledger.go | 552 ---------- .../cmd/route-proxy/ledger_test.go | 291 ------ modules/route-proxy/cmd/route-proxy/main.go | 975 ------------------ modules/route-proxy/cmd/route-proxy/plans.go | 147 --- .../cmd/route-proxy/policy_test.go | 265 ----- .../route-proxy/cmd/route-proxy/reach_test.go | 331 ------ .../cmd/route-proxy/routes_test.go | 589 ----------- modules/route-proxy/contract/contract.go | 77 -- modules/route-proxy/contract/contract_test.go | 85 -- modules/route-proxy/contract/go.mod | 3 - modules/route-proxy/contract/request.go | 128 --- .../route-proxy/contract/route.schema.json | 28 - modules/route-proxy/contract/state.go | 152 --- modules/route-proxy/files/acme.env | 3 - modules/route-proxy/files/internal-acme.env | 3 - modules/route-proxy/go.mod | 19 - modules/route-proxy/go.sum | 32 - modules/route-proxy/module.json | 272 ----- 31 files changed, 5343 deletions(-) delete mode 100644 internal/ownmanifest/public_issuer_test.go delete mode 100644 modules/route-proxy/README.md delete mode 100644 modules/route-proxy/cmd/route-provider/main.go delete mode 100644 modules/route-proxy/cmd/route-provider/provider.go delete mode 100644 modules/route-proxy/cmd/route-provider/provider_test.go delete mode 100644 modules/route-proxy/cmd/route-provider/tools.go delete mode 100644 modules/route-proxy/cmd/route-provider/tools_test.go delete mode 100644 modules/route-proxy/cmd/route-proxy/authority_test.go delete mode 100644 modules/route-proxy/cmd/route-proxy/bus.go delete mode 100644 modules/route-proxy/cmd/route-proxy/challenge_test.go delete mode 100644 modules/route-proxy/cmd/route-proxy/forwarded_test.go delete mode 100644 modules/route-proxy/cmd/route-proxy/ledger.go delete mode 100644 modules/route-proxy/cmd/route-proxy/ledger_test.go delete mode 100644 modules/route-proxy/cmd/route-proxy/main.go delete mode 100644 modules/route-proxy/cmd/route-proxy/plans.go delete mode 100644 modules/route-proxy/cmd/route-proxy/policy_test.go delete mode 100644 modules/route-proxy/cmd/route-proxy/reach_test.go delete mode 100644 modules/route-proxy/cmd/route-proxy/routes_test.go delete mode 100644 modules/route-proxy/contract/contract.go delete mode 100644 modules/route-proxy/contract/contract_test.go delete mode 100644 modules/route-proxy/contract/go.mod delete mode 100644 modules/route-proxy/contract/request.go delete mode 100644 modules/route-proxy/contract/route.schema.json delete mode 100644 modules/route-proxy/contract/state.go delete mode 100644 modules/route-proxy/files/acme.env delete mode 100644 modules/route-proxy/files/internal-acme.env delete mode 100644 modules/route-proxy/go.mod delete mode 100644 modules/route-proxy/go.sum delete mode 100644 modules/route-proxy/module.json diff --git a/internal/gosource/gosource_test.go b/internal/gosource/gosource_test.go index cba556ba..07bd0630 100644 --- a/internal/gosource/gosource_test.go +++ b/internal/gosource/gosource_test.go @@ -172,41 +172,6 @@ func TestANarrowedTreeHoldsTheBuildSourceAndNothingElse(t *testing.T) { } } -// This repository's own programs: the route proxy lives in its module's own Go module, so its build source is -// not the controller's, and neither holds the other's command. -func TestThisRepositorysProgramsHaveBuildSourcesOfTheirOwn(t *testing.T) { - root := filepath.Join("..", "..") - proxy, err := GoBuildSource(filepath.Join(root, "modules", "route-proxy"), "cmd/route-proxy") - if err != nil { - t.Fatal(err) - } - controller, err := GoBuildSource(root, "cmd/mesh-controller") - if err != nil { - t.Fatal(err) - } - for _, c := range []struct { - entries []string - name string - file string - held bool - }{ - {proxy, "the route proxy", "cmd/route-proxy/main.go", true}, - {proxy, "the route proxy", "cmd/route-proxy/bus.go", true}, - {proxy, "the route proxy", "go.mod", true}, - {proxy, "the route proxy", "cmd/route-provider/main.go", false}, - {proxy, "the route proxy", "README.md", false}, - {controller, "the controller", "cmd/mesh-controller/main.go", true}, - {controller, "the controller", "internal/conditions/condition.go", true}, - {controller, "the controller", "internal/inventory/migrations/0001-nodes.sql", true}, - {controller, "the controller", "modules/route-proxy/cmd/route-proxy/main.go", false}, - {controller, "the controller", "README.md", false}, - } { - if SourceHolds(c.entries, c.file) != c.held { - t.Errorf("%s: %s held %v, wanted %v", c.name, c.file, !c.held, c.held) - } - } -} - // C or assembly beside Go holds its package's directory whole — an include may name a file below it — and a // go.mod made between the module's root and a package is a change. func TestNativeSourcesHoldTheirDirectoryWhole(t *testing.T) { diff --git a/internal/ownmanifest/image_place_test.go b/internal/ownmanifest/image_place_test.go index 155d514e..56bdce41 100644 --- a/internal/ownmanifest/image_place_test.go +++ b/internal/ownmanifest/image_place_test.go @@ -43,28 +43,6 @@ func imagesOutOfPlace(t *testing.T, root string, manifests map[string][]byte) [] return out } -// Every image a module of this repository declares is built from a Dockerfile in the module's own directory -// (novox/hq ADR 0487 clause 13). The catalogue's own modules are held to the same by checks/imageplace in mesh-catalog. -func TestEveryImageArtifactBuildsFromADockerfileInItsOwnModuleDirectory(t *testing.T) { - root := filepath.Join("..", "..", "modules") - paths, err := filepath.Glob(filepath.Join(root, "*", "module.json")) - if err != nil || len(paths) == 0 { - t.Fatalf("no module manifests found in this repository's modules: %v", err) - } - manifests := map[string][]byte{} - for _, p := range paths { - raw, err := os.ReadFile(p) - if err != nil { - t.Fatal(err) - } - manifests[filepath.Base(filepath.Dir(p))] = raw - } - if got := imagesOutOfPlace(t, root, manifests); len(got) != 0 { - t.Errorf("these image artifacts do not build from a Dockerfile in their own module's directory "+ - "(ADR 0487 clause 13): %v", got) - } -} - // The check fails on each bad case and passes on a good one, so it holds something. func TestAnImageWhoseDockerfileIsOutOfPlaceIsFoundAndOneInPlaceIsNot(t *testing.T) { root := t.TempDir() diff --git a/internal/ownmanifest/public_issuer_test.go b/internal/ownmanifest/public_issuer_test.go deleted file mode 100644 index 5cecb50c..00000000 --- a/internal/ownmanifest/public_issuer_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package ownmanifest - -import ( - "path/filepath" - "slices" - "testing" - - . "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The public issuer is the proxy's own fact (novox/hq ADR 0226), and the folding of it must not -// move the proxy's account. -// -// route-proxy keeps each ACME authority's account and certificates in a directory named after a -// digest of the directory URL and of the root bundle its `trust` container copies in -// (modules/route-proxy/cmd/route-proxy, forThisAuthority). Until ADR 0226 the URL was rendered from a binding to -// `public-acme`'s `acme-ca`, spelled with the port. A URL spelled any other way — even the same -// authority without `:443` — or a root bundle from another image is a new authority to the proxy: -// a new account, and every routed name ordered again, on two machines at once, against Let's -// Encrypt's rate limits. So what the module now states is held to exactly what the binding rendered. - -// renderedBeforeTheFold is route-proxy's acme.env as the binding to public-acme rendered it on every -// machine running the proxy, read from the controller's plan on 2026-10-06. -const renderedBeforeTheFold = "ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org:443/directory\n" + - "ACME_ROOTS=https://acme-v02.api.letsencrypt.org:443\n" + - "ACME_ROOTS_PATH=\n" - -// trustImage is the image whose system bundle becomes the public root the account directory is -// named after. Moving it renames that directory. -const trustImage = "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" - -func TestRouteProxyKeepsItsPublicAccountDirectory(t *testing.T) { - m := catalogueManifest(t, "route-proxy") - for _, r := range m.Requires { - if r == "acme-ca" { - t.Fatal("route-proxy still asks for acme-ca; the public issuer is its own fact (ADR 0226)") - } - } - - // As a build would leave it: each artifact a container names resolved to an image. Which image - // does not matter to the file checked here. An archive resource carries no image: it unpacks a bundle. - for _, res := range m.Resources { - if res["type"] == "archive" { - continue - } - if artifact, ok := res["artifact"].(string); ok { - delete(res, "artifact") - res["image"] = "registry.invalid/route-proxy/" + artifact + - "@sha256:1111111111111111111111111111111111111111111111111111111111111111" - } - } - r := Resolution{ - Node: "anchor", - Modules: []Manifest{m}, - Needs: []Needed{{ - Name: "internal-acme-ca", From: "home", At: "home.internal", For: "route-proxy", - Serves: map[string]any{ - "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem", - }, - }}, - } - // Its own broker credential, sealed as the mesh would; what it is does not matter here. - out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{ - "route-proxy": {"broker": "sealed"}}}) - if err != nil { - t.Fatalf("route-proxy could not be composed for a machine: %v", err) - } - env := fileNamed(out, "route-proxy.acme-env") - if env == nil { - t.Fatalf("nothing writes the public issuer's environment: %v", out) - } - if got, _ := env["content"].(string); got != renderedBeforeTheFold { - t.Fatalf("acme.env changed, which moves the proxy's account and reorders every certificate:\n"+ - "got %q\nwant %q", got, renderedBeforeTheFold) - } - if fileNamed(out, "route-proxy.bound-acme-ca") != nil { - t.Error("a binding to acme-ca is still written") - } - - var trust string - if m.Build != nil { - for _, a := range m.Build.Artifacts { - if a.Name == "trust" { - trust = a.From - } - } - } - if trust != trustImage { - t.Errorf("the trust image is %q, not %q: its system bundle is the public root the account "+ - "directory is named after, so moving it reorders every certificate. Move it only with a "+ - "plan for the account (ADR 0226)", trust, trustImage) - } -} - -// The proxy's program is mounted into the pinned trust image, not baked into an image (novox/hq ADR 0480 clauses 1, -// 2 and 4): a Go bundle of the module's own Go module stamped for alpine, unpacked by an archive resource, mounted -// read-only at /opt/mesh, called through args, and named under restart-on so a new program never leaves the old -// one running. The container's name, network and logging are what the jail and the routes rely on. -// -// And its routes are the seat mesh-provisioner's plans alone (issue 774, research 072 row 34): it reads them, and -// receives no file of contributions; no module of this repository does. -func TestRouteProxyRunsItsMountedProgramOnTheTrustImage(t *testing.T) { - m := catalogueManifest(t, "route-proxy") - if !slices.Contains(m.Reads, "mesh-provisioner.plans") { - t.Errorf("route-proxy does not read mesh-provisioner.plans, where its routes are: %v", m.Reads) - } - dirs, _ := filepath.Glob(filepath.Join("..", "..", "modules", "*")) - for _, dir := range dirs { - if r := catalogueManifest(t, filepath.Base(dir)); len(r.Receives) > 0 { - t.Errorf("%s receives %v: a route, like every provision here, is served from the seat's plans, never a "+ - "received file", r.Module, r.Receives) - } - } - var bundle *Artifact - for i, a := range m.Build.Artifacts { - if a.Name == "server" { - bundle = &m.Build.Artifacts[i] - } - if a.Kind == "image" { - t.Errorf("artifact %s is an image built from a Dockerfile (ADR 0452 §1)", a.Name) - } - } - if bundle == nil || bundle.Kind != "bundle" || bundle.Language != "go" || bundle.System != "alpine" || - bundle.From != "cmd/route-proxy" || bundle.Binary != "route-proxy" { - t.Fatalf("the server artifact is not a Go bundle of cmd/route-proxy stamped for alpine: %+v", bundle) - } - var archive, server map[string]any - for _, r := range m.Resources { - switch { - case r["type"] == "archive" && r["artifact"] == "server": - archive = r - case r["id"] == "server": - server = r - } - } - if archive == nil || server == nil { - t.Fatalf("archive %v or server container %v is missing", archive, server) - } - if server["artifact"] != "trust" || server["name"] != "route-proxy" || server["network"] != "host" || server["logging"] != "journald" { - t.Errorf("the server container moved: %v", server) - } - mount := archive["path"].(string) + ":/opt/mesh:ro" - mounted := false - for _, v := range server["volumes"].([]any) { - mounted = mounted || v == mount - } - if !mounted { - t.Errorf("the archive is not mounted read-only at /opt/mesh (%s): %v", mount, server["volumes"]) - } - if args, _ := server["args"].([]any); len(args) != 1 || args[0] != "/opt/mesh/route-proxy" { - t.Errorf("args are %v, want the mounted program", server["args"]) - } - restarts := false - for _, v := range server["restart-on"].([]any) { - restarts = restarts || v == archive["id"] - } - if !restarts { - t.Errorf("restart-on %v does not name the archive %v", server["restart-on"], archive["id"]) - } -} diff --git a/modules/route-proxy/README.md b/modules/route-proxy/README.md deleted file mode 100644 index f5ddb810..00000000 --- a/modules/route-proxy/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# route-proxy — the mesh's public ingress - -The shipping form of the reference reverse proxy (novox/hq ADR 0007, novox/hq 08-connectivity §3 -"Exposure"). It is the `traefik` replacement: the module that **provides `route`**. - -## What it is - -**A route is a provision** (issue 774, research 072 row 34; the operator, 2026-10-31: "there is only 1 path, the other -is simply deprecated/invalid"). A module that must be reachable declares `requires: ["route"]` and contributes the -name it wants and the port it listens on; this module provides `route`. The controller composes each consumer's plan, -and the seat `mesh-provisioner` keeps it in its state `plans`: the plan's entry for the route names this proxy as -its provider and carries what the consumer contributes, composed (its names joined to the node's domains, its -endpoint's port, its policy), and where the consumer's machine is on the private network, as one opaque payload -(`contributed`) the seat never reads. The proxy follows the plans on the bus, reads each contribution with its own -contract (below), and forwards by the `Host` header of each request to `http://:`, or over loopback for a -consumer on its own machine. - -## Its contract: a package of its own - -**route-proxy defines its own contract, and other modules use it** (the operator, 2026-10-31). It is the Go module -`git.novox.be/novox/mesh-controller/modules/route-proxy/contract`, in `contract/` here with its own `go.mod`, standard -library only, released by tags `modules/route-proxy/contract/vX.Y.Z`, the way mesh-sdk is released (`go/vX.Y.Z`); this -module depends on it by its version like any other. It holds: - -- **the route a consumer asks** (`Request`, read strictly by `DecodeRequest`: no field it does not name, a name of - either kind, a port where the rule forwards, http or https, a positive body limit, a secret's file never a - credential) and its JSON schema (`route.schema.json`, `Schema`), the form of a manifest's `contributes.route`; -- **the events this proxy says** on `mesh.mod.route-proxy.event.`: `route.served` and `route.withdrawn` as the - names it serves change, `route.refused` with why for a route a plan gives it and it does not serve (`Event`); -- **what it keeps on the bus**, its states `routes` and `unrouted`, their keys and records (`Route`, `Unrouted`, - `Other`, `Key`, `Split`); -- **its tools' names and arguments** (`ToolRoutes`, `ToolRoute`, `ToolUnrouted`, `RoutesArgs`, `RouteArgs`). - -The controller does not write the proxy's configuration. It composes the facts into the plans and the proxy turns them -into whatever it runs. That boundary is why swapping this proxy for another costs nothing to the modules that publish -through it. **There is no received file and no second source:** a route no plan names is not served, and a module -declaring `receives.route` is refused by the catalogue check (mesh-catalog `checks/credentialform`), but for the -migration scaffolding still listed there. - -| field | value | why | -|---|---|---| -| `provides` | `route` (scope `mesh`, answered by the helper) | a module on any node may require it | -| `serves.route` | `{}` | a route hands back a name, not a credential — the consumer already knows the name it asked for | -| `reads` | `mesh-provisioner.plans`, `mesh-provisioner.provisions`, `mesh-delivery-assigner.placement` | the plans are where the routes are; the helper reads both; the placement says who is inside | -| `emits` | `route.served`, `route.withdrawn`, `route.refused` | what it serves, said as it changes (the contract's `Event`) | -| `listens` | `80` and `443`, both `from: anywhere` | the one machine with a public opening; the firewall opens these for free (ADR 0045) | - -Until the bus answers, the proxy routes nothing and says so on its log; it refuses to start without its broker -credential (`MESH_BROKER_FILE`). Who is inside the private network, and so may be served an internal-only name, is -every node's address on it as the seat mesh-placement decides it (mesh-sdk `delivery.InsideAddresses`); until that -is read, an internal-only name is served to this machine alone. - -## Which issuer: Let's Encrypt, stated here (novox/hq ADR 0226) - -**The public issuer is this module's own fact, not a provision.** Until 2026-10-06 it came from a -second module, `public-acme`, that ran nothing and offered `acme-ca` pointing at Let's Encrypt; every -node running a proxy was assigned it too, and nothing else ever consumed it. It is retired: the proxy -names the production directory in its own `acme.env`, and the only issuer it is still bound to is the -mesh's own (`internal-acme-ca`, from `step-ca`). - -**`acme.env` is byte for byte what the binding rendered.** The proxy keeps each authority's account -and certificates in a directory under `/var/lib/route-proxy/acme` named after a digest of the -directory URL — `https://acme-v02.api.letsencrypt.org:443/directory`, port included, as the binding -spelled it — and of the root bundle the `trust` container copies in. Any change to either is a new -authority to the proxy: a new account, and every routed name ordered again against Let's Encrypt's -rate limits. So the URL keeps the `:443`, `ACME_ROOTS_PATH` stays empty, and the `trust` artifact's -pinned image is not moved without a decision; mesh-controller's -`TestRouteProxyKeepsItsPublicAccountDirectory` holds all three. - -## How it ships the Go proxy - -The proxy is a Go program, unlike the TypeScript tool-runtime modules. Its source sits in this module, at -`cmd/route-proxy`, in the module's own Go module (`go.mod`, with a `vendor/` directory), the contract written as -something that runs. No image is built for it (novox/hq ADR 0480): the `server` artifact is a Go bundle compiled -for alpine, an `archive` resource unpacks it onto the machine, and the `route-proxy` container runs the pinned -`trust` image (the same alpine digest whose system roots the public account directory is named after) with the -archive mounted read-only at `/opt/mesh` and `/opt/mesh/route-proxy` as its program. A new build of the program -restarts the container, because the archive is in its `restart-on`. - -In the lab, a scenario stocks the same alpine image and serves it by digest. - -## Certificates, and the endpoint (resolves 04-ISSUES/004) - -TLS is opt-in and terminates here for public names, via ACME (autocert). A proxy with no -`TLS_LISTEN` serves plain HTTP; with it, it obtains a publicly-trusted certificate for exactly the -names the mesh routes here (`HostPolicy` = only routed names, so a scan cannot spend the account's -issuance quota) and answers the HTTP-01 challenge on `:80` at the name being certified. - -**`ACME_DIRECTORY` defaults to Let's Encrypt *staging*, not production.** This closes -novox/hq 04-ISSUES/004 (certificate issuance targets production): -the issuer is a per-node choice, and the safe endpoint is the default. Production issuance is -rate-limited per domain and per account and does not replenish quickly; defaulting to production -would leave the safe path depending on somebody remembering to opt out of it, on exactly the work -most likely to iterate. A staging certificate is trusted by no browser, so the mistake announces -itself on the first request rather than a fortnight later at the rate limit. - -**The module states production.** The binary's staging default stands for anything that runs it -without the module — the lab, a developer's machine — and the module's `acme.env` names production -for every node it is assigned to, because a node assigned the public proxy is one that serves public -traffic. - -| env | default | meaning | -|---|---|---| -| `MESH_BROKER_FILE` | *(required)* | the proxy's broker credential: the plans it routes from, and its membership | -| `LISTEN` | `:80` | HTTP, and the ACME HTTP-01 challenge | -| `TLS_LISTEN` | `:443` | HTTPS; unset to serve plain HTTP only | -| `ACME_CACHE` | `/acme` | where issued certificates persist; required when `TLS_LISTEN` is set, so a restart does not re-order | -| `ACME_DIRECTORY` | Let's Encrypt **staging** in the binary; production in the module's `acme.env` | the issuer | -| `ACME_CA_BUNDLE` | *(unset)* | a file of roots to trust for the issuer's own API — set only for a private/lab authority whose API certificate the world does not yet trust | - -## Answered by the provider helper (issue 792) - -A small Go program beside the proxy (`cmd/route-provider`) runs the SDK's provider helper for `route`, with no credential, -and the offer says `"answered-by": "helper"`: a handed consumer (gitea's origins file) is filled by its machine's client -from this module's entry, which carries the public names the controller composed for it. The module is **not** marked -moved: its own files, `acme.env` and the broker secret, are composed as before, and the proxy itself is unchanged. - -## What it says on the bus, and its tools (issue 774) - -The proxy keeps two states, announced readable (`conn.Keeps`, novox/hq ADR 0495), each keyed `.`: - -| state | one key per | holds | written when | -|---|---|---|---| -| `routes` | served name | the rules it is served by (path, module, node, target, refusal, redirect, authentication); its certificate (authority, issuer, not-after, last renewal, outcome, or why it failed); the last upstream answer (status, time, error, path) | a route arrives, changes or leaves; an authority stores a certificate; a handshake fails to find one or hands out one not yet recorded; an upstream answer differs from the last one written, or the same one is a minute old | -| `unrouted` | name answered "no route for", in a request or a TLS handshake, under a domain the proxy serves | when it was last asked, how often since the start, whether it is an internal-only name asked from outside; never who asked (the journal keeps that, for fail2ban) | first asked, at most five new names a minute, then at most once a minute; deleted when the name is served; kept a week. Every other refusal is only counted, in one key per proxy (`._other`: the last minute's count and the total), written once a minute. A refused write is dropped, never retried | - -Nothing is written on a timer. The provider helper's binary serves the module's tools from those states, one call each: -`route_proxy_routes` (every served name with its target, certificate expiry and last answer), `route_proxy_route` -(one name in full, or that it is not served and when it was last asked for) and `route_proxy_unrouted` (the names -refused, newest first). diff --git a/modules/route-proxy/cmd/route-provider/main.go b/modules/route-proxy/cmd/route-provider/main.go deleted file mode 100644 index b1eecda7..00000000 --- a/modules/route-proxy/cmd/route-provider/main.go +++ /dev/null @@ -1,45 +0,0 @@ -// route-provider: route-proxy's Go half, one binary the node's runtime launches and speaks MCP to over stdio -// through the Go SDK. It runs the SDK's provider helper that answers the consumers of `route` (issue 792), and serves -// the module's tools, which read what the proxy keeps on the bus (issue 774, tools.go). -// -// stdout is the MCP channel, so everything this module says, it says on stderr. -package main - -import ( - "context" - "fmt" - "os" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/provider" - - "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" -) - -func say(format string, args ...any) { - fmt.Fprintf(os.Stderr, "[route-proxy] "+format+"\n", args...) -} - -func main() { - node := os.Getenv("MESH_NODE") - if node == "" { - say("MESH_NODE is not set; answering no consumers") - } else { - p := newProvider(node, provider.StateBus(ModuleName), say) - go func() { - // The helper's watches go through the runtime's channel, which Serve holds: one asked before it does is - // refused ("before Serve"), Run returns and the helper stays stopped until the process restarts. - <-stdio.Served() - if err := p.Run(context.Background()); err != nil { - say("the provider helper stopped: %v", err) - } - }() - } - tools := front{routes: stdio.State(contract.RoutesState), unrouted: stdio.State(contract.UnroutedState), - now: time.Now}.tools() - if err := stdio.Serve("", tools); err != nil { - say("%v", err) - os.Exit(1) - } -} diff --git a/modules/route-proxy/cmd/route-provider/provider.go b/modules/route-proxy/cmd/route-provider/provider.go deleted file mode 100644 index 39973d4f..00000000 --- a/modules/route-proxy/cmd/route-provider/provider.go +++ /dev/null @@ -1,38 +0,0 @@ -package main - -// route-proxy handed to the provisioner (novox/hq ADR 0349 clause 12, issue 792): the module runs the SDK's provider -// helper for `route`, so a consumer of a route — a handed gitea reading `${bound:route:name-web}` — is told its public -// name from the entry in this module's bucket, the one its machine's client fills its files from. -// -// **It composes nothing.** A route's public name is the label its consumer chose joined to the node's public domain, -// which only the controller knows (the domain is placement), so the controller composes it and carries it in the -// consumer's plan entry (`names`); the helper copies it into the entry's values unchanged. There is no credential for -// a route and nothing to make. The proxy itself serves the same plans' entries (cmd/route-proxy plans.go), the one -// source of routes (issue 774, research 072 row 34). - -import ( - "git.novox.be/novox/mesh-sdk/go/provider" - - "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" -) - -// ProvisionName is what route-proxy provides, as its manifest says, and ModuleName this module. -const ( - ProvisionName = contract.Provision - ModuleName = contract.Module -) - -// newProvider is route-proxy answering its consumers over bus on node: the route, served as the manifest serves it -// (nothing of its own), with no credential, and nothing to make or retire for a consumer. -func newProvider(node string, bus provider.Bus, logf func(string, ...any)) *provider.Provider { - return provider.New(provider.Options{ - Node: node, - Module: ModuleName, - Offers: map[string]provider.Offer{ProvisionName: { - Credential: provider.NoCredential, - Serves: map[string]any{}, - }}, - Bus: bus, - Logf: logf, - }) -} diff --git a/modules/route-proxy/cmd/route-provider/provider_test.go b/modules/route-proxy/cmd/route-provider/provider_test.go deleted file mode 100644 index 34b143b4..00000000 --- a/modules/route-proxy/cmd/route-provider/provider_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package main - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "sync" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/provider" - "git.novox.be/novox/mesh-sdk/go/provisions" -) - -// bucketBus is the helper's bus over in-memory buckets. -type bucketBus struct { - mu sync.Mutex - buckets map[string]map[string][]byte -} - -func (b *bucketBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error { - b.mu.Lock() - defer b.mu.Unlock() - for k, v := range b.buckets[bucket] { - handle(k, v) - } - return nil -} - -func (b *bucketBus) Put(_ context.Context, bucket, key string, value []byte) error { - b.mu.Lock() - defer b.mu.Unlock() - if b.buckets[bucket] == nil { - b.buckets[bucket] = map[string][]byte{} - } - b.buckets[bucket][key] = append([]byte(nil), value...) - return nil -} - -func (b *bucketBus) Delete(_ context.Context, bucket, key string) error { - b.mu.Lock() - defer b.mu.Unlock() - delete(b.buckets[bucket], key) - return nil -} - -// A consumer of a route is answered with the names its plan's entry carries, and nothing of a credential (issue 792), -// from the seat mesh-provisioner's plans and keys (novox/hq ADR 0570 clause 2), never the controller's assignments: a -// module not handed whose route the seat keys (grafana, issue 774) has a plan of provisions alone, and a helper that -// read only the assignments left its key asked for good, so the assigner withheld every new build of 38 such modules. -func TestAConsumerOfARouteIsToldItsNamesFromItsPlanEntry(t *testing.T) { - bus := &bucketBus{buckets: map[string]map[string][]byte{}} - for module, names := range map[string]map[string]string{ - "gitea": {"name": "git.example.org", "internal-name": "git.novox.internal", "name-web": "git.example.org"}, - "grafana": {"name": "grafana.example.org"}, - } { - plan, err := provisions.EncodePlan(provisions.Plan{Node: "g14", Module: module, Plan: catalogue.Plan{ - Provisions: []catalogue.PlanProvision{{Provision: ProvisionName, Provider: "novox", ProviderModule: ModuleName, - At: "10.7.0.1", From: "novox", As: "mesh_g14_" + module, Names: names}}}}) - if err != nil { - t.Fatal(err) - } - key := "g14." + module - if err := bus.Put(context.Background(), provisions.PlansBucket, key, plan); err != nil { - t.Fatal(err) - } - asked, err := json.Marshal(provisions.Provision{V: provisions.Version, Provision: ProvisionName, - Consumer: provisions.End{Node: "g14", Module: module}, Provider: provisions.End{Node: "novox", Module: ModuleName}, - State: provisions.Asked}) - if err != nil { - t.Fatal(err) - } - if err := bus.Put(context.Background(), provisions.ProvisionsBucket, key+"."+ProvisionName, asked); err != nil { - t.Fatal(err) - } - } - p := newProvider("novox", bus, t.Logf) - out, err := p.Pass(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(out) != 2 || out[0].Status != provider.Answered || out[1].Status != provider.Answered { - t.Fatalf("the pass did %+v", out) - } - for key, want := range map[string]map[string]string{ - "g14.gitea.route": {"name-web": "git.example.org", "name": "git.example.org", "internal-name": "git.novox.internal"}, - "g14.grafana.route": {"name": "grafana.example.org"}, - } { - var e provider.Entry - if err := json.Unmarshal(bus.buckets[provider.BucketOf(ModuleName)][key], &e); err != nil { - t.Fatalf("%s: %v", key, err) - } - for k, v := range want { - if e.Values[k] != v { - t.Errorf("%s says %s %v, want %s", key, k, e.Values[k], v) - } - } - if e.State != provisions.Provided || e.VaultName != "" || e.User != "" || e.Refused != "" { - t.Errorf("the entry %s is %+v", key, e) - } - } -} - -// route-proxy's own files and resources stay as they were (issue 792): the module answers its route through the -// helper but is not marked moved, so its acme.env, its broker secret and its containers are composed as today and -// the public ingress does not change. Everything of the manifest but its build, what it provides and the state it -// keeps is pinned by this digest; changing the ingress means changing it here, on purpose. Re-pinned in R4d (novox/hq -// ADR 0480): the program is an archive mounted into the trust image, the container and its files are otherwise as before. -// Re-pinned for issue 774 (research 072 row 34): the received routes file, its directory, mount and ROUTES went, since -// the proxy serves from the seat's plans alone; the ingress, its certificates and its files are otherwise as before. -func TestRouteProxyIsNotMovedAndKeepsItsOwnFiles(t *testing.T) { - raw, err := os.ReadFile("../../module.json") - if err != nil { - t.Fatal(err) - } - var m map[string]any - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatal(err) - } - if _, moved := m["moved"]; moved { - t.Fatal("route-proxy is marked moved: its own files would be handed") - } - provides, _ := m["provides"].([]any) - if len(provides) != 1 || provides[0].(map[string]any)["answered-by"] != "helper" { - t.Fatalf("route is not said to be answered by the helper: %v", provides) - } - // Its states and its tools are what it says on the bus, not what it runs or writes (issue 774). - for _, k := range []string{"build", "provides", "state", "reads", "tools", "replaces"} { - delete(m, k) - } - // Refreshed on purpose for the events its contract names (`emits`: route.served, route.withdrawn, route.refused). - pinned, err := json.Marshal(m) // sorted keys, compact - if err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(pinned) - if got := hex.EncodeToString(sum[:]); got != "3a44c56cc021be4008f521d16fa02e1c0f0d2eedab2f4d0b60f7d3177269e542" { - t.Fatalf("route-proxy's own manifest changed: digest %s", got) - } - // The two acme templates are pinned the same way (issue 849): they are what the proxy's certificates are asked - // with, so a change to either is a change to the public ingress. - for file, want := range map[string]string{ - "acme.env": "d66eba145c893080e30e013329fc00e51395f0c31fc8621a20c7b26abc11b52f", - "internal-acme.env": "baf4da75379183758c8ae505be4dd8a79d5b089ae1e7c8ded4d232df40251df5", - } { - text, err := os.ReadFile("../../files/" + file) - if err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(text) - if got := hex.EncodeToString(sum[:]); got != want { - t.Fatalf("route-proxy's files/%s changed: digest %s, pinned %s; if the change is meant, check that the "+ - "public and internal ingress still ask for the certificates they should, then pin the new digest here", - file, got, want) - } - } -} diff --git a/modules/route-proxy/cmd/route-provider/tools.go b/modules/route-proxy/cmd/route-provider/tools.go deleted file mode 100644 index 6129a4bc..00000000 --- a/modules/route-proxy/cmd/route-provider/tools.go +++ /dev/null @@ -1,296 +0,0 @@ -package main - -// route-proxy's tools (issue 774): the web front's command line. Each answers from what the proxy keeps on the bus -// (route-proxy_routes and route-proxy_unrouted, written on change by the proxy itself), in one call, for every -// machine running a proxy: which sites are served and where they go, whether their certificates are good, what the -// last request through each got back, and which names were asked for and are served nowhere. - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - - "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" -) - -// The tools' names, as the manifest declares them. -const ( - RoutesTool = contract.ToolRoutes - RouteTool = contract.ToolRoute - UnroutedTool = contract.ToolUnrouted -) - -// reader is one of the module's states, as stdio.KeptState reads it. -type reader interface { - Keys() ([]string, error) - Get(key string) (*stdio.StateEntry, error) -} - -// front is what the tools read: the routes and the refusals, and the clock a certificate's days left are counted by. -type front struct { - routes, unrouted reader - now func() time.Time -} - -// RouteLine is one served name in the list: where it goes, how long its certificate holds, what it last answered. -type RouteLine struct { - Name string `json:"name"` - Proxy string `json:"proxy"` - Reach string `json:"reach"` - Targets []string `json:"targets"` - // Reachable is yes when the last answer upstream gave was below 500, no when it was 500 or more or none came, - // unknown before any request. - Reachable string `json:"reachable"` - // Certificate is its outcome, issuer and expiry in words; ExpiresInDays is the whole days until not-after. - Certificate string `json:"certificate"` - ExpiresInDays *int `json:"expires_in_days,omitempty"` - LastAnswer *contract.Answer `json:"last_answer,omitempty"` -} - -func (f front) tools() []stdio.Tool { - node := map[string]any{"type": "string", "description": "only the proxy on this machine; every machine when left out"} - return []stdio.Tool{ - {Name: RoutesTool, - Description: "Every site (web name, host name) the mesh's web front serves through route-proxy, the reverse proxy " + - "on the public machine: for each route the module and node it forwards to, its TLS certificate's issuer and " + - "expiry (days left), and the last answer upstream gave (status, time, error), so is X reachable and is its " + - "certificate good is answered without curl. Read from the proxy's routes state on the bus. Read-only.", - Input: map[string]any{"type": "object", "properties": map[string]any{"node": node}}, - Run: func(args map[string]any) (any, error) { - at, _ := args["node"].(string) - return f.list(at) - }}, - {Name: RouteTool, - Description: "One site the mesh's web front (route-proxy) is asked about, in full: every rule it is served by " + - "(path, module, node, target, refusal, redirect, authentication), its certificate (issuer, not-after, last " + - "renewal and its outcome, or why it failed) and the last upstream answer. A name with no route says so, and " + - "when it was last asked for. Answers is X reachable, why does this site not load. Read-only.", - Input: map[string]any{"type": "object", "required": []string{"name"}, "properties": map[string]any{ - "name": map[string]any{"type": "string", "description": "the site's name, such as app.example.org"}, - "node": node}}, - Run: func(args map[string]any) (any, error) { - name, _ := args["name"].(string) - at, _ := args["node"].(string) - return f.one(name, at) - }}, - {Name: UnroutedTool, - Description: "Names the mesh's web front (route-proxy) answered \"no route for\": a site asked for in a request or " + - "a TLS handshake that no route serves, newest first, with when it was last asked and how often. Only names under a " + - "domain the proxy serves are kept by name, a few new ones a minute; every other refusal (a scan) is counted per " + - "proxy, the last minute's count and the total since its start. A site that should be served and is missing " + - "its route shows here. Kept a week. Read-only.", - Input: map[string]any{"type": "object", "properties": map[string]any{"node": node}}, - Run: func(args map[string]any) (any, error) { - at, _ := args["node"].(string) - return f.refusals(at) - }}, - } -} - -// read is every value of a state, of node's proxy alone when node is named, decoded into a fresh T each. -func read[T any](state reader, node string) ([]T, error) { - keys, err := state.Keys() - if err != nil { - return nil, err - } - var out []T - for _, key := range keys { - at, _ := contract.Split(key) - if node != "" && at != node { - continue - } - entry, err := state.Get(key) - if err != nil { - return nil, err - } - if entry == nil { - continue // deleted between the list and the read - } - var v T - if err := json.Unmarshal(entry.Value, &v); err != nil { - return nil, fmt.Errorf("%s holds what is not a route-proxy record: %w", key, err) - } - out = append(out, v) - } - return out, nil -} - -func (f front) list(node string) (map[string]any, error) { - routes, err := read[contract.Route](f.routes, node) - if err != nil { - return nil, err - } - lines := make([]RouteLine, 0, len(routes)) - unreachable, expiring := 0, 0 - for _, r := range routes { - line := f.line(r) - if line.Reachable == "no" { - unreachable++ - } - if line.ExpiresInDays != nil && *line.ExpiresInDays < 14 || r.Certificate.Outcome == contract.Failed { - expiring++ - } - lines = append(lines, line) - } - sort.Slice(lines, func(i, j int) bool { - if lines[i].Name != lines[j].Name { - return lines[i].Name < lines[j].Name - } - return lines[i].Proxy < lines[j].Proxy - }) - return map[string]any{"routes": lines, "served": len(lines), "unreachable": unreachable, - "certificates_failing_or_within_14_days": expiring}, nil -} - -func (f front) line(r contract.Route) RouteLine { - line := RouteLine{Name: r.Name, Proxy: r.Proxy, Reach: r.Reach, LastAnswer: r.LastAnswer, Reachable: "unknown", - Targets: []string{}} - for _, t := range r.Targets { - line.Targets = append(line.Targets, describe(t)) - } - if a := r.LastAnswer; a != nil { - line.Reachable = "yes" - if a.Error != "" || a.Status >= 500 || a.Status == 0 { - line.Reachable = "no" - } - } - c := r.Certificate - words := []string{} - if c.Outcome != "" { - words = append(words, c.Outcome) - } - if c.Issuer != "" { - words = append(words, "by "+c.Issuer) - } - if c.NotAfter != nil { - days := int(c.NotAfter.Sub(f.now()).Hours() / 24) - line.ExpiresInDays = &days - words = append(words, "until "+c.NotAfter.UTC().Format("2006-01-02")) - } - if c.Error != "" { - words = append(words, "("+c.Error+")") - } - if len(words) == 0 { - words = append(words, "none seen yet") - } - line.Certificate = c.Authority + ": " + strings.Join(words, " ") - return line -} - -// describe is one rule in a few words: path → module@node at target, or what it answers with. -func describe(t contract.Target) string { - path := t.Path - if path == "" { - path = "/" - } - var what string - switch { - case t.Deny: - what = "refused" - case t.Redirect != "": - what = "redirected to " + t.Redirect - default: - what = fmt.Sprintf("%s@%s at %s", t.Module, t.Node, t.Target) - } - if t.Sealed != "" { - what += " (authentication cannot be checked: " + t.Sealed + ")" - } else if t.Auth { - what += " (authenticated)" - } - return path + " → " + what -} - -func (f front) one(name, node string) (map[string]any, error) { - name = strings.ToLower(strings.TrimSpace(name)) - if name == "" { - return nil, fmt.Errorf("name is the site to read, such as app.example.org") - } - routes, err := read[contract.Route](f.routes, node) - if err != nil { - return nil, err - } - var found []contract.Route - for _, r := range routes { - if r.Name == name { - found = append(found, r) - } - } - if len(found) > 0 { - out := map[string]any{"name": name, "routed": true, "routes": found} - if len(found) == 1 { - out["reachable"] = f.line(found[0]).Reachable - } - return out, nil - } - asked, _, err := f.asked(node) - if err != nil { - return nil, err - } - for _, a := range asked { - if a.Name == name { - return map[string]any{"name": name, "routed": false, "asked": a, - "says": fmt.Sprintf("no proxy serves %s; the proxy on %s answered \"no route\" for it, last at %s", - name, a.Proxy, a.AskedAt.UTC().Format(time.RFC3339))}, nil - } - } - return map[string]any{"name": name, "routed": false, - "says": fmt.Sprintf("no proxy serves %s, and none was asked for it this week", name)}, nil -} - -func (f front) refusals(node string) (map[string]any, error) { - asked, others, err := f.asked(node) - if err != nil { - return nil, err - } - sort.Slice(asked, func(i, j int) bool { return asked[i].AskedAt.After(asked[j].AskedAt) }) - if asked == nil { - asked = []contract.Unrouted{} - } - return map[string]any{"unrouted": asked, "names": len(asked), "other_refusals": others}, nil -} - -// asked is the names refused and kept by name, and each proxy's count of the refusals not kept by name. -func (f front) asked(node string) ([]contract.Unrouted, []contract.Other, error) { - keys, err := f.unrouted.Keys() - if err != nil { - return nil, nil, err - } - names, others := keySet{}, keySet{} - for _, key := range keys { - if _, name := contract.Split(key); name == contract.OtherName { - others[key] = true - } else { - names[key] = true - } - } - asked, err := read[contract.Unrouted](only{f.unrouted, names}, node) - if err != nil { - return nil, nil, err - } - counted, err := read[contract.Other](only{f.unrouted, others}, node) - if counted == nil { - counted = []contract.Other{} - } - return asked, counted, err -} - -// keySet is a set of keys; only is a state narrowed to them. -type keySet map[string]bool - -type only struct { - reader - keys keySet -} - -func (o only) Keys() ([]string, error) { - out := make([]string, 0, len(o.keys)) - for k := range o.keys { - out = append(out, k) - } - sort.Strings(out) - return out, nil -} diff --git a/modules/route-proxy/cmd/route-provider/tools_test.go b/modules/route-proxy/cmd/route-provider/tools_test.go deleted file mode 100644 index da91dfe8..00000000 --- a/modules/route-proxy/cmd/route-provider/tools_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package main - -import ( - "encoding/json" - "sort" - "strings" - "testing" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" - - "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" -) - -// held is a state as the bus holds it, read-only. -type held map[string]any - -func (h held) Keys() ([]string, error) { - out := []string{} - for k := range h { - out = append(out, k) - } - sort.Strings(out) - return out, nil -} - -func (h held) Get(key string) (*stdio.StateEntry, error) { - v, ok := h[key] - if !ok { - return nil, nil - } - raw, err := json.Marshal(v) - return &stdio.StateEntry{Key: key, Value: raw}, err -} - -// The web front's tools answer from the bus, in one call each (issue 774): every site with its target, certificate -// expiry and last answer; one site in full, or that it is not served and when it was asked for; and the names the -// proxy answered "no route for". -func TestTheWebFrontsToolsAnswerFromTheBus(t *testing.T) { - now := time.Date(2026, 10, 30, 21, 0, 0, 0, time.UTC) - expires := now.Add(30 * 24 * time.Hour) - routes := held{ - "anchor.grafana.example": contract.Route{Name: "grafana.example", Proxy: "anchor", Reach: contract.ReachPublic, - Targets: []contract.Target{{Module: "grafana", Node: "home-server", Target: "http://10.0.0.2:3000"}}, - Certificate: contract.Certificate{Authority: contract.ReachPublic, Issuer: "Let's Encrypt R11", NotAfter: &expires, Outcome: contract.Issued}, - LastAnswer: &contract.Answer{Status: 502, At: now, Error: "dial tcp 10.0.0.2:3000: connect: connection refused"}}, - "anchor.drive.example": contract.Route{Name: "drive.example", Proxy: "anchor", Reach: contract.ReachPublic, - Targets: []contract.Target{{Module: "nextcloud", Node: "home-server", Target: "http://10.0.0.2:8080"}}, - Certificate: contract.Certificate{Authority: contract.ReachPublic}, - LastAnswer: &contract.Answer{Status: 200, At: now}}, - } - unrouted := held{ - "anchor.old.example": contract.Unrouted{Name: "old.example", Proxy: "anchor", AskedAt: now.Add(-time.Hour), Times: 3}, - "anchor.wiki.example": contract.Unrouted{Name: "wiki.example", Proxy: "anchor", AskedAt: now, Times: 1}, - "anchor._other": contract.Other{Proxy: "anchor", Minute: now, LastMinute: 40, SinceStart: 900}, - } - tools := map[string]stdio.Tool{} - for _, tool := range (front{routes: routes, unrouted: unrouted, now: func() time.Time { return now }}).tools() { - tools[tool.Name] = tool - } - - for _, row := range []struct { - tool string - args map[string]any - want []string // what the answer says, in its JSON - }{ - {RoutesTool, nil, []string{`"served":2`, `"unreachable":1`, - `"name":"grafana.example"`, `"reachable":"no"`, `"expires_in_days":30`, `/ → grafana@home-server at http://10.0.0.2:3000`, - `"name":"drive.example"`, `"reachable":"yes"`, `public: none seen yet`}}, - {RoutesTool, map[string]any{"node": "home-server"}, []string{`"served":0`}}, - {RouteTool, map[string]any{"name": "Grafana.Example"}, []string{`"routed":true`, `"reachable":"no"`, - `connection refused`, `"issuer":"Let's Encrypt R11"`}}, - {RouteTool, map[string]any{"name": "wiki.example"}, []string{`"routed":false`, - `answered \"no route\" for it, last at 2026-10-30T21:00:00Z`}}, - {RouteTool, map[string]any{"name": "nothing.example"}, []string{`"routed":false`, `none was asked for it`}}, - {UnroutedTool, nil, []string{`"names":2`, `"unrouted":[{"name":"wiki.example"`, - `"other_refusals":[{"proxy":"anchor"`, `"last_minute":40`, `"since_start":900`}}, - } { - t.Run(row.tool, func(t *testing.T) { - tool, ok := tools[row.tool] - if !ok { - t.Fatalf("%s is not served", row.tool) - } - if !strings.Contains(tool.Description, "route-proxy") { - t.Errorf("%s's description does not name the web front, so a search does not find it", row.tool) - } - answer, err := tool.Run(row.args) - if err != nil { - t.Fatal(err) - } - raw, _ := json.Marshal(answer) - for _, w := range row.want { - if !strings.Contains(string(raw), w) { - t.Errorf("%s(%v) does not say %s:\n%s", row.tool, row.args, w, raw) - } - } - }) - } -} diff --git a/modules/route-proxy/cmd/route-proxy/authority_test.go b/modules/route-proxy/cmd/route-proxy/authority_test.go deleted file mode 100644 index 9c2b3275..00000000 --- a/modules/route-proxy/cmd/route-proxy/authority_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - "path/filepath" - "strings" - "testing" -) - -// The lab's first root, and the one a re-initialised CA generates in its place. -const ( - firstRoot = "-----BEGIN CERTIFICATE-----\nMIIBeFIRST\n-----END CERTIFICATE-----\n" - secondRoot = "-----BEGIN CERTIFICATE-----\nMIIBeSECOND\n-----END CERTIFICATE-----\n" -) - -// A re-initialised CA does not need somebody to delete the cache by hand. -// -// autocert keeps its account key at one fixed name and reuses it for ever. When an internal CA is -// re-initialised it has never heard of that account, rejects every use of it, and autocert has no -// path back: nothing is re-registered, no order reaches the CA, and issuance stops with nothing -// saying why. Naming the cache after the authority means the account is only ever found where it is -// still valid — the new root lands in a directory with no account in it, and autocert registers. -func TestTheAccountCacheIsNamedAfterItsAuthority(t *testing.T) { - t.Run("a new CA root means a new account cache", func(t *testing.T) { - const directory = "https://anchor.internal/acme/acme/directory" - before := forThisAuthority("/var/lib/route-proxy/acme", directory, []byte(firstRoot)) - after := forThisAuthority("/var/lib/route-proxy/acme", directory, []byte(secondRoot)) - if before == after { - t.Fatalf("a re-initialised CA reuses the account it was rejected for: %s", before) - } - }) - - // And the SAME authority keeps the account it registered, restart after restart. - // - // This is the whole reason ACME_CACHE is required in the first place: an account and its - // certificates that did not persist would be re-ordered on every restart, which works silently until - // a rate limit says it does not. Whitespace around the delivered root is not a new authority — the - // mesh writes that file, and a trailing newline coming or going must not throw away an account. - t.Run("the same authority keeps its account", func(t *testing.T) { - const directory = "https://anchor.internal/acme/acme/directory" - first := forThisAuthority("/var/lib/route-proxy/acme", directory, []byte(firstRoot)) - again := forThisAuthority("/var/lib/route-proxy/acme", directory, []byte("\n"+firstRoot+"\n\n")) - if first != again { - t.Errorf("the same authority was given two caches, so every restart orders again:\n%s\n%s", - first, again) - } - }) - - // Staging and production are different authorities, and were sharing one account. - // - // The latent fault of the same shape: pointing ACME_DIRECTORY at production after testing against - // staging reused the staging account, because the cache had no idea they were different. - t.Run("staging and production do not share an account", func(t *testing.T) { - staging := forThisAuthority("/acme", stagingDirectory, nil) - production := forThisAuthority("/acme", "https://acme-v02.api.letsencrypt.org/directory", nil) - if staging == production { - t.Errorf("two issuers share one account: %s", staging) - } - }) - - // It stays inside the directory the mesh gave it, and is a plain name. - // - // The mesh owns ACME_CACHE and mounts it; a name derived from a certificate that escaped it — or - // that carried a separator out of the PEM — would put an account somewhere nothing persists. - t.Run("the account cache stays where the mesh put it", func(t *testing.T) { - const cache = "/var/lib/route-proxy/acme" - got := forThisAuthority(cache, "https://anchor.internal/acme/acme/directory", []byte(firstRoot)) - if !strings.HasPrefix(got, cache+"/") { - t.Fatalf("the account cache is not under %s: %s", cache, got) - } - name := strings.TrimPrefix(got, cache+"/") - if name != filepath.Base(got) || strings.ContainsAny(name, "/.") { - t.Errorf("the account cache is not a plain name: %q", name) - } - }) -} diff --git a/modules/route-proxy/cmd/route-proxy/bus.go b/modules/route-proxy/cmd/route-proxy/bus.go deleted file mode 100644 index ff34c075..00000000 --- a/modules/route-proxy/cmd/route-proxy/bus.go +++ /dev/null @@ -1,194 +0,0 @@ -package main - -import ( - "fmt" - "log" - "sort" - "strings" - "sync/atomic" - "time" - - "github.com/nats-io/nats.go" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/announce" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/membership" - "git.novox.be/novox/mesh-sdk/go/process" - "git.novox.be/novox/mesh-sdk/go/provisions" - - "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" -) - -// keptStates is every state route-proxy keeps, as both its halves write them: the routes and the refusals this -// proxy writes, readable by others, and the bound entries the provider helper writes. -func keptStates() []announce.State { - return []announce.State{ - {Name: contract.BoundState}, - {Name: contract.RoutesState, Readable: true}, - {Name: contract.UnroutedState, Readable: true}, - } -} - -// What the mesh says this proxy routes, and who is inside the mesh, read on the bus. -// -// **The routes are the seat mesh-provisioner's plans, alone** (issue 774, research 072 row 34): every plan's entry -// naming this proxy as a route's provider, followed, so a route added or withdrawn reaches a running proxy without a -// restart, and said on the bus as it changes (contract.EventServed, EventWithdrawn, EventRefused). **Who is inside** -// is every node's address on the private network as the seat mesh-placement decides it (mesh-sdk -// delivery.InsideAddresses), which is who may be served an internal name; until it is read, an internal name is -// served to this machine alone. - -// follow connects with the credential in path, follows the plans and the placement, and retries the connection for -// as long as it takes: until the bus answers, the proxy routes nothing, and says so. -func follow(path string, held *table) { - for { - err := followOnce(path, held) - if err == nil { - return - } - log.Printf("cannot follow the plans that say what this proxy routes, so it routes what it last read: %v", err) - time.Sleep(30 * time.Second) - } -} - -func followOnce(path string, held *table) error { - account, err := process.ReadAccount(path) - if err != nil { - return err - } - conn, err := process.Connect(account, nats.Name(account.Node+"."+account.Module)) - if err != nil { - return fmt.Errorf("connecting to the bus at %s: %w", membership.BareAddress(account.URL), err) - } - // What this proxy keeps, said on the bus (novox/hq ADR 0495; issue 774): the names it serves and the names it - // refused, readable by any module granted a read of them; the provider helper's entries, read by the machines' - // clients through their own grant. Said before the first write, so no write of an unannounced state is refused. - if err := conn.Keeps(keptStates()...); err != nil { - log.Printf("cannot say on the bus what this proxy keeps, so it keeps nothing there: %v", err) - } else { - held.ledger.attach(account.Node, conn.State(contract.RoutesState), conn.State(contract.UnroutedState)) - } - say := func(eventType string, e contract.Event) { - if err := conn.Emit(eventType, e); err != nil { - log.Printf("cannot say %s of %q on the bus: %v", eventType, e.Name, err) - } - } - // Who is inside first, so the first table is served to the machines it is meant for. - if err := followInside(conn.Read(delivery.PlacementRead), held); err != nil { - log.Printf("cannot follow %s, so an internal name is served to this machine alone: %v", delivery.PlacementRead, err) - } - if err := followPlans(conn.Read(provisions.PlansRead), newPlanned(account.Node, say), held, say); err != nil { - conn.Close() - return err - } - return nil -} - -// planWatch is a state this proxy watches: every current key, then every change. -type planWatch interface { - Watch(key string, handle func(stdio.StateChange) error) error -} - -// followPlans serves what the plans give this proxy: the whole table once every current plan is read, then again on -// every change that alters it. A table built from part of the plans would withdraw every route not yet read. -func followPlans(plans planWatch, given *planned, held *table, say func(string, contract.Event)) error { - var read atomic.Bool // every current plan is read: from here, each change is served as it comes - err := plans.Watch("", func(c stdio.StateChange) error { - var value []byte - if c.Op != "delete" && c.Op != "purge" { - value = c.Value - } - if changed := given.take(c.Key, value); changed && read.Load() { - serve(given, held, say) - } - return nil - }) - if err != nil { - return fmt.Errorf("watching %s: %w", provisions.PlansRead, err) - } - read.Store(true) - serve(given, held, say) - return nil -} - -// serve replaces what the proxy serves with what the plans give it, and says each name it now serves or serves no -// more. -func serve(given *planned, held *table, say func(string, contract.Event)) { - before := held.served() - routes, public := routesOf(given.contributions()) - held.set(routes, public) - after := held.served() - for _, e := range servedChanges(given.node, before, after) { - say(e.kind, e.Event) - } - log.Printf("serving %d route(s) from the seat's plans: %s", len(routes), strings.Join(held.names(), ", ")) -} - -// change is one event serve says. -type change struct { - kind string - contract.Event -} - -// servedChanges is what changed between two tables' names, each name once, in order: a name now served, or now -// served at another reach or by another module, is served; a name no longer served is withdrawn. -func servedChanges(proxy string, before, after map[string]contract.Event) []change { - var out []change - for _, name := range sortedNames(after) { - if was, ok := before[name]; !ok || was != after[name] { - e := after[name] - e.Proxy = proxy - out = append(out, change{contract.EventServed, e}) - } - } - for _, name := range sortedNames(before) { - if _, ok := after[name]; !ok { - out = append(out, change{contract.EventWithdrawn, contract.Event{Proxy: proxy, Name: name}}) - } - } - return out -} - -func sortedNames(m map[string]contract.Event) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// followInside follows the seat mesh-placement's state and serves an internal name to every node's address on the -// private network it decides, each time it changes (mesh-sdk delivery.InsideAddresses; research 072 rows 33 and 34: -// the membership that carried them went with the assignments stream). A key that does not read keeps who is inside -// as it was. -func followInside(placement planWatch, held *table) error { - keys := delivery.NewPlacementKeys() - var read atomic.Bool - apply := func() { - inside, err := sourcesOf(delivery.InsideAddresses(keys.All())) - if err != nil { - log.Printf("the placement says an address that is not one, so who is inside stays as it was: %v", err) - return - } - held.setInside(inside) - log.Printf("internal names are served to %d machine(s)", len(inside)) - } - err := placement.Watch("", func(c stdio.StateChange) error { - if _, err := keys.Put(c.Key, c.Value, c.Revision, c.Op == "delete" || c.Op == "purge"); err != nil { - log.Printf("the placement's key %s does not read, so who is inside stays as it was: %v", c.Key, err) - return nil - } - if read.Load() { - apply() - } - return nil - }) - if err != nil { - return err - } - read.Store(true) - apply() - return nil -} diff --git a/modules/route-proxy/cmd/route-proxy/challenge_test.go b/modules/route-proxy/cmd/route-proxy/challenge_test.go deleted file mode 100644 index 3afd1413..00000000 --- a/modules/route-proxy/cmd/route-proxy/challenge_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package main - -// The challenge path falls through for real. autocert's own HTTPHandler answers 404 itself for a -// token it does not hold and never consults its fallback on the challenge path — the -// predecessor's fault, the edge owning /.well-known/acme-challenge outright, rediscovered live -// when Mailu's renewal died behind this proxy on cutover day (2026-09-26). These tests pin the -// three behaviours tokenOrRoute exists for. - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "golang.org/x/crypto/acme/autocert" -) - -func routedTo(t *testing.T, marker string) http.Handler { - t.Helper() - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - if _, err := w.Write([]byte(marker)); err != nil { - t.Fatal(err) - } - }) -} - -func TestATokenNoAuthorityHoldsIsRoutedNot404d(t *testing.T) { - m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())} - h := tokenOrRoute(routedTo(t, "the workload answered"), m) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/somebody-elses-token", nil)) - - if rec.Code != http.StatusOK || rec.Body.String() != "the workload answered" { - t.Fatalf("a token no authority holds must reach plain routing; got %d %q", rec.Code, rec.Body.String()) - } -} - -func TestATokenAManagerHoldsIsAnsweredByIt(t *testing.T) { - // autocert reads a token it does not have in memory from its cache, under "+http-01" — - // which is also how a token would survive the manager restarting mid-issuance. - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "held-token+http-01"), []byte("the-key-authorization"), 0o600); err != nil { - t.Fatal(err) - } - m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(dir)} - h := tokenOrRoute(routedTo(t, "must not be reached"), m) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/held-token", nil)) - - if rec.Code != http.StatusOK || rec.Body.String() != "the-key-authorization" { - t.Fatalf("the manager holding a token answers it; got %d %q", rec.Code, rec.Body.String()) - } -} - -func TestASecondAuthorityIsProbedBeforeRouting(t *testing.T) { - first := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())} - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "internal-token+http-01"), []byte("internal-key"), 0o600); err != nil { - t.Fatal(err) - } - second := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(dir)} - h := tokenOrRoute(routedTo(t, "must not be reached"), first, second) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest("GET", "http://git.internal/.well-known/acme-challenge/internal-token", nil)) - - if rec.Code != http.StatusOK || rec.Body.String() != "internal-key" { - t.Fatalf("the second authority's token is found by probing past the first; got %d %q", rec.Code, rec.Body.String()) - } -} - -func TestAnAuthorityWhosePolicyRefusesTheNameIsProbedPast(t *testing.T) { - // autocert checks the host policy before the token and answers 403 — the internal authority - // does this for every public name. A policy refusal is as much "not mine" as a missing token: - // the request must still reach plain routing, where the workload's own ACME client answers. - refusing := &autocert.Manager{ - Prompt: autocert.AcceptTOS, - Cache: autocert.DirCache(t.TempDir()), - HostPolicy: func(ctx context.Context, host string) error { - return fmt.Errorf("no internal-only route for %q in this mesh", host) - }, - } - h := tokenOrRoute(routedTo(t, "the workload answered"), refusing) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/mailus-token", nil)) - - if rec.Code != http.StatusOK || rec.Body.String() != "the workload answered" { - t.Fatalf("a policy refusal must fall through to routing; got %d %q", rec.Code, rec.Body.String()) - } -} - -func TestAnOrdinaryPathNeverTouchesTheChallengeMachinery(t *testing.T) { - m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())} - h := tokenOrRoute(routedTo(t, "routed"), m) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest("GET", "http://site.example/index.html", nil)) - - if rec.Code != http.StatusOK || rec.Body.String() != "routed" { - t.Fatalf("an ordinary path goes straight to routing; got %d %q", rec.Code, rec.Body.String()) - } -} diff --git a/modules/route-proxy/cmd/route-proxy/forwarded_test.go b/modules/route-proxy/cmd/route-proxy/forwarded_test.go deleted file mode 100644 index 0c31068f..00000000 --- a/modules/route-proxy/cmd/route-proxy/forwarded_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "crypto/tls" - "net/http" - "net/http/httptest" - "net/url" - "testing" -) - -// A backend behind the proxy learns the client used TLS and which name it asked for, so the addresses -// it writes into its own pages are the ones a client can use (2026-10-03: a forge's Go import tag -// named an http clone URL, and Go refused the module path). -func TestABackendIsToldTheRequestWasHTTPSAndForWhichName(t *testing.T) { - var proto, host, fwdHost, fwdFor string - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - proto, host, fwdHost, fwdFor = r.Header.Get("X-Forwarded-Proto"), r.Host, r.Header.Get("X-Forwarded-Host"), r.Header.Get("X-Forwarded-For") - })) - defer backend.Close() - where, _ := url.Parse(backend.URL) - req := httptest.NewRequest(http.MethodGet, "https://git.example.org/novox/mesh-sdk/go?go-get=1", nil) - req.TLS = &tls.ConnectionState{} - req.Host = "git.example.org" - req.RemoteAddr = "192.0.2.7:51000" - towards(where).ServeHTTP(httptest.NewRecorder(), req) - if proto != "https" || fwdHost != "git.example.org" || host != "git.example.org" || fwdFor != "192.0.2.7" { - t.Errorf("the backend was told proto=%q host=%q forwarded-host=%q for=%q", proto, host, fwdHost, fwdFor) - } -} diff --git a/modules/route-proxy/cmd/route-proxy/ledger.go b/modules/route-proxy/cmd/route-proxy/ledger.go deleted file mode 100644 index 30aecb19..00000000 --- a/modules/route-proxy/cmd/route-proxy/ledger.go +++ /dev/null @@ -1,552 +0,0 @@ -package main - -import ( - "context" - "crypto/x509" - "encoding/json" - "encoding/pem" - "log" - "net/http" - "reflect" - "sort" - "strings" - "sync" - "time" - - "golang.org/x/crypto/acme/autocert" - - stdio "git.novox.be/novox/mesh-sdk/go" - - "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" -) - -// What this proxy says on the bus about the names it serves and the names it refuses (issue 774). -// -// **On change, never on a timer.** A route arriving or leaving, a certificate issued or failing, an upstream answer -// that differs from the last one written, a name asked for that is not served: each marks its key, and one writer -// puts the marked keys. An answer that is the same as the last one written is written again only once it is older -// than refreshAfter, so a site answering 200 all day costs one write a minute, not one per request. -// -// Until the bus is reached nothing is written; what happened meanwhile is held and written when it is. - -// refreshAfter is how old a written answer, refusal or certificate failure may be before the same one is written -// again with its new time. -const refreshAfter = time.Minute - -// mostAsked bounds the names held in memory as asked and not served: a scan sends any name it likes. -const mostAsked = 4096 - -// newPerMinute is how many names not seen before are kept by name in a minute; past it they are only counted. -const newPerMinute = 5 - -// kvStore is one of the module's states, as process.State is one. -type kvStore interface { - Put(key string, value any) (uint64, error) - Delete(key string) error - Keys() ([]string, error) - Get(key string) (*stdio.StateEntry, error) -} - -// ledger is what this proxy keeps of what it serves and refuses, and writes to the bus. -type ledger struct { - mu sync.Mutex - now func() time.Time - - node string - routes, unrouted kvStore - - served map[string]*contract.Route - previous map[string]contract.Route // what the bus held for this machine when it was reached - asked map[string]*contract.Unrouted - - answerWritten map[string]time.Time - askedWritten map[string]time.Time - - dirtyRoutes map[string]bool // a name to put when served, to delete when not - dirtyAsked map[string]bool - - // domains are the registrable domains of the names served: a refused name under one is kept by name. - domains map[string]bool - newMinute time.Time - newInMinute int - other contract.Other - otherMinute time.Time - otherInMinute int - otherDirty bool - routesFailing, askFailed bool // a failed write said once, until one succeeds - - kick chan struct{} -} - -func newLedger() *ledger { - return &ledger{ - now: time.Now, - served: map[string]*contract.Route{}, - previous: map[string]contract.Route{}, - asked: map[string]*contract.Unrouted{}, - answerWritten: map[string]time.Time{}, - askedWritten: map[string]time.Time{}, - dirtyRoutes: map[string]bool{}, - dirtyAsked: map[string]bool{}, - domains: map[string]bool{}, - kick: make(chan struct{}, 1), - } -} - -// loop writes whatever was marked, each time something was. -func (l *ledger) loop() { - for range l.kick { - l.flush() - } -} - -func (l *ledger) wake() { - select { - case l.kick <- struct{}{}: - default: - } -} - -// attach is the bus reached, as node: what the bus held for this machine is read back, so a restart keeps a name's -// certificate and last answer, and everything held is written. -func (l *ledger) attach(node string, routes, unrouted kvStore) { - if l == nil { - return - } - before := map[string]contract.Route{} - if keys, err := routes.Keys(); err != nil { - log.Printf("cannot read back what this proxy said it serves: %v", err) - } else { - for _, key := range keys { - at, name := contract.Split(key) - if at != node { - continue - } - entry, err := routes.Get(key) - if err != nil || entry == nil { - continue - } - var r contract.Route - if json.Unmarshal(entry.Value, &r) == nil { - before[name] = r - } - } - } - l.mu.Lock() - l.node, l.routes, l.unrouted = node, routes, unrouted - for name, r := range before { - l.previous[name] = r - l.dirtyRoutes[name] = true // put again when served, deleted when no longer - if now := l.served[name]; now != nil { - keepWhatWasKnown(now, r) - } - } - for name := range l.served { - l.dirtyRoutes[name] = true - } - for name := range l.asked { - l.dirtyAsked[name] = true - } - l.mu.Unlock() - l.wake() -} - -// keepWhatWasKnown carries a certificate and a last answer read back from the bus onto a route that knows none yet. -func keepWhatWasKnown(r *contract.Route, before contract.Route) { - if r.Certificate.Outcome == "" && r.Certificate.NotAfter == nil && before.Certificate.Authority == r.Certificate.Authority { - r.Certificate = before.Certificate - } - if r.LastAnswer == nil { - r.LastAnswer = before.LastAnswer - } -} - -// serving is the table this proxy now serves: a name new or changed is written, a name gone is deleted, and a name -// that was asked for unserved and now is served leaves the unrouted state. -func (l *ledger) serving(routes map[string][]rule, public map[string]bool) { - if l == nil { - return - } - l.mu.Lock() - defer l.mu.Unlock() - for name, rules := range routes { - reach := contract.ReachInternal - if public[name] { - reach = contract.ReachPublic - } - targets := targetsOf(rules) - r := l.served[name] - if r == nil { - r = &contract.Route{Name: name} - if before, ok := l.previous[name]; ok { - r.Certificate.Authority = reach - keepWhatWasKnown(r, before) - } - l.served[name] = r - l.dirtyRoutes[name] = true - } - if r.Reach != reach || !reflect.DeepEqual(r.Targets, targets) { - r.Reach, r.Targets = reach, targets - l.dirtyRoutes[name] = true - } - if r.Certificate.Authority != reach { - r.Certificate = contract.Certificate{Authority: reach} - l.dirtyRoutes[name] = true - } - if _, was := l.asked[name]; was { - delete(l.asked, name) - l.dirtyAsked[name] = true - } - } - l.domains = map[string]bool{} - for name := range routes { - if d := contract.Registrable(name); d != "" { - l.domains[d] = true - } - } - for name := range l.served { - if _, still := routes[name]; !still { - delete(l.served, name) - delete(l.answerWritten, name) - l.dirtyRoutes[name] = true - } - } - l.wake() -} - -func targetsOf(rules []rule) []contract.Target { - out := make([]contract.Target, 0, len(rules)) - for _, r := range rules { - out = append(out, contract.Target{Path: r.path, Module: r.from, Node: r.node, Target: r.target, - Deny: r.policy.deny, Redirect: r.policy.redirectTo, Auth: len(r.policy.users) > 0 || r.policy.sealed != "", - Sealed: r.policy.sealed}) - } - return out -} - -// answered is an upstream answer to a request for name: its status, or the error that stood in for one. -func (l *ledger) answered(name, path string, status int, failed error) { - if l == nil { - return - } - l.mu.Lock() - defer l.mu.Unlock() - r := l.served[name] - if r == nil { - return - } - now := l.now() - why := "" - if failed != nil { - why = failed.Error() - } - before := r.LastAnswer - r.LastAnswer = &contract.Answer{Status: status, At: now, Error: why, Path: path} - if before != nil && before.Status == status && before.Error == why && now.Sub(l.answerWritten[name]) < refreshAfter { - return - } - l.answerWritten[name] = now - l.dirtyRoutes[name] = true - l.wake() -} - -// refused is a name this proxy answered "no route for": in a request, or in a handshake's server name. -// -// **Only a name under a domain this proxy serves is kept by name**, and at most newPerMinute new ones a minute: a scan -// sends any name it likes, and each kept name is a key on the bus. Every other refusal is counted, and the count is -// written once a minute under the proxy's OtherName key. Nothing here scans: forgetting old names is the writer's. -// Who asked is not kept: the journal line beside it is what the machine's intrusion prevention reads. -func (l *ledger) refused(name string, internalOnly, handshake bool) { - if l == nil { - return - } - l.mu.Lock() - defer l.mu.Unlock() - if _, isServed := l.served[name]; isServed && !internalOnly { - return - } - now := l.now() - a := l.asked[name] - if a == nil { - if !contract.Keyable(name) || !l.domains[contract.Registrable(name)] || !l.mayAddName(now) { - l.countOther(now) - return - } - a = &contract.Unrouted{Name: name} - l.asked[name] = a - } - a.AskedAt, a.InternalOnly, a.Handshake = now, internalOnly, handshake - a.Times++ - if a.Times > 1 && now.Sub(l.askedWritten[name]) < refreshAfter { - return - } - l.askedWritten[name] = now - l.dirtyAsked[name] = true - l.wake() -} - -// mayAddName says whether another new name may be kept this minute, and counts it. -func (l *ledger) mayAddName(now time.Time) bool { - if minute := now.Truncate(time.Minute); !minute.Equal(l.newMinute) { - l.newMinute, l.newInMinute = minute, 0 - } - if l.newInMinute >= newPerMinute { - return false - } - l.newInMinute++ - return true -} - -// countOther counts a refusal not kept by name. A minute's count is written when the next minute's first one comes. -func (l *ledger) countOther(now time.Time) { - minute := now.Truncate(time.Minute) - if !minute.Equal(l.otherMinute) { - if l.otherInMinute > 0 { - l.other.Minute, l.other.LastMinute = l.otherMinute, l.otherInMinute - l.otherDirty = true - l.wake() - } - l.otherMinute, l.otherInMinute = minute, 0 - } - l.otherInMinute++ - l.other.SinceStart++ -} - -// forgetOldestAsked drops the names asked longest ago from memory until mostAsked are left; the bus keeps them until -// their time to live ends. Called by the writer, never on a request. -func (l *ledger) forgetOldestAsked() { - if len(l.asked) <= mostAsked { - return - } - names := make([]string, 0, len(l.asked)) - for name := range l.asked { - names = append(names, name) - } - sort.Slice(names, func(i, j int) bool { return l.asked[names[i]].AskedAt.Before(l.asked[names[j]].AskedAt) }) - for _, name := range names[:len(names)-mostAsked] { - delete(l.asked, name) - delete(l.askedWritten, name) - delete(l.dirtyAsked, name) - } -} - -// certified is a certificate an authority issued for name, as autocert stored it. -func (l *ledger) certified(name string, leaf *x509.Certificate) { - if l == nil { - return - } - l.mu.Lock() - defer l.mu.Unlock() - r := l.served[name] - if r == nil { - return - } - now := l.now() - notAfter := leaf.NotAfter.UTC() - r.Certificate.Issuer, r.Certificate.NotAfter = issuerOf(leaf), ¬After - r.Certificate.RenewedAt, r.Certificate.At = &now, &now - r.Certificate.Outcome, r.Certificate.Error = contract.Issued, "" - l.dirtyRoutes[name] = true - l.wake() -} - -// handedOut is the certificate a handshake for name was answered with: written when it is not the one recorded. -func (l *ledger) handedOut(name string, leaf *x509.Certificate) { - if l == nil || leaf == nil { - return - } - l.mu.Lock() - defer l.mu.Unlock() - r := l.served[name] - if r == nil { - return - } - notAfter := leaf.NotAfter.UTC() - if r.Certificate.NotAfter != nil && r.Certificate.NotAfter.Equal(notAfter) && r.Certificate.Outcome != contract.Failed { - return - } - now := l.now() - r.Certificate.Issuer, r.Certificate.NotAfter, r.Certificate.At = issuerOf(leaf), ¬After, &now - if r.Certificate.Outcome != contract.Issued || r.Certificate.Error != "" { - r.Certificate.Outcome, r.Certificate.Error = contract.Cached, "" - } - l.dirtyRoutes[name] = true - l.wake() -} - -// certificateFailed is a handshake for name that found no certificate to answer with, and why. -func (l *ledger) certificateFailed(name string, failed error) { - if l == nil { - return - } - l.mu.Lock() - defer l.mu.Unlock() - r := l.served[name] - if r == nil { - return - } - now := l.now() - same := r.Certificate.Outcome == contract.Failed && r.Certificate.Error == failed.Error() - if same && r.Certificate.At != nil && now.Sub(*r.Certificate.At) < refreshAfter { - return - } - r.Certificate.Outcome, r.Certificate.Error, r.Certificate.At = contract.Failed, failed.Error(), &now - l.dirtyRoutes[name] = true - l.wake() -} - -func issuerOf(leaf *x509.Certificate) string { - if cn := leaf.Issuer.CommonName; cn != "" { - if len(leaf.Issuer.Organization) > 0 { - return leaf.Issuer.Organization[0] + " " + cn - } - return cn - } - return leaf.Issuer.String() -} - -// flush writes every marked key. A route's write that fails stays marked and is tried again at the next change; a -// refused name's write that fails is dropped, never tried again: the bucket refusing it is full, or the bus is away, -// and either way it is not worth a storm of retries. Each kind of failure is logged once, until a write succeeds. -func (l *ledger) flush() { - type put struct { - store kvStore - key string - value any // nil deletes - route bool - } - l.mu.Lock() - if l.routes == nil { - l.mu.Unlock() - return - } - l.forgetOldestAsked() - var puts []put - for name := range l.dirtyRoutes { - p := put{store: l.routes, key: contract.Key(l.node, name), route: true} - if r := l.served[name]; r != nil { - c := *r - c.Proxy = l.node - p.value = c - } - puts = append(puts, p) - } - for name := range l.dirtyAsked { - p := put{store: l.unrouted, key: contract.Key(l.node, name)} - if a := l.asked[name]; a != nil { - c := *a - c.Proxy = l.node - p.value = c - } - puts = append(puts, p) - } - if l.otherDirty { - o := l.other - o.Proxy = l.node - puts = append(puts, put{store: l.unrouted, key: contract.Key(l.node, contract.OtherName), value: o}) - } - l.dirtyRoutes, l.dirtyAsked, l.otherDirty = map[string]bool{}, map[string]bool{}, false - l.mu.Unlock() - - for _, p := range puts { - var err error - if p.value == nil { - err = p.store.Delete(p.key) - } else { - _, err = p.store.Put(p.key, p.value) - } - l.mu.Lock() - switch { - case err == nil && p.route: - l.routesFailing = false - case err == nil: - l.askFailed = false - case p.route: - if !l.routesFailing { - log.Printf("cannot write %s to the bus, kept for the next change: %v", p.key, err) - } - l.routesFailing = true - _, name := contract.Split(p.key) - l.dirtyRoutes[name] = true - default: - if !l.askFailed { - log.Printf("cannot write %s to the bus, dropped with every refused name until a write succeeds: %v", - p.key, err) - } - l.askFailed = true - } - l.mu.Unlock() - } -} - -// recordingCache is an authority's certificate cache that tells the ledger of every certificate stored in it: the -// one place autocert says it was issued one. -type recordingCache struct { - autocert.Cache - held *table -} - -func (c recordingCache) Put(ctx context.Context, key string, data []byte) error { - if err := c.Cache.Put(ctx, key, data); err != nil { - return err - } - name := strings.TrimSuffix(key, "+rsa") - if strings.Contains(name, "+") || !c.held.routed(name) { - return nil // the account key, a challenge token - } - if leaf := leafOf(data); leaf != nil { - c.held.ledger.certified(bareHost(name), leaf) - } - return nil -} - -// leafOf is the first certificate in what autocert stores: the private key, then the chain, in PEM. -func leafOf(data []byte) *x509.Certificate { - for { - var block *pem.Block - block, data = pem.Decode(data) - if block == nil { - return nil - } - if block.Type == "CERTIFICATE" { - leaf, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return nil - } - return leaf - } - } -} - -// answerRecorder carries a proxied request's answer through and remembers its status and the proxy's error. -type answerRecorder struct { - http.ResponseWriter - status int - err error -} - -func (a *answerRecorder) WriteHeader(status int) { - if a.status == 0 { - a.status = status - } - a.ResponseWriter.WriteHeader(status) -} - -func (a *answerRecorder) Write(b []byte) (int, error) { - if a.status == 0 { - a.status = http.StatusOK - } - return a.ResponseWriter.Write(b) -} - -// Unwrap lets http.ResponseController reach the connection beneath: a flush, an upgrade's hijack. -func (a *answerRecorder) Unwrap() http.ResponseWriter { return a.ResponseWriter } - -// proxyFailed is the reverse proxy's answer when upstream gave none: the error kept for the ledger, and a 502. -func proxyFailed(w http.ResponseWriter, r *http.Request, err error) { - if a, ok := w.(*answerRecorder); ok { - a.err = err - } - log.Printf("http: proxy error for %s%s: %v", r.Host, r.URL.Path, err) - w.WriteHeader(http.StatusBadGateway) -} diff --git a/modules/route-proxy/cmd/route-proxy/ledger_test.go b/modules/route-proxy/cmd/route-proxy/ledger_test.go deleted file mode 100644 index 91c01113..00000000 --- a/modules/route-proxy/cmd/route-proxy/ledger_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package main - -import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/json" - "encoding/pem" - "errors" - "fmt" - "math/big" - "net" - "net/http" - "net/http/httptest" - "sort" - "strings" - "testing" - "time" - - "golang.org/x/crypto/acme/autocert" - - stdio "git.novox.be/novox/mesh-sdk/go" - - "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" -) - -// fakeState is a state on the bus, in memory, counting what was written to it. -type fakeState struct { - values map[string]json.RawMessage - puts int - tries int // every Put asked, refused or not - fail error // what every Put is refused with, while set -} - -func newFakeState() *fakeState { return &fakeState{values: map[string]json.RawMessage{}} } - -func (f *fakeState) Put(key string, value any) (uint64, error) { - f.tries++ - if f.fail != nil { - return 0, f.fail - } - raw, err := json.Marshal(value) - if err != nil { - return 0, err - } - f.values[key] = raw - f.puts++ - return uint64(f.puts), nil -} -func (f *fakeState) Delete(key string) error { delete(f.values, key); return nil } -func (f *fakeState) Keys() ([]string, error) { - out := []string{} - for k := range f.values { - out = append(out, k) - } - sort.Strings(out) - return out, nil -} -func (f *fakeState) Get(key string) (*stdio.StateEntry, error) { - v, ok := f.values[key] - if !ok { - return nil, nil - } - return &stdio.StateEntry{Key: key, Value: v}, nil -} - -func (f *fakeState) route(t *testing.T, key string) *contract.Route { - t.Helper() - raw, ok := f.values[key] - if !ok { - return nil - } - var r contract.Route - if err := json.Unmarshal(raw, &r); err != nil { - t.Fatal(err) - } - return &r -} - -// issuedPEM is what autocert stores for a name: the key, then the certificate, signed by an issuer named here. -func issuedPEM(t *testing.T, name string, notAfter time.Time) []byte { - t.Helper() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - template := &x509.Certificate{SerialNumber: big.NewInt(1), DNSNames: []string{name}, - Subject: pkix.Name{CommonName: name}, Issuer: pkix.Name{CommonName: "R11", Organization: []string{"Let's Encrypt"}}, - NotBefore: notAfter.Add(-90 * 24 * time.Hour), NotAfter: notAfter} - issuer := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: template.Issuer, NotBefore: template.NotBefore, - NotAfter: notAfter, IsCA: true, BasicConstraintsValid: true} - der, err := x509.CreateCertificate(rand.Reader, template, issuer, &key.PublicKey, key) - if err != nil { - t.Fatal(err) - } - keyDER, _ := x509.MarshalECPrivateKey(key) - out := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) - return append(out, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...) -} - -// What the proxy tells the bus (issue 774): each served name with where it goes, its certificate and the last -// upstream answer; each name refused; and only on change. Without the ledger nothing reaches the bus, and the -// operator's question "is grafana reachable" is answered with curl. -func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) { - notAfter := time.Date(2027, 1, 28, 12, 0, 0, 0, time.UTC) - gone := func() string { // an address nothing listens on: the upstream that does not answer - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer l.Close() - return l.Addr().String() - }() - _, port, _ := net.SplitHostPort(gone) - contributions := []contribution{ - {From: "grafana", Node: "anchor", At: "127.0.0.1", Route: contract.Request{Name: "grafana.example", Port: int(mustFloat(t, port))}}, - {From: "drive", Node: "home-server", At: "10.0.0.2", Route: contract.Request{InternalName: "drive.internal", Port: 8080}}, - } - - for _, row := range []struct { - name string - act func(t *testing.T, held *table) - check func(t *testing.T, routes, unrouted *fakeState) - }{ - {"a served name is written with the module and node it forwards to", - func(t *testing.T, held *table) {}, - func(t *testing.T, routes, _ *fakeState) { - r := routes.route(t, "anchor.grafana.example") - if r == nil || r.Proxy != "anchor" || r.Reach != contract.ReachPublic || len(r.Targets) != 1 || - r.Targets[0].Module != "grafana" || r.Targets[0].Node != "anchor" || r.Targets[0].Target != "http://127.0.0.1:"+port { - t.Fatalf("grafana's route is not on the bus as served: %+v", r) - } - if d := routes.route(t, "anchor.drive.internal"); d == nil || d.Reach != contract.ReachInternal || - d.Certificate.Authority != contract.ReachInternal { - t.Fatalf("an internal-only name is not on the bus as internal: %+v", d) - } - }}, - {"a withdrawn route is deleted", - func(t *testing.T, held *table) { - routes, public := routesOf(contributions[1:]) - held.set(routes, public) - }, - func(t *testing.T, routes, _ *fakeState) { - if routes.route(t, "anchor.grafana.example") != nil { - t.Fatal("a route no longer served is still on the bus") - } - }}, - {"an upstream that does not answer is written as a 502 with why, once a minute", - func(t *testing.T, held *table) { - for range 3 { - req := httptest.NewRequest(http.MethodGet, "http://grafana.example/login", nil) - handler(held).ServeHTTP(httptest.NewRecorder(), req) - } - }, - func(t *testing.T, routes, _ *fakeState) { - r := routes.route(t, "anchor.grafana.example") - if r == nil || r.LastAnswer == nil || r.LastAnswer.Status != http.StatusBadGateway || - r.LastAnswer.Error == "" || r.LastAnswer.Path != "/login" { - t.Fatalf("the failed answer is not on the bus: %+v", r) - } - if routes.puts != 3 { // the two routes, then the first answer; the same answer again is not written - t.Fatalf("the same answer was written %d times in all, want 3 writes", routes.puts) - } - }}, - {"a name under a served domain is written as asked, without who asked, and leaves when it is served", - func(t *testing.T, held *table) { - req := httptest.NewRequest(http.MethodGet, "http://wiki.grafana.example/", nil) - req.RemoteAddr = "192.0.2.7:51000" - handler(held).ServeHTTP(httptest.NewRecorder(), req) - held.ledger.flush() - var a contract.Unrouted - raw := held.ledger.unrouted.(*fakeState).values["anchor.wiki.grafana.example"] - if err := json.Unmarshal(raw, &a); err != nil || a.Times != 1 || a.AskedAt.IsZero() { - t.Fatalf("the refused name is not on the bus as asked: %s", raw) - } - if strings.Contains(string(raw), "192.0.2.7") || strings.Contains(string(raw), `"from"`) { - t.Fatalf("who asked is on the bus: %s", raw) - } - routes, public := routesOf(append(contributions, contribution{From: "grafana", Node: "anchor", - Route: contract.Request{Name: "wiki.grafana.example", Port: 3000}})) - held.set(routes, public) - }, - func(t *testing.T, _, unrouted *fakeState) { - if len(unrouted.values) != 0 { - t.Fatalf("a name now served is still written as unrouted: %v", unrouted.values) - } - }}, - {"a handshake for a name not routed is written as asked", - func(t *testing.T, held *table) { - pick := certificateFor(held, func(*tls.ClientHelloInfo) (*tls.Certificate, error) { - return nil, errors.New("no public route for \"forgotten.grafana.example\" in this mesh, so no certificate is asked for") - }, nil) - _, _ = pick(&tls.ClientHelloInfo{ServerName: "forgotten.grafana.example"}) - }, - func(t *testing.T, _, unrouted *fakeState) { - var a contract.Unrouted - if err := json.Unmarshal(unrouted.values["anchor.forgotten.grafana.example"], &a); err != nil || !a.Handshake { - t.Fatalf("a handshake refused for its name is not on the bus: %v", unrouted.values) - } - }}, - {"a flood of names writes at most the limit by name, counts the rest once a minute, and never retries a refused write", - func(t *testing.T, held *table) { - clock := time.Date(2026, 10, 30, 21, 0, 0, 0, time.UTC) - held.ledger.now = func() time.Time { return clock } - full := held.ledger.unrouted.(*fakeState) - for i := range 40 { - held.ledger.refused(fmt.Sprintf("x%d.grafana.example", i), false, false) // under a served domain - held.ledger.refused(fmt.Sprintf("x%d.scan.test", i), false, true) // under none - } - held.ledger.flush() - if named := len(full.values); named != newPerMinute { - t.Fatalf("a flood wrote %d names, want the limit %d: %v", named, newPerMinute, full.values) - } - full.fail = errors.New("nats: maximum bytes exceeded") - clock = clock.Add(time.Minute) - held.ledger.refused("y.grafana.example", false, false) // a new minute: one more name, - held.ledger.refused("v.scan.test", false, false) // and the last minute's count - held.ledger.flush() - tries := full.tries - held.ledger.refused("y.grafana.example", false, false) - held.ledger.refused("z.scan.test", false, false) - held.ledger.flush() - if full.tries != tries { - t.Fatalf("writes the bucket refused were tried again: %d tries, then %d", tries, full.tries) - } - full.fail = nil - clock = clock.Add(time.Minute) - held.ledger.refused("w.scan.test", false, false) - }, - func(t *testing.T, _, unrouted *fakeState) { - var o contract.Other - if err := json.Unmarshal(unrouted.values["anchor._other"], &o); err != nil || o.LastMinute != 2 || o.SinceStart != 78 { - t.Fatalf("the refusals not kept by name are not counted on the bus: %s", unrouted.values["anchor._other"]) - } - }}, - {"a certificate an authority issued is written with its issuer, expiry and renewal", - func(t *testing.T, held *table) { - cache := recordingCache{Cache: autocert.DirCache(t.TempDir()), held: held} - if err := cache.Put(context.Background(), "grafana.example", issuedPEM(t, "grafana.example", notAfter)); err != nil { - t.Fatal(err) - } - _ = cache.Put(context.Background(), "acme_account+key", []byte("not a certificate")) - }, - func(t *testing.T, routes, _ *fakeState) { - c := routes.route(t, "anchor.grafana.example").Certificate - if c.Outcome != contract.Issued || c.Issuer != "Let's Encrypt R11" || c.NotAfter == nil || - !c.NotAfter.Equal(notAfter) || c.RenewedAt == nil { - t.Fatalf("the issued certificate is not on the bus: %+v", c) - } - }}, - {"a certificate that cannot be had is written with why", - func(t *testing.T, held *table) { - pick := certificateFor(held, func(*tls.ClientHelloInfo) (*tls.Certificate, error) { - return nil, errors.New("acme: urn:ietf:params:acme:error:rateLimited") - }, nil) - _, _ = pick(&tls.ClientHelloInfo{ServerName: "grafana.example"}) - }, - func(t *testing.T, routes, _ *fakeState) { - c := routes.route(t, "anchor.grafana.example").Certificate - if c.Outcome != contract.Failed || c.Error != "acme: urn:ietf:params:acme:error:rateLimited" { - t.Fatalf("the failed certificate is not on the bus: %+v", c) - } - }}, - } { - t.Run(row.name, func(t *testing.T) { - held := newTable() - held.ledger = newLedger() - routes, unrouted := newFakeState(), newFakeState() - held.ledger.attach("anchor", routes, unrouted) - table, public := routesOf(contributions) - held.set(table, public) - held.ledger.flush() - row.act(t, held) - held.ledger.flush() - row.check(t, routes, unrouted) - }) - } -} - -func mustFloat(t *testing.T, port string) float64 { - t.Helper() - var n float64 - if err := json.Unmarshal([]byte(port), &n); err != nil { - t.Fatal(err) - } - return n -} diff --git a/modules/route-proxy/cmd/route-proxy/main.go b/modules/route-proxy/cmd/route-proxy/main.go deleted file mode 100644 index 9203ba46..00000000 --- a/modules/route-proxy/cmd/route-proxy/main.go +++ /dev/null @@ -1,975 +0,0 @@ -// A reverse proxy, in the form the mesh expects one. -// -// **A route is a provision** (novox/hq ADR 0007, 08-connectivity §3; issue 774, research 072 row 34). A -// module that must be reachable declares it requires `route` and contributes the name it wants; the -// proxy provides `route` and serves every consumer whose plan, in the seat mesh-provisioner's state -// `plans`, names it as the route's provider. It is the mirror of a database grant: there the consumer -// supplies a name and receives credentials, here it supplies a target and receives a name. -// -// **It is an example, not part of the controller.** The controller decides and never touches a -// machine. A real deployment runs whatever proxy it likes — the contract is the plan's entry, not -// this program. What lives here is that contract, written as something that runs so it can be read -// rather than described. -// -// **A route also carries what a request arriving at it may do** (novox/hq ADR 0108). The grant used -// to say only where to send traffic, so this proxy applied nothing; the four things the ingress it -// replaces actually relies on are now part of the contribution. The set is closed at four, because -// an open middleware surface recreates the thing being replaced and is far harder to narrow later -// than a closed one is to widen. -// -// What it is given, in each plan's entry for the route: every contribution the consumer makes, -// composed by the controller, and where the consumer's machine is on the private network (plans.go). -// Each contribution's values carry the name and port, and optionally: -// -// path the path prefix this rule is scoped to; absent means every path -// priority which rule wins where two match; higher first, and the order is total -// deny refuse the request outright — the shape an incident mitigation needs -// redirect answer with a permanent redirect to this name, keeping the path and query -// auth the *path of a secret* holding `user:hash` lines, never the credential itself -// -// A host may appear more than once, which is what path scoping means: one rule refusing a path -// while another serves everything else on the same name. -// -// **`auth` names a secret and never holds one.** A declaration carrying a credential is refused -// outright rather than served unprotected, and a secret that cannot be read makes the route refuse -// rather than open — a gate that cannot check is not a gate that opens. -// -// It follows the plans on the bus rather than being restarted, for the same reason the provisioner -// does: a route arriving or leaving is an ordinary event and must not drop the connections of every -// other workload. -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "crypto/subtle" - "crypto/tls" - "crypto/x509" - "encoding/hex" - "fmt" - "log" - "net" - "net/http" - "net/http/httputil" - "net/netip" - "net/url" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" - - "golang.org/x/crypto/acme" - "golang.org/x/crypto/acme/autocert" - "golang.org/x/crypto/bcrypt" - - "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" -) - -// Where public certificates come from when nothing says otherwise. -// -// **Staging, deliberately** (novox/hq 04-ISSUES/004). Production issuance is rate-limited per -// domain and per account, the quota does not replenish quickly, and exhausting it removes the -// ability to issue a certificate somebody actually needs. Defaulting to production would leave -// the safe path depending on remembering to opt out of it, on exactly the work most likely to -// iterate — standing up a node, changing how names resolve. -// -// A staging certificate is trusted by no browser, so the mistake announces itself on the first -// request rather than a fortnight later at the rate limit. -const stagingDirectory = "https://acme-staging-v02.api.letsencrypt.org/directory" - -// issuer is the ACME directory to ask. The lab points this at its own issuer; a node serving real -// traffic points it at production, and says so explicitly. -func issuer() string { - if named := strings.TrimSpace(os.Getenv("ACME_DIRECTORY")); named != "" { - return named - } - return stagingDirectory -} - -// onlyWhatTheMeshSaid refuses to obtain a certificate for a name this proxy was not given. -// -// **The policy that stops a quota from being spent by accident.** Without it, anything that can -// reach port 443 and send a name triggers an issuance attempt for it — so a scan, or one -// misconfigured client, becomes a stream of failed orders against the account's rate limit. What -// this proxy may certify is exactly what the mesh told it to route, which is already the answer -// to what it may serve. -func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy { - return func(_ context.Context, host string) error { - if held.eligibleForACME(host) { - return nil - } - return fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for", host) - } -} - -// onlyInternalNamesTheMeshSaid is onlyWhatTheMeshSaid's mirror for the internal authority — the -// same quota-spending concern applies even to an authority with no rate limit of its own, because -// an order for a name this proxy does not actually route is a bug worth refusing rather than -// serving. -func onlyInternalNamesTheMeshSaid(held *table) autocert.HostPolicy { - return func(_ context.Context, host string) error { - if held.eligibleForInternalACME(host) { - return nil - } - return fmt.Errorf("no internal-only route for %q in this mesh, so no certificate is asked for", host) - } -} - -// contribution is one route a consumer asked for: the module, its machine, where that machine is, the local name of -// its contribution, and the route, composed by the mesh and read with route-proxy's contract (plans.go). -type contribution struct { - From string `json:"from"` - Node string `json:"node"` - // At is where that machine is on the private network. The mesh knows it; a proxy that had to - // build it from the node name would be a naming convention copied into every provider. - At string `json:"at"` - Local string `json:"local,omitempty"` - Route contract.Request `json:"route"` -} - -// policy is what a rule does with a request that matched it. -// -// **Decided by the mesh, not here** (novox/hq ADR 0108). A route grant used to hand back a name and -// say nothing about what the name admitted, so this proxy admitted everything. The set is closed at -// four — authentication, refusal, path scoping, redirect — because an open middleware surface -// recreates the thing being replaced and is far harder to narrow later than a closed one is to widen. -type policy struct { - // deny refuses the request outright, whatever it is. - deny bool - // redirectTo answers with a permanent redirect instead of proxying. The request's own path and - // query are carried across, which is what canonicalising one public name onto another means. - redirectTo string - // users is what a request must present, read at load time from the secret the declaration - // *named*. A declaration never carries the credential itself. - users map[string]string - // sealed is set when authentication was declared and the secret could not be read. The rule then - // refuses everything and says why. - // - // **Fail closed.** The alternative — serve the route unauthenticated because the gate is - // missing — turns an unreadable file into a silently public admin surface, which is the exact - // outcome ADR 0108 exists to prevent. A gate that cannot check is not a gate that opens. - sealed string -} - -// rule is one way a host may be routed. A host may have several, which is what path scoping means. -type rule struct { - path string // "" matches every path - priority int - policy policy - to *httputil.ReverseProxy - target string - // insecure skips certificate verification when target is reached over https. For a backend - // that terminates TLS with its own certificate this proxy has no reason to trust — Mailu's - // webmail front is the first of these — never for anything reached over plain http, where - // there is nothing to verify in the first place. - insecure bool - // maxRequestBody is the largest body, in bytes, this route carries. Zero is no limit, which is - // what every route gets by saying nothing: this proxy has never limited a body, and a default - // arriving with the field would change every route that never asked for one. - // - // **Configuration, not a policy** (novox/hq ADR 0108 closed that set at four). It belongs beside - // `insecure` for the same reason `insecure` is not a policy: both tune how this proxy carries a - // request to a backend, rather than deciding what the name admits or who may reach it. A - // registry is the case that needs it — image layers arrive as single requests of gigabytes, and - // a proxy's own default refuses them long before the workload is reached. - maxRequestBody int64 - // from and node are the consumer that contributed the rule and its machine, which the bus is told (issue 774). - from, node string -} - -// table is what the proxy is currently serving, replaced whole whenever the plans change what they give it. -// -// Replaced rather than merged: the plans are the whole truth about who has a route, so merging -// would keep serving a name whose module was unassigned — which is the stale-route fault -// 08-connectivity lists as open, reintroduced one level down. -// -// Keyed by host to an *ordered* list rather than to one target, because two of the four policies -// need a single host routed more than one way: a refusal on a path the ordinary route also matches, -// and a certificate-challenge path on a host that otherwise serves a workload. -type table struct { - mu sync.RWMutex - to map[string][]rule - // public is which routed hosts are eligible for a real certificate — every host reached as a - // route's own `name`, never one reached only as its `internal-name`. A private alias can never - // pass ACME's own validation (it has no public DNS to prove it against), so asking for it is - // not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent. - public map[string]bool - // inside is where a request must come from to be served a name that is only internal: every - // machine's address on the private network, as the mesh issued it in this proxy's membership - // (novox/hq ADR 0167). Empty until it is issued, and then only the machine itself is inside. - inside sources - // ledger is what this proxy says on the bus about what it serves and refuses (issue 774); nil says nothing. - ledger *ledger -} - -// sources is who may be served an internal name: the private network's addresses as the mesh -// issued them. The machine itself is always inside — anything on a machine may call anything on it -// (novox/hq ADR 0144) — so loopback needs no entry. -type sources []netip.Prefix - -// sourcesOf reads the addresses the mesh issued, each a single address or a range. One that does -// not parse is an error, not an entry skipped: the proxy would otherwise serve internal names to -// fewer machines than the mesh said, and say nothing. -func sourcesOf(mesh []string) (sources, error) { - var out sources - for _, entry := range mesh { - entry = strings.TrimSpace(entry) - if prefix, err := netip.ParsePrefix(entry); err == nil { - out = append(out, prefix.Masked()) - continue - } - addr, err := netip.ParseAddr(entry) - if err != nil { - return nil, fmt.Errorf("%q is not an address on the private network", entry) - } - addr = addr.Unmap() - out = append(out, netip.PrefixFrom(addr, addr.BitLen())) - } - return out, nil -} - -// holds says whether a request from this remote address came from the mesh or the machine itself. -// -// **By source, which the mesh's guard deliberately is not** — it names interfaces, because a source -// address can be claimed by whoever sends the packet. The proxy cannot see the interface a request -// arrived on, and here the claim does not carry: a connection needs its replies, and replies to a -// mesh address leave by the tunnel, never back to the claimant. -func (s sources) holds(remote string) bool { - host := remote - if h, _, err := net.SplitHostPort(remote); err == nil { - host = h - } - addr, err := netip.ParseAddr(host) - if err != nil { - return false - } - addr = addr.Unmap() - if addr.IsLoopback() { - return true - } - for _, prefix := range s { - if prefix.Contains(addr) { - return true - } - } - return false -} - -func (t *table) set(routes map[string][]rule, public map[string]bool) { - made := map[string][]rule{} - for host, rules := range routes { - kept := make([]rule, 0, len(rules)) - for _, r := range rules { - // A rule that only refuses or only redirects has nowhere to send anything, and needs - // nowhere: it answers by itself. - if r.policy.deny || r.policy.redirectTo != "" { - kept = append(kept, r) - continue - } - where, err := url.Parse(r.target) - if err != nil { - log.Printf("route %s points at %q, which is not a URL: %v", host, r.target, err) - continue - } - r.to = towards(where) - if r.insecure { - r.to.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} - } - kept = append(kept, r) - } - if len(kept) == 0 { - continue - } - inOrder(kept) - made[host] = kept - } - t.mu.Lock() - t.to = made - t.public = public - t.mu.Unlock() - t.ledger.serving(made, public) -} - -// inOrder puts the rules for one host into the order they are matched in, and does so totally. -// -// **Equal priorities must resolve identically every time** (ADR 0108). Sorting only by priority -// leaves rules that share one in whatever order the map produced, so the same declaration would -// serve differently between restarts — a proxy that is not reproducible. Longest path first within a -// priority is also the intuitive reading: the more specific rule wins. The last two keys exist only -// to make the order total. -func inOrder(rules []rule) { - sort.SliceStable(rules, func(i, j int) bool { - a, b := rules[i], rules[j] - if a.priority != b.priority { - return a.priority > b.priority - } - if len(a.path) != len(b.path) { - return len(a.path) > len(b.path) - } - if a.path != b.path { - return a.path < b.path - } - return a.target < b.target - }) -} - -// find is the rule that answers this request, or nothing if the host is not routed here at all. -func (t *table) find(host, path string) (rule, bool) { - t.mu.RLock() - defer t.mu.RUnlock() - for _, r := range t.to[bareHost(host)] { - if r.path == "" || strings.HasPrefix(path, r.path) { - return r, true - } - } - return rule{}, false -} - -// routed says whether this proxy serves the name at all, whatever the path. -// -// Separate from find because certificate issuance is a question about the *name*: a host whose only -// rules are path-scoped is still a name this proxy answers to, and still needs a certificate. -// eligibleForACME says whether this proxy may ask a certificate authority for this name — every -// host reached as a route's own public `name`, never one reached only as its `internal-name` -// alias, which no public CA can ever validate. -func (t *table) eligibleForACME(host string) bool { - t.mu.RLock() - defer t.mu.RUnlock() - bare := bareHost(host) - return len(t.to[bare]) > 0 && t.public[bare] -} - -func (t *table) routed(host string) bool { - t.mu.RLock() - defer t.mu.RUnlock() - return len(t.to[bareHost(host)]) > 0 -} - -// eligibleForInternalACME says whether this proxy may ask its *internal* authority for a -// certificate for this name — every host it routes that is not also a route's public `name`. -// -// **The mesh has two name spaces and two authorities** (novox/hq 03-DESIGN/01-to-be/08-connectivity -// §2): a public name is certified by a public CA, an internal one by the mesh's own. This is -// composed only from `to` and `public`, which routesOf already builds correctly — a host never -// lands in both a route's own `name` and only its `internal-name`, so nothing new has to be -// tracked to tell the two apart. -func (t *table) eligibleForInternalACME(host string) bool { - t.mu.RLock() - defer t.mu.RUnlock() - bare := bareHost(host) - return len(t.to[bare]) > 0 && !t.public[bare] -} - -// bareHost is the name without the port, lower-cased. -// -// The port is not part of the name: a request to app.example:8080 is for app.example. Lower-cased -// because a Host header is not case-sensitive, and a route that only answers the spelling in the -// manifest answers half the requests made to it. -func bareHost(host string) string { - if h, _, err := net.SplitHostPort(host); err == nil { - host = h - } - return strings.ToLower(host) -} - -// hiddenFrom says whether this host must look unrouted to a request from this address: it is -// only an internal name, and the request did not come from the private network. -// -// **The proxy is the only way in to a routed endpoint, so it is what makes `internal` true** -// (novox/hq ADR 0138, issue 191). It answers public names on the same listeners, so a request from -// anywhere can carry any Host header; a name being internal keeps nobody out unless this check does. -// Answered exactly as a name that was never routed, so an outsider learns nothing from asking. -func (t *table) hiddenFrom(host, remote string) bool { - if !t.eligibleForInternalACME(host) { - return false - } - t.mu.RLock() - defer t.mu.RUnlock() - return !t.inside.holds(remote) -} - -// setInside replaces who the mesh is, as the membership said. -func (t *table) setInside(inside sources) { - t.mu.Lock() - t.inside = inside - t.mu.Unlock() -} - -// namesSeenFrom is what this proxy says it serves to a request from this address — every routed -// name, less the internal-only ones when the request came from outside. -func (t *table) namesSeenFrom(remote string) []string { - out := []string{} - for _, name := range t.names() { - if !t.hiddenFrom(name, remote) { - out = append(out, name) - } - } - return out -} - -// served is every name served, as an event says it: how far it reaches and the module whose rule serves it first. -func (t *table) served() map[string]contract.Event { - t.mu.RLock() - defer t.mu.RUnlock() - out := make(map[string]contract.Event, len(t.to)) - for name, rules := range t.to { - e := contract.Event{Name: name, Reach: contract.ReachInternal} - if t.public[name] { - e.Reach = contract.ReachPublic - } - if len(rules) > 0 { - e.Module, e.Node = rules[0].from, rules[0].node - } - out[name] = e - } - return out -} - -func (t *table) names() []string { - t.mu.RLock() - defer t.mu.RUnlock() - out := make([]string, 0, len(t.to)) - for name := range t.to { - out = append(out, name) - } - sort.Strings(out) - return out -} - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, "mesh-route-proxy: "+err.Error()) - os.Exit(1) - } -} - -func run() error { - listen := strings.TrimSpace(os.Getenv("LISTEN")) - if listen == "" { - listen = ":80" - } - // **The seat's plans are the one source of routes** (issue 774, research 072 row 34). Without the bus this proxy - // knows no route, so it refuses to start rather than serving an empty table and saying nothing. - credential := strings.TrimSpace(os.Getenv("MESH_BROKER_FILE")) - if credential == "" { - return fmt.Errorf("MESH_BROKER_FILE is not set, so this proxy cannot read the plans that say what it routes") - } - - held := newTable() - held.ledger = newLedger() - go held.ledger.loop() - go follow(credential, held) - - // TLS is opt-in. A proxy with no `TLS_LISTEN` serves plain HTTP exactly as before — which is - // what an internal-only mesh wants, and what the mesh's own certificate authority already - // covers for names inside it (novox/hq 08-connectivity). This is for names reachable from - // outside, where the authority has to be one the world already trusts. - secure := strings.TrimSpace(os.Getenv("TLS_LISTEN")) - if secure == "" { - return http.ListenAndServe(listen, handler(held)) - } - - cache := strings.TrimSpace(os.Getenv("ACME_CACHE")) - if cache == "" { - // Refused rather than defaulted. Without somewhere durable to keep them, every restart - // orders new certificates — which works, silently, until the rate limit says it does not. - return fmt.Errorf("TLS_LISTEN is set and ACME_CACHE is not: certificates need somewhere " + - "to persist, or every restart orders them again") - } - publicManager, err := newManager(cache, issuer(), strings.TrimSpace(os.Getenv("ACME_CA_BUNDLE")), - onlyWhatTheMeshSaid(held), held) - if err != nil { - return err - } - log.Printf("issuing public certificates from %s, for whatever the mesh routes here", issuer()) - - // The internal authority is optional: unset means this proxy serves internal-only aliases over - // plain HTTP exactly as it always has, which is the standalone-binary default and a safe one — - // it asks nothing of an authority it was not told about. - var internalManager *autocert.Manager - if directory := strings.TrimSpace(os.Getenv("INTERNAL_ACME_DIRECTORY")); directory != "" { - internalManager, err = newManager(cache, directory, strings.TrimSpace(os.Getenv("INTERNAL_ACME_CA_BUNDLE")), - onlyInternalNamesTheMeshSaid(held), held) - if err != nil { - return fmt.Errorf("internal certificate authority: %w", err) - } - log.Printf("issuing internal certificates from %s, for every internal-only alias this routes", - directory) - } - - // Port 80 answers the HTTP-01 challenge and goes on proxying everything else. The challenge - // must be answered *at the name being certified*, which is why issuance happens on the node - // that is publicly reachable rather than wherever the workload runs. - // - // **autocert's own HTTPHandler does not fall through on the challenge path.** For a token it - // does not hold it answers 404 itself; its fallback only ever sees non-challenge paths — which - // is exactly the predecessor's fault, the edge owning `/.well-known/acme-challenge` outright, - // rediscovered live when Mailu's renewal died behind this proxy on cutover day. tokenOrRoute - // probes each manager and hands a token neither authority recognises to plain routing, which - // is what lets a consumer's own ACME client — Mailu's, certifying its own name for a protocol - // this proxy never proxies — answer its own challenge through an ordinary path-scoped route. - port80 := tokenOrRoute(handler(held), publicManager) - if internalManager != nil { - port80 = tokenOrRoute(handler(held), publicManager, internalManager) - } - go func() { - if err := http.ListenAndServe(listen, port80); err != nil { - log.Printf("plain HTTP stopped: %v", err) - } - }() - - tlsConfig := publicManager.TLSConfig() - tlsConfig.GetCertificate = certificateFor(held, tlsConfig.GetCertificate, internalManager) - - server := &http.Server{ - Addr: secure, - Handler: handler(held), - TLSConfig: tlsConfig, - } - return server.ListenAndServeTLS("", "") -} - -// tokenOrRoute serves port 80: each manager answers the challenge tokens it is itself holding, -// and a token none of them holds is routed like any other request instead of being 404'd at the -// edge. -// -// autocert gives no way to ask "is this your token?" — its HTTPHandler both answers and refuses — -// so each manager is probed against a buffered writer and its refusal (404 on the challenge path) -// is discarded in favour of the next candidate. The probe is cheap: the handler answers from -// memory, and the path only carries traffic while an issuance is actually running. -func tokenOrRoute(routes http.Handler, managers ...*autocert.Manager) http.Handler { - const challengePrefix = "/.well-known/acme-challenge/" - // Non-challenge paths never reach a manager at all; autocert's tryHTTP01 switch still has to - // be armed, which HTTPHandler is the only exported way to do. - probes := make([]http.Handler, len(managers)) - for i, m := range managers { - probes[i] = m.HTTPHandler(routes) - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasPrefix(r.URL.Path, challengePrefix) { - routes.ServeHTTP(w, r) - return - } - for _, probe := range probes { - buffered := &probedResponse{header: make(http.Header)} - probe.ServeHTTP(buffered, r) - // Two shapes of "not mine": 404, a token this manager is not holding — and 403, a - // name its host policy would never certify at all (autocert checks the policy before - // the token, so the internal authority answers 403 for every public name). - if buffered.status == http.StatusNotFound || buffered.status == http.StatusForbidden { - continue - } - buffered.replayTo(w) - return - } - routes.ServeHTTP(w, r) // no authority holds it: the workload behind a routed path may - }) -} - -// probedResponse buffers one handler's answer so a refusal can be discarded unseen. -type probedResponse struct { - header http.Header - status int - body bytes.Buffer -} - -func (p *probedResponse) Header() http.Header { return p.header } - -func (p *probedResponse) WriteHeader(status int) { - if p.status == 0 { - p.status = status - } -} - -func (p *probedResponse) Write(b []byte) (int, error) { - if p.status == 0 { - p.status = http.StatusOK - } - return p.body.Write(b) -} - -func (p *probedResponse) replayTo(w http.ResponseWriter) { - for k, vs := range p.header { - for _, v := range vs { - w.Header().Add(k, v) - } - } - status := p.status - if status == 0 { - status = http.StatusOK - } - w.WriteHeader(status) - _, _ = w.Write(p.body.Bytes()) -} - -// newManager is one ACME authority's autocert manager: where to ask, what to trust it with, and -// which names it may be asked to certify. -// -// **Trusting an authority names a file rather than skipping verification.** An issuer that is not -// one of the public ones — the lab's, or the mesh's own step-ca — serves its own ACME API over TLS -// with a certificate nothing trusts yet. *Skip* would also apply the day this points at a public -// issuer, and nothing would say so; naming a bundle is a deliberate, visible act instead. -func newManager(cache, directory, bundle string, policy autocert.HostPolicy, held *table) (*autocert.Manager, error) { - client := &acme.Client{DirectoryURL: directory} - var root []byte - if bundle != "" { - read, err := os.ReadFile(bundle) - if err != nil { - return nil, fmt.Errorf("the CA bundle names %s and it cannot be read: %w", bundle, err) - } - root = read - // An empty bundle means the issuer's root is already in the system trust store — a public - // authority whose root ships with the OS, pointed at by a provider that serves an empty - // root (novox/hq ADR 0066). The mesh always writes the bundle file, so it exists and holds - // nothing; that is the signal to fall back to the system roots, the same as if nothing had - // named a bundle at all. A file that holds bytes but no certificate is still a - // misconfiguration and is refused, because there the operator meant to trust something. - if strings.TrimSpace(string(root)) != "" { - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(root) { - return nil, fmt.Errorf("%s holds no certificate this can trust", bundle) - } - client.HTTPClient = &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}, - } - } - } - // Where this authority's account and certificates are kept. Per authority, not per proxy — see - // forThisAuthority, which is what makes a re-initialised CA heal itself, and what lets the - // public and internal authorities share one ACME_CACHE without colliding: they hash to - // different names because their directories differ. - mine := forThisAuthority(cache, directory, root) - return &autocert.Manager{ - Cache: recordingCache{Cache: autocert.DirCache(mine), held: held}, - Prompt: autocert.AcceptTOS, - HostPolicy: policy, - Client: client, - }, nil -} - -// forThisAuthority is where one ACME authority's account and certificates are kept. -// -// **A cached ACME account belongs to the authority that issued it, and nothing in the cache says -// so** (novox/hq ADR 0066). autocert keeps its account key at one fixed name — `acme_account+key` — -// in whatever directory it is given, and reuses it for ever. That is right while the authority stays -// the same and silently wrong the moment it does not: an internal CA that is re-initialised is a new -// authority with a new root, it has never heard of the account in the cache, and every attempt to -// use it is rejected. autocert has no path back from that. Nothing is retried, nothing is -// re-registered, no order ever reaches the CA — issuance simply stops, with no error anybody sees, -// until a person deletes the directory by hand and finds out that was the answer. -// -// So the directory is named after the authority instead of being shared by all of them. The name is -// a digest of the two things that identify one: the directory URL, and the root this proxy was told -// to verify it with. Re-initialising the CA produces a new root; the mesh delivers it as a changed -// bundle; this proxy restarts on that file and lands in a directory with no account in it, so -// autocert registers afresh and orders again. **The healing is that the question "is this account -// still valid" never has to be asked** — an account is only ever found where it is still valid. -// -// It also fixes a latent one of the same shape: pointing ACME_DIRECTORY at production after testing -// against staging reused the staging account, because the cache had no idea they were different. -// -// The old directories stay on disk, unused. Left rather than deleted: they are the only copy of -// certificates that may still be valid, and this program is not the thing that should decide a -// certificate is finished with. -func forThisAuthority(cache, directory string, root []byte) string { - sum := sha256.Sum256([]byte(directory + "\x00" + string(bytes.TrimSpace(root)))) - return filepath.Join(cache, hex.EncodeToString(sum[:])[:16]) -} - -// certificateFor picks the certificate a handshake is answered with. -// -// Dispatched by which authority may certify this name at all — the same question -// eligibleForInternalACME already answers, asked once more at handshake time rather than only when -// an order is placed, since a cached certificate is served here on every request and never goes -// through HostPolicy again. And refused, exactly as an unrouted name is, to a client outside the -// private network asking for a name that is only internal: the certificate would name it. -func certificateFor(held *table, fromPublic func(*tls.ClientHelloInfo) (*tls.Certificate, error), - internalManager *autocert.Manager) func(*tls.ClientHelloInfo) (*tls.Certificate, error) { - var fromInternal func(*tls.ClientHelloInfo) (*tls.Certificate, error) - if internalManager != nil { - fromInternal = internalManager.TLSConfig().GetCertificate - } - pick := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { - if held.eligibleForInternalACME(hello.ServerName) { - if hello.Conn != nil && held.hiddenFrom(hello.ServerName, hello.Conn.RemoteAddr().String()) { - held.ledger.refused(bareHost(hello.ServerName), true, true) - return nil, fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for", - hello.ServerName) - } - if fromInternal != nil { - return fromInternal(hello) - } - } - return fromPublic(hello) - } - // What the bus is told of a handshake (issue 774): a routed name's certificate, or why it has none; a name not - // routed here, which a browser asking for a site the mesh forgot to route meets before any request is made. - return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { - cert, err := pick(hello) - name := bareHost(hello.ServerName) - switch { - case name == "": - case !held.routed(name): - held.ledger.refused(name, false, true) - case err != nil: - if !strings.HasPrefix(err.Error(), "no public route for") { - held.ledger.certificateFailed(name, err) - } - case cert != nil: - leaf := cert.Leaf - if leaf == nil && len(cert.Certificate) > 0 { - leaf, _ = x509.ParseCertificate(cert.Certificate[0]) - } - held.ledger.handedOut(name, leaf) - } - return cert, err - } -} - -// newTable is an empty routing table. -func newTable() *table { - return &table{to: map[string][]rule{}} -} - -// handler is the proxy itself, separated so it can be driven by a test without a listener. -func handler(held *table) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hidden := held.hiddenFrom(r.Host, r.RemoteAddr) - matched, known := held.find(r.Host, r.URL.Path) - if hidden || !known { - // **Named, not a bare 404.** A route that was withdrawn and a name that never existed - // are different things, and a proxy that says only "not found" makes an operator go - // and read the mesh to tell them apart. What it is serving is the answer to both. - // - // And since a host may now be routed only on some paths, those are a third thing: - // saying "no route for this name" while listing that very name as served is a - // contradiction an operator would have to disbelieve the proxy to get past. - // **Said in the log as well as to the client.** A name this mesh does not serve, asked - // for from outside, is what a scanner does, and the machine's intrusion prevention reads - // this proxy's log for exactly that line (novox/hq ADR 0179): the address last, as the - // jail's filter expects it. - log.Printf("refused: no route for %q, asked from %s", r.Host, r.RemoteAddr) - if hidden || !held.routed(r.Host) { - held.ledger.refused(bareHost(r.Host), hidden, false) - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusNotFound) - if !hidden && held.routed(r.Host) { - fmt.Fprintf(w, "%s is served here, but no route covers %q.\n", - bareHost(r.Host), r.URL.Path) - return - } - fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n", - r.Host, strings.Join(held.namesSeenFrom(r.RemoteAddr), ", ")) - return - } - - switch { - case matched.policy.sealed != "": - // Declared a gate, cannot check it. Refused, and says why — an operator reading this - // learns the secret is missing, rather than wondering why a protected name is 503. - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusServiceUnavailable) - fmt.Fprintf(w, "this route requires authentication and its credentials cannot be read: %s\n", - matched.policy.sealed) - return - - case matched.policy.deny: - http.Error(w, "this path is not served to you", http.StatusForbidden) - return - - case matched.policy.redirectTo != "": - http.Redirect(w, r, canonical(matched.policy.redirectTo, r.URL), http.StatusMovedPermanently) - return - - case len(matched.policy.users) > 0 && !allowed(matched.policy.users, r): - // The realm is the name asked for, so a browser's prompt says which route it is for. - w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", bareHost(r.Host))) - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - if matched.maxRequestBody > 0 { - // Refused on the declared length where there is one, so an upload that cannot succeed - // is answered before it is carried; and capped while reading for a chunked body, which - // declares no length at all. Without the second, a limit is advice. - if r.ContentLength > matched.maxRequestBody { - http.Error(w, fmt.Sprintf("request body too large for this route: %d bytes is the most it carries", - matched.maxRequestBody), http.StatusRequestEntityTooLarge) - return - } - r.Body = http.MaxBytesReader(w, r.Body, matched.maxRequestBody) - } - - // The upstream's answer is what the bus is told of this name (issue 774): its status, or why there was none. - answer := &answerRecorder{ResponseWriter: w} - matched.to.ServeHTTP(answer, r) - status := answer.status - if status == 0 && answer.err == nil && r.Header.Get("Upgrade") != "" { - status = http.StatusSwitchingProtocols - } - held.ledger.answered(bareHost(r.Host), r.URL.Path, status, answer.err) - }) -} - -// canonical is where a redirect sends this request. -// -// The declaration names the destination *name*; the request keeps its own path and query. That is -// what canonicalising one public name onto another means — a link to a page under the old name has -// to arrive at the same page under the new one, or the redirect silently loses every deep link. -func canonical(to string, from *url.URL) string { - where, err := url.Parse(to) - if err != nil { - return to - } - if where.Path == "" || where.Path == "/" { - where.Path = from.Path - } - if where.RawQuery == "" { - where.RawQuery = from.RawQuery - } - return where.String() -} - -// allowed says whether the request presented credentials this route accepts. -// -// **Every path costs one bcrypt comparison**, including an unknown user, which is why the miss -// compares against a fixed hash rather than returning early. Returning early would make an unknown -// user measurably faster than a known one with a wrong password, and that difference is a way to -// enumerate the users of a route from outside it. -func allowed(users map[string]string, r *http.Request) bool { - // A hash of nothing anybody knows. Its only job is to cost what a real comparison costs. - const absent = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" - user, password, ok := r.BasicAuth() - if !ok { - return false - } - want, known := users[user] - if !known { - want = absent - } - if err := bcrypt.CompareHashAndPassword([]byte(want), []byte(password)); err != nil { - return false - } - // `known` is checked after the comparison, not instead of it, so the timing is the same either - // way. subtle.ConstantTimeByteEq keeps the branch from being the thing that differs. - return subtle.ConstantTimeByteEq(boolByte(known), 1) == 1 -} - -func boolByte(b bool) byte { - if b { - return 1 - } - return 0 -} - -// routesOf turns the routes the plans give into host → the rules for that host, and which of those hosts is a public -// name — the second is `name`, ACME-eligible; a host reached only through `internal-name` never appears there. -// -// **A route may carry either name, or both** (novox/hq ADR 0138). How far an endpoint reaches decides which names the -// mesh composes, so an endpoint that reaches only the private network arrives with an `internal-name` and no `name`. -// That is a whole route, not a malformed one: it is served under its internal name and certified by the internal -// authority. Only a route with neither name has nothing to be served under (novox/hq issue 191). -func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) { - out := map[string][]rule{} - public := map[string]bool{} - for _, c := range contributions { - // The contract admitted the route whole (contract.Request.Validate): it names a host, a rule that forwards - // gives a port, its scheme is http or https, its body limit is whole and positive, and its auth names a - // secret's file rather than holding a credential (novox/hq ADR 0108). - r := c.Route - name := strings.TrimSpace(r.Name) - internal := strings.TrimSpace(r.InternalName) - made := rule{path: r.Prefix(), priority: r.Priority} - made.policy.deny, made.policy.redirectTo = r.Deny, r.Redirect - if named := strings.TrimSpace(r.Auth); named != "" { - users, err := usersFrom(named) - if err != nil { - // Fail closed: the rule is kept so the name stays routed and answers, and it - // answers by refusing. Dropping it instead would make the name 404 and read as a - // withdrawn route rather than an unreadable secret. - made.policy.sealed = err.Error() - } - made.policy.users = users - } - - // Only a rule that actually proxies needs somewhere to send the request. - if r.Forwards() { - // Where the mesh says that machine is. Empty means it is this one — a workload beside - // the proxy is ordinary, and reaching it over loopback is both correct and the only - // thing that works when there is no private network. - at := c.At - if at == "" { - at = "127.0.0.1" - } - // http unless the contribution says otherwise. A backend that terminates its own TLS - // with a certificate this proxy has no reason to trust — Mailu's webmail front is the - // first of these — is the reason `insecure` exists, and it stays the exception. - made.insecure = r.Insecure - if r.MaxRequestBody != nil { - made.maxRequestBody = *r.MaxRequestBody - } - made.target = fmt.Sprintf("%s://%s:%d", r.SchemeOrDefault(), at, r.Port) - } - made.from, made.node = c.From, c.Node - - if name != "" { - host := strings.ToLower(name) - out[host] = append(out[host], made) - public[host] = true - } - - // The internal-network name, the same rule under a second host — a predecessor proxy - // answered both for one route, as a convenience (reaching a service over the VPN without a - // public TLS round trip), not as an access boundary; composing it here restores exactly - // that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR - // 0056's internalDomain half). And the only name, when the endpoint reaches no further than - // the private network. - if internal != "" { - out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made) - } - } - return out, public -} - -// usersFrom reads the credentials the mesh mounted, in the one format every htpasswd already is. -func usersFrom(path string) (map[string]string, error) { - raw, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("cannot read the secret named for this route: %w", err) - } - users := map[string]string{} - for _, line := range strings.Split(string(raw), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - user, hash, ok := strings.Cut(line, ":") - if !ok || user == "" || hash == "" { - continue - } - users[user] = hash - } - if len(users) == 0 { - return nil, fmt.Errorf("the secret named for this route holds no usable credentials") - } - return users, nil -} - -// towards proxies to one backend and tells it what the client asked: **X-Forwarded-Proto, -Host and -// -For**, set from the request this proxy received. A backend that builds its own addresses — a forge -// writing its clone URL into a page, a login redirect — otherwise sees the plain HTTP hop from this -// proxy and writes `http://`, though every client reached it over TLS: Go refused the forge's module -// path for exactly that on 2026-10-03, its import tag naming an http clone URL. -// The standard library's NewSingleHostReverseProxy sets only X-Forwarded-For. -func towards(where *url.URL) *httputil.ReverseProxy { - return &httputil.ReverseProxy{Rewrite: func(pr *httputil.ProxyRequest) { - pr.SetURL(where) - pr.Out.Host = pr.In.Host - pr.SetXForwarded() - }, ErrorHandler: proxyFailed} -} diff --git a/modules/route-proxy/cmd/route-proxy/plans.go b/modules/route-proxy/cmd/route-proxy/plans.go deleted file mode 100644 index e10c6275..00000000 --- a/modules/route-proxy/cmd/route-proxy/plans.go +++ /dev/null @@ -1,147 +0,0 @@ -package main - -// The routes this proxy serves, read from the seat mesh-provisioner's plans and nothing else (issue 774; research 072 -// row 34, the operator on 2026-10-31: "there is only 1 path, the other is simply deprecated/invalid"). -// -// **A route is a provision, in route-proxy's own contract.** A module that must be reachable requires `route`; the -// controller composes its plan on its node, and the plan's entry for the route names this proxy as its provider and -// carries what the module contributes, composed (its names joined to the node's domains, the machine port its -// placement assigned its endpoint, never the software's own), and where the module's machine is on the private -// network, as one opaque payload the seat never reads (mesh-sdk catalogue.PlanProvision.Contributed). The proxy decodes each contribution with its contract (contract.DecodeRequest) -// and serves every entry naming it. There is no file and no second source: a route the plans do not name is not -// served, and a route the contract refuses is said refused (contract.EventRefused), never served half. - -import ( - "fmt" - "log" - "sort" - "sync" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/provisions" - - "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" -) - -// planned is the routes the seat's plans give this proxy on its node, by plan key, kept so a plan that cannot be read -// leaves what it gave served (a bind missed would read as a route withdrawn). -type planned struct { - node string - // said is where a refusal is told: the bus's events, nothing in a test that does not ask. - said func(eventType string, e contract.Event) - mu sync.Mutex - by map[string][]contribution -} - -func newPlanned(node string, said func(string, contract.Event)) *planned { - if said == nil { - said = func(string, contract.Event) {} - } - return &planned{node: node, said: said, by: map[string][]contribution{}} -} - -// take is one change of a plan's key: its value, or nil when the key was deleted. It answers whether what is served -// changed, and says on the log and the bus what it could not serve. -func (p *planned) take(key string, value []byte) bool { - p.mu.Lock() - defer p.mu.Unlock() - if value == nil { - _, had := p.by[key] - delete(p.by, key) - return had - } - gave, refused, err := routesOfPlan(p.node, key, value) - if err != nil { - log.Printf("the plan %s is not readable, so what it routes is kept as it was: %v", key, err) - return false - } - for _, r := range refused { - log.Printf("the plan %s gives %s on %s a route this proxy does not serve: %s", key, r.Module, r.Node, r.Why) - p.said(contract.EventRefused, r) - } - if len(gave) == 0 { - _, had := p.by[key] - delete(p.by, key) - return had - } - p.by[key] = gave - return true -} - -// contributions is every route the plans give this proxy, in the order of their plans' keys. -func (p *planned) contributions() []contribution { - p.mu.Lock() - defer p.mu.Unlock() - keys := make([]string, 0, len(p.by)) - for k := range p.by { - keys = append(keys, k) - } - sort.Strings(keys) - var out []contribution - for _, k := range keys { - out = append(out, p.by[k]...) - } - return out -} - -// routesOfPlan is the routes one plan gives the proxy on node: each contribution the plan's entries naming this proxy -// on node as the route's provider carry, decoded with the contract, reached on its own machine over loopback and on -// another at the address the plan says. A plan of another module, or one routing elsewhere, gives none. Refused is -// what the plan names and the proxy does not serve, each with why: an entry carrying nothing to serve, a payload or a -// route the contract does not read, or a consumer elsewhere with no address on the private network. -func routesOfPlan(node, key string, value []byte) ([]contribution, []contract.Event, error) { - plan, err := provisions.DecodePlan(value) - if err != nil { - return nil, nil, err - } - if plan.Node+"."+plan.Module != key { - return nil, nil, fmt.Errorf("it is the plan of %s.%s, under the key %s", plan.Node, plan.Module, key) - } - var out []contribution - var refused []contract.Event - refuse := func(local, why string) { - refused = append(refused, contract.Event{Proxy: node, Module: plan.Module, Node: plan.Node, Local: local, Why: why}) - } - named, carried := false, false - for _, e := range plan.Plan.Provisions { - if !routesHere(node, e) { - continue - } - named = true - // What the module contributes is on the requirement's first entry alone, however many local names it has. - if len(e.Contributed) == 0 { - continue - } - carried = true - given, err := catalogue.DecodeContributed(e.Contributed) - if err != nil { - refuse("", err.Error()) - continue - } - at := "" - if plan.Node != node { - if given.At == "" { - refuse("", fmt.Sprintf("it runs on %s and its plan says no address on the private network for it", plan.Node)) - continue - } - at = given.At - } - for _, one := range given.Each { - route, err := contract.DecodeRequest(one.Values) - if err != nil { - refuse(one.Local, err.Error()) - continue - } - out = append(out, contribution{From: plan.Module, Node: plan.Node, At: at, Local: one.Local, Route: route}) - } - } - if named && !carried { - refuse("", "its plan names this proxy for its route and carries nothing to serve") - } - return out, refused, nil -} - -// routesHere is whether a plan's entry is a route this proxy on node provides. -func routesHere(node string, e catalogue.PlanProvision) bool { - return e.Provision == contract.Provision && e.Provider == node && e.ProviderModule == contract.Module -} diff --git a/modules/route-proxy/cmd/route-proxy/policy_test.go b/modules/route-proxy/cmd/route-proxy/policy_test.go deleted file mode 100644 index 0f2b3646..00000000 --- a/modules/route-proxy/cmd/route-proxy/policy_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package main - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - - "golang.org/x/crypto/bcrypt" -) - -// What a route carries about the requests arriving at it — novox/hq ADR 0108. -// -// Each test here is one of the four capabilities that record closed the set at, plus the negative -// case it promised would be refused. The negative case is the one that rots quietly: nothing fails -// if it stops working, so nothing tells you it has. - -// served starts a workload and gives back the host and port the mesh would have recorded for it. -func served(t *testing.T, body string) (string, int) { - t.Helper() - workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(body)) - })) - t.Cleanup(workload.Close) - host, port, _ := strings.Cut(strings.TrimPrefix(workload.URL, "http://"), ":") - n, err := strconv.Atoi(port) - if err != nil { - t.Fatal(err) - } - return host, n -} - -// ask makes one request through the proxy for a given name and path, without following redirects. -func ask(t *testing.T, proxy, name, path string, auth [2]string) *http.Response { - t.Helper() - req, err := http.NewRequest(http.MethodGet, proxy+path, nil) - if err != nil { - t.Fatal(err) - } - req.Host = name - if auth[0] != "" { - req.SetBasicAuth(auth[0], auth[1]) - } - client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }} - answer, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = answer.Body.Close() }) - return answer -} - -func proxyFor(t *testing.T, routesJSON string) string { - t.Helper() - routes, public, err := routesFrom(routesJSON) - if err != nil { - t.Fatal(err) - } - held := newTable() - held.set(routes, public) - server := httptest.NewServer(handler(held)) - t.Cleanup(server.Close) - return server.URL -} - -// A refusal on a path shadows the ordinary route for that path and leaves every other path alone. -// -// **This is why path scoping is a prerequisite and not a sibling capability.** The rule being -// reproduced matches a path on a host that is already routed to a workload, so a table mapping a -// host to one target cannot express it at all — no amount of authentication or source filtering -// would have helped. -func TestARefusedPathShadowsTheRouteAndLeavesTheRestServed(t *testing.T) { - at, port := served(t, "the workload") - proxy := proxyFor(t, `{"given":[ - {"from":"forge","node":"anchor","at":"`+at+`","values":{"name":"forge.example","port":`+strconv.Itoa(port)+`}}, - {"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","priority":100,"deny":true}} - ]}`) - - if got := ask(t, proxy, "forge.example", "/api/internal/hook", [2]string{}).StatusCode; got != http.StatusForbidden { - t.Fatalf("the refused path answered %d, so the block that was put in front of it during an "+ - "incident is not in front of it any more", got) - } - if got := ask(t, proxy, "forge.example", "/", [2]string{}).StatusCode; got != http.StatusOK { - t.Fatalf("refusing one path took the whole route with it: %d", got) - } -} - -// A redirect answers with the redirect, and the request keeps its own path and query. -// -// Losing the path would turn canonicalising one name onto another into "every deep link now lands -// on the front page", which is the kind of breakage that produces no error anywhere. -func TestARedirectKeepsThePathAndQuery(t *testing.T) { - proxy := proxyFor(t, `{"given":[ - {"from":"site","node":"anchor","values":{"name":"www.example","redirect":"https://example/"}} - ]}`) - - answer := ask(t, proxy, "www.example", "/deep/page?ref=1", [2]string{}) - if answer.StatusCode != http.StatusMovedPermanently { - t.Fatalf("a declared redirect answered %d", answer.StatusCode) - } - where := answer.Header.Get("Location") - if !strings.Contains(where, "/deep/page") || !strings.Contains(where, "ref=1") { - t.Fatalf("the redirect dropped the path or the query: %q", where) - } -} - -// Authentication refuses a request with no credentials, admits one with the right ones, and refuses -// the wrong ones — with the credentials read from the secret the declaration *named*. -func TestAuthenticationAdmitsOnlyWhatTheSecretSays(t *testing.T) { - at, port := served(t, "the console") - hash, err := bcrypt.GenerateFromPassword([]byte("correct horse"), bcrypt.MinCost) - if err != nil { - t.Fatal(err) - } - secret := filepath.Join(t.TempDir(), "console-auth") - if err := os.WriteFile(secret, []byte("# a comment\nadmin:"+string(hash)+"\n"), 0o600); err != nil { - t.Fatal(err) - } - - proxy := proxyFor(t, `{"given":[ - {"from":"console","node":"anchor","at":"`+at+`","values":{"name":"console.example","port":`+strconv.Itoa(port)+`,"auth":"`+secret+`"}} - ]}`) - - if got := ask(t, proxy, "console.example", "/", [2]string{}).StatusCode; got != http.StatusUnauthorized { - t.Fatalf("an admin surface with no login of its own answered %d without credentials", got) - } - if got := ask(t, proxy, "console.example", "/", [2]string{"admin", "wrong"}).StatusCode; got != http.StatusUnauthorized { - t.Fatalf("the wrong password answered %d", got) - } - if got := ask(t, proxy, "console.example", "/", [2]string{"admin", "correct horse"}).StatusCode; got != http.StatusOK { - t.Fatalf("the right password answered %d", got) - } -} - -// The negative case ADR 0108 promised would be refused: a credential in the declaration. -// -// **Refused whole, not tolerated and not served unprotected.** A hash carried in a declaration was -// the rejected option; nothing in the running system should quietly accept it later, because the -// precedent is far easier to set than to withdraw. If this test is deleted the option returns and -// nothing else notices. -func TestACredentialInTheDeclarationIsRefusedRatherThanServed(t *testing.T) { - inline := []string{ - `{"given":[{"from":"c","node":"n","at":"127.0.0.1","values":{"name":"c.example","port":8080,"auth":"admin:$2a$10$abcdefghijklmnopqrstuv"}}]}`, - `{"given":[{"from":"c","node":"n","at":"127.0.0.1","values":{"name":"c.example","port":8080,"auth":"$2a$10$abcdefghijklmnopqrstuv"}}]}`, - } - for _, body := range inline { - routes, _, err := routesFrom(body) - if err != nil { - t.Fatal(err) - } - if len(routes) != 0 { - t.Fatalf("a declaration carrying a credential was served anyway: %v", routes) - } - } -} - -// Authentication declared, secret unreadable: the route refuses. It does not serve unprotected. -// -// **Fail closed.** The alternative turns a missing file into a silently public admin surface, which -// is the outcome the whole record exists to prevent. It answers rather than 404s, so an operator -// sees "cannot read the credentials" instead of concluding the route was withdrawn. -func TestAnUnreadableSecretFailsClosed(t *testing.T) { - at, port := served(t, "the console") - missing := filepath.Join(t.TempDir(), "not-mounted") - - proxy := proxyFor(t, `{"given":[ - {"from":"console","node":"anchor","at":"`+at+`","values":{"name":"console.example","port":`+strconv.Itoa(port)+`,"auth":"`+missing+`"}} - ]}`) - - answer := ask(t, proxy, "console.example", "/", [2]string{}) - if answer.StatusCode == http.StatusOK { - t.Fatal("a route whose credentials could not be read served the workload unprotected") - } - if answer.StatusCode != http.StatusServiceUnavailable { - t.Fatalf("expected the route to say it cannot check, got %d", answer.StatusCode) - } -} - -// Equal priorities resolve the same way every time, so the same declaration serves the same way -// after a restart. -// -// Sorting only by priority leaves rules that share one in whatever order the map produced. The -// proxy would still work, and would work differently between restarts — which is the hardest kind -// of fault to believe when it is reported. -func TestRulesThatShareAPriorityAreStillTotallyOrdered(t *testing.T) { - first := []rule{ - {path: "/a", priority: 10, target: "http://x:1"}, - {path: "/bb", priority: 10, target: "http://y:2"}, - {path: "", priority: 10, target: "http://z:3"}, - } - second := []rule{ - {path: "", priority: 10, target: "http://z:3"}, - {path: "/bb", priority: 10, target: "http://y:2"}, - {path: "/a", priority: 10, target: "http://x:1"}, - } - inOrder(first) - inOrder(second) - for i := range first { - if first[i].path != second[i].path || first[i].target != second[i].target { - t.Fatalf("two orderings of the same rules disagree at %d: %q vs %q", - i, first[i].path, second[i].path) - } - } - // And the more specific rule is matched first, which is the intuitive reading. - if first[0].path != "/bb" { - t.Fatalf("the longest path is not matched first: %q", first[0].path) - } -} - -// Priority decides before path length does, so a rule can be made to win regardless of specificity. -func TestPriorityOutranksPathLength(t *testing.T) { - rules := []rule{ - {path: "/very/long/path", priority: 1, target: "http://x:1"}, - {path: "", priority: 100, target: "http://y:2"}, - } - inOrder(rules) - if rules[0].priority != 100 { - t.Fatalf("a higher priority did not win: %+v", rules[0]) - } -} - -// A priority above a port number survives, because a priority is an ordering and not a port. -// -// **Found by review, and it was load-bearing.** Priority was first read with the port reader, which -// caps at 65535 — so a rule declared above that silently became priority 0 and stopped shadowing the -// route it exists to shadow. The one real rule this has to reproduce is declared at 100000, so the -// capability would have shipped looking complete and doing nothing. -func TestAPriorityAboveAPortNumberSurvives(t *testing.T) { - at, port := served(t, "the workload") - proxy := proxyFor(t, `{"given":[ - {"from":"forge","node":"anchor","at":"`+at+`","values":{"name":"forge.example","port":`+strconv.Itoa(port)+`}}, - {"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","priority":100000,"deny":true}} - ]}`) - - if got := ask(t, proxy, "forge.example", "/api/internal/hook", [2]string{}).StatusCode; got != http.StatusForbidden { - t.Fatalf("a rule declared at priority 100000 answered %d instead of refusing", got) - } -} - -// A host routed only on some paths says so, rather than claiming the name is not served here. -// -// Saying "no route for this name" while listing that very name as served is a contradiction an -// operator has to disbelieve the proxy to get past — and path scoping makes it reachable, because a -// host can now have rules that none of this request's paths match. -func TestAHostRoutedOnlyOnSomePathsSaysSo(t *testing.T) { - proxy := proxyFor(t, `{"given":[ - {"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","deny":true}} - ]}`) - - answer := ask(t, proxy, "forge.example", "/elsewhere", [2]string{}) - if answer.StatusCode != http.StatusNotFound { - t.Fatalf("an uncovered path answered %d", answer.StatusCode) - } - body := make([]byte, 256) - n, _ := answer.Body.Read(body) - said := string(body[:n]) - if !strings.Contains(said, "is served here") || !strings.Contains(said, "/elsewhere") { - t.Fatalf("the refusal does not distinguish an uncovered path from an unserved name: %q", said) - } -} diff --git a/modules/route-proxy/cmd/route-proxy/reach_test.go b/modules/route-proxy/cmd/route-proxy/reach_test.go deleted file mode 100644 index 25372c14..00000000 --- a/modules/route-proxy/cmd/route-proxy/reach_test.go +++ /dev/null @@ -1,331 +0,0 @@ -package main - -import ( - "crypto/tls" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "net/url" - "reflect" - "slices" - "sort" - "strings" - "testing" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" - - "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" -) - -// behind is a workload the proxy can send to, and a table routing one public name and one -// internal-only name to it, with the mesh's machines as the membership would issue them. -func behind(t *testing.T, mesh ...string) *table { - t.Helper() - workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - io.WriteString(w, "the workload") - })) - t.Cleanup(workload.Close) - at, _ := url.Parse(workload.URL) - host, port, _ := net.SplitHostPort(at.Host) - - routes, public, err := routesFrom(fmt.Sprintf(`{"given":[ - {"from":"app","node":"anchor","at":%q, - "values":{"name":"app.example","internal-name":"app.anchor.internal","port":%s}}, - {"from":"admin","node":"anchor","at":%q, - "values":{"internal-name":"admin.anchor.internal","port":%s}} - ]}`, host, port, host, port)) - if err != nil { - t.Fatal(err) - } - held := newTable() - inside, err := sourcesOf(mesh) - if err != nil { - t.Fatal(err) - } - held.setInside(inside) - held.set(routes, public) - return held -} - -// askFrom is what the proxy answers a request for host coming from remote. -func askFrom(held *table, host, remote string) (int, string) { - r := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil) - r.RemoteAddr = remote - w := httptest.NewRecorder() - handler(held).ServeHTTP(w, r) - return w.Code, w.Body.String() -} - -// **An internal-only name is served to the private network and to nobody else** (novox/hq ADR -// 0138, issue 191). The proxy answers public names on the same listeners, so without this a name -// being internal kept nobody out: a request from the internet only had to carry it. -func TestAnInternalOnlyNameIsServedOnlyInsideThePrivateNetwork(t *testing.T) { - held := behind(t, "10.10.0.1", "10.10.0.7") - - if code, body := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK || - body != "the workload" { - t.Errorf("a request from the private network was not served: %d %q", code, body) - } - if code, body := askFrom(held, "admin.anchor.internal", "127.0.0.1:51000"); code != http.StatusOK { - t.Errorf("a request from the machine itself was not served: %d %q", code, body) - } - - code, body := askFrom(held, "admin.anchor.internal", "203.0.113.9:51000") - if code != http.StatusNotFound { - t.Fatalf("a request from outside the private network reached an internal-only name: %d %q", - code, body) - } - // Answered as a name never routed, and the list of what is served does not name it either — - // otherwise the refusal would tell an outsider exactly what to ask for from inside. - if strings.Contains(strings.SplitN(body, "\n", 2)[1], "admin.anchor.internal") { - t.Errorf("the refusal names the internal-only route to an outsider: %q", body) - } - if !strings.Contains(body, "app.example") { - t.Errorf("the refusal stopped listing the public names: %q", body) - } -} - -// The internal name of a route that also has a public one is internal too: served inside, and to -// an outsider only under the public name. Nothing is lost — the outsider has the public name — and a -// name stays one thing whichever route it came from. -func TestAnInternalAliasOfAPublicRouteIsServedInsideOnly(t *testing.T) { - held := behind(t, "10.10.0.1", "10.10.0.7") - if code, body := askFrom(held, "app.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK { - t.Errorf("the internal alias stopped answering the private network: %d %q", code, body) - } - if code, _ := askFrom(held, "app.anchor.internal", "203.0.113.9:51000"); code != http.StatusNotFound { - t.Errorf("the internal alias was served to an outsider: %d", code) - } - if code, _ := askFrom(held, "app.example", "203.0.113.9:51000"); code != http.StatusOK { - t.Errorf("the public name was refused to an outsider: %d", code) - } -} - -// Before a membership has said who the mesh is, only the machine itself is inside — refused to -// everyone else, never served to everyone. -func TestUntilTheMeshIsIssuedAnInternalOnlyNameIsServedToTheMachineAlone(t *testing.T) { - held := behind(t) - if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusNotFound { - t.Errorf("an internal-only name was served with no private network said: %d", code) - } - if code, _ := askFrom(held, "admin.anchor.internal", "[::1]:51000"); code != http.StatusOK { - t.Errorf("an internal-only name was refused to the machine itself: %d", code) - } -} - -type from struct { - net.Conn - remote net.Addr -} - -func (c from) RemoteAddr() net.Addr { return c.remote } - -// The handshake refuses an internal-only name to an outsider too: the certificate would name it, -// and serving it would answer the question the routing refuses to. -func TestTheHandshakeRefusesAnInternalOnlyNameToAnOutsider(t *testing.T) { - held := behind(t, "10.10.0.1", "10.10.0.7") - served := &tls.Certificate{} - pick := certificateFor(held, func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return served, nil }, nil) - hello := func(name, remote string) *tls.ClientHelloInfo { - addr, _ := net.ResolveTCPAddr("tcp", remote) - return &tls.ClientHelloInfo{ServerName: name, Conn: from{remote: addr}} - } - - if _, err := pick(hello("admin.anchor.internal", "203.0.113.9:443")); err == nil { - t.Error("an outsider was handed a certificate for an internal-only name") - } - if got, err := pick(hello("admin.anchor.internal", "10.10.0.7:443")); err != nil || got != served { - t.Errorf("a client on the private network was refused: %v", err) - } - if got, err := pick(hello("app.example", "203.0.113.9:443")); err != nil || got != served { - t.Errorf("a public name was refused to an outsider: %v", err) - } -} - -// The mesh is issued as machines' addresses; a range is read as well. One that does not parse is -// refused rather than skipped, so a typo never quietly narrows or widens who is inside. -func TestTheMeshIsReadAsAddressesAndRanges(t *testing.T) { - if _, err := sourcesOf([]string{"10.10.0.1", "not-an-address"}); err == nil { - t.Error("an entry that is not an address was accepted") - } - inside, err := sourcesOf([]string{"10.10.0.1", "fd00::1", "10.20.0.0/24"}) - if err != nil { - t.Fatal(err) - } - for remote, want := range map[string]bool{ - "10.10.0.1:1": true, - "[::ffff:10.10.0.1]:1": true, - "[fd00::1]:1": true, - "10.20.0.200:1": true, - "10.10.0.2:1": false, - "192.168.1.10:1": false, - "not-an-address": false, - } { - if inside.holds(remote) != want { - t.Errorf("%s inside the mesh: got %v, want %v", remote, !want, want) - } - } -} - -// fakePlans is the seat's plans as a watch hands them: every current key, then each change after Watch returned. -type fakePlans struct { - current map[string]string - handle func(stdio.StateChange) error -} - -func (f *fakePlans) Watch(_ string, handle func(stdio.StateChange) error) error { - f.handle = handle - for k, v := range f.current { - if err := handle(stdio.StateChange{Key: k, Op: "put", Value: json.RawMessage(v), Current: true}); err != nil { - return err - } - } - return nil -} - -// plan is a plan of the seat mesh-provisioner for module on node, its entries as given. -func plan(node, module, entries string) string { - return fmt.Sprintf(`{"v":1,"node":%q,"module":%q,"plan":{"templates":{"source":"","digest":""},"files":null,`+ - `"provisions":[%s]}}`, node, module, entries) -} - -// contributed is a plan entry's opaque payload: the consumer's machine at at, and each contribution by local name. -func contributed(at string, each ...string) string { - var parts []string - for i := 0; i+1 < len(each); i += 2 { - parts = append(parts, fmt.Sprintf(`{"local":%q,"values":%s}`, each[i], each[i+1])) - } - return fmt.Sprintf(`"contributed":{"v":1,"at":%q,"each":[%s]}`, at, strings.Join(parts, ",")) -} - -// What the plans give is what is served, and nothing else (issue 774, research 072 row 34): each entry naming this -// proxy on its node as the route's provider, every contribution of it read with route-proxy's contract, reached over -// loopback on this machine and at the plan's address on another; an entry routing elsewhere, carrying nothing, naming -// no address or a route the contract refuses is not served, and is said refused. A change is served as it comes and -// said served or withdrawn, a plan that cannot be read keeps what it gave, and a deleted one withdraws it. The -// placement says only who is inside: an internal-only name is served to the machines it decides addresses for. -func TestThePlansAreServedAndThePlacementSaysWhoIsInside(t *testing.T) { - const route = `{"provision":"route","provider":"anchor","provider-module":"route-proxy",` - gitea := route + contributed("10.10.0.7", - "web", `{"name":"git.example","internal-name":"git.anchor.internal","port":3000}`, - "api", `{"internal-name":"git-api.anchor.internal","port":3001}`, - "refused", `{"name":"bad.example","port":3002,"host":"x"}`) + `}` - plans := &fakePlans{current: map[string]string{ - "laptop.gitea": plan("laptop", "gitea", gitea+`,{"provision":"postgres-database","provider":"anchor",`+ - `"provider-module":"postgres",`+contributed("10.10.0.7", "", `{"name":"gitea"}`)+`}`), - "anchor.blog": plan("anchor", "blog", route+contributed("10.10.0.1", "", `{"name":"blog.example","port":2368}`)+`}`), - "anchor.other": plan("anchor", "other", `{"provision":"route","provider":"edge","provider-module":"route-proxy",`+ - contributed("", "", `{"name":"other.example","port":80}`)+`}`), - "anchor.older": plan("anchor", "older", route+`"local":""}`), - "laptop.lost": plan("laptop", "lost", route+contributed("", "", `{"name":"lost.example","port":80}`)+`}`), - }} - held := newTable() - var said []string - say := func(kind string, e contract.Event) { - said = append(said, kind+" "+e.Proxy+" "+e.Name+" "+e.Module+" "+e.Local+" "+e.Why) - } - if err := followPlans(plans, newPlanned("anchor", say), held, say); err != nil { - t.Fatal(err) - } - served := func() map[string]string { - out := map[string]string{} - held.mu.RLock() - defer held.mu.RUnlock() - for host, rules := range held.to { - out[host] = rules[0].target - } - return out - } - want := map[string]string{"git.example": "http://10.10.0.7:3000", "git.anchor.internal": "http://10.10.0.7:3000", - "git-api.anchor.internal": "http://10.10.0.7:3001", "blog.example": "http://127.0.0.1:2368"} - if got := served(); !reflect.DeepEqual(got, want) { - t.Fatalf("serving %v, want %v", got, want) - } - if !held.eligibleForACME("git.example") || held.eligibleForACME("git-api.anchor.internal") { - t.Error("a public name is not certified publicly, or an internal-only one is") - } - // Each refusal said with why, and each name served said once. - sort.Strings(said) - for _, w := range []string{ - "route.refused anchor gitea refused not a route this contract reads: json: unknown field \"host\"", - "route.refused anchor lost it runs on laptop and its plan says no address on the private network for it", - "route.refused anchor older its plan names this proxy for its route and carries nothing to serve", - "route.served anchor blog.example blog ", - "route.served anchor git-api.anchor.internal gitea ", - } { - if !slices.Contains(said, w) { - t.Errorf("not said %q, said:\n%s", w, strings.Join(said, "\n")) - } - } - if len(said) != 7 { - t.Errorf("said %d events, want 3 refusals and 4 names served:\n%s", len(said), strings.Join(said, "\n")) - } - for _, row := range []struct { - name, key, value string - served []string - withdrawn []string - say []string - }{ - {"a route arrives", "anchor.new", plan("anchor", "new", route+contributed("", "", `{"name":"new.example","port":81}`)+`}`), - []string{"new.example"}, nil, []string{"route.served anchor new.example new "}}, - {"an unreadable plan keeps what it gave", "laptop.gitea", `{"v":1,`, []string{"git.example"}, nil, nil}, - {"a deleted plan withdraws its routes", "laptop.gitea", "", nil, []string{"git.example", "git-api.anchor.internal"}, - []string{"route.withdrawn anchor git-api.anchor.internal ", "route.withdrawn anchor git.anchor.internal ", - "route.withdrawn anchor git.example "}}, - } { - said = nil - change := stdio.StateChange{Key: row.key, Op: "put", Value: json.RawMessage(row.value)} - if row.value == "" { - change = stdio.StateChange{Key: row.key, Op: "delete"} - } - if err := plans.handle(change); err != nil { - t.Fatal(err) - } - got := served() - for _, host := range row.served { - if _, ok := got[host]; !ok { - t.Errorf("%s: %s is not served (%v)", row.name, host, got) - } - } - for _, host := range row.withdrawn { - if _, ok := got[host]; ok { - t.Errorf("%s: %s is still served", row.name, host) - } - } - if !slices.Equal(said, row.say) { - t.Errorf("%s: said %q, want %q", row.name, said, row.say) - } - } - - held = behind(t) - if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code != http.StatusNotFound { - t.Errorf("before the placement is read, a machine of the mesh was served an internal-only name: %d", code) - } - laptop, err := delivery.PlacementValues(delivery.Placement{Node: "laptop", Decided: delivery.NodeDecided{Address: "10.10.0.7"}}) - if err != nil { - t.Fatal(err) - } - placement := &fakePlans{current: map[string]string{}} - for k, v := range laptop { - placement.current[k] = string(v) - } - if err := followInside(placement, held); err != nil { - t.Fatal(err) - } - if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound { - t.Error("a machine the placement decides an address for was refused the internal-only route") - } - if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.8:1"); code != http.StatusNotFound { - t.Error("a machine the placement does not name was served the internal-only route") - } - if err := placement.handle(stdio.StateChange{Key: "laptop.node", Op: "put", Value: json.RawMessage(`{`)}); err != nil { - t.Fatal(err) - } - if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound { - t.Error("an unreadable placement key changed who is inside") - } -} diff --git a/modules/route-proxy/cmd/route-proxy/routes_test.go b/modules/route-proxy/cmd/route-proxy/routes_test.go deleted file mode 100644 index edf01d0a..00000000 --- a/modules/route-proxy/cmd/route-proxy/routes_test.go +++ /dev/null @@ -1,589 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/provisions" - - "git.novox.be/novox/mesh-controller/modules/route-proxy/contract" -) - -// routesFrom is the table a test's routes make, written as `{"given":[{"from","node","at","values"}…]}`: the -// contributions the plans give the proxy, each read with route-proxy's contract as routesOfPlan reads it, and one the -// contract refuses left out, as routesOfPlan leaves it out. -func routesFrom(body string) (map[string][]rule, map[string]bool, error) { - var said struct { - Given []struct { - From, Node, At string - Values json.RawMessage - } `json:"given"` - } - if err := json.Unmarshal([]byte(body), &said); err != nil { - return nil, nil, err - } - var given []contribution - for _, g := range said.Given { - route, err := contract.DecodeRequest(g.Values) - if err != nil { - continue - } - given = append(given, contribution{From: g.From, Node: g.Node, At: g.At, Route: route}) - } - routes, public := routesOf(given) - return routes, public, nil -} - -// plain is the table an ordinary set of routes makes: one host, one target, no policy. -func plain(routes map[string]string) map[string][]rule { - out := map[string][]rule{} - for host, target := range routes { - out[host] = []rule{{target: target}} - } - return out -} - -// allPublic is every host in a routes map, ACME-eligible — the ordinary case for a test with no -// internal-name alias of its own to distinguish. -func allPublic(routes map[string][]rule) map[string]bool { - out := map[string]bool{} - for host := range routes { - out[host] = true - } - return out -} - -// targetOf is where a host's first matching rule sends a request. -func targetOf(routes map[string][]rule, host string) string { - if rules := routes[host]; len(rules) > 0 { - return rules[0].target - } - return "" -} - -// A route is a grant: the consumer supplies a target, and where that machine is comes from the -// mesh rather than from a naming convention the proxy has to know. -func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) { - routes, _, err := routesFrom(`{"contributions":1,"requirement":"route","given":[ - {"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}} - ]}`) - if err != nil { - t.Fatal(err) - } - // Lower-cased, because a Host header is not case-sensitive and a route that only answers the - // spelling in the manifest answers half the requests made to it. - if targetOf(routes, "app.example") != "http://laptop.internal:8080" { - t.Fatalf("the route does not point at the consumer: %v", routes) - } -} - -// A route with an internal-name alias is reachable under both hostnames, pointed at the same -// target — the same convenience a predecessor proxy gave for reaching a service over the VPN -// without a public TLS round trip. -func TestARouteWithAnInternalNameIsReachableUnderBoth(t *testing.T) { - routes, public, err := routesFrom(`{"given":[ - {"from":"app","node":"anchor","at":"anchor.internal", - "values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}} - ]}`) - if err != nil { - t.Fatal(err) - } - if targetOf(routes, "app.example") != "http://anchor.internal:8080" { - t.Fatalf("the public name does not point at the consumer: %v", routes) - } - if targetOf(routes, "app.anchor.internal") != "http://anchor.internal:8080" { - t.Fatalf("the internal alias does not point at the same consumer: %v", routes) - } - if !public["app.example"] { - t.Errorf("the public name is not eligible for a certificate: %v", public) - } - if public["app.anchor.internal"] { - t.Errorf("the internal alias is eligible for a certificate no public CA could ever issue: %v", - public) - } -} - -// A route whose endpoint reaches only the private network carries an internal name and no public -// one (novox/hq ADR 0138), and is served under that name rather than skipped as naming nothing — -// skipping it left every internal-only module unreachable by name (novox/hq issue 191). -func TestARouteWithOnlyAnInternalNameIsServed(t *testing.T) { - routes, public, err := routesFrom(`{"given":[ - {"from":"app","node":"anchor","at":"anchor.internal", - "values":{"internal-name":"App.Anchor.Internal","port":8443,"scheme":"https","insecure":true}} - ]}`) - if err != nil { - t.Fatal(err) - } - if targetOf(routes, "app.anchor.internal") != "https://anchor.internal:8443" { - t.Fatalf("the internal-only route is not served: %v", routes) - } - if len(routes) != 1 { - t.Errorf("an internal-only route made hosts it never named: %v", routes) - } - if len(public) != 0 { - t.Errorf("an internal-only route made a name eligible for a public certificate: %v", public) - } - - held := newTable() - held.set(routes, public) - if err := onlyInternalNamesTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err != nil { - t.Errorf("the internal authority refused the internal-only route's name: %v", err) - } - if err := onlyWhatTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err == nil { - t.Error("a public certificate was ordered for an internal-only name") - } -} - -// A route with no internal-name composed gets no second host — the ordinary case, unchanged. -func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) { - routes, _, err := routesFrom(`{"given":[ - {"from":"app","node":"anchor","values":{"name":"app.example","port":8080}} - ]}`) - if err != nil { - t.Fatal(err) - } - if len(routes) != 1 { - t.Fatalf("a route with no internal-name grew a second host: %v", routes) - } -} - -// A route is dialled where the machine publishes its module's endpoint, as the module's declaration publishes it (issue -// 774, research 072 row 34; 2026-10-31: gitea's 3000, published at 20000, and grafana's 3000 on another machine, -// published at 20012, answered 502): over loopback beside the proxy, which is the only thing that works without a -// private network, and at its machine's address otherwise. Each plan is composed by the SDK as the seat -// mesh-provisioner composes it, its ports as compose's portsFor gives them (the placement's for a port the mesh may -// move, the manifest's own otherwise), and read as this proxy reads it: the live shapes the old proxy served alike. -func TestAComposedPlanIsDialledWhereTheMachinePublishesIt(t *testing.T) { - proxy := catalogue.Manifest{Module: "route-proxy", Version: "1", - Provides: []catalogue.Offer{{Name: "route", Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}}} - world := catalogue.World{Offered: map[string][]catalogue.Provider{ - "route": {{Node: "anchor", At: "anchor.internal", Module: "route-proxy"}}}} - for _, c := range []struct { - name, node, module string - port int // the endpoint's port, as listens says it - published []any // the container's ports; nil for the host's network - placed map[int]int // what mesh-placement holds for the module - target string - }{ - {"beside the proxy, moved by the placement (gitea)", "anchor", "gitea", 3000, []any{"3000"}, map[int]int{3000: 20000}, - "http://127.0.0.1:20000"}, - {"on another machine, moved by the placement (grafana)", "home", "grafana", 3000, []any{"3000"}, - map[int]int{3000: 20012}, "http://home.internal:20012"}, - {"a bare port the placement holds none for (influxdb)", "home", "influxdb", 8086, []any{"8086"}, nil, - "http://home.internal:8086"}, - {"a fixed mapping (mailu 7080:80)", "anchor", "mailu", 7080, []any{"25", "7080:80"}, nil, "http://127.0.0.1:7080"}, - {"a fixed mapping the placement names another port of (photos 9102:9000)", "anchor", "photos", 9102, - []any{"9102:9000"}, map[int]int{9000: 20011}, "http://127.0.0.1:9102"}, - {"the host's network (home-assistant)", "anchor", "home-assistant", 8123, nil, nil, "http://127.0.0.1:8123"}, - } { - container := map[string]any{"type": "container", "name": c.module, "image": "x"} - if c.published != nil { - container["ports"] = c.published - } else { - container["network"] = "host" - } - module := catalogue.Manifest{Module: c.module, Version: "1", Requires: []string{"route"}, - Listens: []catalogue.Listening{{Name: "web", Port: c.port, Protocol: "tcp", From: "mesh"}}, - Contributes: map[string]map[string]any{"route": {"label": c.module, "endpoint": "web"}}, - Resources: []map[string]any{container}} - node := catalogue.Node{Name: c.node, At: c.node + ".internal", PublicDomain: "example.org", - Capabilities: map[string]bool{"container-runtime": true}} - r, err := catalogue.Resolve(map[string]catalogue.Manifest{module.Module: module, proxy.Module: proxy}, - []string{module.Module}, node, world) - if err != nil { - t.Fatalf("%s: %v", c.name, err) - } - r.Account = "op" - for i, n := range r.Needs { - r.Needs[i].AnsweredByHelper = n.Name == "route" // as the plan sets it (compose's answeredByHelper) - } - ports := map[int]int{} - for _, l := range module.Listens { // compose's portsFor - where, mayAssign := module.MachineSide(l.Port) - if at, known := c.placed[l.Port]; mayAssign && known { - where = at - } - ports[l.Port] = where - } - composed, err := r.Compose(catalogue.Rendering{Hands: true, Provisioner: "mesh-provisioner-client", - ArtifactStore: "store:5000", Planned: true, Ports: map[string]map[int]int{c.module: ports}}) - if err != nil { - t.Fatalf("%s: %v", c.name, err) - } - value, err := provisions.EncodePlan(provisions.Plan{Node: c.node, Module: c.module, Plan: composed.Plans[c.module]}) - if err != nil { - t.Fatalf("%s: %v", c.name, err) - } - given, refused, err := routesOfPlan("anchor", c.node+"."+c.module, value) - if err != nil || len(refused) != 0 { - t.Fatalf("%s: the plan gives %v, refused %v (%v)", c.name, given, refused, err) - } - routes, _ := routesOf(given) - if got := targetOf(routes, c.module+".example.org"); got != c.target { - t.Errorf("%s: dialled at %q, want %q", c.name, got, c.target) - } - } -} - -// A route may name a target reached over https, for a backend that terminates its own TLS — the -// shape Mailu's webmail front needs, which this proxy reaches as a plain workload otherwise. -func TestARouteMayTargetHttps(t *testing.T) { - routes, _, err := routesFrom(`{"given":[ - {"from":"mail","node":"anchor","at":"anchor.internal", - "values":{"name":"mail.example","port":7443,"scheme":"https","insecure":true}} - ]}`) - if err != nil { - t.Fatal(err) - } - if targetOf(routes, "mail.example") != "https://anchor.internal:7443" { - t.Fatalf("an https target was not built as one: %v", routes) - } - if !routes["mail.example"][0].insecure { - t.Fatal("insecure was declared and not carried onto the rule") - } -} - -// End to end: a backend terminating TLS with a certificate nothing would ordinarily trust is still -// reached when the route declared `insecure`, and the response comes back through unmodified. -func TestTheProxyReachesAnInsecureHttpsBackend(t *testing.T) { - workload := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("the workload, over its own TLS")) - })) - defer workload.Close() - target := strings.TrimPrefix(workload.URL, "https://") - - held := newTable() - routes := map[string][]rule{"mail.example": {{target: "https://" + target, insecure: true}}} - held.set(routes, allPublic(routes)) - - proxy := httptest.NewServer(handler(held)) - defer proxy.Close() - - asked, err := http.NewRequest(http.MethodGet, proxy.URL, nil) - if err != nil { - t.Fatal(err) - } - asked.Host = "mail.example" - answer, err := http.DefaultClient.Do(asked) - if err != nil { - t.Fatal(err) - } - defer answer.Body.Close() - if answer.StatusCode != http.StatusOK { - t.Fatalf("an insecure https backend was not reached: %d", answer.StatusCode) - } -} - -// End to end through the proxy itself: a request for the name reaches the workload, and a name -// nobody asked for is refused in a way that says what IS served. -func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) { - workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("the workload")) - })) - defer workload.Close() - target := strings.TrimPrefix(workload.URL, "http://") - host, port, _ := strings.Cut(target, ":") - - held := newTable() - held.set(plain(map[string]string{"app.example": "http://" + host + ":" + port}), allPublic(plain(map[string]string{"app.example": "http://" + host + ":" + port}))) - - proxy := httptest.NewServer(handler(held)) - defer proxy.Close() - - asked, err := http.NewRequest(http.MethodGet, proxy.URL, nil) - if err != nil { - t.Fatal(err) - } - asked.Host = "app.example" - answer, err := http.DefaultClient.Do(asked) - if err != nil { - t.Fatal(err) - } - defer answer.Body.Close() - if answer.StatusCode != http.StatusOK { - t.Fatalf("a request for a served name got %d", answer.StatusCode) - } - - // And a name that is not served says which are — a route withdrawn and a name that never - // existed are different things, and a bare 404 makes an operator go and read the mesh. - other, _ := http.NewRequest(http.MethodGet, proxy.URL, nil) - other.Host = "nobody.example" - refused, err := http.DefaultClient.Do(other) - if err != nil { - t.Fatal(err) - } - defer refused.Body.Close() - if refused.StatusCode != http.StatusNotFound { - t.Fatalf("a name nobody asked for got %d", refused.StatusCode) - } - body := make([]byte, 256) - n, _ := refused.Body.Read(body) - if !strings.Contains(string(body[:n]), "app.example") { - t.Fatalf("the refusal does not say what is served: %s", body[:n]) - } -} - -// The file is the whole truth about who has a route, so the table replaces rather than merges. -// -// Merging would keep serving a name whose module was unassigned — the stale-route fault -// 08-connectivity lists as open, reintroduced one level down. A stale public name pointing at -// nothing fails more visibly than a stale grant, which is exactly why it must not survive. -func TestWithdrawingARouteStopsServingIt(t *testing.T) { - held := newTable() - initial := plain(map[string]string{ - "going.example": "http://a.internal:80", - "staying.example": "http://b.internal:80", - }) - held.set(initial, allPublic(initial)) - held.set(plain(map[string]string{"staying.example": "http://b.internal:80"}), allPublic(plain(map[string]string{"staying.example": "http://b.internal:80"}))) - - if _, still := held.find("going.example", "/"); still { - t.Fatal("a route whose module was unassigned is still served") - } - if _, kept := held.find("staying.example", "/"); !kept { - t.Fatal("withdrawing one route took another with it") - } -} - -// A Host header carries a port and the name does not. -func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) { - held := newTable() - held.set(plain(map[string]string{"app.example": "http://a.internal:8080"}), allPublic(plain(map[string]string{"app.example": "http://a.internal:8080"}))) - if _, found := held.find("app.example:8080", "/"); !found { - t.Fatal("a request to app.example:8080 did not find the route for app.example") - } -} - -// Defends novox/hq 04-ISSUES/004: issuance targets staging unless something says otherwise. -// -// The failure this guards is not a broken proxy. It is a working one that quietly spends a -// production quota which does not replenish for a week, on exactly the work most likely to -// iterate. -func TestTheIssuerIsStagingUnlessNamed(t *testing.T) { - t.Setenv("ACME_DIRECTORY", "") - if got := issuer(); !strings.Contains(got, "staging") { - t.Fatalf("with nothing set the issuer is %q, and a default that spends production quota "+ - "is a default nobody chose", got) - } - - t.Setenv("ACME_DIRECTORY", "https://acme-v02.api.letsencrypt.org/directory") - if got := issuer(); strings.Contains(got, "staging") { - t.Fatalf("an issuer was named explicitly and %q was used instead", got) - } -} - -// A certificate is only ever asked for on a name the mesh routes here. -// -// **Without this, anything that can reach the port spends the quota.** A scan sending arbitrary -// names, or one misconfigured client, becomes a stream of failed orders against the account's -// rate limit — and the proxy would look healthy throughout. -func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) { - held := newTable() - held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}), allPublic(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}))) - policy := onlyWhatTheMeshSaid(held) - - if err := policy(context.Background(), "photos.example"); err != nil { - t.Errorf("a name the mesh routes here was refused a certificate: %v", err) - } - for _, name := range []string{"unknown.example", "", "photos.example.evil"} { - if err := policy(context.Background(), name); err == nil { - t.Errorf("a certificate would be ordered for %q, which the mesh never mentioned", name) - } - } -} - -// A certificate is asked for on a route's public name, never on its internal-network alias — no -// public CA can validate a private name, and asking anyway would only spend the account's rate -// limit on an order that can never succeed. -func TestNoCertificateIsAskedForOnAnInternalAlias(t *testing.T) { - routes, public, err := routesFrom(`{"given":[ - {"from":"app","node":"anchor","at":"anchor.internal", - "values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}} - ]}`) - if err != nil { - t.Fatal(err) - } - held := newTable() - held.set(routes, public) - policy := onlyWhatTheMeshSaid(held) - - if err := policy(context.Background(), "app.example"); err != nil { - t.Errorf("the route's public name was refused a certificate: %v", err) - } - if err := policy(context.Background(), "app.anchor.internal"); err == nil { - t.Error("a certificate was ordered for the internal alias, which no public CA can validate") - } -} - -// A certificate is asked of the *internal* authority only for a name that is routed here and is -// not a route's own public name — the internal-network alias, never the route it accompanies. -func TestTheInternalAuthorityOnlyCertifiesInternalOnlyAliases(t *testing.T) { - routes, public, err := routesFrom(`{"given":[ - {"from":"app","node":"anchor","at":"anchor.internal", - "values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}} - ]}`) - if err != nil { - t.Fatal(err) - } - held := newTable() - held.set(routes, public) - policy := onlyInternalNamesTheMeshSaid(held) - - if err := policy(context.Background(), "app.anchor.internal"); err != nil { - t.Errorf("the internal alias was refused by its own authority: %v", err) - } - if err := policy(context.Background(), "app.example"); err == nil { - t.Error("the internal authority certified a route's public name, which the public authority already covers") - } - if err := policy(context.Background(), "unrouted.internal"); err == nil { - t.Error("the internal authority certified a name nobody routed here") - } -} - -// A route withdrawn stops being certifiable, without the proxy restarting. -func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) { - held := newTable() - held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}), allPublic(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}))) - policy := onlyWhatTheMeshSaid(held) - if err := policy(context.Background(), "photos.example"); err != nil { - t.Fatal(err) - } - - held.set(nil, nil) - if err := policy(context.Background(), "photos.example"); err == nil { - t.Fatal("a withdrawn route can still order certificates, so the policy read a copy taken " + - "once rather than what is served now") - } -} - -// A route may say the largest body it carries, and the proxy holds requests to it. -// -// The registry is why: image layers arrive as single requests of gigabytes, and a proxy's own -// default refuses them long before the workload is reached. It is configuration beside `insecure`, -// not a fifth policy — novox/hq ADR 0108 closed that set at four. -func TestARouteMayLimitTheBodyItCarries(t *testing.T) { - routes, _, err := routesFrom(`{"given":[ - {"from":"registry","node":"anchor","at":"anchor.internal", - "values":{"name":"images.example","port":5000,"max-request-body":21474836480}} - ]}`) - if err != nil { - t.Fatal(err) - } - rules := routes["images.example"] - if len(rules) != 1 { - t.Fatalf("the route is not served once: %v", routes) - } - if rules[0].maxRequestBody != 21474836480 { - t.Fatalf("the limit did not survive the contribution: %d", rules[0].maxRequestBody) - } -} - -// Saying nothing leaves the route unlimited, which is what every route already got. -func TestARouteThatSaysNothingCarriesAnySize(t *testing.T) { - routes, _, err := routesFrom(`{"given":[ - {"from":"app","node":"anchor","at":"anchor.internal","values":{"name":"app.example","port":8080}} - ]}`) - if err != nil { - t.Fatal(err) - } - if got := routes["app.example"][0].maxRequestBody; got != 0 { - t.Fatalf("a route that asked for no limit got one: %d", got) - } -} - -// A request larger than the route carries is refused by the proxy, with the limit named, and the -// workload never sees it. A request within it is proxied normally. -func TestABodyOverTheLimitIsRefusedAndOneUnderItIsCarried(t *testing.T) { - var reached int - workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - reached++ - fmt.Fprintf(w, "carried %d bytes", len(body)) - })) - defer workload.Close() - - at := strings.TrimPrefix(workload.URL, "http://") - host, port, _ := strings.Cut(at, ":") - routes, _, err := routesFrom(`{"given":[ - {"from":"registry","node":"anchor","at":"` + host + `", - "values":{"name":"images.example","port":` + port + `,"max-request-body":8}} - ]}`) - if err != nil { - t.Fatal(err) - } - held := newTable() - held.set(routes, map[string]bool{}) - proxy := httptest.NewServer(handler(held)) - defer proxy.Close() - - over, err := post(proxy.URL, "images.example", "123456789") - if err != nil { - t.Fatal(err) - } - defer over.Body.Close() - if over.StatusCode != http.StatusRequestEntityTooLarge { - t.Fatalf("a body over the limit answered %d, not 413", over.StatusCode) - } - if reached != 0 { - t.Fatalf("the workload was reached by a request the route said it would not carry") - } - - under, err := post(proxy.URL, "images.example", "1234") - if err != nil { - t.Fatal(err) - } - defer under.Body.Close() - if under.StatusCode != http.StatusOK { - t.Fatalf("a body within the limit answered %d, not 200", under.StatusCode) - } - if reached != 1 { - t.Fatalf("the workload was not reached by a request within the limit") - } -} - -// post sends a body to the proxy as the named route, since a route is found by the Host header. -func post(url, host, body string) (*http.Response, error) { - asked, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) - if err != nil { - return nil, err - } - asked.Host = host - asked.Header.Set("Content-Type", "application/octet-stream") - return http.DefaultClient.Do(asked) -} - -// A contribution that cannot be served is skipped rather than served wrongly: one that names nothing to be served -// under; one with no port, which would proxy to :0; a scheme that is neither http nor https, refused rather than -// guessed at; and a body limit that is not a whole positive number of bytes, since serving it without the limit would -// carry exactly what the module said not to carry, and report success doing it. -func TestAContributionThatCannotBeServedIsSkipped(t *testing.T) { - for _, row := range []struct{ why, given, served string }{ - {"neither name", `{"from":"app","node":"anchor","at":"anchor.internal","values":{"internal-name":" ","port":8080}}`, ""}, - {"no port", `{"from":"a","node":"n","at":"n.internal","values":{"name":"no-port.example"}}, - {"from":"b","node":"n","at":"n.internal","values":{"port":8080}}, - {"from":"c","node":"n","at":"n.internal","values":{"name":"fine.example","port":8080}}`, "fine.example"}, - {"an unknown scheme", `{"from":"a","node":"n","at":"n.internal","values":{"name":"bad.example","port":80,"scheme":"ftp"}}`, ""}, - {"a limit in words", `{"from":"r","node":"a","at":"a.internal","values":{"name":"images.example","port":5000,"max-request-body":"lots"}}`, ""}, - {"a negative limit", `{"from":"r","node":"a","at":"a.internal","values":{"name":"images.example","port":5000,"max-request-body":-1}}`, ""}, - {"a zero limit", `{"from":"r","node":"a","at":"a.internal","values":{"name":"images.example","port":5000,"max-request-body":0}}`, ""}, - {"a fractional limit", `{"from":"r","node":"a","at":"a.internal","values":{"name":"images.example","port":5000,"max-request-body":1.5}}`, ""}, - } { - routes, public, err := routesFrom(`{"given":[` + row.given + `]}`) - if err != nil { - t.Fatal(err) - } - switch { - case row.served == "" && (len(routes) != 0 || len(public) != 0): - t.Errorf("%s: an unusable contribution was served: %v %v", row.why, routes, public) - case row.served != "" && (len(routes) != 1 || targetOf(routes, row.served) == ""): - t.Errorf("%s: an unusable contribution was served beside the usable one: %v", row.why, routes) - } - } -} diff --git a/modules/route-proxy/contract/contract.go b/modules/route-proxy/contract/contract.go deleted file mode 100644 index 99b8d46b..00000000 --- a/modules/route-proxy/contract/contract.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package contract is route-proxy's contract for the provision `route`: what a consumer asks a route with, what the -// proxy keeps on the bus about what it serves, the events it says, and its tools' arguments (issue 774; research 072 -// row 34; the operator, 2026-10-31: "the route-proxy should define its own contract for the seat (with the message -// contracts of course) which other modules can use"). -// -// **Published by the repository that holds route-proxy, as its own Go module**, versioned by tags of the form -// `modules/route-proxy/contract/vX.Y.Z`, the subdirectory form Go's module system expects, the same way mesh-sdk is -// published (`go/vX.Y.Z`). A module that asks a route, reads what the proxy serves or hears its events imports this -// package; nothing imports the repository. Standard library only, so a consumer takes nothing else with it. -// -// **How a route travels.** A module that must be reachable requires `route` and says, in its manifest's -// `contributes.route`, one Request, or several by local name; Schema is that object's JSON schema. The controller -// composes each (its label joined to the node's domains into `name` and `internal-name`, its endpoint's port into -// `port`) and the seat mesh-provisioner carries the result in the consumer's plan entry as an opaque payload -// (mesh-sdk catalogue.PlanProvision.Contributed, in the generic catalogue.ContributedForm). Neither the seat nor its -// client knows a field of a route: route-proxy decodes each contribution with DecodeRequest, and refuses, saying why -// (EventRefused), what this contract does not admit. -// -// **Two name spaces, two authorities** (novox/hq ADR 0056, ADR 0138). `name` is public: served to anyone and -// certified by the public ACME directory (Let's Encrypt). `internal-name` is served only inside the private network -// and certified by the mesh's own authority. A route carries either, or both. -package contract - -// Module is the module that provides a route, and Provision what it provides: what a plan's entry names when -// route-proxy is a route's provider. -const ( - Module = "route-proxy" - Provision = "route" -) - -// Version is the version of the Request form this package reads and writes. -const Version = 1 - -// The events route-proxy says (its manifest's `emits`), on mesh.mod.route-proxy.event., each with an Event. -const ( - // EventServed: a name is now served by this proxy (it was not, or it is now served from other rules). - EventServed = "route.served" - // EventWithdrawn: a name this proxy served is served no more, because no plan gives it any longer. - EventWithdrawn = "route.withdrawn" - // EventRefused: a plan gives this proxy a route it does not serve, and Why says why. - EventRefused = "route.refused" -) - -// Event is the body of every event route-proxy says: the proxy's machine, the name (empty for a refusal that named -// none), how far it reaches, and who asked it: the consumer module, its machine and the local name of its -// contribution. Why is set on a refusal alone. -type Event struct { - Proxy string `json:"proxy"` - Name string `json:"name,omitempty"` - Reach string `json:"reach,omitempty"` - Module string `json:"module,omitempty"` - Node string `json:"node,omitempty"` - Local string `json:"local,omitempty"` - Why string `json:"why,omitempty"` -} - -// The tools route-proxy serves (its manifest's `tools`), each answering from its states RoutesState and UnroutedState. -const ( - // ToolRoutes lists every name served, with RoutesArgs. - ToolRoutes = "route_proxy_routes" - // ToolRoute is one name in full, with RouteArgs. - ToolRoute = "route_proxy_route" - // ToolUnrouted lists the names asked for and not served, with RoutesArgs. - ToolUnrouted = "route_proxy_unrouted" -) - -// RoutesArgs are ToolRoutes' and ToolUnrouted's arguments: Node narrows to the proxy on one machine, every machine -// when empty. -type RoutesArgs struct { - Node string `json:"node,omitempty"` -} - -// RouteArgs are ToolRoute's arguments: the name, such as app.example.org, and optionally the machine whose proxy. -type RouteArgs struct { - Name string `json:"name"` - Node string `json:"node,omitempty"` -} diff --git a/modules/route-proxy/contract/contract_test.go b/modules/route-proxy/contract/contract_test.go deleted file mode 100644 index 5095e0ea..00000000 --- a/modules/route-proxy/contract/contract_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package contract - -import ( - "encoding/json" - "reflect" - "slices" - "strings" - "testing" -) - -func limit(n int64) *int64 { return &n } - -// A route is read strictly and whole (issue 774): what the contract names is admitted, what it does not, or a route -// it cannot serve, is refused naming why; and the published schema names exactly the fields the decoder reads. -func TestARouteIsReadStrictly(t *testing.T) { - for _, c := range []struct { - raw, refused string - want Request - }{ - {raw: `{"name":"app.example.org","internal-name":"app.anchor.internal","label":"app","endpoint":"web","port":8080}`, - want: Request{Name: "app.example.org", InternalName: "app.anchor.internal", Label: "app", Endpoint: "web", Port: 8080}}, - {raw: `{"internal-name":"app.anchor.internal","port":443,"scheme":"https","insecure":true,"max-request-body":1048576}`, - want: Request{InternalName: "app.anchor.internal", Port: 443, Scheme: "https", Insecure: true, MaxRequestBody: limit(1048576)}}, - {raw: `{"name":"git.example.org","path":"/api/internal","deny":true,"priority":100000}`, - want: Request{Name: "git.example.org", Path: "/api/internal", Deny: true, Priority: 100000}}, - {raw: `{"name":"old.example.org","redirect":"https://new.example.org"}`, - want: Request{Name: "old.example.org", Redirect: "https://new.example.org"}}, - {raw: `{"name":"admin.example.org","port":80,"auth":"/run/secrets/admin"}`, - want: Request{Name: "admin.example.org", Port: 80, Auth: "/run/secrets/admin"}}, - {raw: `{"label":"app","port":8080}`, refused: "names nothing"}, - {raw: `{"name":"app.example.org"}`, refused: "gives no port"}, - {raw: `{"name":"app.example.org","port":70000}`, refused: "not a port"}, - {raw: `{"name":"app.example.org","port":80.5}`, refused: "not a route"}, - {raw: `{"name":"app.example.org","port":"80"}`, refused: "not a route"}, - {raw: `{"name":"app.example.org","port":80,"scheme":"ftp"}`, refused: "neither http nor https"}, - {raw: `{"name":"app.example.org","port":80,"max-request-body":-1}`, refused: "max-request-body"}, - {raw: `{"name":"app.example.org","port":80,"max-request-body":0}`, refused: "max-request-body"}, - {raw: `{"name":"app.example.org","port":80,"auth":"admin:$2y$05$abc"}`, refused: "credential"}, - {raw: `{"name":"app.example.org","port":80,"host":"x"}`, refused: "unknown field \"host\""}, - {raw: `{"name":"app.example.org","port":80} {}`, refused: "more than one"}, - } { - got, err := DecodeRequest([]byte(c.raw)) - if c.refused != "" { - if err == nil || !strings.Contains(err.Error(), c.refused) { - t.Errorf("DecodeRequest(%s) = %+v, %v; want refused for %q", c.raw, got, err, c.refused) - } - continue - } - if err != nil || !reflect.DeepEqual(got, c.want) { - t.Errorf("DecodeRequest(%s) = %+v, %v; want %+v", c.raw, got, err, c.want) - continue - } - // What it encodes, it reads back the same. - if raw, err := got.Encode(); err != nil { - t.Errorf("Encode(%+v): %v", got, err) - } else if again, err := DecodeRequest(raw); err != nil || !reflect.DeepEqual(again, got) { - t.Errorf("Encode then DecodeRequest of %+v = %+v, %v", got, again, err) - } - } - if (Request{Path: "api"}).Prefix() != "/api" || (Request{}).Prefix() != "" || (Request{Scheme: " HTTPS"}).SchemeOrDefault() != "https" || - (Request{}).SchemeOrDefault() != "http" || (Request{Redirect: "x"}).Forwards() { - t.Error("Prefix, SchemeOrDefault or Forwards says otherwise than the contract") - } - - var schema struct { - Properties map[string]json.RawMessage `json:"properties"` - AdditionalProperties bool `json:"additionalProperties"` - } - if err := json.Unmarshal(Schema, &schema); err != nil || schema.AdditionalProperties { - t.Fatalf("the schema is not a closed object: %v", err) - } - var tags []string - for f := range reflect.TypeFor[Request]().Fields() { - tags = append(tags, strings.Split(f.Tag.Get("json"), ",")[0]) - } - var said []string - for name := range schema.Properties { - said = append(said, name) - } - slices.Sort(tags) - slices.Sort(said) - if !slices.Equal(tags, said) { - t.Fatalf("the schema names %v, the decoder reads %v", said, tags) - } -} diff --git a/modules/route-proxy/contract/go.mod b/modules/route-proxy/contract/go.mod deleted file mode 100644 index 91110457..00000000 --- a/modules/route-proxy/contract/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module git.novox.be/novox/mesh-controller/modules/route-proxy/contract - -go 1.26.0 diff --git a/modules/route-proxy/contract/request.go b/modules/route-proxy/contract/request.go deleted file mode 100644 index 1a10c98b..00000000 --- a/modules/route-proxy/contract/request.go +++ /dev/null @@ -1,128 +0,0 @@ -package contract - -import ( - "bytes" - _ "embed" - "encoding/json" - "errors" - "fmt" - "strings" -) - -// Request is one route a consumer asks for, as its manifest's `contributes.route` says it and the controller composes -// it: the object Schema describes. Every field is optional to the decoder; Validate says which a route needs. -type Request struct { - // Name is the public name, served to anyone and certified by the public ACME directory; InternalName the name on - // the private network, served inside it alone and certified by the mesh's own authority. The controller composes - // both from Label and the node's domains, as far as the endpoint reaches (novox/hq ADR 0138); a manifest writes a - // label, never a domain. - Name string `json:"name,omitempty"` - InternalName string `json:"internal-name,omitempty"` - Label string `json:"label,omitempty"` - // Endpoint names the manifest's endpoint the route serves; the controller fills Port from it. - Endpoint string `json:"endpoint,omitempty"` - Port int `json:"port,omitempty"` - // Path is the path prefix the rule is scoped to, every path when empty; Priority which rule wins where two match, - // higher first. - Path string `json:"path,omitempty"` - Priority int `json:"priority,omitempty"` - // Scheme is how the consumer is reached, http (the default) or https; Insecure skips verifying its certificate, - // for a backend that terminates TLS with its own. - Scheme string `json:"scheme,omitempty"` - Insecure bool `json:"insecure,omitempty"` - // MaxRequestBody is the largest body, in bytes, the route carries; no limit when absent. Said, it is whole and - // positive: a limit the proxy cannot keep is a route it does not serve. - MaxRequestBody *int64 `json:"max-request-body,omitempty"` - // The policy of the rule, a closed set (novox/hq ADR 0108): Deny refuses every request, Redirect answers with a - // permanent redirect to that address, and Auth names the file of the secret a request must present, never the - // secret itself. - Deny bool `json:"deny,omitempty"` - Redirect string `json:"redirect,omitempty"` - Auth string `json:"auth,omitempty"` -} - -// Schema is Request's JSON schema, published with this package for what checks a manifest's `contributes.route`. -// -//go:embed route.schema.json -var Schema []byte - -// DecodeRequest reads one contribution's object strictly: no field this contract does not name, one document, and -// a route Validate admits. What it refuses, it refuses whole, naming why: a route is never served half. -func DecodeRequest(raw []byte) (Request, error) { - var r Request - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.DisallowUnknownFields() - if err := dec.Decode(&r); err != nil { - return Request{}, fmt.Errorf("not a route this contract reads: %w", err) - } - if dec.More() { - return Request{}, errors.New("not a route this contract reads: more than one document") - } - if err := r.Validate(); err != nil { - return Request{}, err - } - return r, nil -} - -// Encode is r as a contribution's object, refused where DecodeRequest would refuse it. -func (r Request) Encode() ([]byte, error) { - if err := r.Validate(); err != nil { - return nil, err - } - return json.Marshal(r) -} - -// Validate says what makes r no route: no name of either kind; a port outside 1-65535, or none where the rule -// forwards (neither Deny nor Redirect); a scheme other than http or https; a body limit that is not positive; and a -// credential written where a secret's file is to be named (novox/hq ADR 0108: refused rather than tolerated). -func (r Request) Validate() error { - if strings.TrimSpace(r.Name) == "" && strings.TrimSpace(r.InternalName) == "" { - return errors.New("the route names nothing to be served under: neither name nor internal-name") - } - if r.Port < 0 || r.Port > 65535 { - return fmt.Errorf("the route's port %d is not a port", r.Port) - } - if r.Forwards() && r.Port == 0 { - return errors.New("the route forwards and gives no port") - } - switch strings.ToLower(strings.TrimSpace(r.Scheme)) { - case "", "http", "https": - default: - return fmt.Errorf("the route's scheme %q is neither http nor https", r.Scheme) - } - if r.MaxRequestBody != nil && *r.MaxRequestBody <= 0 { - return fmt.Errorf("the route's max-request-body %d is not a positive number of bytes", *r.MaxRequestBody) - } - if LooksLikeACredential(r.Auth) { - return errors.New("the route's auth holds a credential rather than the name of a secret's file (novox/hq ADR 0108)") - } - return nil -} - -// Forwards is whether the rule sends a request on to the consumer, rather than answering it itself. -func (r Request) Forwards() bool { return !r.Deny && r.Redirect == "" } - -// Prefix is the path prefix the rule is scoped to, beginning with a slash, or "" for every path. -func (r Request) Prefix() string { - p := strings.TrimSpace(r.Path) - if p != "" && !strings.HasPrefix(p, "/") { - p = "/" + p - } - return p -} - -// SchemeOrDefault is how the consumer is reached: the scheme, lower-cased, http when none is said. -func (r Request) SchemeOrDefault() string { - if s := strings.ToLower(strings.TrimSpace(r.Scheme)); s != "" { - return s - } - return "http" -} - -// LooksLikeACredential is the check that keeps a secret out of a declaration: a value holding a `:` (the htpasswd -// separator) or opening with a bcrypt identifier is a credential, not a path, and no path the mesh writes needs -// either. It errs towards refusing. -func LooksLikeACredential(v string) bool { - v = strings.TrimSpace(v) - return strings.Contains(v, ":") || strings.HasPrefix(v, "$2") -} diff --git a/modules/route-proxy/contract/route.schema.json b/modules/route-proxy/contract/route.schema.json deleted file mode 100644 index af55a78d..00000000 --- a/modules/route-proxy/contract/route.schema.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.novox.be/novox/mesh-controller/modules/route-proxy/contract/route.schema.json", - "title": "route", - "description": "One route a consumer asks route-proxy for, as a manifest's contributes.route says it (one, or several by local name) and the controller composes it. Version 1 of route-proxy's contract.", - "type": "object", - "additionalProperties": false, - "anyOf": [ - {"required": ["name"]}, - {"required": ["internal-name"]}, - {"required": ["label"]} - ], - "properties": { - "name": {"type": "string", "description": "the public name, served to anyone and certified by the public ACME directory; composed from label"}, - "internal-name": {"type": "string", "description": "the name on the private network, served inside it alone and certified by the mesh's own authority; composed from label"}, - "label": {"type": "string", "description": "the subdomain the module chooses; joined to the node's domains by the controller"}, - "endpoint": {"type": "string", "description": "the manifest's endpoint the route serves; the controller fills port from it"}, - "port": {"type": "integer", "minimum": 1, "maximum": 65535}, - "path": {"type": "string", "description": "the path prefix the rule is scoped to; every path when absent"}, - "priority": {"type": "integer", "description": "which rule wins where two match, higher first"}, - "scheme": {"enum": ["http", "https"], "description": "how the consumer is reached; http when absent"}, - "insecure": {"type": "boolean", "description": "skip verifying the consumer's certificate over https"}, - "max-request-body": {"type": "integer", "minimum": 1, "description": "the largest body, in bytes, the route carries"}, - "deny": {"type": "boolean", "description": "refuse every request matching the rule"}, - "redirect": {"type": "string", "description": "answer with a permanent redirect to this address"}, - "auth": {"type": "string", "description": "the file of the secret a request must present, never the secret itself"} - } -} diff --git a/modules/route-proxy/contract/state.go b/modules/route-proxy/contract/state.go deleted file mode 100644 index e1fce955..00000000 --- a/modules/route-proxy/contract/state.go +++ /dev/null @@ -1,152 +0,0 @@ -package contract - -// What the web front keeps on the bus about the names it serves, and about the names it was asked for and does not -// serve (issue 774). The proxy writes it, on change, in its states `routes` and `unrouted`, announced readable; the -// module's tools, and any module that reads them, read it from there, so the question "is this site reachable, and -// is its certificate good" is answered from the bus rather than with curl. -// -// A key is `.`: the machine whose proxy answers, then the name as asked, lower-cased. A node's name holds -// no dot, so the first dot splits them. - -import ( - "net" - "regexp" - "strings" - "time" -) - -// The states route-proxy keeps, by their local names (the buckets are route-proxy_). -const ( - // RoutesState holds one key per served name: where it forwards, its certificate, the last answer it got. - RoutesState = "routes" - // UnroutedState holds one key per name the proxy answered "no route for" with, and when it was last asked. - UnroutedState = "unrouted" - // BoundState is the provider helper's bucket of consumer entries (mesh-sdk go/provider). - BoundState = "bound" -) - -// The words a route's reach and a certificate's authority are said in. -const ( - ReachPublic = "public" - ReachInternal = "internal" -) - -// The words a certificate's outcome is said in. -const ( - // Issued: the authority issued it while this proxy ran, at RenewedAt. - Issued = "issued" - // Cached: this proxy served it from what it kept, and saw no issuance of it since it started. - Cached = "cached" - // Failed: the last attempt to have one failed, Error says why. - Failed = "failed" -) - -// Target is one rule a name is served by: what a request matching it reaches, or what it is answered with. -type Target struct { - // Path is the path prefix the rule is scoped to; empty is every path. - Path string `json:"path,omitempty"` - // Module and Node are the consumer that contributed the route and the machine it runs on. - Module string `json:"module,omitempty"` - Node string `json:"node,omitempty"` - // Target is where the request is forwarded, scheme://address:port; empty for a rule that answers itself. - Target string `json:"target,omitempty"` - Deny bool `json:"deny,omitempty"` - Redirect string `json:"redirect,omitempty"` - // Auth says the rule asks for credentials; Sealed says why it cannot check them, and so refuses everything. - Auth bool `json:"auth,omitempty"` - Sealed string `json:"sealed,omitempty"` -} - -// Certificate is what this proxy knows of a name's certificate. -type Certificate struct { - // Authority is which issuer certifies the name: public (the ACME directory the module names) or internal (the - // mesh's own). - Authority string `json:"authority"` - Issuer string `json:"issuer,omitempty"` - NotAfter *time.Time `json:"not_after,omitempty"` - // RenewedAt is when an authority last issued it to this proxy. - RenewedAt *time.Time `json:"renewed_at,omitempty"` - // Outcome is issued, cached or failed; empty until a handshake or an issuance said anything. - Outcome string `json:"outcome,omitempty"` - Error string `json:"error,omitempty"` - At *time.Time `json:"at,omitempty"` -} - -// Answer is the last answer the proxy got from upstream for a name: the status it carried back, or the error that -// stood in for one (a 502). -type Answer struct { - Status int `json:"status"` - At time.Time `json:"at"` - Error string `json:"error,omitempty"` - Path string `json:"path,omitempty"` -} - -// Route is one served name, the value of a key of RoutesState. -type Route struct { - Name string `json:"name"` - // Proxy is the machine whose proxy serves it. - Proxy string `json:"proxy"` - // Reach is public (a route's own name) or internal (only its internal-name, served inside the private network). - Reach string `json:"reach"` - Targets []Target `json:"targets"` - Certificate Certificate `json:"certificate"` - LastAnswer *Answer `json:"last_answer,omitempty"` -} - -// Unrouted is one name the proxy answered "no route for", the value of a key of UnroutedState. -type Unrouted struct { - Name string `json:"name"` - Proxy string `json:"proxy"` - // AskedAt is when the name was last asked for. Who asked is not kept here: the journal keeps it, for the - // machine's intrusion prevention. - AskedAt time.Time `json:"asked_at"` - // Times is how often it was asked since the proxy last started. - Times int `json:"times"` - // InternalOnly says the name is served, but only inside the private network, and was asked from outside. - InternalOnly bool `json:"internal_only,omitempty"` - // Handshake says it was asked in a TLS handshake (the name a client sent), not in an HTTP request. - Handshake bool `json:"handshake,omitempty"` -} - -// OtherName is the name of the one key per proxy that counts the refusals not kept by name: names under no domain -// the proxy serves (what a scan sends), and names past the limit of new names a minute. Its key is -// `._other`; no host name begins with an underscore. -const OtherName = "_other" - -// Other is the value of a proxy's OtherName key. -type Other struct { - Proxy string `json:"proxy"` - // Minute is the last whole minute counted, and LastMinute how many refusals it held. - Minute time.Time `json:"minute"` - LastMinute int `json:"last_minute"` - // SinceStart is every refusal not kept by name since the proxy started. - SinceStart int `json:"since_start"` -} - -// Registrable is the domain a name is registered under, as its last two labels; "" for an address or a single -// label. Two labels is wrong for a public suffix of two (co.uk); it only decides which refused names are kept by name. -func Registrable(name string) string { - if net.ParseIP(strings.Trim(name, "[]")) != nil { - return "" - } - labels := strings.Split(name, ".") - if len(labels) < 2 { - return "" - } - return strings.Join(labels[len(labels)-2:], ".") -} - -var keyable = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_.-]{0,251}[a-z0-9_])?$`) - -// Keyable says whether a name can be a key's name: what a host name or an address is. A name asked for in a request -// is whatever a client sent; one no key could hold is no site anybody runs. -func Keyable(name string) bool { return keyable.MatchString(name) && !strings.Contains(name, "..") } - -// Key is the key of name served or asked at node's proxy. -func Key(node, name string) string { return node + "." + name } - -// Split is a key's node and name. -func Split(key string) (node, name string) { - node, name, _ = strings.Cut(key, ".") - return node, name -} diff --git a/modules/route-proxy/files/acme.env b/modules/route-proxy/files/acme.env deleted file mode 100644 index 428aae2b..00000000 --- a/modules/route-proxy/files/acme.env +++ /dev/null @@ -1,3 +0,0 @@ -ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org:443/directory -ACME_ROOTS=https://acme-v02.api.letsencrypt.org:443 -ACME_ROOTS_PATH= diff --git a/modules/route-proxy/files/internal-acme.env b/modules/route-proxy/files/internal-acme.env deleted file mode 100644 index 72b0f7bb..00000000 --- a/modules/route-proxy/files/internal-acme.env +++ /dev/null @@ -1,3 +0,0 @@ -INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path} -INTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots} -INTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots} diff --git a/modules/route-proxy/go.mod b/modules/route-proxy/go.mod deleted file mode 100644 index 70768d68..00000000 --- a/modules/route-proxy/go.mod +++ /dev/null @@ -1,19 +0,0 @@ -module route-proxy - -go 1.26.0 - -require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 - github.com/nats-io/nats.go v1.54.0 - golang.org/x/crypto v0.57.0 -) - -require ( - git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0 - github.com/klauspost/compress v1.20.0 // indirect - github.com/nats-io/nkeys v0.4.16 // indirect - github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/net v0.58.0 // indirect - golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect -) diff --git a/modules/route-proxy/go.sum b/modules/route-proxy/go.sum deleted file mode 100644 index dc764c96..00000000 --- a/modules/route-proxy/go.sum +++ /dev/null @@ -1,32 +0,0 @@ -git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0 h1:89jE1oBK+l7gFJjz6wx8mUD3KxrqSDa7pUTlQkzC1do= -git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0/go.mod h1:Yr+Xp89kpMiKH6kA06EU+d9I2CyzrK9yMX7J3pSv0hE= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= -github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= -github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= -github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= -github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= -github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= -github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= -github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= -github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= -github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= -github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= -github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= -github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= -github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= -github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json deleted file mode 100644 index 668ab88a..00000000 --- a/modules/route-proxy/module.json +++ /dev/null @@ -1,272 +0,0 @@ -{ - "module": "route-proxy", - "version": "1", - "slug": "rproxy", - "capabilities": [ - "container-runtime" - ], - "provides": [ - { - "name": "route", - "scope": "mesh", - "credential": { - "form": "none" - }, - "identity": false, - "answered-by": "helper" - } - ], - "serves": { - "route": {} - }, - "requires": [ - "internal-acme-ca" - ], - "binds": { - "internal-acme-ca": "${dir:state}/internal-acme-ca.json" - }, - "own-secrets": { - "broker": "${dir:mesh-state}/broker" - }, - "listens": [ - { - "name": "http", - "port": 80, - "protocol": "tcp", - "from": "anywhere", - "why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified" - }, - { - "name": "https", - "port": 443, - "protocol": "tcp", - "from": "anywhere", - "why": "public HTTPS for every name the mesh routes here" - } - ], - "data": { - "own": [ - { - "id": "acme", - "path": "${dir:acme-cache}", - "class": "rebuildable", - "why": "issued certificates, issued again" - }, - { - "id": "ca", - "path": "${dir:ca-dir}", - "class": "cache", - "why": "the authority's roots, fetched again at every start" - } - ] - }, - "resources": [ - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": "." - }, - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, - { - "id": "acme-cache", - "type": "directory", - "path": "/var/lib/route-proxy/acme", - "mode": "0700" - }, - { - "id": "ca-dir", - "type": "directory", - "path": "/var/lib/route-proxy/ca", - "mode": "0755" - }, - { - "id": "acme-env", - "type": "file", - "path": "${dir:state}/acme.env", - "mode": "0600", - "template": "files/acme.env" - }, - { - "id": "internal-acme-env", - "type": "file", - "path": "${dir:state}/internal-acme.env", - "mode": "0600", - "template": "files/internal-acme.env" - }, - { - "id": "trust", - "type": "container", - "name": "route-proxy-trust", - "artifact": "trust", - "run-once": true, - "network": "host", - "env-file": [ - "${dir:state}/acme.env" - ], - "volumes": [ - "${dir:ca-dir}:/ca" - ], - "args": [ - "sh", - "-c", - "if [ -z \"$ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/root.crt \"$ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/root.crt && exit 0; sleep 2; done; echo \"the authority at $ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1" - ], - "restart-on": [ - "acme-env" - ] - }, - { - "id": "internal-trust", - "type": "container", - "name": "route-proxy-internal-trust", - "artifact": "trust", - "run-once": true, - "network": "host", - "env-file": [ - "${dir:state}/internal-acme.env" - ], - "volumes": [ - "${dir:ca-dir}:/ca" - ], - "args": [ - "sh", - "-c", - "if [ -z \"$INTERNAL_ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/internal-root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/internal-root.crt \"$INTERNAL_ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/internal-root.crt && exit 0; sleep 2; done; echo \"the authority at $INTERNAL_ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1" - ], - "restart-on": [ - "internal-acme-env" - ] - }, - { - "id": "program-route-proxy", - "type": "archive", - "path": "${dir:state}/route-proxy", - "artifact": "server", - "unknown": "program" - }, - { - "id": "server", - "type": "container", - "name": "route-proxy", - "artifact": "trust", - "network": "host", - "env-file": [ - "${dir:state}/acme.env", - "${dir:state}/internal-acme.env" - ], - "volumes": [ - "${dir:acme-cache}:/acme", - "${dir:ca-dir}:/ca:ro", - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:state}/route-proxy:/opt/mesh:ro" - ], - "env": { - "LISTEN": ":80", - "TLS_LISTEN": ":443", - "ACME_CACHE": "/acme", - "ACME_CA_BUNDLE": "/ca/root.crt", - "INTERNAL_ACME_CA_BUNDLE": "/ca/internal-root.crt", - "MESH_BROKER_FILE": "/run/secrets/broker" - }, - "args": [ - "/opt/mesh/route-proxy" - ], - "restart-on": [ - "trust", - "acme-env", - "internal-trust", - "internal-acme-env", - "program-route-proxy" - ], - "logging": "journald", - "health": { - "kind": "tcp", - "endpoint": "http", - "interval": "10s", - "timeout": "5s", - "looks": 3, - "grace": "60s" - } - } - ], - "build": { - "artifacts": [ - { - "name": "server", - "kind": "bundle", - "language": "go", - "system": "alpine", - "from": "cmd/route-proxy", - "binary": "route-proxy" - }, - { - "name": "trust", - "kind": "upstream", - "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" - }, - { - "name": "provider", - "kind": "bundle", - "language": "go", - "system": "arch", - "from": "cmd/route-provider", - "binary": "route-provider", - "loads": [ - "route-provider" - ] - }, - { - "name": "files", - "kind": "archive", - "from": "files" - } - ] - }, - "jails": [ - { - "name": "route-proxy", - "failregex": "^.*TLS handshake error from :\\d+: (?:no public route for|acme/autocert: missing server name)\n ^.*refused: no route for .*, asked from :\\d+$", - "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=route-proxy\nport = http,https\nmaxretry = 10\nfindtime = 1d\nbantime = 1d" - } - ], - "state": [ - "bound", - "routes", - { - "name": "unrouted", - "ttl-seconds": 604800 - } - ], - "tools": [ - "route_proxy_routes", - "route_proxy_route", - "route_proxy_unrouted" - ], - "emits": [ - "route.served", - "route.withdrawn", - "route.refused" - ], - "replaces": { - "route_proxy_routes": [ - "curl -I" - ], - "route_proxy_route": [ - "openssl s_client -connect" - ], - "route_proxy_unrouted": [ - "docker logs route-proxy" - ] - }, - "reads": [ - "mesh-delivery-assigner.placement", - "mesh-provisioner.plans", - "mesh-provisioner.provisions" - ] -} -- 2.54.0 From 5970367173948a4de9893854200f107ff06f9dba Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:16:50 +0100 Subject: [PATCH 2/2] Empty the controller's repository: its jobs moved to the parts ADR 0590 and 0593 name, so nothing builds here against the new mesh-sdk Nothing runs the controller (unassigned, out of the catalog) and the group's check builds every member; the repository is archived after the delivery. Issue 1126. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- Dockerfile | 43 - Makefile | 112 - README.md | 219 +- cmd/mesh-controller/accept_outside.go | 149 - cmd/mesh-controller/acting.go | 414 -- cmd/mesh-controller/acts.go | 479 --- cmd/mesh-controller/acts_test.go | 135 - .../addresses_integration_test.go | 231 -- cmd/mesh-controller/addresses_test.go | 80 - cmd/mesh-controller/adopting_test.go | 660 --- cmd/mesh-controller/adoption.go | 1113 ----- .../advisories_counted_test.go | 180 - cmd/mesh-controller/agent_account.go | 253 -- .../agent_account_integration_test.go | 161 - cmd/mesh-controller/agent_account_test.go | 134 - cmd/mesh-controller/api.go | 186 - cmd/mesh-controller/api_test.go | 79 - cmd/mesh-controller/apply_failing.go | 164 - cmd/mesh-controller/apply_failing_test.go | 123 - cmd/mesh-controller/ask.go | 62 - cmd/mesh-controller/asker.go | 1009 ----- cmd/mesh-controller/asker_bus_test.go | 153 - cmd/mesh-controller/asker_test.go | 1006 ----- cmd/mesh-controller/asker_wire.go | 613 --- cmd/mesh-controller/assignment_accounts.go | 489 --- .../assignment_accounts_test.go | 498 --- cmd/mesh-controller/bindings.go | 127 - .../bindings_integration_test.go | 102 - cmd/mesh-controller/bindings_test.go | 77 - cmd/mesh-controller/board.go | 324 -- cmd/mesh-controller/board_test.go | 154 - cmd/mesh-controller/bucket_log_fill.go | 207 - cmd/mesh-controller/bucket_log_fill_test.go | 165 - cmd/mesh-controller/build.go | 270 -- cmd/mesh-controller/build_integration_test.go | 133 - cmd/mesh-controller/build_source.go | 705 ---- .../build_source_integration_test.go | 298 -- cmd/mesh-controller/build_source_test.go | 162 - cmd/mesh-controller/build_test.go | 68 - .../builder_on_the_bus_test.go | 117 - cmd/mesh-controller/bus_runs_test.go | 29 - cmd/mesh-controller/bus_step.go | 504 --- cmd/mesh-controller/bus_step_wants_test.go | 33 - cmd/mesh-controller/bus_users_closed_test.go | 92 - cmd/mesh-controller/buscertificate.go | 258 -- cmd/mesh-controller/buscertificate_test.go | 121 - cmd/mesh-controller/busobjects.go | 260 -- cmd/mesh-controller/busobjects_test.go | 162 - .../calls_verb_integration_test.go | 36 - cmd/mesh-controller/calls_verb_test.go | 27 - cmd/mesh-controller/chain.go | 105 - cmd/mesh-controller/checks.go | 188 - cmd/mesh-controller/checks_test.go | 95 - cmd/mesh-controller/collect.go | 285 -- cmd/mesh-controller/collection.go | 166 - cmd/mesh-controller/compose_callers_test.go | 59 - cmd/mesh-controller/compose_fallbacks_test.go | 114 - cmd/mesh-controller/compose_helpers_test.go | 33 - cmd/mesh-controller/compose_input.go | 416 -- .../compose_input_credentials.go | 285 -- ...pose_input_credentials_integration_test.go | 359 -- .../compose_input_integration_test.go | 264 -- ...compose_input_settings_integration_test.go | 45 - .../compose_input_settings_test.go | 45 - .../compose_made_integration_test.go | 54 - .../compose_once_integration_test.go | 44 - cmd/mesh-controller/compose_perform.go | 265 -- .../compose_perform_integration_test.go | 235 -- cmd/mesh-controller/compose_sdk.go | 581 --- cmd/mesh-controller/compose_sdk_test.go | 158 - .../composed_digest_race_test.go | 78 - .../composed_integration_test.go | 182 - cmd/mesh-controller/composed_judge.go | 343 -- cmd/mesh-controller/composed_judge_test.go | 212 - cmd/mesh-controller/composed_keys.go | 446 -- cmd/mesh-controller/composed_keys_test.go | 338 -- cmd/mesh-controller/composed_live.go | 450 -- .../composed_node_last_test.go | 56 - cmd/mesh-controller/composed_objects_test.go | 102 - .../composed_operator_keys_test.go | 58 - cmd/mesh-controller/composed_plans_test.go | 29 - cmd/mesh-controller/composed_rejudge.go | 169 - .../composed_served_integration_test.go | 618 --- cmd/mesh-controller/composed_start.go | 358 -- cmd/mesh-controller/composed_start_test.go | 185 - cmd/mesh-controller/composed_trace.go | 51 - cmd/mesh-controller/composed_userlist_test.go | 108 - cmd/mesh-controller/composed_writer.go | 514 --- cmd/mesh-controller/composed_writer_test.go | 413 -- cmd/mesh-controller/composer.go | 127 - cmd/mesh-controller/composer_test.go | 143 - cmd/mesh-controller/condition_looks.go | 76 - cmd/mesh-controller/condition_looks_test.go | 89 - cmd/mesh-controller/conditions.go | 491 --- .../conditions_brief_integration_test.go | 43 - .../conditions_integration_test.go | 32 - cmd/mesh-controller/conditions_test.go | 84 - cmd/mesh-controller/config_move.go | 616 --- .../config_moved_integration_test.go | 467 --- cmd/mesh-controller/config_moved_test.go | 332 -- cmd/mesh-controller/config_read.go | 128 - cmd/mesh-controller/confirm.go | 67 - cmd/mesh-controller/container_groups_test.go | 40 - cmd/mesh-controller/core_box.go | 295 -- .../core_box_integration_test.go | 129 - cmd/mesh-controller/core_box_test.go | 131 - .../credential_on_assign_test.go | 99 - cmd/mesh-controller/data.go | 933 ----- cmd/mesh-controller/data_integration_test.go | 155 - cmd/mesh-controller/data_test.go | 369 -- cmd/mesh-controller/deadletters.go | 166 - cmd/mesh-controller/deadletters_aging.go | 117 - cmd/mesh-controller/deadletters_aging_test.go | 69 - .../deadletters_integration_test.go | 88 - cmd/mesh-controller/deadletters_test.go | 56 - ...declaration_capability_integration_test.go | 110 - cmd/mesh-controller/declaration_files.go | 256 -- .../declaration_files_integration_test.go | 222 - cmd/mesh-controller/declaration_files_test.go | 509 --- cmd/mesh-controller/declaration_room.go | 41 - cmd/mesh-controller/declaration_sweep.go | 353 -- .../declaration_sweep_wanted_test.go | 43 - .../declared_packages_integration_test.go | 115 - cmd/mesh-controller/desk_secret.go | 445 -- .../desk_secret_integration_test.go | 96 - cmd/mesh-controller/desk_secret_test.go | 648 --- cmd/mesh-controller/doctor.go | 684 --- .../doctor_integration_test.go | 246 -- cmd/mesh-controller/doctor_test.go | 202 - cmd/mesh-controller/facts.go | 635 --- cmd/mesh-controller/facts_format_test.go | 68 - cmd/mesh-controller/facts_test.go | 158 - cmd/mesh-controller/fingerprints_at_test.go | 93 - cmd/mesh-controller/forge_address_test.go | 160 - cmd/mesh-controller/forge_read.go | 143 - cmd/mesh-controller/forge_wait_test.go | 148 - cmd/mesh-controller/given.go | 202 - cmd/mesh-controller/given_again_test.go | 34 - cmd/mesh-controller/given_test.go | 51 - .../grant_follows_binding_integration_test.go | 160 - .../grant_follows_binding_test.go | 52 - cmd/mesh-controller/grants.go | 288 -- cmd/mesh-controller/grants_test.go | 119 - cmd/mesh-controller/handacts.go | 367 -- cmd/mesh-controller/handacts_test.go | 54 - cmd/mesh-controller/handover_test.go | 195 - cmd/mesh-controller/haves.go | 135 - cmd/mesh-controller/haves_test.go | 181 - cmd/mesh-controller/healers.go | 640 --- .../healers_integration_test.go | 248 -- cmd/mesh-controller/healers_test.go | 213 - cmd/mesh-controller/health_probes.go | 135 - cmd/mesh-controller/heard_withheld.go | 66 - cmd/mesh-controller/heard_withheld_test.go | 43 - cmd/mesh-controller/held.go | 128 - cmd/mesh-controller/held_test.go | 132 - .../helpers_kept_integration_test.go | 109 - cmd/mesh-controller/helpers_kept_test.go | 70 - cmd/mesh-controller/hidden_input.go | 26 - cmd/mesh-controller/holders_silent_test.go | 117 - cmd/mesh-controller/holdings.go | 163 - cmd/mesh-controller/holdings_test.go | 112 - .../hosts_behind_integration_test.go | 42 - cmd/mesh-controller/hosts_behind_test.go | 74 - .../identity_bound_integration_test.go | 102 - cmd/mesh-controller/image_missing.go | 241 -- cmd/mesh-controller/image_missing_test.go | 125 - cmd/mesh-controller/issued.go | 330 -- .../issued_integration_test.go | 96 - cmd/mesh-controller/issued_test.go | 255 -- cmd/mesh-controller/issued_to_vault.go | 107 - cmd/mesh-controller/issued_to_vault_test.go | 220 - .../join_through_the_tunnel_test.go | 104 - cmd/mesh-controller/kept_compositions.go | 352 -- cmd/mesh-controller/kept_helpers.go | 221 - cmd/mesh-controller/key_takein.go | 654 --- .../key_takein_integration_test.go | 271 -- cmd/mesh-controller/key_takein_test.go | 262 -- cmd/mesh-controller/last_heard.go | 120 - .../last_heard_integration_test.go | 82 - cmd/mesh-controller/last_heard_test.go | 112 - cmd/mesh-controller/lease_command_test.go | 70 - cmd/mesh-controller/lease_integration_test.go | 162 - cmd/mesh-controller/lease_test.go | 36 - cmd/mesh-controller/left_out.go | 222 - .../left_out_integration_test.go | 127 - cmd/mesh-controller/left_out_test.go | 144 - .../legacy_takein_integration_test.go | 297 -- cmd/mesh-controller/licence.go | 481 --- cmd/mesh-controller/local_network.go | 49 - cmd/mesh-controller/local_network_test.go | 65 - .../loop_own_consumers_test.go | 67 - cmd/mesh-controller/machine_lines.go | 135 - cmd/mesh-controller/machine_lines_test.go | 88 - cmd/mesh-controller/machine_network.go | 413 -- cmd/mesh-controller/machine_network_test.go | 51 - cmd/mesh-controller/machine_units.go | 157 - cmd/mesh-controller/machine_units_test.go | 6 - cmd/mesh-controller/main.go | 399 -- cmd/mesh-controller/mesh_for_test.go | 153 - cmd/mesh-controller/meshcli.go | 364 -- .../meshcli_integration_test.go | 193 - .../meshcli_reads_integration_test.go | 67 - cmd/mesh-controller/meshcli_reads_test.go | 67 - cmd/mesh-controller/meshcli_stdin_test.go | 101 - cmd/mesh-controller/meshcli_test.go | 128 - cmd/mesh-controller/mirrors.go | 237 -- .../mirrors_integration_test.go | 127 - cmd/mesh-controller/mirrors_test.go | 187 - cmd/mesh-controller/module_health.go | 754 ---- cmd/mesh-controller/module_health_test.go | 200 - cmd/mesh-controller/modules.go | 1509 ------- cmd/mesh-controller/modules_test.go | 26 - .../modules_wire_store_test.go | 5 - cmd/mesh-controller/modules_wire_test.go | 54 - cmd/mesh-controller/moved.go | 410 -- cmd/mesh-controller/moved_asker_test.go | 140 - cmd/mesh-controller/moved_bus_test.go | 130 - cmd/mesh-controller/moved_test.go | 105 - cmd/mesh-controller/network.go | 612 --- cmd/mesh-controller/network_test.go | 296 -- .../new_module_approved_test.go | 163 - cmd/mesh-controller/new_module_bus_test.go | 131 - .../new_module_on_merge_test.go | 139 - cmd/mesh-controller/new_modules.go | 291 -- cmd/mesh-controller/new_modules_test.go | 41 - cmd/mesh-controller/no_vendor_test.go | 27 - cmd/mesh-controller/nodes.go | 1006 ----- cmd/mesh-controller/nodes_test.go | 156 - .../one_resolution_integration_test.go | 95 - cmd/mesh-controller/one_resolution_test.go | 27 - cmd/mesh-controller/operator.go | 307 -- cmd/mesh-controller/operator_wait.go | 386 -- cmd/mesh-controller/order_test.go | 178 - cmd/mesh-controller/outside_accept_test.go | 85 - cmd/mesh-controller/outside_not_given_test.go | 115 - cmd/mesh-controller/outside_wait_test.go | 113 - cmd/mesh-controller/own_grants.go | 71 - cmd/mesh-controller/own_grants_test.go | 21 - cmd/mesh-controller/owned_paths_test.go | 23 - cmd/mesh-controller/paths_source_test.go | 40 - cmd/mesh-controller/pending.go | 788 ---- cmd/mesh-controller/pending_keys_test.go | 39 - cmd/mesh-controller/pending_test.go | 166 - cmd/mesh-controller/person_wait_test.go | 100 - cmd/mesh-controller/placement_export.go | 50 - .../placement_export_integration_test.go | 71 - cmd/mesh-controller/placement_move.go | 99 - cmd/mesh-controller/placement_moved_test.go | 111 - .../placement_words_moved_integration_test.go | 92 - .../placement_words_moved_test.go | 160 - cmd/mesh-controller/plain_words.go | 853 ---- cmd/mesh-controller/plain_words_test.go | 206 - cmd/mesh-controller/plan.go | 426 -- cmd/mesh-controller/plan_test.go | 3 - .../plans_gone_store_integration_test.go | 28 - cmd/mesh-controller/predefined.go | 28 - cmd/mesh-controller/predefined_head_test.go | 75 - .../predefined_integration_test.go | 61 - cmd/mesh-controller/predefined_test.go | 50 - cmd/mesh-controller/probe_agent_root.go | 371 -- cmd/mesh-controller/probe_agent_root_test.go | 265 -- cmd/mesh-controller/probes.go | 1285 ------ cmd/mesh-controller/process_accounts.go | 135 - .../process_accounts_integration_test.go | 135 - cmd/mesh-controller/process_accounts_test.go | 36 - .../process_issue_integration_test.go | 97 - cmd/mesh-controller/process_issue_test.go | 48 - cmd/mesh-controller/process_live_test.go | 302 -- cmd/mesh-controller/proposals.go | 974 ----- .../proposals_integration_test.go | 135 - cmd/mesh-controller/proposals_test.go | 857 ---- cmd/mesh-controller/provider_hold.go | 322 -- cmd/mesh-controller/provisioned_test.go | 74 - .../provisioner_account_integration_test.go | 88 - .../provisioner_account_test.go | 23 - cmd/mesh-controller/provisioning_failed.go | 31 - cmd/mesh-controller/pull_declares.go | 145 - cmd/mesh-controller/pull_declares_test.go | 166 - cmd/mesh-controller/push.go | 585 --- cmd/mesh-controller/readable.go | 332 -- cmd/mesh-controller/readable_test.go | 212 - cmd/mesh-controller/reads-outside | 1 - cmd/mesh-controller/reconnects.go | 173 - .../reconnects_integration_test.go | 142 - cmd/mesh-controller/reconnects_test.go | 63 - cmd/mesh-controller/record_mirrors.go | 455 -- cmd/mesh-controller/record_mirrors_test.go | 191 - cmd/mesh-controller/references.go | 103 - cmd/mesh-controller/register.go | 218 - .../register_hand_integration_test.go | 86 - cmd/mesh-controller/register_test.go | 129 - cmd/mesh-controller/registry_verbs.go | 490 --- .../registry_verbs_integration_test.go | 75 - cmd/mesh-controller/registry_verbs_test.go | 264 -- cmd/mesh-controller/rehearse.go | 114 - cmd/mesh-controller/rehearse_test.go | 77 - .../reissue_verified_sender.go | 229 -- ...eissue_verified_sender_integration_test.go | 62 - .../reissue_verified_sender_test.go | 141 - cmd/mesh-controller/replay164_test.go | 76 - cmd/mesh-controller/replay237_test.go | 87 - cmd/mesh-controller/replay239_test.go | 82 - cmd/mesh-controller/replay292_test.go | 33 - cmd/mesh-controller/replay327_test.go | 117 - cmd/mesh-controller/replay340_test.go | 78 - cmd/mesh-controller/replay401_test.go | 85 - cmd/mesh-controller/repository_words.go | 22 - cmd/mesh-controller/restarts_words_test.go | 28 - cmd/mesh-controller/retire_verbs.go | 479 --- cmd/mesh-controller/retirement.go | 343 -- .../retirement_integration_test.go | 191 - cmd/mesh-controller/retirement_test.go | 304 -- cmd/mesh-controller/rollout.go | 502 --- cmd/mesh-controller/rollout_test.go | 95 - cmd/mesh-controller/roster_failure_test.go | 48 - cmd/mesh-controller/rotate.go | 296 -- cmd/mesh-controller/rotate_test.go | 27 - cmd/mesh-controller/rotate_vault_test.go | 124 - cmd/mesh-controller/runtime_health_test.go | 57 - cmd/mesh-controller/same_commit_test.go | 17 - .../sayable_integration_test.go | 150 - cmd/mesh-controller/sayable_test.go | 227 - .../seat_dependencies_integration_test.go | 119 - cmd/mesh-controller/seat_dependencies_test.go | 41 - cmd/mesh-controller/seat_pass.go | 81 - cmd/mesh-controller/seat_pass_test.go | 137 - cmd/mesh-controller/seats.go | 332 -- cmd/mesh-controller/seats_test.go | 119 - cmd/mesh-controller/seatverbs.go | 1617 -------- .../seatverbs_integration_test.go | 164 - .../seatverbs_schema_integration_test.go | 186 - cmd/mesh-controller/seatverbs_schema_test.go | 179 - cmd/mesh-controller/seatverbs_test.go | 288 -- cmd/mesh-controller/secret.go | 706 ---- .../secret_integration_test.go | 33 - cmd/mesh-controller/secret_move.go | 336 -- cmd/mesh-controller/secret_move_test.go | 158 - cmd/mesh-controller/secret_test.go | 87 - cmd/mesh-controller/selfexec.go | 46 - .../selfexec_integration_test.go | 138 - cmd/mesh-controller/selfexec_test.go | 52 - cmd/mesh-controller/sendable.go | 194 - .../sendable_integration_test.go | 335 -- cmd/mesh-controller/sendable_test.go | 109 - .../sequence_integration_test.go | 41 - cmd/mesh-controller/sequence_test.go | 41 - cmd/mesh-controller/served_own_names_test.go | 62 - cmd/mesh-controller/serving_group.go | 60 - cmd/mesh-controller/servingbus.go | 49 - .../settings_effective_integration_test.go | 38 - .../settings_effective_test.go | 82 - cmd/mesh-controller/settings_forward.go | 372 -- cmd/mesh-controller/shared_stores_test.go | 86 - cmd/mesh-controller/shares_test.go | 77 - cmd/mesh-controller/signals.go | 843 ---- cmd/mesh-controller/signals_test.go | 598 --- cmd/mesh-controller/source.go | 103 - cmd/mesh-controller/source_test.go | 45 - cmd/mesh-controller/standing.go | 189 - .../standing_integration_test.go | 105 - cmd/mesh-controller/standing_test.go | 26 - cmd/mesh-controller/states_announced.go | 216 - .../states_announced_integration_test.go | 118 - cmd/mesh-controller/states_announced_test.go | 56 - cmd/mesh-controller/status.go | 668 --- cmd/mesh-controller/status_summary.go | 216 - cmd/mesh-controller/status_summary_test.go | 154 - cmd/mesh-controller/status_test.go | 123 - cmd/mesh-controller/stores.go | 262 -- cmd/mesh-controller/stuck_probe.go | 221 - .../stuck_probe_integration_test.go | 82 - cmd/mesh-controller/stuck_probe_test.go | 122 - cmd/mesh-controller/sweep_keeps_fresh_test.go | 67 - cmd/mesh-controller/switch_list.go | 204 - cmd/mesh-controller/switch_list_test.go | 46 - cmd/mesh-controller/take_in_consumer.go | 452 -- cmd/mesh-controller/take_preview_test.go | 143 - cmd/mesh-controller/terminal_settings_test.go | 443 -- .../testdata/config-moved/mesh.json | 3661 ----------------- .../testdata/declaration-ace.json.gz | Bin 74424 -> 0 bytes .../testdata/declaration-g14.json.gz | Bin 45025 -> 0 bytes .../testdata/left-out/nfs-server.json | 142 - .../testdata/mesh-host-module.json | 49 - .../testdata/network-drill.json | 170 - .../testdata/placement-moved/anchor.json | 46 - .../testdata/placement-moved/laptop.json | 37 - .../placement-words-moved/anchor.json | 2219 ---------- .../placement-words-moved/laptop.json | 279 -- .../testdata/placement-words-moved/mesh.json | 3288 --------------- cmd/mesh-controller/try.go | 292 -- cmd/mesh-controller/try_integration_test.go | 48 - cmd/mesh-controller/try_test.go | 125 - cmd/mesh-controller/unbound.go | 21 - cmd/mesh-controller/unbound_test.go | 58 - .../unheld_report_integration_test.go | 36 - cmd/mesh-controller/unknown_fields.go | 65 - cmd/mesh-controller/unknown_fields_test.go | 54 - cmd/mesh-controller/unseen.go | 63 - .../untaken_integration_test.go | 80 - cmd/mesh-controller/untaken_test.go | 49 - cmd/mesh-controller/upgrades.go | 648 --- cmd/mesh-controller/usage_test.go | 26 - cmd/mesh-controller/vault_handed_test.go | 85 - cmd/mesh-controller/vault_secrets.go | 403 -- .../vault_secrets_integration_test.go | 229 -- cmd/mesh-controller/vault_secrets_test.go | 323 -- cmd/mesh-controller/vault_switch_test.go | 415 -- cmd/mesh-controller/warranted.go | 113 - cmd/mesh-controller/warranted_test.go | 82 - cmd/mesh-controller/watchdogs.go | 618 --- cmd/mesh-controller/well_scope_test.go | 75 - cmd/mesh-controller/witness.go | 119 - examples/README.md | 25 - examples/objectstore-provisioner/Dockerfile | 33 - examples/objectstore-provisioner/main.go | 384 -- .../objectstore-provisioner/naming_test.go | 68 - examples/postgres-provisioner/Dockerfile | 21 - examples/postgres-provisioner/main.go | 415 -- examples/postgres-provisioner/where_test.go | 109 - examples/redis-provisioner/Dockerfile | 20 - examples/redis-provisioner/main.go | 408 -- examples/redis-provisioner/naming_test.go | 18 - go.mod | 36 - go.sum | 57 - internal/artifacts/blob.go | 57 - internal/artifacts/blob_test.go | 44 - internal/artifacts/hold.go | 345 -- internal/artifacts/hold_test.go | 268 -- internal/artifacts/live_registry_test.go | 132 - internal/artifacts/platforms_test.go | 176 - internal/artifacts/store.go | 322 -- internal/artifacts/store_test.go | 134 - internal/artifacts/tagged.go | 179 - internal/artifacts/tagged_integration_test.go | 72 - internal/artifacts/tagged_test.go | 26 - internal/beside/beside.go | 109 - internal/broker/agreement.go | 125 - internal/broker/agreement_catalogue_test.go | 292 -- internal/broker/agreement_streams_test.go | 38 - internal/broker/announced.go | 82 - internal/broker/announced_test.go | 92 - .../broker/announced_test_helpers_test.go | 13 - internal/broker/broker.go | 111 - internal/broker/broker_test.go | 196 - internal/broker/buildbacklog.go | 47 - internal/broker/buildbacklog_test.go | 88 - internal/broker/bus_module_emits_test.go | 34 - internal/broker/callers.go | 117 - internal/broker/callers_live_test.go | 102 - internal/broker/callers_test.go | 119 - internal/broker/cancelled.go | 11 - internal/broker/cancelled_test.go | 19 - internal/broker/composed.go | 196 - internal/broker/composed_live_test.go | 145 - internal/broker/composed_test.go | 203 - internal/broker/condition_grant_test.go | 74 - internal/broker/config_owner_live_test.go | 248 -- internal/broker/consumer_from_now_test.go | 222 - internal/broker/consumer_upgrade_test.go | 174 - internal/broker/controller_asks_test.go | 53 - internal/broker/controller_buckets.go | 441 -- .../controller_buckets_integration_test.go | 162 - internal/broker/controller_buckets_test.go | 309 -- internal/broker/creates_streams_test.go | 106 - internal/broker/declaration_files.go | 123 - .../broker/declaration_files_live_test.go | 316 -- internal/broker/declaration_files_test.go | 131 - .../broker/declared_packages_mirror_test.go | 41 - internal/broker/delivery_gate_grant_test.go | 130 - internal/broker/delivery_parts_grant_test.go | 700 ---- internal/broker/derived.go | 357 -- internal/broker/derived_test.go | 214 - internal/broker/genesis_template_test.go | 197 - internal/broker/grants_test.go | 44 - internal/broker/have_bucket_live_test.go | 154 - internal/broker/have_bucket_test.go | 115 - internal/broker/invokes_test.go | 107 - internal/broker/issued_test.go | 53 - internal/broker/jetstream.go | 549 --- internal/broker/jetstream_test.go | 101 - internal/broker/log.go | 184 - internal/broker/log_integration_test.go | 69 - internal/broker/log_test.go | 250 -- internal/broker/membership.go | 178 - internal/broker/membership_test.go | 120 - internal/broker/modstream_work_queue_test.go | 81 - internal/broker/modstreams.go | 343 -- internal/broker/modstreams_live_test.go | 85 - internal/broker/modstreams_test.go | 307 -- .../modstreams_workqueue_integration_test.go | 101 - internal/broker/move_secrets_test.go | 88 - internal/broker/msgttl_live_test.go | 88 - internal/broker/nats.go | 1770 -------- internal/broker/nats_golden_test.go | 54 - internal/broker/nats_test.go | 514 --- internal/broker/no_build_queue_test.go | 34 - internal/broker/node_key_test.go | 58 - internal/broker/onnats.go | 50 - internal/broker/process_grants.go | 52 - internal/broker/process_grants_test.go | 236 -- internal/broker/process_state_watch_test.go | 41 - internal/broker/prov_applied_live_test.go | 137 - internal/broker/provisioner.go | 217 - internal/broker/provisioner_have_test.go | 29 - internal/broker/provisioner_live_test.go | 395 -- .../broker/provisioner_server_live_test.go | 219 - internal/broker/provisioner_test.go | 236 -- internal/broker/raise.go | 73 - internal/broker/raise_live_test.go | 193 - internal/broker/readiness.go | 139 - internal/broker/readiness_test.go | 98 - internal/broker/reads-outside | 1 - internal/broker/sdk_membership.go | 38 - internal/broker/seattools_test.go | 57 - internal/broker/seattraffic.go | 308 -- internal/broker/seattraffic_test.go | 453 -- internal/broker/snapshot_live_test.go | 185 - internal/broker/snapshot_test.go | 75 - internal/broker/state.go | 265 -- internal/broker/state_integration_test.go | 105 - internal/broker/state_keyttl_test.go | 31 - internal/broker/state_test.go | 190 - .../broker/stream_names_agreement_test.go | 47 - internal/broker/streams.go | 607 --- internal/broker/streams_test.go | 282 -- internal/broker/system_exports_live_test.go | 174 - internal/broker/system_exports_test.go | 145 - internal/broker/testdata/composed.conf | 78 - internal/broker/userlist_switch.go | 55 - internal/broker/users.go | 456 -- internal/broker/users_test.go | 352 -- .../vault_calls_live_integration_test.go | 130 - internal/broker/vault_calls_live_test.go | 105 - internal/broker/waits.go | 94 - internal/broker/waits_test.go | 135 - internal/broker/witness_grants_test.go | 65 - internal/broker/worker_type_change_test.go | 163 - internal/broker/writers.go | 326 -- internal/broker/writers_test.go | 229 -- internal/builderkeys/builderkeys.go | 336 -- .../builderkeys_integration_test.go | 143 - internal/builderkeys/builderkeys_test.go | 86 - internal/buildreads/buildreads.go | 39 - internal/chain/backend.go | 319 -- internal/chain/bus.go | 145 - internal/chain/chain.go | 277 -- internal/chain/chain_test.go | 396 -- internal/chain/keys.go | 104 - internal/chain/keys_test.go | 38 - internal/chain/pending_test.go | 63 - internal/chain/records.go | 257 -- internal/conditions/bus.go | 159 - internal/conditions/bus_test.go | 117 - internal/conditions/condition.go | 353 -- internal/conditions/events.go | 76 - internal/conditions/looks.go | 163 - internal/conditions/looks_test.go | 161 - internal/conditions/memory.go | 180 - internal/conditions/memory_test.go | 30 - internal/conditions/plain.go | 431 -- internal/conditions/plain_test.go | 284 -- internal/conditions/sayable_test.go | 46 - internal/conditions/store.go | 802 ---- internal/conditions/store_test.go | 516 --- internal/conditions/tried_test.go | 105 - internal/envfile/envfile.go | 39 - internal/envfile/envfile_test.go | 44 - internal/envfile/port.go | 146 - internal/envfile/port_test.go | 78 - internal/gosource/gosource.go | 528 --- .../gosource/gosource_integration_test.go | 84 - internal/gosource/gosource_test.go | 201 - internal/gosource/reads-outside | 1 - internal/identity/authority.go | 232 -- internal/identity/authority_test.go | 253 -- internal/identity/fortest.go | 62 - internal/identity/identity.go | 239 -- internal/identity/identity_test.go | 377 -- .../identity/migrations/0001-signing-key.sql | 30 - .../identity/migrations/0002-node-keys.sql | 38 - .../migrations/0003-the-mesh-authority.sql | 32 - internal/identity/read_kept.go | 47 - internal/inventory/addresses.go | 129 - internal/inventory/adoption.go | 367 -- internal/inventory/adoption_test.go | 163 - internal/inventory/agent_account_test.go | 95 - internal/inventory/artifacts_test.go | 68 - internal/inventory/asks_lab_test.go | 249 -- internal/inventory/bindings.go | 115 - internal/inventory/bindings_test.go | 76 - internal/inventory/build_lookups.go | 26 - internal/inventory/buildinput_test.go | 44 - internal/inventory/builds.go | 423 -- internal/inventory/builds_test.go | 288 -- internal/inventory/busrecords.go | 617 --- .../inventory/busrecords_integration_test.go | 316 -- internal/inventory/busrecords_test.go | 92 - internal/inventory/busstep.go | 72 - internal/inventory/bususers.go | 429 -- .../inventory/bususers_integration_test.go | 195 - internal/inventory/bususers_test.go | 45 - internal/inventory/c5_still_reads_test.go | 32 - internal/inventory/catalogue.go | 1652 -------- internal/inventory/catalogue_test.go | 749 ---- internal/inventory/chain.go | 130 - internal/inventory/chain_assignments.go | 373 -- .../inventory/chain_assignments_key_test.go | 72 - internal/inventory/chain_builds.go | 1265 ------ .../inventory/chain_builds_layout_test.go | 461 --- internal/inventory/chain_builds_test.go | 184 - internal/inventory/chain_drop_test.go | 106 - .../chain_first_read_lays_out_test.go | 35 - internal/inventory/chain_links.go | 34 - internal/inventory/chain_pending_test.go | 269 -- internal/inventory/chain_requests.go | 213 - internal/inventory/chain_requests_test.go | 25 - internal/inventory/chain_source_test.go | 151 - internal/inventory/chain_store_test.go | 367 -- internal/inventory/collection.go | 564 --- internal/inventory/collection_lately_test.go | 92 - internal/inventory/collection_test.go | 345 -- internal/inventory/collection_wanted_test.go | 37 - internal/inventory/config_move.go | 217 - internal/inventory/config_move_test.go | 220 - internal/inventory/core_box.go | 65 - internal/inventory/core_box_test.go | 49 - internal/inventory/data.go | 326 -- internal/inventory/data_test.go | 195 - internal/inventory/declared_packages_test.go | 83 - internal/inventory/dependencies.go | 187 - internal/inventory/dependencies_test.go | 190 - internal/inventory/doing_test.go | 249 -- internal/inventory/forget_test.go | 242 -- internal/inventory/fortest.go | 218 - internal/inventory/givable_test.go | 28 - internal/inventory/given.go | 192 - internal/inventory/given_test.go | 197 - internal/inventory/heals.go | 126 - internal/inventory/heals_test.go | 45 - internal/inventory/health.go | 214 - internal/inventory/holdings_test.go | 176 - internal/inventory/inventory.go | 29 - internal/inventory/inventory_test.go | 472 --- internal/inventory/issued.go | 155 - internal/inventory/issued_test.go | 180 - internal/inventory/looks.go | 90 - internal/inventory/looks_test.go | 55 - internal/inventory/machine.go | 83 - internal/inventory/machine_test.go | 57 - internal/inventory/migrations/0001-nodes.sql | 42 - .../migrations/0002-enrolment-tokens.sql | 38 - .../migrations/0003-what-a-node-owns.sql | 18 - .../inventory/migrations/0004-the-overlay.sql | 38 - .../0005-modules-and-assignments.sql | 36 - .../0006-where-a-module-came-from.sql | 20 - .../inventory/migrations/0007-settings.sql | 25 - .../migrations/0008-which-provider.sql | 26 - .../inventory/migrations/0009-secrets.sql | 44 - internal/inventory/migrations/0010-builds.sql | 44 - .../migrations/0011-what-a-node-did.sql | 33 - .../migrations/0012-a-modules-own-secret.sql | 29 - ...0013-a-secret-the-mesh-cannot-reinvent.sql | 16 - .../0014-what-a-machine-was-last-sent.sql | 15 - ...15-a-consumer-is-a-module-on-a-machine.sql | 31 - .../0016-the-mesh-assigns-the-port.sql | 33 - .../0017-what-a-machine-already-holds.sql | 12 - .../0018-a-report-names-its-declaration.sql | 12 - .../migrations/0019-a-nodes-public-domain.sql | 16 - ...20-a-module-is-a-repository-and-a-path.sql | 16 - ...1-what-to-do-when-a-module-is-upgraded.sql | 26 - .../0022-a-build-keeps-what-it-was-told.sql | 26 - ...0023-an-operator-key-and-a-second-seal.sql | 22 - ...edential-is-sealed-to-the-operator-too.sql | 10 - ...ilure-that-repeats-is-said-to-be-stuck.sql | 16 - ...air-credential-says-where-it-came-from.sql | 14 - ...hold-several-secrets-from-one-provider.sql | 12 - ...-a-token-is-claimed-before-it-is-spent.sql | 11 - .../0029-a-node-is-adopted-or-converged.sql | 21 - .../0030-what-an-adopted-node-reports.sql | 12 - .../0031-the-tunnel-a-node-found.sql | 27 - .../0032-a-source-may-live-on-a-seat.sql | 12 - .../0033-a-carried-peer-is-nameable.sql | 10 - .../migrations/0034-the-seats-are-data.sql | 17 - .../0035-a-seat-keeps-its-former-names.sql | 12 - .../0036-a-node-has-an-operator-account.sql | 13 - .../0037-the-bus-keeps-its-users-hashes.sql | 36 - .../0038-a-person-may-reach-the-mesh.sql | 19 - ...at-is-held-by-one-assignment-on-record.sql | 24 - .../0040-the-broker-seat-delivers-the-bus.sql | 12 - ...is-told-its-membership-for-the-new-bus.sql | 11 - ...-build-says-which-repositories-it-read.sql | 13 - ...-machine-says-which-networks-it-routes.sql | 19 - ...-machine-says-which-links-face-outside.sql | 26 - ...0045-a-machine-says-which-host-runs-it.sql | 15 - .../migrations/0046-each-send-is-numbered.sql | 11 - .../0047-a-seat-carries-its-tools.sql | 12 - ...fact-store-seat-is-named-for-its-scope.sql | 25 - ...dential-is-one-value-sealed-many-times.sql | 8 - .../0050-a-pin-names-the-module.sql | 12 - .../0051-a-pin-made-before-is-completed.sql | 19 - .../0052-a-merge-is-a-plan-the-mesh-keeps.sql | 19 - .../0053-a-machine-says-what-strays-on-it.sql | 3 - .../0054-a-machine-says-what-filters-it.sql | 7 - ...-an-older-build-never-replaces-a-newer.sql | 23 - .../0056-what-the-store-no-longer-keeps.sql | 23 - .../0058-a-newer-plan-supersedes-an-older.sql | 22 - ...060-the-per-node-resolver-seat-is-gone.sql | 17 - ...1-a-send-records-the-builds-it-carried.sql | 14 - ...ted-seat-has-several-holders-on-record.sql | 14 - .../0063-the-resolver-file-is-the-uplinks.sql | 17 - .../0064-a-machines-names-are-one-seats.sql | 23 - ...r-says-which-consumer-it-keeps-failing.sql | 20 - ...-the-durations-the-bounds-are-set-from.sql | 31 - ...ecret-lives-until-the-first-good-start.sql | 15 - .../migrations/0068-order-and-one-writer.sql | 40 - .../0070-a-known-failure-heals-itself.sql | 34 - .../0071-a-binding-to-data-is-kept.sql | 31 - ...72-a-module-declares-the-data-it-holds.sql | 68 - ...a-build-rolls-out-gated-and-rolls-back.sql | 72 - ...074-a-build-says-what-it-was-made-from.sql | 13 - .../0075-a-walk-waits-for-its-delivery.sql | 9 - .../0076-a-module-says-how-it-is-healthy.sql | 26 - ...0077-a-machine-says-how-its-network-is.sql | 10 - .../0078-what-a-change-replaces.sql | 26 - ...079-a-token-is-issued-for-a-tunnel-key.sql | 7 - .../0080-a-failed-unit-is-never-silent.sql | 9 - .../0081-a-mirrored-base-is-recorded.sql | 30 - ...0082-an-assignment-waits-for-its-build.sql | 52 - ...de-names-the-account-its-agents-run-as.sql | 13 - ...-a-build-asked-at-the-terminal-says-so.sql | 18 - ...keeps-when-a-root-search-began-pending.sql | 9 - ...6-a-plan-keeps-when-its-merge-was-made.sql | 10 - ...records-what-its-stores-schema-reaches.sql | 10 - .../0088-a-build-says-its-build-source.sql | 9 - ...rges-are-assembled-in-a-rolling-window.sql | 28 - .../0090-a-layer-says-who-set-it.sql | 8 - .../0091-a-secret-ask-is-bounded.sql | 18 - ...ading-says-the-path-it-was-measured-at.sql | 21 - .../0093-a-walk-keeps-its-phases.sql | 10 - ...ation-says-the-generation-it-came-from.sql | 72 - ...tion-keeps-its-looks-beside-the-bucket.sql | 20 - .../0096-a-report-says-a-busy-container.sql | 11 - .../0097-placement-is-not-config.sql | 46 - .../0098-the-list-of-predefined-keys.sql | 20 - .../0099-the-secrets-move-into-the-vault.sql | 22 - ...00-a-send-keeps-the-provisioners-plans.sql | 10 - ...the-cores-credentials-the-vault-sealed.sql | 18 - ...2-the-settings-layers-move-into-config.sql | 15 - .../0103-a-walk-keeps-its-backups.sql | 6 - .../0104-the-chain-moves-to-the-bus.sql | 21 - .../0105-the-chain-leaves-the-store.sql | 52 - .../0106-a-node-says-it-reads-modules.sql | 4 - .../0107-a-refused-registration-is-kept.sql | 10 - .../0108-a-bus-account-says-it-is-sealed.sql | 8 - internal/inventory/mirrored_test.go | 178 - internal/inventory/mirrors.go | 256 -- internal/inventory/named_peer_test.go | 94 - internal/inventory/nodes.go | 1258 ------ internal/inventory/operator.go | 220 - internal/inventory/operator_test.go | 222 - internal/inventory/order.go | 231 -- internal/inventory/outside.go | 252 -- internal/inventory/outside_test.go | 197 - internal/inventory/pending.go | 531 --- internal/inventory/pending_test.go | 27 - internal/inventory/people_test.go | 134 - internal/inventory/placement_move.go | 106 - internal/inventory/placement_test.go | 224 - internal/inventory/placement_words_test.go | 264 -- internal/inventory/ports.go | 309 -- internal/inventory/ports_test.go | 355 -- internal/inventory/predefined.go | 76 - internal/inventory/predefined_test.go | 71 - internal/inventory/provisioned.go | 37 - internal/inventory/provisioning.go | 522 --- internal/inventory/provisioning_test.go | 395 -- internal/inventory/public_domain_test.go | 74 - internal/inventory/reachable.go | 64 - .../inventory/reachable_integration_test.go | 33 - internal/inventory/reachable_test.go | 55 - internal/inventory/read_state.go | 255 -- internal/inventory/reads-outside | 1 - .../record_mirrors_integration_test.go | 37 - internal/inventory/record_mirrors_test.go | 87 - internal/inventory/refused.go | 33 - internal/inventory/refused_test.go | 28 - internal/inventory/registered_held_test.go | 21 - internal/inventory/reported.go | 42 - internal/inventory/retire_test.go | 93 - internal/inventory/rootfree_grants_test.go | 119 - internal/inventory/schema.go | 34 - internal/inventory/seats.go | 423 -- internal/inventory/seats_test.go | 233 -- internal/inventory/secret_ask.go | 110 - internal/inventory/secret_ask_test.go | 62 - .../secret_family_integration_test.go | 58 - internal/inventory/secret_family_test.go | 29 - internal/inventory/secret_move.go | 218 - internal/inventory/secret_move_test.go | 123 - internal/inventory/secrets.go | 1176 ------ internal/inventory/secrets_test.go | 994 ----- internal/inventory/settings_judged_test.go | 71 - internal/inventory/source.go | 189 - internal/inventory/standing_switch_test.go | 49 - internal/inventory/state_keyttl_test.go | 16 - internal/inventory/store_as_run_test.go | 51 - internal/inventory/stored_silent_file_test.go | 74 - internal/inventory/streams_test.go | 89 - internal/inventory/template_test.go | 27 - internal/inventory/tunnel.go | 421 -- internal/inventory/tunnel_test.go | 281 -- internal/inventory/unknown_field_test.go | 36 - internal/inventory/unseen.go | 138 - internal/inventory/unseen_test.go | 85 - internal/inventory/waits_grants_test.go | 167 - internal/lease/lease.go | 687 ---- internal/lease/lease_test.go | 519 --- internal/lease/witness.go | 180 - internal/lease/witness_test.go | 59 - internal/licences/adapters/adapters.go | 273 -- internal/licences/adapters/adapters_test.go | 120 - internal/licences/fortest.go | 76 - internal/licences/licences.go | 727 ---- internal/licences/licences_test.go | 312 -- .../0001-a-licence-is-a-named-thing.sql | 88 - .../migrations/0002-vendor-not-provider.sql | 22 - .../0003-a-manager-and-its-refresh-token.sql | 33 - internal/licences/refresh_test.go | 391 -- internal/licences/submitrefresh_test.go | 155 - internal/link/advisories.go | 300 -- internal/link/advisories_integration_test.go | 59 - internal/link/advisories_test.go | 41 - internal/link/announce.go | 81 - internal/link/ask.go | 71 - internal/link/build.go | 111 - internal/link/bus.go | 211 - internal/link/bus_integration_test.go | 124 - internal/link/bus_nats.go | 29 - internal/link/bus_test.go | 30 - internal/link/busfaults.go | 235 -- internal/link/busfaults_integration_test.go | 96 - internal/link/busfaults_test.go | 34 - internal/link/calls.go | 773 ---- internal/link/calls_integration_test.go | 38 - internal/link/calls_kept.go | 162 - internal/link/calls_kept_integration_test.go | 219 - internal/link/calls_kept_test.go | 108 - internal/link/calls_test.go | 278 -- internal/link/calls_wait_test.go | 55 - internal/link/conformance_integration_test.go | 148 - internal/link/conformance_sources_test.go | 139 - internal/link/conformance_test.go | 150 - internal/link/contracts.go | 63 - internal/link/contracts_test.go | 99 - internal/link/deadletters.go | 654 --- internal/link/deadletters_integration_test.go | 279 -- .../link/deadletters_module_stream_test.go | 50 - internal/link/deadletters_restricted_test.go | 253 -- internal/link/deadletters_test.go | 122 - internal/link/deadletters_work_queue_test.go | 88 - internal/link/delivery_merged_test.go | 102 - internal/link/enrol_bus_credential_test.go | 109 - internal/link/enrol_claim_test.go | 115 - internal/link/enrol_proof_test.go | 13 - internal/link/enrol_reply_probe_test.go | 80 - internal/link/enrol_shape_test.go | 128 - internal/link/enrol_tunnel_key_test.go | 39 - internal/link/enrolment.go | 468 --- internal/link/events.go | 305 -- internal/link/handacts.go | 209 - internal/link/handacts_test.go | 88 - internal/link/handover.go | 199 - internal/link/handover_integration_test.go | 75 - internal/link/handover_test.go | 115 - internal/link/health.go | 56 - internal/link/health_test.go | 37 - internal/link/heard_order_integration_test.go | 105 - internal/link/heard_order_test.go | 35 - internal/link/heard_test.go | 288 -- internal/link/judge_directories_test.go | 62 - internal/link/merges.go | 79 - internal/link/meshcli.go | 248 -- internal/link/meshcli_integration_test.go | 96 - internal/link/meshcli_test.go | 214 - internal/link/movesecret.go | 129 - internal/link/movesecret_integration_test.go | 58 - internal/link/movesecret_test.go | 44 - internal/link/order.go | 61 - internal/link/order_test.go | 81 - internal/link/pages.go | 302 -- internal/link/pages_integration_test.go | 59 - internal/link/pages_test.go | 55 - internal/link/profile_report_test.go | 47 - internal/link/protocol.go | 676 --- internal/link/protocol_node_key_test.go | 11 - internal/link/protocol_test.go | 61 - internal/link/reads-outside | 1 - internal/link/receive.go | 123 - internal/link/receive_fake_test.go | 100 - internal/link/receive_nats.go | 479 --- .../link/receive_nats_integration_test.go | 428 -- internal/link/receive_nats_test.go | 244 -- internal/link/rekey_test.go | 137 - internal/link/replay314_test.go | 82 - internal/link/replay330_test.go | 95 - internal/link/report_retry_test.go | 65 - internal/link/retirement.go | 274 -- internal/link/seat_as_test.go | 49 - internal/link/seatask.go | 142 - internal/link/seattools.go | 133 - internal/link/serve.go | 674 --- internal/link/setuid_search_test.go | 61 - internal/link/signer.go | 12 - internal/link/source_inputs_test.go | 35 - internal/link/source_moved_held_test.go | 60 - internal/link/standby_nats_test.go | 72 - internal/link/standing.go | 118 - internal/link/standing_integration_test.go | 48 - internal/link/standing_test.go | 170 - internal/link/store_window_test.go | 129 - internal/link/vault_changed_test.go | 16 - internal/link/watched.go | 296 -- internal/link/window.go | 120 - internal/link/window_test.go | 84 - internal/lint/emptyonerror.go | 237 -- internal/lint/emptyonerror_test.go | 100 - internal/lint/reads-outside | 1 - internal/lint/removed_test.go | 74 - internal/outward/outward.go | 337 -- internal/outward/outward_test.go | 125 - .../controller_is_a_process_test.go | 133 - internal/ownmanifest/doc.go | 5 - internal/ownmanifest/genesis_image_test.go | 29 - internal/ownmanifest/helpers_test.go | 109 - internal/ownmanifest/image_place_test.go | 70 - internal/ownmanifest/own_module_json_test.go | 123 - internal/ownmanifest/reads-outside | 1 - internal/ownmanifest/seats_test.go | 50 - internal/recordmirror/mirror.go | 355 -- .../recordmirror/mirror_integration_test.go | 295 -- internal/recordmirror/mirror_store_test.go | 198 - internal/recordmirror/mirror_test.go | 125 - .../recordmirror/provisioning_live_test.go | 163 - internal/recordmirror/readers_test.go | 190 - internal/recordmirror/reads-outside | 1 - internal/secrets/operator_test.go | 75 - internal/secrets/seal.go | 218 - internal/secrets/seal_test.go | 216 - internal/secrets/sealedbox_xcheck_test.go | 79 - .../testdata/module-sealedbox-fixture.json | 7 - internal/store/fortest.go | 149 - internal/store/fortest_test.go | 25 - internal/store/live_test.go | 289 -- internal/store/manifest_test.go | 52 - internal/store/migrate.go | 257 -- internal/store/migrate_test.go | 206 - internal/store/reads-outside | 1 - internal/store/ready_test.go | 82 - internal/store/settings_test.go | 267 -- internal/store/store.go | 290 -- internal/testbus/testbus.go | 81 - internal/testbus/testbus_integration_test.go | 45 - internal/testbus/testbus_test.go | 99 - internal/token/token.go | 165 - internal/token/token_test.go | 209 - lab/adopt-the-tunnel/README.md | 109 - lab/adopt-the-tunnel/adopt-the-tunnel.test.ts | 174 - lab/adopt-the-tunnel/adopt-the-tunnel.yml | 50 - module.json | 153 - testdata/beside/CAPTURED | 36 - .../modules/adwaita/files/cursor-index.theme | 5 - .../modules/adwaita/files/env-gtk-theme | 1 - .../modules/adwaita/files/env-gtk2-rc-files | 1 - .../adwaita/files/env-qt-qpa-platformtheme | 1 - .../modules/adwaita/files/env-qt-select | 1 - .../adwaita/files/env-qt-style-override | 1 - .../modules/adwaita/files/env-xcursor-size | 1 - .../modules/adwaita/files/env-xcursor-theme | 1 - .../modules/adwaita/files/gtk-settings.ini | 9 - .../modules/adwaita/files/portals.conf | 11 - .../modules/adwaita/files/qt6ct.conf | 29 - .../modules/adwaita/files/xinitrc-normal | 10 - .../modules/adwaita/files/xresources-normal | 3 - .../modules/adwaita/module.json.captured | 176 - .../artifact-store-tools/files/store-address | 1 - .../artifact-store-tools/files/store-names | 4 - .../artifact-store-tools/module.json.captured | 100 - .../files/bin/zephyrus-backlight | 33 - .../files/bin/zephyrus-display | 41 - .../files/bin/zephyrus-kbd-notify | 20 - .../files/bin/zephyrus-media | 27 - .../files/bin/zephyrus-notify | 12 - .../files/bin/zephyrus-session | 46 - .../files/bin/zephyrus-touchpad | 29 - .../asus-zephyrus-g14/module.json.captured | 229 -- .../UPower.conf.d/50-asus-zephyrus-g14.conf | 11 - .../30-asus-zephyrus-g14-touchpad.conf | 12 - .../asus-zephyrus-g14/templates/after-wake | 7 - .../templates/display-session | 30 - .../templates/hotkeys-triggers | 10 - .../modprobe.d/g14-nvidia-power.conf | 16 - .../modprobe.d/video-brightness-switch.conf | 6 - .../asus-zephyrus-g14.conf | 7 - .../asus-zephyrus-g14-nvidia.conf | 7 - .../asus-zephyrus-g14-nvidia.conf | 7 - .../asus-zephyrus-g14-nvidia.conf | 7 - .../templates/udev/rules.d/90-backlight.rules | 5 - .../modules/audit-logger/module.json.captured | 69 - .../modules/avahi/module.json.captured | 43 - .../modules/baserow/files/config.json | 4 - .../modules/baserow/files/server.env | 7 - .../modules/baserow/module.json.captured | 158 - .../modules/blueman/module.json.captured | 37 - .../modules/bluetooth/module.json.captured | 53 - .../modules/brother/files/bucket.json | 3 - .../modules/brother/files/fetch.json | 3 - .../modules/brother/files/settings.json | 9 - .../modules/brother/files/store.json | 9 - .../modules/brother/module.json.captured | 314 -- .../modules/buildah/module.json.captured | 24 - .../modules/ca-trust/files/anchor | 58 - .../ca-trust/files/mesh-ca-trust.service | 16 - .../modules/ca-trust/module.json.captured | 66 - .../modules/claude-code/files/agent-account | 1 - .../modules/claude-code/files/claude-agent | 13 - .../modules/claude-code/files/facts.json | 4 - .../modules/claude-code/files/settings.json | 10 - .../modules/claude-code/files/zsh-normal | 12 - .../modules/claude-code/module.json.captured | 249 -- .../claude-licence-manager/files/database.url | 1 - .../files/settings.json | 7 - .../module.json.captured | 184 - .../clipmenu/files/bin/clipmenud-session | 41 - .../modules/clipmenu/files/bin/xsel | 20 - .../modules/clipmenu/files/env-cm-histlength | 1 - .../modules/clipmenu/files/env-cm-max-clips | 1 - .../modules/clipmenu/files/env-cm-selections | 1 - .../clipmenu/files/i3/50-clipmenu.conf | 5 - .../files/systemd/clipmenud-mesh.conf | 14 - .../modules/clipmenu/module.json.captured | 135 - .../modules/confluence/files/config.json | 1 - .../modules/confluence/module.json.captured | 76 - .../modules/cups/module.json.captured | 58 - .../modules/dbus/module.json.captured | 67 - .../modules/desk-channel/files/settings.json | 3 - .../modules/desk-channel/module.json.captured | 106 - .../modules/dev-tools/module.json.captured | 38 - .../modules/disk-load/module.json.captured | 40 - .../modules/distribution/module.json.captured | 99 - .../modules/dmenu/module.json.captured | 33 - .../modules/dnsmasq/files/dnsmasq.conf | 87 - .../modules/dnsmasq/files/mesh.conf | 15 - .../modules/dnsmasq/module.json.captured | 165 - .../docker-compose/module.json.captured | 40 - .../modules/docker/files/daemon.json | 1 - .../modules/docker/files/docker-prune.service | 15 - .../modules/docker/files/docker-prune.timer | 11 - .../modules/docker/files/log-rotation.conf | 5 - .../modules/docker/module.json.captured | 180 - .../mesh-catalog/modules/dunst/files/dunstrc | 110 - .../modules/dunst/files/i3/50-dunst.conf | 4 - .../modules/dunst/module.json.captured | 123 - .../modules/fail2ban/files/fail2ban | 7 - .../files/iptables-allports-dualchain.conf | 40 - .../modules/fail2ban/files/jail.local | 48 - .../fail2ban/files/mesh-own-addresses.json | 3 - .../modules/fail2ban/files/own-addresses | 0 .../modules/fail2ban/files/recidive.conf | 9 - .../modules/fail2ban/files/sshd.conf | 8 - .../modules/fail2ban/module.json.captured | 195 - .../mesh-catalog/modules/feh/files/fehbg | 6 - .../modules/feh/files/i3/50-feh.conf | 3 - .../modules/feh/files/xinitrc-normal | 2 - .../modules/feh/module.json.captured | 75 - .../modules/flatpak/module.json.captured | 42 - .../modules/fonts/files/50-mesh-fonts.conf | 40 - .../modules/fonts/module.json.captured | 70 - .../modules/forticlient/module.json.captured | 57 - .../modules/gitea/files/agent-teams.json | 1 - .../modules/gitea/files/config.json | 1 - .../modules/gitea/files/consumers.json | 1 - .../mesh-catalog/modules/gitea/files/origins | 1 - .../modules/gitea/files/repositories.json | 1 - .../modules/gitea/files/server.env | 7 - .../modules/gitea/module.json.captured | 580 --- .../modules/gitlab/files/config.json | 1 - .../modules/gitlab/module.json.captured | 65 - .../modules/gnome-keyring/files/pam/login | 4 - .../modules/gnome-keyring/files/pam/passwd | 3 - .../modules/gnome-keyring/files/xinitrc-first | 5 - .../gnome-keyring/module.json.captured | 82 - .../modules/go/module.json.captured | 23 - .../modules/grafana/files/admin.secret | 1 - .../modules/grafana/files/config.json | 4 - .../modules/grafana/files/influxdb-api | 1 - .../modules/grafana/files/mesh-influxdb.yaml | 19 - .../modules/grafana/files/oidc-client.secret | 1 - .../modules/grafana/files/oidc.env | 13 - .../modules/grafana/module.json.captured | 225 - .../modules/hello-web/files/index.html | 1 - .../modules/hello-web/module.json.captured | 82 - .../modules/home-assistant/files/config.json | 1 - .../home-assistant/files/provisions.env | 4 - .../home-assistant/module.json.captured | 209 - .../modules/hostname/files/hostname | 1 - .../mesh-catalog/modules/hostname/files/hosts | 7 - .../modules/hostname/module.json.captured | 61 - .../mesh-catalog/modules/i3/files/config | 201 - .../modules/i3/files/env-xdg-current-desktop | 1 - .../modules/i3/files/env-xdg-session-desktop | 1 - .../mesh-catalog/modules/i3/files/wms/i3 | 9 - .../modules/i3/files/xinitrc-last | 2 - .../modules/i3/module.json.captured | 124 - .../i3status-rust/files/bin/i3bar-watchdog | 64 - .../i3status-rust/files/bin/i3status-updates | 63 - .../i3status-rust/files/bin/i3status-value | 13 - .../i3status-rust/files/bottom-bar.toml | 109 - .../files/i3/60-i3status-rust.conf | 34 - .../files/icons/custom-icons.toml | 135 - .../modules/i3status-rust/files/top-bar.toml | 37 - .../i3status-rust/files/xinitrc-normal | 3 - .../i3status-rust/module.json.captured | 126 - .../modules/icecast/files/config.json | 1 - .../modules/icecast/files/icecast.xml | 56 - .../modules/icecast/module.json.captured | 152 - .../modules/influxdb/files/config.json | 1 - .../modules/influxdb/files/server.env | 6 - .../modules/influxdb/module.json.captured | 201 - .../modules/invoicing/files/api.env | 9 - .../modules/invoicing/module.json.captured | 128 - .../modules/jira/files/config.json | 1 - .../modules/jira/module.json.captured | 75 - .../files/90-mesh-kernel-boot-entries.hook | 13 - .../modules/kernel/files/boot-entries.sh | 133 - .../modules/kernel/files/huge-pages.sh | 84 - .../files/mesh-kernel-boot-entries.service | 22 - .../files/mesh-kernel-huge-pages.service | 25 - .../modules/kernel/files/modules-load.conf | 4 - .../modules/kernel/module.json.captured | 144 - .../modules/keycloak/files/admin.env | 2 - .../modules/keycloak/files/config.json | 1 - .../modules/keycloak/files/consumers.json | 1 - .../modules/keycloak/files/database.env | 3 - .../modules/keycloak/files/hostname.env | 1 - .../modules/keycloak/module.json.captured | 247 -- .../mesh-catalog/modules/lab/files/lab.env | 3 - .../modules/lab/module.json.captured | 121 - .../modules/lemurs/files/config.toml | 369 -- .../mesh-catalog/modules/lemurs/files/xserver | 8 - .../modules/lemurs/module.json.captured | 84 - .../modules/letta/files/config.json | 3 - .../mesh-catalog/modules/letta/files/pgpass | 1 - .../modules/letta/files/server.env | 6 - .../mesh-catalog/modules/letta/files/start.sh | 23 - .../modules/letta/module.json.captured | 156 - .../local-model-consumer/files/openai.env | 3 - .../local-model-consumer/module.json.captured | 42 - .../modules/localization/files/locale.conf | 3 - .../modules/localization/files/vconsole.conf | 3 - .../modules/localization/module.json.captured | 61 - .../modules/logrotate/files/logrotate.conf | 32 - .../modules/logrotate/module.json.captured | 58 - .../modules/mailu/files/admin.env | 2 - .../modules/mailu/files/config.json | 1 - .../modules/mailu/files/database.env | 5 - .../modules/mailu/files/mailu.env | 78 - .../modules/mailu/files/secret.env | 1 - .../modules/mailu/module.json.captured | 793 ---- .../modules/marrytts/module.json.captured | 37 - .../modules/matrix/files/conduit.toml | 15 - .../modules/matrix/files/element.json | 53 - .../modules/matrix/module.json.captured | 257 -- .../modules/memory-pressure/files/oomd.conf | 8 - .../memory-pressure/files/root-slice.conf | 5 - .../modules/memory-pressure/files/sysctl.conf | 9 - .../memory-pressure/files/user-manager.conf | 7 - .../memory-pressure/files/zram-generator.conf | 11 - .../memory-pressure/module.json.captured | 130 - .../repos/archive/modules/demo/files/Z.txt | 1 - .../repos/archive/modules/demo/files/a.txt | 1 - .../repos/archive/modules/demo/files/bin/run | 2 - .../archive/modules/demo/files/sub/b.conf | 1 - .../modules/mesh-build-go/files/forge | 7 - .../modules/mesh-build-go/files/gitconfig | 2 - .../modules/mesh-build-go/files/known_hosts | 1 - .../modules/mesh-build-go/files/registry | 1 - .../modules/mesh-build-go/files/settings.json | 5 - .../mesh-build-go/module.json.captured | 207 - .../modules/mesh-build-image/files/forge | 7 - .../modules/mesh-build-image/files/gitconfig | 2 - .../mesh-build-image/files/known_hosts | 1 - .../modules/mesh-build-image/files/registry | 1 - .../mesh-build-image/files/settings.json | 5 - .../mesh-build-image/module.json.captured | 184 - .../modules/mesh-build-node/files/forge | 7 - .../modules/mesh-build-node/files/gitconfig | 2 - .../modules/mesh-build-node/files/known_hosts | 1 - .../modules/mesh-build-node/files/registry | 1 - .../mesh-build-node/files/settings.json | 5 - .../mesh-build-node/module.json.captured | 224 - .../modules/mesh-catalog/files/database.url | 1 - .../modules/mesh-catalog/module.json.captured | 101 - .../modules/mesh-cli/files/zsh-normal | 3 - .../modules/mesh-cli/module.json.captured | 37 - .../mesh-conditions/module.json.captured | 87 - .../modules/mesh-config/module.json.captured | 103 - .../mesh-delivery-gate/files/settings.json | 5 - .../mesh-delivery-gate/module.json.captured | 152 - .../modules/mesh-files/module.json.captured | 60 - .../mesh-hand-acts/module.json.captured | 65 - .../modules/mesh-healers/module.json.captured | 82 - .../modules/mesh-issues/files/settings.json | 172 - .../modules/mesh-issues/module.json.captured | 373 -- .../modules/mesh-knowledge/files/database.url | 1 - .../mesh-knowledge/files/settings.json | 5 - .../mesh-knowledge/module.json.captured | 228 - .../mesh-mail-notifier/module.json.captured | 68 - .../mesh-mail-reader/files/callers.json | 3 - .../mesh-mail-reader/files/database-url | 1 - .../mesh-mail-reader/module.json.captured | 141 - .../modules/mesh-mail-web/files/oidc.json | 5 - .../mesh-mail-web/files/operators.json | 3 - .../mesh-mail-web/module.json.captured | 131 - .../modules/mesh-mail/files/accounts.json | 3 - .../modules/mesh-mail/files/database-url | 1 - .../modules/mesh-mail/files/grants.json | 3 - .../modules/mesh-mail/files/store.json | 9 - .../modules/mesh-mail/module.json.captured | 317 -- .../module.json.captured | 60 - .../mesh-provisioner/module.json.captured | 81 - .../modules/mesh-reads/module.json.captured | 55 - .../mesh-self-check/module.json.captured | 87 - .../modules/mesh-vault/module.json.captured | 206 - .../modules/mesh-watcher/files/settings.json | 3 - .../modules/mesh-watcher/module.json.captured | 77 - .../modules/messenger/files/settings.json | 6 - .../modules/messenger/module.json.captured | 213 - .../modules/minio/files/object-store.json | 6 - .../mesh-catalog/modules/minio/files/root.env | 2 - .../modules/minio/module.json.captured | 236 -- .../modules/model-usage/files/database.url | 1 - .../modules/model-usage/module.json.captured | 107 - .../modules/mongodb/files/server-root.secret | 1 - .../modules/mongodb/module.json.captured | 173 - .../modules/mosquitto/files/bootstrap.env | 7 - .../modules/mosquitto/files/mosquitto.conf | 20 - .../modules/mosquitto/module.json.captured | 197 - .../modules/mounts/files/mounts.conf | 11 - .../modules/mounts/module.json.captured | 158 - .../modules/mssql/files/backup.sql | 14 - .../mesh-catalog/modules/mssql/files/sa.env | 2 - .../modules/mssql/module.json.captured | 177 - .../modules/n8n/files/n8n-database.secret | 1 - .../mesh-catalog/modules/n8n/files/server.env | 12 - .../modules/n8n/module.json.captured | 173 - .../mesh-catalog/modules/nats/files/nats.conf | 46 - .../modules/nats/module.json.captured | 230 -- .../modules/netcheck/module.json.captured | 58 - .../modules/networkmanager/files/50-mesh.conf | 63 - .../networkmanager/module.json.captured | 71 - .../nextcloud-client/module.json.captured | 38 - .../modules/nextcloud/files/config.json | 6 - .../modules/nextcloud/files/server.env | 14 - .../modules/nextcloud/module.json.captured | 172 - .../modules/nfs-server/files/50-mesh.conf | 12 - .../modules/nfs-server/files/shares.conf | 10 - .../modules/nfs-server/module.json.captured | 192 - .../nftables/files/mesh-filter.service | 14 - .../modules/nftables/files/mesh.conf | 5 - .../modules/nftables/module.json.captured | 91 - .../modules/nm-applet/module.json.captured | 37 - .../modules/node-env/files/50-mesh.conf | 4 - .../modules/node-env/files/environment.sh | 5 - .../modules/node-env/module.json.captured | 55 - .../modules/nodejs/module.json.captured | 28 - .../modules/nodered/files/config.json | 3 - .../modules/nodered/files/mqtt.env | 4 - .../modules/nodered/files/settings.js | 48 - .../modules/nodered/files/settings.json | 14 - .../modules/nodered/module.json.captured | 297 -- .../modules/ollama/module.json.captured | 65 - .../modules/only-office/files/server.env | 6 - .../modules/only-office/module.json.captured | 295 -- .../openai-consumer/module.json.captured | 61 - .../modules/openrazer/module.json.captured | 52 - .../modules/pacman/files/keeper.json | 5 - .../modules/pacman/files/pacman.conf | 29 - .../modules/pacman/files/reflector.conf | 8 - .../modules/pacman/module.json.captured | 257 -- .../modules/photos-eef/module.json.captured | 56 - .../modules/photos-filip/module.json.captured | 56 - .../modules/picom/files/picom.conf | 38 - .../modules/picom/module.json.captured | 90 - .../polychromatic/files/display-session | 6 - .../polychromatic/module.json.captured | 40 - .../modules/postgres/files/consumers.json | 1 - .../modules/postgres/module.json.captured | 202 - .../modules/power/files/bin/power-moment | 90 - .../modules/power/module.json.captured | 450 -- .../modules/power/templates/bar-battery | 9 - .../modules/power/templates/bar-power-draw | 6 - .../templates/mesh-power/moments/after-boot | 4 - .../templates/mesh-power/moments/after-wake | 4 - .../mesh-power/moments/before-shutdown | 4 - .../templates/mesh-power/moments/before-sleep | 4 - .../templates/mesh-power/moments/on-battery | 4 - .../templates/mesh-power/moments/on-mains | 4 - .../systemd/logind.conf.d/power.conf | 8 - .../system/halt.target.d/mesh-power.conf | 8 - .../system/hibernate.target.d/mesh-power.conf | 3 - .../hybrid-sleep.target.d/mesh-power.conf | 3 - .../system/mesh-power-after-boot.service | 13 - .../system/mesh-power-after-wake.service | 11 - .../system/mesh-power-before-shutdown.service | 18 - .../system/mesh-power-before-sleep.service | 11 - .../systemd/system/mesh-power-supply.service | 10 - .../system/poweroff.target.d/mesh-power.conf | 8 - .../system/reboot.target.d/mesh-power.conf | 8 - .../system/sleep.target.d/mesh-power.conf | 3 - .../mesh-power.conf | 3 - .../system/suspend.target.d/mesh-power.conf | 3 - .../udev/rules.d/90-mesh-power.rules | 5 - .../modules/powerlevel10k/files/zsh-normal | 3 - .../powerlevel10k/module.json.captured | 49 - .../modules/records/files/config.json | 1 - .../mesh-catalog/modules/records/files/origin | 1 - .../modules/records/module.json.captured | 103 - .../modules/redis/files/redis.conf | 3 - .../modules/redis/module.json.captured | 157 - .../modules/restic/files/backups.conf | 2 - .../modules/restic/files/data.conf | 2 - .../modules/restic/files/retention.conf | 6 - .../modules/restic/module.json.captured | 205 - .../mesh-catalog/modules/rofi/files/bin/dmenu | 26 - .../modules/rofi/files/bin/rofi-launch | 39 - .../modules/rofi/files/bin/rofi-powermenu | 54 - .../modules/rofi/files/config.rasi | 54 - .../modules/rofi/files/i3/50-rofi.conf | 8 - .../rofi/files/themes/mesh-powermenu.rasi | 172 - .../modules/rofi/files/themes/mesh.rasi | 263 -- .../modules/rofi/module.json.captured | 124 - .../modules/route-adapter/files/config.json | 6 - .../route-adapter/module.json.captured | 136 - .../modules/screen-lock/files/bin/screen-lock | 57 - .../modules/screen-lock/files/xinitrc-normal | 8 - .../modules/screen-lock/module.json.captured | 78 - .../modules/searxng/files/config.json | 1 - .../modules/searxng/files/settings.yml | 16 - .../modules/searxng/module.json.captured | 229 -- .../modules/sensors/module.json.captured | 69 - .../modules/showcase/files/README.md | 6 - .../modules/showcase/files/marker.txt | 1 - .../modules/showcase/module.json.captured | 210 - .../modules/showcase/templates/showcase.env | 5 - .../modules/slack/files/display-session | 11 - .../modules/slack/module.json.captured | 41 - .../modules/snapd/module.json.captured | 31 - .../modules/ssh-client/files/config | 6 - .../modules/ssh-client/files/forge | 7 - .../modules/ssh-client/files/gitconfig | 2 - .../modules/ssh-client/files/known_hosts | 1 - .../modules/ssh-client/module.json.captured | 112 - .../modules/sshd/files/10-mesh.conf | 10 - .../modules/sshd/module.json.captured | 54 - .../modules/step-ca/files/config.json | 1 - .../modules/step-ca/files/init.env | 2 - .../modules/step-ca/module.json.captured | 109 - .../modules/sudo/files/10-mesh-operator | 9 - .../modules/sudo/module.json.captured | 47 - .../modules/supabase/files/analytics-env.env | 14 - .../supabase/files/analytics-reconcile.exs | 86 - .../modules/supabase/files/analytics-start.sh | 41 - .../modules/supabase/files/db-init/jwt.sql | 2 - .../modules/supabase/files/db-init/logs.sql | 6 - .../modules/supabase/files/db-init/pooler.sql | 6 - .../supabase/files/db-init/realtime.sql | 4 - .../modules/supabase/files/db-init/roles.sql | 7 - .../supabase/files/db-init/supabase.sql | 3 - .../supabase/files/db-init/webhooks.sql | 208 - .../modules/supabase/files/functions-env.env | 6 - .../supabase/files/functions-main/index.ts | 94 - .../modules/supabase/files/gotrue.env | 28 - .../modules/supabase/files/kong.yml | 242 -- .../modules/supabase/files/meta-env.env | 6 - .../modules/supabase/files/pooler-env.env | 14 - .../modules/supabase/files/pooler.exs | 43 - .../modules/supabase/files/postgrest.conf | 7 - .../modules/supabase/files/realtime-env.env | 16 - .../modules/supabase/files/settings.json | 7 - .../modules/supabase/files/storage-env.env | 13 - .../modules/supabase/files/studio-env.env | 18 - .../modules/supabase/files/vector.yml | 258 -- .../modules/supabase/module.json.captured | 659 --- .../systemd-networkd/files/00-mesh0.network | 35 - .../systemd-networkd/module.json.captured | 65 - .../systemd-resolved/module.json.captured | 113 - .../modules/systemd/module.json.captured | 71 - .../modules/telegram/module.json.captured | 79 - .../modules/time-sync/files/50-mesh.conf | 6 - .../modules/time-sync/module.json.captured | 81 - .../modules/triggerhappy/files/service.conf | 8 - .../modules/triggerhappy/files/triggers.conf | 4 - .../modules/triggerhappy/module.json.captured | 81 - .../modules/umami/files/server.env | 3 - .../modules/umami/module.json.captured | 145 - .../modules/unifi/files/config.json | 4 - .../modules/unifi/module.json.captured | 203 - .../modules/website/module.json.captured | 60 - .../modules/xclip/module.json.captured | 35 - .../modules/xdg/files/mimeapps.list | 29 - .../modules/xdg/files/user-dirs.conf | 9 - .../modules/xdg/files/user-dirs.dirs | 17 - .../modules/xdg/files/user-dirs.locale | 1 - .../modules/xdg/module.json.captured | 97 - .../mesh-catalog/modules/xorg/files/xinitrc | 51 - .../modules/xorg/files/xresources | 13 - .../modules/xorg/module.json.captured | 137 - .../modules/xterm/files/env-terminal | 1 - .../modules/xterm/files/xresources-normal | 31 - .../modules/xterm/module.json.captured | 236 -- .../mesh-catalog/modules/zfs/files/scrub.conf | 6 - .../modules/zfs/module.json.captured | 235 -- .../zsh-autosuggestions/files/zsh-normal | 2 - .../zsh-autosuggestions/module.json.captured | 30 - .../zsh-syntax-highlighting/files/zsh-last | 3 - .../module.json.captured | 30 - .../mesh-catalog/modules/zsh/files/env-editor | 1 - .../modules/zsh/files/env-path-local-bin | 1 - .../modules/zsh/files/env-path-scripts | 1 - .../modules/zsh/files/env-path-scripts-bin | 1 - .../mesh-catalog/modules/zsh/files/env-visual | 1 - .../modules/zsh/files/env-xdg-config-home | 1 - .../mesh-catalog/modules/zsh/files/tools.conf | 3 - .../mesh-catalog/modules/zsh/files/zshenv | 9 - .../mesh-catalog/modules/zsh/files/zshrc | 60 - .../modules/zsh/module.json.captured | 160 - testdata/beside/mesh-catalog/predefined.json | 18 - .../examples/foundation-first-node.lock | 267 -- .../apply/placement_guard.go.captured | 626 --- .../conformance/events/module-event.json | 44 - 1441 files changed, 4 insertions(+), 204711 deletions(-) delete mode 100644 Dockerfile delete mode 100644 Makefile delete mode 100644 cmd/mesh-controller/accept_outside.go delete mode 100644 cmd/mesh-controller/acting.go delete mode 100644 cmd/mesh-controller/acts.go delete mode 100644 cmd/mesh-controller/acts_test.go delete mode 100644 cmd/mesh-controller/addresses_integration_test.go delete mode 100644 cmd/mesh-controller/addresses_test.go delete mode 100644 cmd/mesh-controller/adopting_test.go delete mode 100644 cmd/mesh-controller/adoption.go delete mode 100644 cmd/mesh-controller/advisories_counted_test.go delete mode 100644 cmd/mesh-controller/agent_account.go delete mode 100644 cmd/mesh-controller/agent_account_integration_test.go delete mode 100644 cmd/mesh-controller/agent_account_test.go delete mode 100644 cmd/mesh-controller/api.go delete mode 100644 cmd/mesh-controller/api_test.go delete mode 100644 cmd/mesh-controller/apply_failing.go delete mode 100644 cmd/mesh-controller/apply_failing_test.go delete mode 100644 cmd/mesh-controller/ask.go delete mode 100644 cmd/mesh-controller/asker.go delete mode 100644 cmd/mesh-controller/asker_bus_test.go delete mode 100644 cmd/mesh-controller/asker_test.go delete mode 100644 cmd/mesh-controller/asker_wire.go delete mode 100644 cmd/mesh-controller/assignment_accounts.go delete mode 100644 cmd/mesh-controller/assignment_accounts_test.go delete mode 100644 cmd/mesh-controller/bindings.go delete mode 100644 cmd/mesh-controller/bindings_integration_test.go delete mode 100644 cmd/mesh-controller/bindings_test.go delete mode 100644 cmd/mesh-controller/board.go delete mode 100644 cmd/mesh-controller/board_test.go delete mode 100644 cmd/mesh-controller/bucket_log_fill.go delete mode 100644 cmd/mesh-controller/bucket_log_fill_test.go delete mode 100644 cmd/mesh-controller/build.go delete mode 100644 cmd/mesh-controller/build_integration_test.go delete mode 100644 cmd/mesh-controller/build_source.go delete mode 100644 cmd/mesh-controller/build_source_integration_test.go delete mode 100644 cmd/mesh-controller/build_source_test.go delete mode 100644 cmd/mesh-controller/build_test.go delete mode 100644 cmd/mesh-controller/builder_on_the_bus_test.go delete mode 100644 cmd/mesh-controller/bus_runs_test.go delete mode 100644 cmd/mesh-controller/bus_step.go delete mode 100644 cmd/mesh-controller/bus_step_wants_test.go delete mode 100644 cmd/mesh-controller/bus_users_closed_test.go delete mode 100644 cmd/mesh-controller/buscertificate.go delete mode 100644 cmd/mesh-controller/buscertificate_test.go delete mode 100644 cmd/mesh-controller/busobjects.go delete mode 100644 cmd/mesh-controller/busobjects_test.go delete mode 100644 cmd/mesh-controller/calls_verb_integration_test.go delete mode 100644 cmd/mesh-controller/calls_verb_test.go delete mode 100644 cmd/mesh-controller/chain.go delete mode 100644 cmd/mesh-controller/checks.go delete mode 100644 cmd/mesh-controller/checks_test.go delete mode 100644 cmd/mesh-controller/collect.go delete mode 100644 cmd/mesh-controller/collection.go delete mode 100644 cmd/mesh-controller/compose_callers_test.go delete mode 100644 cmd/mesh-controller/compose_fallbacks_test.go delete mode 100644 cmd/mesh-controller/compose_helpers_test.go delete mode 100644 cmd/mesh-controller/compose_input.go delete mode 100644 cmd/mesh-controller/compose_input_credentials.go delete mode 100644 cmd/mesh-controller/compose_input_credentials_integration_test.go delete mode 100644 cmd/mesh-controller/compose_input_integration_test.go delete mode 100644 cmd/mesh-controller/compose_input_settings_integration_test.go delete mode 100644 cmd/mesh-controller/compose_input_settings_test.go delete mode 100644 cmd/mesh-controller/compose_made_integration_test.go delete mode 100644 cmd/mesh-controller/compose_once_integration_test.go delete mode 100644 cmd/mesh-controller/compose_perform.go delete mode 100644 cmd/mesh-controller/compose_perform_integration_test.go delete mode 100644 cmd/mesh-controller/compose_sdk.go delete mode 100644 cmd/mesh-controller/compose_sdk_test.go delete mode 100644 cmd/mesh-controller/composed_digest_race_test.go delete mode 100644 cmd/mesh-controller/composed_integration_test.go delete mode 100644 cmd/mesh-controller/composed_judge.go delete mode 100644 cmd/mesh-controller/composed_judge_test.go delete mode 100644 cmd/mesh-controller/composed_keys.go delete mode 100644 cmd/mesh-controller/composed_keys_test.go delete mode 100644 cmd/mesh-controller/composed_live.go delete mode 100644 cmd/mesh-controller/composed_node_last_test.go delete mode 100644 cmd/mesh-controller/composed_objects_test.go delete mode 100644 cmd/mesh-controller/composed_operator_keys_test.go delete mode 100644 cmd/mesh-controller/composed_plans_test.go delete mode 100644 cmd/mesh-controller/composed_rejudge.go delete mode 100644 cmd/mesh-controller/composed_served_integration_test.go delete mode 100644 cmd/mesh-controller/composed_start.go delete mode 100644 cmd/mesh-controller/composed_start_test.go delete mode 100644 cmd/mesh-controller/composed_trace.go delete mode 100644 cmd/mesh-controller/composed_userlist_test.go delete mode 100644 cmd/mesh-controller/composed_writer.go delete mode 100644 cmd/mesh-controller/composed_writer_test.go delete mode 100644 cmd/mesh-controller/composer.go delete mode 100644 cmd/mesh-controller/composer_test.go delete mode 100644 cmd/mesh-controller/condition_looks.go delete mode 100644 cmd/mesh-controller/condition_looks_test.go delete mode 100644 cmd/mesh-controller/conditions.go delete mode 100644 cmd/mesh-controller/conditions_brief_integration_test.go delete mode 100644 cmd/mesh-controller/conditions_integration_test.go delete mode 100644 cmd/mesh-controller/conditions_test.go delete mode 100644 cmd/mesh-controller/config_move.go delete mode 100644 cmd/mesh-controller/config_moved_integration_test.go delete mode 100644 cmd/mesh-controller/config_moved_test.go delete mode 100644 cmd/mesh-controller/config_read.go delete mode 100644 cmd/mesh-controller/confirm.go delete mode 100644 cmd/mesh-controller/container_groups_test.go delete mode 100644 cmd/mesh-controller/core_box.go delete mode 100644 cmd/mesh-controller/core_box_integration_test.go delete mode 100644 cmd/mesh-controller/core_box_test.go delete mode 100644 cmd/mesh-controller/credential_on_assign_test.go delete mode 100644 cmd/mesh-controller/data.go delete mode 100644 cmd/mesh-controller/data_integration_test.go delete mode 100644 cmd/mesh-controller/data_test.go delete mode 100644 cmd/mesh-controller/deadletters.go delete mode 100644 cmd/mesh-controller/deadletters_aging.go delete mode 100644 cmd/mesh-controller/deadletters_aging_test.go delete mode 100644 cmd/mesh-controller/deadletters_integration_test.go delete mode 100644 cmd/mesh-controller/deadletters_test.go delete mode 100644 cmd/mesh-controller/declaration_capability_integration_test.go delete mode 100644 cmd/mesh-controller/declaration_files.go delete mode 100644 cmd/mesh-controller/declaration_files_integration_test.go delete mode 100644 cmd/mesh-controller/declaration_files_test.go delete mode 100644 cmd/mesh-controller/declaration_room.go delete mode 100644 cmd/mesh-controller/declaration_sweep.go delete mode 100644 cmd/mesh-controller/declaration_sweep_wanted_test.go delete mode 100644 cmd/mesh-controller/declared_packages_integration_test.go delete mode 100644 cmd/mesh-controller/desk_secret.go delete mode 100644 cmd/mesh-controller/desk_secret_integration_test.go delete mode 100644 cmd/mesh-controller/desk_secret_test.go delete mode 100644 cmd/mesh-controller/doctor.go delete mode 100644 cmd/mesh-controller/doctor_integration_test.go delete mode 100644 cmd/mesh-controller/doctor_test.go delete mode 100644 cmd/mesh-controller/facts.go delete mode 100644 cmd/mesh-controller/facts_format_test.go delete mode 100644 cmd/mesh-controller/facts_test.go delete mode 100644 cmd/mesh-controller/fingerprints_at_test.go delete mode 100644 cmd/mesh-controller/forge_address_test.go delete mode 100644 cmd/mesh-controller/forge_read.go delete mode 100644 cmd/mesh-controller/forge_wait_test.go delete mode 100644 cmd/mesh-controller/given.go delete mode 100644 cmd/mesh-controller/given_again_test.go delete mode 100644 cmd/mesh-controller/given_test.go delete mode 100644 cmd/mesh-controller/grant_follows_binding_integration_test.go delete mode 100644 cmd/mesh-controller/grant_follows_binding_test.go delete mode 100644 cmd/mesh-controller/grants.go delete mode 100644 cmd/mesh-controller/grants_test.go delete mode 100644 cmd/mesh-controller/handacts.go delete mode 100644 cmd/mesh-controller/handacts_test.go delete mode 100644 cmd/mesh-controller/handover_test.go delete mode 100644 cmd/mesh-controller/haves.go delete mode 100644 cmd/mesh-controller/haves_test.go delete mode 100644 cmd/mesh-controller/healers.go delete mode 100644 cmd/mesh-controller/healers_integration_test.go delete mode 100644 cmd/mesh-controller/healers_test.go delete mode 100644 cmd/mesh-controller/health_probes.go delete mode 100644 cmd/mesh-controller/heard_withheld.go delete mode 100644 cmd/mesh-controller/heard_withheld_test.go delete mode 100644 cmd/mesh-controller/held.go delete mode 100644 cmd/mesh-controller/held_test.go delete mode 100644 cmd/mesh-controller/helpers_kept_integration_test.go delete mode 100644 cmd/mesh-controller/helpers_kept_test.go delete mode 100644 cmd/mesh-controller/hidden_input.go delete mode 100644 cmd/mesh-controller/holders_silent_test.go delete mode 100644 cmd/mesh-controller/holdings.go delete mode 100644 cmd/mesh-controller/holdings_test.go delete mode 100644 cmd/mesh-controller/hosts_behind_integration_test.go delete mode 100644 cmd/mesh-controller/hosts_behind_test.go delete mode 100644 cmd/mesh-controller/identity_bound_integration_test.go delete mode 100644 cmd/mesh-controller/image_missing.go delete mode 100644 cmd/mesh-controller/image_missing_test.go delete mode 100644 cmd/mesh-controller/issued.go delete mode 100644 cmd/mesh-controller/issued_integration_test.go delete mode 100644 cmd/mesh-controller/issued_test.go delete mode 100644 cmd/mesh-controller/issued_to_vault.go delete mode 100644 cmd/mesh-controller/issued_to_vault_test.go delete mode 100644 cmd/mesh-controller/join_through_the_tunnel_test.go delete mode 100644 cmd/mesh-controller/kept_compositions.go delete mode 100644 cmd/mesh-controller/kept_helpers.go delete mode 100644 cmd/mesh-controller/key_takein.go delete mode 100644 cmd/mesh-controller/key_takein_integration_test.go delete mode 100644 cmd/mesh-controller/key_takein_test.go delete mode 100644 cmd/mesh-controller/last_heard.go delete mode 100644 cmd/mesh-controller/last_heard_integration_test.go delete mode 100644 cmd/mesh-controller/last_heard_test.go delete mode 100644 cmd/mesh-controller/lease_command_test.go delete mode 100644 cmd/mesh-controller/lease_integration_test.go delete mode 100644 cmd/mesh-controller/lease_test.go delete mode 100644 cmd/mesh-controller/left_out.go delete mode 100644 cmd/mesh-controller/left_out_integration_test.go delete mode 100644 cmd/mesh-controller/left_out_test.go delete mode 100644 cmd/mesh-controller/legacy_takein_integration_test.go delete mode 100644 cmd/mesh-controller/licence.go delete mode 100644 cmd/mesh-controller/local_network.go delete mode 100644 cmd/mesh-controller/local_network_test.go delete mode 100644 cmd/mesh-controller/loop_own_consumers_test.go delete mode 100644 cmd/mesh-controller/machine_lines.go delete mode 100644 cmd/mesh-controller/machine_lines_test.go delete mode 100644 cmd/mesh-controller/machine_network.go delete mode 100644 cmd/mesh-controller/machine_network_test.go delete mode 100644 cmd/mesh-controller/machine_units.go delete mode 100644 cmd/mesh-controller/machine_units_test.go delete mode 100644 cmd/mesh-controller/main.go delete mode 100644 cmd/mesh-controller/mesh_for_test.go delete mode 100644 cmd/mesh-controller/meshcli.go delete mode 100644 cmd/mesh-controller/meshcli_integration_test.go delete mode 100644 cmd/mesh-controller/meshcli_reads_integration_test.go delete mode 100644 cmd/mesh-controller/meshcli_reads_test.go delete mode 100644 cmd/mesh-controller/meshcli_stdin_test.go delete mode 100644 cmd/mesh-controller/meshcli_test.go delete mode 100644 cmd/mesh-controller/mirrors.go delete mode 100644 cmd/mesh-controller/mirrors_integration_test.go delete mode 100644 cmd/mesh-controller/mirrors_test.go delete mode 100644 cmd/mesh-controller/module_health.go delete mode 100644 cmd/mesh-controller/module_health_test.go delete mode 100644 cmd/mesh-controller/modules.go delete mode 100644 cmd/mesh-controller/modules_test.go delete mode 100644 cmd/mesh-controller/modules_wire_store_test.go delete mode 100644 cmd/mesh-controller/modules_wire_test.go delete mode 100644 cmd/mesh-controller/moved.go delete mode 100644 cmd/mesh-controller/moved_asker_test.go delete mode 100644 cmd/mesh-controller/moved_bus_test.go delete mode 100644 cmd/mesh-controller/moved_test.go delete mode 100644 cmd/mesh-controller/network.go delete mode 100644 cmd/mesh-controller/network_test.go delete mode 100644 cmd/mesh-controller/new_module_approved_test.go delete mode 100644 cmd/mesh-controller/new_module_bus_test.go delete mode 100644 cmd/mesh-controller/new_module_on_merge_test.go delete mode 100644 cmd/mesh-controller/new_modules.go delete mode 100644 cmd/mesh-controller/new_modules_test.go delete mode 100644 cmd/mesh-controller/no_vendor_test.go delete mode 100644 cmd/mesh-controller/nodes.go delete mode 100644 cmd/mesh-controller/nodes_test.go delete mode 100644 cmd/mesh-controller/one_resolution_integration_test.go delete mode 100644 cmd/mesh-controller/one_resolution_test.go delete mode 100644 cmd/mesh-controller/operator.go delete mode 100644 cmd/mesh-controller/operator_wait.go delete mode 100644 cmd/mesh-controller/order_test.go delete mode 100644 cmd/mesh-controller/outside_accept_test.go delete mode 100644 cmd/mesh-controller/outside_not_given_test.go delete mode 100644 cmd/mesh-controller/outside_wait_test.go delete mode 100644 cmd/mesh-controller/own_grants.go delete mode 100644 cmd/mesh-controller/own_grants_test.go delete mode 100644 cmd/mesh-controller/owned_paths_test.go delete mode 100644 cmd/mesh-controller/paths_source_test.go delete mode 100644 cmd/mesh-controller/pending.go delete mode 100644 cmd/mesh-controller/pending_keys_test.go delete mode 100644 cmd/mesh-controller/pending_test.go delete mode 100644 cmd/mesh-controller/person_wait_test.go delete mode 100644 cmd/mesh-controller/placement_export.go delete mode 100644 cmd/mesh-controller/placement_export_integration_test.go delete mode 100644 cmd/mesh-controller/placement_move.go delete mode 100644 cmd/mesh-controller/placement_moved_test.go delete mode 100644 cmd/mesh-controller/placement_words_moved_integration_test.go delete mode 100644 cmd/mesh-controller/placement_words_moved_test.go delete mode 100644 cmd/mesh-controller/plain_words.go delete mode 100644 cmd/mesh-controller/plain_words_test.go delete mode 100644 cmd/mesh-controller/plan.go delete mode 100644 cmd/mesh-controller/plan_test.go delete mode 100644 cmd/mesh-controller/plans_gone_store_integration_test.go delete mode 100644 cmd/mesh-controller/predefined.go delete mode 100644 cmd/mesh-controller/predefined_head_test.go delete mode 100644 cmd/mesh-controller/predefined_integration_test.go delete mode 100644 cmd/mesh-controller/predefined_test.go delete mode 100644 cmd/mesh-controller/probe_agent_root.go delete mode 100644 cmd/mesh-controller/probe_agent_root_test.go delete mode 100644 cmd/mesh-controller/probes.go delete mode 100644 cmd/mesh-controller/process_accounts.go delete mode 100644 cmd/mesh-controller/process_accounts_integration_test.go delete mode 100644 cmd/mesh-controller/process_accounts_test.go delete mode 100644 cmd/mesh-controller/process_issue_integration_test.go delete mode 100644 cmd/mesh-controller/process_issue_test.go delete mode 100644 cmd/mesh-controller/process_live_test.go delete mode 100644 cmd/mesh-controller/proposals.go delete mode 100644 cmd/mesh-controller/proposals_integration_test.go delete mode 100644 cmd/mesh-controller/proposals_test.go delete mode 100644 cmd/mesh-controller/provider_hold.go delete mode 100644 cmd/mesh-controller/provisioned_test.go delete mode 100644 cmd/mesh-controller/provisioner_account_integration_test.go delete mode 100644 cmd/mesh-controller/provisioner_account_test.go delete mode 100644 cmd/mesh-controller/provisioning_failed.go delete mode 100644 cmd/mesh-controller/pull_declares.go delete mode 100644 cmd/mesh-controller/pull_declares_test.go delete mode 100644 cmd/mesh-controller/push.go delete mode 100644 cmd/mesh-controller/readable.go delete mode 100644 cmd/mesh-controller/readable_test.go delete mode 100644 cmd/mesh-controller/reads-outside delete mode 100644 cmd/mesh-controller/reconnects.go delete mode 100644 cmd/mesh-controller/reconnects_integration_test.go delete mode 100644 cmd/mesh-controller/reconnects_test.go delete mode 100644 cmd/mesh-controller/record_mirrors.go delete mode 100644 cmd/mesh-controller/record_mirrors_test.go delete mode 100644 cmd/mesh-controller/references.go delete mode 100644 cmd/mesh-controller/register.go delete mode 100644 cmd/mesh-controller/register_hand_integration_test.go delete mode 100644 cmd/mesh-controller/register_test.go delete mode 100644 cmd/mesh-controller/registry_verbs.go delete mode 100644 cmd/mesh-controller/registry_verbs_integration_test.go delete mode 100644 cmd/mesh-controller/registry_verbs_test.go delete mode 100644 cmd/mesh-controller/rehearse.go delete mode 100644 cmd/mesh-controller/rehearse_test.go delete mode 100644 cmd/mesh-controller/reissue_verified_sender.go delete mode 100644 cmd/mesh-controller/reissue_verified_sender_integration_test.go delete mode 100644 cmd/mesh-controller/reissue_verified_sender_test.go delete mode 100644 cmd/mesh-controller/replay164_test.go delete mode 100644 cmd/mesh-controller/replay237_test.go delete mode 100644 cmd/mesh-controller/replay239_test.go delete mode 100644 cmd/mesh-controller/replay292_test.go delete mode 100644 cmd/mesh-controller/replay327_test.go delete mode 100644 cmd/mesh-controller/replay340_test.go delete mode 100644 cmd/mesh-controller/replay401_test.go delete mode 100644 cmd/mesh-controller/repository_words.go delete mode 100644 cmd/mesh-controller/restarts_words_test.go delete mode 100644 cmd/mesh-controller/retire_verbs.go delete mode 100644 cmd/mesh-controller/retirement.go delete mode 100644 cmd/mesh-controller/retirement_integration_test.go delete mode 100644 cmd/mesh-controller/retirement_test.go delete mode 100644 cmd/mesh-controller/rollout.go delete mode 100644 cmd/mesh-controller/rollout_test.go delete mode 100644 cmd/mesh-controller/roster_failure_test.go delete mode 100644 cmd/mesh-controller/rotate.go delete mode 100644 cmd/mesh-controller/rotate_test.go delete mode 100644 cmd/mesh-controller/rotate_vault_test.go delete mode 100644 cmd/mesh-controller/runtime_health_test.go delete mode 100644 cmd/mesh-controller/same_commit_test.go delete mode 100644 cmd/mesh-controller/sayable_integration_test.go delete mode 100644 cmd/mesh-controller/sayable_test.go delete mode 100644 cmd/mesh-controller/seat_dependencies_integration_test.go delete mode 100644 cmd/mesh-controller/seat_dependencies_test.go delete mode 100644 cmd/mesh-controller/seat_pass.go delete mode 100644 cmd/mesh-controller/seat_pass_test.go delete mode 100644 cmd/mesh-controller/seats.go delete mode 100644 cmd/mesh-controller/seats_test.go delete mode 100644 cmd/mesh-controller/seatverbs.go delete mode 100644 cmd/mesh-controller/seatverbs_integration_test.go delete mode 100644 cmd/mesh-controller/seatverbs_schema_integration_test.go delete mode 100644 cmd/mesh-controller/seatverbs_schema_test.go delete mode 100644 cmd/mesh-controller/seatverbs_test.go delete mode 100644 cmd/mesh-controller/secret.go delete mode 100644 cmd/mesh-controller/secret_integration_test.go delete mode 100644 cmd/mesh-controller/secret_move.go delete mode 100644 cmd/mesh-controller/secret_move_test.go delete mode 100644 cmd/mesh-controller/secret_test.go delete mode 100644 cmd/mesh-controller/selfexec.go delete mode 100644 cmd/mesh-controller/selfexec_integration_test.go delete mode 100644 cmd/mesh-controller/selfexec_test.go delete mode 100644 cmd/mesh-controller/sendable.go delete mode 100644 cmd/mesh-controller/sendable_integration_test.go delete mode 100644 cmd/mesh-controller/sendable_test.go delete mode 100644 cmd/mesh-controller/sequence_integration_test.go delete mode 100644 cmd/mesh-controller/sequence_test.go delete mode 100644 cmd/mesh-controller/served_own_names_test.go delete mode 100644 cmd/mesh-controller/serving_group.go delete mode 100644 cmd/mesh-controller/servingbus.go delete mode 100644 cmd/mesh-controller/settings_effective_integration_test.go delete mode 100644 cmd/mesh-controller/settings_effective_test.go delete mode 100644 cmd/mesh-controller/settings_forward.go delete mode 100644 cmd/mesh-controller/shared_stores_test.go delete mode 100644 cmd/mesh-controller/shares_test.go delete mode 100644 cmd/mesh-controller/signals.go delete mode 100644 cmd/mesh-controller/signals_test.go delete mode 100644 cmd/mesh-controller/source.go delete mode 100644 cmd/mesh-controller/source_test.go delete mode 100644 cmd/mesh-controller/standing.go delete mode 100644 cmd/mesh-controller/standing_integration_test.go delete mode 100644 cmd/mesh-controller/standing_test.go delete mode 100644 cmd/mesh-controller/states_announced.go delete mode 100644 cmd/mesh-controller/states_announced_integration_test.go delete mode 100644 cmd/mesh-controller/states_announced_test.go delete mode 100644 cmd/mesh-controller/status.go delete mode 100644 cmd/mesh-controller/status_summary.go delete mode 100644 cmd/mesh-controller/status_summary_test.go delete mode 100644 cmd/mesh-controller/status_test.go delete mode 100644 cmd/mesh-controller/stores.go delete mode 100644 cmd/mesh-controller/stuck_probe.go delete mode 100644 cmd/mesh-controller/stuck_probe_integration_test.go delete mode 100644 cmd/mesh-controller/stuck_probe_test.go delete mode 100644 cmd/mesh-controller/sweep_keeps_fresh_test.go delete mode 100644 cmd/mesh-controller/switch_list.go delete mode 100644 cmd/mesh-controller/switch_list_test.go delete mode 100644 cmd/mesh-controller/take_in_consumer.go delete mode 100644 cmd/mesh-controller/take_preview_test.go delete mode 100644 cmd/mesh-controller/terminal_settings_test.go delete mode 100644 cmd/mesh-controller/testdata/config-moved/mesh.json delete mode 100644 cmd/mesh-controller/testdata/declaration-ace.json.gz delete mode 100644 cmd/mesh-controller/testdata/declaration-g14.json.gz delete mode 100644 cmd/mesh-controller/testdata/left-out/nfs-server.json delete mode 100644 cmd/mesh-controller/testdata/mesh-host-module.json delete mode 100644 cmd/mesh-controller/testdata/network-drill.json delete mode 100644 cmd/mesh-controller/testdata/placement-moved/anchor.json delete mode 100644 cmd/mesh-controller/testdata/placement-moved/laptop.json delete mode 100644 cmd/mesh-controller/testdata/placement-words-moved/anchor.json delete mode 100644 cmd/mesh-controller/testdata/placement-words-moved/laptop.json delete mode 100644 cmd/mesh-controller/testdata/placement-words-moved/mesh.json delete mode 100644 cmd/mesh-controller/try.go delete mode 100644 cmd/mesh-controller/try_integration_test.go delete mode 100644 cmd/mesh-controller/try_test.go delete mode 100644 cmd/mesh-controller/unbound.go delete mode 100644 cmd/mesh-controller/unbound_test.go delete mode 100644 cmd/mesh-controller/unheld_report_integration_test.go delete mode 100644 cmd/mesh-controller/unknown_fields.go delete mode 100644 cmd/mesh-controller/unknown_fields_test.go delete mode 100644 cmd/mesh-controller/unseen.go delete mode 100644 cmd/mesh-controller/untaken_integration_test.go delete mode 100644 cmd/mesh-controller/untaken_test.go delete mode 100644 cmd/mesh-controller/upgrades.go delete mode 100644 cmd/mesh-controller/usage_test.go delete mode 100644 cmd/mesh-controller/vault_handed_test.go delete mode 100644 cmd/mesh-controller/vault_secrets.go delete mode 100644 cmd/mesh-controller/vault_secrets_integration_test.go delete mode 100644 cmd/mesh-controller/vault_secrets_test.go delete mode 100644 cmd/mesh-controller/vault_switch_test.go delete mode 100644 cmd/mesh-controller/warranted.go delete mode 100644 cmd/mesh-controller/warranted_test.go delete mode 100644 cmd/mesh-controller/watchdogs.go delete mode 100644 cmd/mesh-controller/well_scope_test.go delete mode 100644 cmd/mesh-controller/witness.go delete mode 100644 examples/README.md delete mode 100644 examples/objectstore-provisioner/Dockerfile delete mode 100644 examples/objectstore-provisioner/main.go delete mode 100644 examples/objectstore-provisioner/naming_test.go delete mode 100644 examples/postgres-provisioner/Dockerfile delete mode 100644 examples/postgres-provisioner/main.go delete mode 100644 examples/postgres-provisioner/where_test.go delete mode 100644 examples/redis-provisioner/Dockerfile delete mode 100644 examples/redis-provisioner/main.go delete mode 100644 examples/redis-provisioner/naming_test.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/artifacts/blob.go delete mode 100644 internal/artifacts/blob_test.go delete mode 100644 internal/artifacts/hold.go delete mode 100644 internal/artifacts/hold_test.go delete mode 100644 internal/artifacts/live_registry_test.go delete mode 100644 internal/artifacts/platforms_test.go delete mode 100644 internal/artifacts/store.go delete mode 100644 internal/artifacts/store_test.go delete mode 100644 internal/artifacts/tagged.go delete mode 100644 internal/artifacts/tagged_integration_test.go delete mode 100644 internal/artifacts/tagged_test.go delete mode 100644 internal/beside/beside.go delete mode 100644 internal/broker/agreement.go delete mode 100644 internal/broker/agreement_catalogue_test.go delete mode 100644 internal/broker/agreement_streams_test.go delete mode 100644 internal/broker/announced.go delete mode 100644 internal/broker/announced_test.go delete mode 100644 internal/broker/announced_test_helpers_test.go delete mode 100644 internal/broker/broker.go delete mode 100644 internal/broker/broker_test.go delete mode 100644 internal/broker/buildbacklog.go delete mode 100644 internal/broker/buildbacklog_test.go delete mode 100644 internal/broker/bus_module_emits_test.go delete mode 100644 internal/broker/callers.go delete mode 100644 internal/broker/callers_live_test.go delete mode 100644 internal/broker/callers_test.go delete mode 100644 internal/broker/cancelled.go delete mode 100644 internal/broker/cancelled_test.go delete mode 100644 internal/broker/composed.go delete mode 100644 internal/broker/composed_live_test.go delete mode 100644 internal/broker/composed_test.go delete mode 100644 internal/broker/condition_grant_test.go delete mode 100644 internal/broker/config_owner_live_test.go delete mode 100644 internal/broker/consumer_from_now_test.go delete mode 100644 internal/broker/consumer_upgrade_test.go delete mode 100644 internal/broker/controller_asks_test.go delete mode 100644 internal/broker/controller_buckets.go delete mode 100644 internal/broker/controller_buckets_integration_test.go delete mode 100644 internal/broker/controller_buckets_test.go delete mode 100644 internal/broker/creates_streams_test.go delete mode 100644 internal/broker/declaration_files.go delete mode 100644 internal/broker/declaration_files_live_test.go delete mode 100644 internal/broker/declaration_files_test.go delete mode 100644 internal/broker/declared_packages_mirror_test.go delete mode 100644 internal/broker/delivery_gate_grant_test.go delete mode 100644 internal/broker/delivery_parts_grant_test.go delete mode 100644 internal/broker/derived.go delete mode 100644 internal/broker/derived_test.go delete mode 100644 internal/broker/genesis_template_test.go delete mode 100644 internal/broker/grants_test.go delete mode 100644 internal/broker/have_bucket_live_test.go delete mode 100644 internal/broker/have_bucket_test.go delete mode 100644 internal/broker/invokes_test.go delete mode 100644 internal/broker/issued_test.go delete mode 100644 internal/broker/jetstream.go delete mode 100644 internal/broker/jetstream_test.go delete mode 100644 internal/broker/log.go delete mode 100644 internal/broker/log_integration_test.go delete mode 100644 internal/broker/log_test.go delete mode 100644 internal/broker/membership.go delete mode 100644 internal/broker/membership_test.go delete mode 100644 internal/broker/modstream_work_queue_test.go delete mode 100644 internal/broker/modstreams.go delete mode 100644 internal/broker/modstreams_live_test.go delete mode 100644 internal/broker/modstreams_test.go delete mode 100644 internal/broker/modstreams_workqueue_integration_test.go delete mode 100644 internal/broker/move_secrets_test.go delete mode 100644 internal/broker/msgttl_live_test.go delete mode 100644 internal/broker/nats.go delete mode 100644 internal/broker/nats_golden_test.go delete mode 100644 internal/broker/nats_test.go delete mode 100644 internal/broker/no_build_queue_test.go delete mode 100644 internal/broker/node_key_test.go delete mode 100644 internal/broker/onnats.go delete mode 100644 internal/broker/process_grants.go delete mode 100644 internal/broker/process_grants_test.go delete mode 100644 internal/broker/process_state_watch_test.go delete mode 100644 internal/broker/prov_applied_live_test.go delete mode 100644 internal/broker/provisioner.go delete mode 100644 internal/broker/provisioner_have_test.go delete mode 100644 internal/broker/provisioner_live_test.go delete mode 100644 internal/broker/provisioner_server_live_test.go delete mode 100644 internal/broker/provisioner_test.go delete mode 100644 internal/broker/raise.go delete mode 100644 internal/broker/raise_live_test.go delete mode 100644 internal/broker/readiness.go delete mode 100644 internal/broker/readiness_test.go delete mode 100644 internal/broker/reads-outside delete mode 100644 internal/broker/sdk_membership.go delete mode 100644 internal/broker/seattools_test.go delete mode 100644 internal/broker/seattraffic.go delete mode 100644 internal/broker/seattraffic_test.go delete mode 100644 internal/broker/snapshot_live_test.go delete mode 100644 internal/broker/snapshot_test.go delete mode 100644 internal/broker/state.go delete mode 100644 internal/broker/state_integration_test.go delete mode 100644 internal/broker/state_keyttl_test.go delete mode 100644 internal/broker/state_test.go delete mode 100644 internal/broker/stream_names_agreement_test.go delete mode 100644 internal/broker/streams.go delete mode 100644 internal/broker/streams_test.go delete mode 100644 internal/broker/system_exports_live_test.go delete mode 100644 internal/broker/system_exports_test.go delete mode 100644 internal/broker/testdata/composed.conf delete mode 100644 internal/broker/userlist_switch.go delete mode 100644 internal/broker/users.go delete mode 100644 internal/broker/users_test.go delete mode 100644 internal/broker/vault_calls_live_integration_test.go delete mode 100644 internal/broker/vault_calls_live_test.go delete mode 100644 internal/broker/waits.go delete mode 100644 internal/broker/waits_test.go delete mode 100644 internal/broker/witness_grants_test.go delete mode 100644 internal/broker/worker_type_change_test.go delete mode 100644 internal/broker/writers.go delete mode 100644 internal/broker/writers_test.go delete mode 100644 internal/builderkeys/builderkeys.go delete mode 100644 internal/builderkeys/builderkeys_integration_test.go delete mode 100644 internal/builderkeys/builderkeys_test.go delete mode 100644 internal/buildreads/buildreads.go delete mode 100644 internal/chain/backend.go delete mode 100644 internal/chain/bus.go delete mode 100644 internal/chain/chain.go delete mode 100644 internal/chain/chain_test.go delete mode 100644 internal/chain/keys.go delete mode 100644 internal/chain/keys_test.go delete mode 100644 internal/chain/pending_test.go delete mode 100644 internal/chain/records.go delete mode 100644 internal/conditions/bus.go delete mode 100644 internal/conditions/bus_test.go delete mode 100644 internal/conditions/condition.go delete mode 100644 internal/conditions/events.go delete mode 100644 internal/conditions/looks.go delete mode 100644 internal/conditions/looks_test.go delete mode 100644 internal/conditions/memory.go delete mode 100644 internal/conditions/memory_test.go delete mode 100644 internal/conditions/plain.go delete mode 100644 internal/conditions/plain_test.go delete mode 100644 internal/conditions/sayable_test.go delete mode 100644 internal/conditions/store.go delete mode 100644 internal/conditions/store_test.go delete mode 100644 internal/conditions/tried_test.go delete mode 100644 internal/envfile/envfile.go delete mode 100644 internal/envfile/envfile_test.go delete mode 100644 internal/envfile/port.go delete mode 100644 internal/envfile/port_test.go delete mode 100644 internal/gosource/gosource.go delete mode 100644 internal/gosource/gosource_integration_test.go delete mode 100644 internal/gosource/gosource_test.go delete mode 100644 internal/gosource/reads-outside delete mode 100644 internal/identity/authority.go delete mode 100644 internal/identity/authority_test.go delete mode 100644 internal/identity/fortest.go delete mode 100644 internal/identity/identity.go delete mode 100644 internal/identity/identity_test.go delete mode 100644 internal/identity/migrations/0001-signing-key.sql delete mode 100644 internal/identity/migrations/0002-node-keys.sql delete mode 100644 internal/identity/migrations/0003-the-mesh-authority.sql delete mode 100644 internal/identity/read_kept.go delete mode 100644 internal/inventory/addresses.go delete mode 100644 internal/inventory/adoption.go delete mode 100644 internal/inventory/adoption_test.go delete mode 100644 internal/inventory/agent_account_test.go delete mode 100644 internal/inventory/artifacts_test.go delete mode 100644 internal/inventory/asks_lab_test.go delete mode 100644 internal/inventory/bindings.go delete mode 100644 internal/inventory/bindings_test.go delete mode 100644 internal/inventory/build_lookups.go delete mode 100644 internal/inventory/buildinput_test.go delete mode 100644 internal/inventory/builds.go delete mode 100644 internal/inventory/builds_test.go delete mode 100644 internal/inventory/busrecords.go delete mode 100644 internal/inventory/busrecords_integration_test.go delete mode 100644 internal/inventory/busrecords_test.go delete mode 100644 internal/inventory/busstep.go delete mode 100644 internal/inventory/bususers.go delete mode 100644 internal/inventory/bususers_integration_test.go delete mode 100644 internal/inventory/bususers_test.go delete mode 100644 internal/inventory/c5_still_reads_test.go delete mode 100644 internal/inventory/catalogue.go delete mode 100644 internal/inventory/catalogue_test.go delete mode 100644 internal/inventory/chain.go delete mode 100644 internal/inventory/chain_assignments.go delete mode 100644 internal/inventory/chain_assignments_key_test.go delete mode 100644 internal/inventory/chain_builds.go delete mode 100644 internal/inventory/chain_builds_layout_test.go delete mode 100644 internal/inventory/chain_builds_test.go delete mode 100644 internal/inventory/chain_drop_test.go delete mode 100644 internal/inventory/chain_first_read_lays_out_test.go delete mode 100644 internal/inventory/chain_links.go delete mode 100644 internal/inventory/chain_pending_test.go delete mode 100644 internal/inventory/chain_requests.go delete mode 100644 internal/inventory/chain_requests_test.go delete mode 100644 internal/inventory/chain_source_test.go delete mode 100644 internal/inventory/chain_store_test.go delete mode 100644 internal/inventory/collection.go delete mode 100644 internal/inventory/collection_lately_test.go delete mode 100644 internal/inventory/collection_test.go delete mode 100644 internal/inventory/collection_wanted_test.go delete mode 100644 internal/inventory/config_move.go delete mode 100644 internal/inventory/config_move_test.go delete mode 100644 internal/inventory/core_box.go delete mode 100644 internal/inventory/core_box_test.go delete mode 100644 internal/inventory/data.go delete mode 100644 internal/inventory/data_test.go delete mode 100644 internal/inventory/declared_packages_test.go delete mode 100644 internal/inventory/dependencies.go delete mode 100644 internal/inventory/dependencies_test.go delete mode 100644 internal/inventory/doing_test.go delete mode 100644 internal/inventory/forget_test.go delete mode 100644 internal/inventory/fortest.go delete mode 100644 internal/inventory/givable_test.go delete mode 100644 internal/inventory/given.go delete mode 100644 internal/inventory/given_test.go delete mode 100644 internal/inventory/heals.go delete mode 100644 internal/inventory/heals_test.go delete mode 100644 internal/inventory/health.go delete mode 100644 internal/inventory/holdings_test.go delete mode 100644 internal/inventory/inventory.go delete mode 100644 internal/inventory/inventory_test.go delete mode 100644 internal/inventory/issued.go delete mode 100644 internal/inventory/issued_test.go delete mode 100644 internal/inventory/looks.go delete mode 100644 internal/inventory/looks_test.go delete mode 100644 internal/inventory/machine.go delete mode 100644 internal/inventory/machine_test.go delete mode 100644 internal/inventory/migrations/0001-nodes.sql delete mode 100644 internal/inventory/migrations/0002-enrolment-tokens.sql delete mode 100644 internal/inventory/migrations/0003-what-a-node-owns.sql delete mode 100644 internal/inventory/migrations/0004-the-overlay.sql delete mode 100644 internal/inventory/migrations/0005-modules-and-assignments.sql delete mode 100644 internal/inventory/migrations/0006-where-a-module-came-from.sql delete mode 100644 internal/inventory/migrations/0007-settings.sql delete mode 100644 internal/inventory/migrations/0008-which-provider.sql delete mode 100644 internal/inventory/migrations/0009-secrets.sql delete mode 100644 internal/inventory/migrations/0010-builds.sql delete mode 100644 internal/inventory/migrations/0011-what-a-node-did.sql delete mode 100644 internal/inventory/migrations/0012-a-modules-own-secret.sql delete mode 100644 internal/inventory/migrations/0013-a-secret-the-mesh-cannot-reinvent.sql delete mode 100644 internal/inventory/migrations/0014-what-a-machine-was-last-sent.sql delete mode 100644 internal/inventory/migrations/0015-a-consumer-is-a-module-on-a-machine.sql delete mode 100644 internal/inventory/migrations/0016-the-mesh-assigns-the-port.sql delete mode 100644 internal/inventory/migrations/0017-what-a-machine-already-holds.sql delete mode 100644 internal/inventory/migrations/0018-a-report-names-its-declaration.sql delete mode 100644 internal/inventory/migrations/0019-a-nodes-public-domain.sql delete mode 100644 internal/inventory/migrations/0020-a-module-is-a-repository-and-a-path.sql delete mode 100644 internal/inventory/migrations/0021-what-to-do-when-a-module-is-upgraded.sql delete mode 100644 internal/inventory/migrations/0022-a-build-keeps-what-it-was-told.sql delete mode 100644 internal/inventory/migrations/0023-an-operator-key-and-a-second-seal.sql delete mode 100644 internal/inventory/migrations/0024-a-pair-credential-is-sealed-to-the-operator-too.sql delete mode 100644 internal/inventory/migrations/0025-a-failure-that-repeats-is-said-to-be-stuck.sql delete mode 100644 internal/inventory/migrations/0026-a-pair-credential-says-where-it-came-from.sql delete mode 100644 internal/inventory/migrations/0027-a-module-may-hold-several-secrets-from-one-provider.sql delete mode 100644 internal/inventory/migrations/0028-a-token-is-claimed-before-it-is-spent.sql delete mode 100644 internal/inventory/migrations/0029-a-node-is-adopted-or-converged.sql delete mode 100644 internal/inventory/migrations/0030-what-an-adopted-node-reports.sql delete mode 100644 internal/inventory/migrations/0031-the-tunnel-a-node-found.sql delete mode 100644 internal/inventory/migrations/0032-a-source-may-live-on-a-seat.sql delete mode 100644 internal/inventory/migrations/0033-a-carried-peer-is-nameable.sql delete mode 100644 internal/inventory/migrations/0034-the-seats-are-data.sql delete mode 100644 internal/inventory/migrations/0035-a-seat-keeps-its-former-names.sql delete mode 100644 internal/inventory/migrations/0036-a-node-has-an-operator-account.sql delete mode 100644 internal/inventory/migrations/0037-the-bus-keeps-its-users-hashes.sql delete mode 100644 internal/inventory/migrations/0038-a-person-may-reach-the-mesh.sql delete mode 100644 internal/inventory/migrations/0039-a-seat-is-held-by-one-assignment-on-record.sql delete mode 100644 internal/inventory/migrations/0040-the-broker-seat-delivers-the-bus.sql delete mode 100644 internal/inventory/migrations/0041-a-machine-is-told-its-membership-for-the-new-bus.sql delete mode 100644 internal/inventory/migrations/0042-a-build-says-which-repositories-it-read.sql delete mode 100644 internal/inventory/migrations/0043-a-machine-says-which-networks-it-routes.sql delete mode 100644 internal/inventory/migrations/0044-a-machine-says-which-links-face-outside.sql delete mode 100644 internal/inventory/migrations/0045-a-machine-says-which-host-runs-it.sql delete mode 100644 internal/inventory/migrations/0046-each-send-is-numbered.sql delete mode 100644 internal/inventory/migrations/0047-a-seat-carries-its-tools.sql delete mode 100644 internal/inventory/migrations/0048-the-artifact-store-seat-is-named-for-its-scope.sql delete mode 100644 internal/inventory/migrations/0049-a-shared-credential-is-one-value-sealed-many-times.sql delete mode 100644 internal/inventory/migrations/0050-a-pin-names-the-module.sql delete mode 100644 internal/inventory/migrations/0051-a-pin-made-before-is-completed.sql delete mode 100644 internal/inventory/migrations/0052-a-merge-is-a-plan-the-mesh-keeps.sql delete mode 100644 internal/inventory/migrations/0053-a-machine-says-what-strays-on-it.sql delete mode 100644 internal/inventory/migrations/0054-a-machine-says-what-filters-it.sql delete mode 100644 internal/inventory/migrations/0055-an-older-build-never-replaces-a-newer.sql delete mode 100644 internal/inventory/migrations/0056-what-the-store-no-longer-keeps.sql delete mode 100644 internal/inventory/migrations/0058-a-newer-plan-supersedes-an-older.sql delete mode 100644 internal/inventory/migrations/0060-the-per-node-resolver-seat-is-gone.sql delete mode 100644 internal/inventory/migrations/0061-a-send-records-the-builds-it-carried.sql delete mode 100644 internal/inventory/migrations/0062-a-replicated-seat-has-several-holders-on-record.sql delete mode 100644 internal/inventory/migrations/0063-the-resolver-file-is-the-uplinks.sql delete mode 100644 internal/inventory/migrations/0064-a-machines-names-are-one-seats.sql delete mode 100644 internal/inventory/migrations/0065-a-provider-says-which-consumer-it-keeps-failing.sql delete mode 100644 internal/inventory/migrations/0066-the-durations-the-bounds-are-set-from.sql delete mode 100644 internal/inventory/migrations/0067-a-given-secret-lives-until-the-first-good-start.sql delete mode 100644 internal/inventory/migrations/0068-order-and-one-writer.sql delete mode 100644 internal/inventory/migrations/0070-a-known-failure-heals-itself.sql delete mode 100644 internal/inventory/migrations/0071-a-binding-to-data-is-kept.sql delete mode 100644 internal/inventory/migrations/0072-a-module-declares-the-data-it-holds.sql delete mode 100644 internal/inventory/migrations/0073-a-build-rolls-out-gated-and-rolls-back.sql delete mode 100644 internal/inventory/migrations/0074-a-build-says-what-it-was-made-from.sql delete mode 100644 internal/inventory/migrations/0075-a-walk-waits-for-its-delivery.sql delete mode 100644 internal/inventory/migrations/0076-a-module-says-how-it-is-healthy.sql delete mode 100644 internal/inventory/migrations/0077-a-machine-says-how-its-network-is.sql delete mode 100644 internal/inventory/migrations/0078-what-a-change-replaces.sql delete mode 100644 internal/inventory/migrations/0079-a-token-is-issued-for-a-tunnel-key.sql delete mode 100644 internal/inventory/migrations/0080-a-failed-unit-is-never-silent.sql delete mode 100644 internal/inventory/migrations/0081-a-mirrored-base-is-recorded.sql delete mode 100644 internal/inventory/migrations/0082-an-assignment-waits-for-its-build.sql delete mode 100644 internal/inventory/migrations/0083-a-node-names-the-account-its-agents-run-as.sql delete mode 100644 internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql delete mode 100644 internal/inventory/migrations/0085-the-controller-keeps-when-a-root-search-began-pending.sql delete mode 100644 internal/inventory/migrations/0086-a-plan-keeps-when-its-merge-was-made.sql delete mode 100644 internal/inventory/migrations/0087-a-controller-records-what-its-stores-schema-reaches.sql delete mode 100644 internal/inventory/migrations/0088-a-build-says-its-build-source.sql delete mode 100644 internal/inventory/migrations/0089-merges-are-assembled-in-a-rolling-window.sql delete mode 100644 internal/inventory/migrations/0090-a-layer-says-who-set-it.sql delete mode 100644 internal/inventory/migrations/0091-a-secret-ask-is-bounded.sql delete mode 100644 internal/inventory/migrations/0092-a-reading-says-the-path-it-was-measured-at.sql delete mode 100644 internal/inventory/migrations/0093-a-walk-keeps-its-phases.sql delete mode 100644 internal/inventory/migrations/0094-a-declaration-says-the-generation-it-came-from.sql delete mode 100644 internal/inventory/migrations/0095-a-condition-keeps-its-looks-beside-the-bucket.sql delete mode 100644 internal/inventory/migrations/0096-a-report-says-a-busy-container.sql delete mode 100644 internal/inventory/migrations/0097-placement-is-not-config.sql delete mode 100644 internal/inventory/migrations/0098-the-list-of-predefined-keys.sql delete mode 100644 internal/inventory/migrations/0099-the-secrets-move-into-the-vault.sql delete mode 100644 internal/inventory/migrations/0100-a-send-keeps-the-provisioners-plans.sql delete mode 100644 internal/inventory/migrations/0101-the-cores-credentials-the-vault-sealed.sql delete mode 100644 internal/inventory/migrations/0102-the-settings-layers-move-into-config.sql delete mode 100644 internal/inventory/migrations/0103-a-walk-keeps-its-backups.sql delete mode 100644 internal/inventory/migrations/0104-the-chain-moves-to-the-bus.sql delete mode 100644 internal/inventory/migrations/0105-the-chain-leaves-the-store.sql delete mode 100644 internal/inventory/migrations/0106-a-node-says-it-reads-modules.sql delete mode 100644 internal/inventory/migrations/0107-a-refused-registration-is-kept.sql delete mode 100644 internal/inventory/migrations/0108-a-bus-account-says-it-is-sealed.sql delete mode 100644 internal/inventory/mirrored_test.go delete mode 100644 internal/inventory/mirrors.go delete mode 100644 internal/inventory/named_peer_test.go delete mode 100644 internal/inventory/nodes.go delete mode 100644 internal/inventory/operator.go delete mode 100644 internal/inventory/operator_test.go delete mode 100644 internal/inventory/order.go delete mode 100644 internal/inventory/outside.go delete mode 100644 internal/inventory/outside_test.go delete mode 100644 internal/inventory/pending.go delete mode 100644 internal/inventory/pending_test.go delete mode 100644 internal/inventory/people_test.go delete mode 100644 internal/inventory/placement_move.go delete mode 100644 internal/inventory/placement_test.go delete mode 100644 internal/inventory/placement_words_test.go delete mode 100644 internal/inventory/ports.go delete mode 100644 internal/inventory/ports_test.go delete mode 100644 internal/inventory/predefined.go delete mode 100644 internal/inventory/predefined_test.go delete mode 100644 internal/inventory/provisioned.go delete mode 100644 internal/inventory/provisioning.go delete mode 100644 internal/inventory/provisioning_test.go delete mode 100644 internal/inventory/public_domain_test.go delete mode 100644 internal/inventory/reachable.go delete mode 100644 internal/inventory/reachable_integration_test.go delete mode 100644 internal/inventory/reachable_test.go delete mode 100644 internal/inventory/read_state.go delete mode 100644 internal/inventory/reads-outside delete mode 100644 internal/inventory/record_mirrors_integration_test.go delete mode 100644 internal/inventory/record_mirrors_test.go delete mode 100644 internal/inventory/refused.go delete mode 100644 internal/inventory/refused_test.go delete mode 100644 internal/inventory/registered_held_test.go delete mode 100644 internal/inventory/reported.go delete mode 100644 internal/inventory/retire_test.go delete mode 100644 internal/inventory/rootfree_grants_test.go delete mode 100644 internal/inventory/schema.go delete mode 100644 internal/inventory/seats.go delete mode 100644 internal/inventory/seats_test.go delete mode 100644 internal/inventory/secret_ask.go delete mode 100644 internal/inventory/secret_ask_test.go delete mode 100644 internal/inventory/secret_family_integration_test.go delete mode 100644 internal/inventory/secret_family_test.go delete mode 100644 internal/inventory/secret_move.go delete mode 100644 internal/inventory/secret_move_test.go delete mode 100644 internal/inventory/secrets.go delete mode 100644 internal/inventory/secrets_test.go delete mode 100644 internal/inventory/settings_judged_test.go delete mode 100644 internal/inventory/source.go delete mode 100644 internal/inventory/standing_switch_test.go delete mode 100644 internal/inventory/state_keyttl_test.go delete mode 100644 internal/inventory/store_as_run_test.go delete mode 100644 internal/inventory/stored_silent_file_test.go delete mode 100644 internal/inventory/streams_test.go delete mode 100644 internal/inventory/template_test.go delete mode 100644 internal/inventory/tunnel.go delete mode 100644 internal/inventory/tunnel_test.go delete mode 100644 internal/inventory/unknown_field_test.go delete mode 100644 internal/inventory/unseen.go delete mode 100644 internal/inventory/unseen_test.go delete mode 100644 internal/inventory/waits_grants_test.go delete mode 100644 internal/lease/lease.go delete mode 100644 internal/lease/lease_test.go delete mode 100644 internal/lease/witness.go delete mode 100644 internal/lease/witness_test.go delete mode 100644 internal/licences/adapters/adapters.go delete mode 100644 internal/licences/adapters/adapters_test.go delete mode 100644 internal/licences/fortest.go delete mode 100644 internal/licences/licences.go delete mode 100644 internal/licences/licences_test.go delete mode 100644 internal/licences/migrations/0001-a-licence-is-a-named-thing.sql delete mode 100644 internal/licences/migrations/0002-vendor-not-provider.sql delete mode 100644 internal/licences/migrations/0003-a-manager-and-its-refresh-token.sql delete mode 100644 internal/licences/refresh_test.go delete mode 100644 internal/licences/submitrefresh_test.go delete mode 100644 internal/link/advisories.go delete mode 100644 internal/link/advisories_integration_test.go delete mode 100644 internal/link/advisories_test.go delete mode 100644 internal/link/announce.go delete mode 100644 internal/link/ask.go delete mode 100644 internal/link/build.go delete mode 100644 internal/link/bus.go delete mode 100644 internal/link/bus_integration_test.go delete mode 100644 internal/link/bus_nats.go delete mode 100644 internal/link/bus_test.go delete mode 100644 internal/link/busfaults.go delete mode 100644 internal/link/busfaults_integration_test.go delete mode 100644 internal/link/busfaults_test.go delete mode 100644 internal/link/calls.go delete mode 100644 internal/link/calls_integration_test.go delete mode 100644 internal/link/calls_kept.go delete mode 100644 internal/link/calls_kept_integration_test.go delete mode 100644 internal/link/calls_kept_test.go delete mode 100644 internal/link/calls_test.go delete mode 100644 internal/link/calls_wait_test.go delete mode 100644 internal/link/conformance_integration_test.go delete mode 100644 internal/link/conformance_sources_test.go delete mode 100644 internal/link/conformance_test.go delete mode 100644 internal/link/contracts.go delete mode 100644 internal/link/contracts_test.go delete mode 100644 internal/link/deadletters.go delete mode 100644 internal/link/deadletters_integration_test.go delete mode 100644 internal/link/deadletters_module_stream_test.go delete mode 100644 internal/link/deadletters_restricted_test.go delete mode 100644 internal/link/deadletters_test.go delete mode 100644 internal/link/deadletters_work_queue_test.go delete mode 100644 internal/link/delivery_merged_test.go delete mode 100644 internal/link/enrol_bus_credential_test.go delete mode 100644 internal/link/enrol_claim_test.go delete mode 100644 internal/link/enrol_proof_test.go delete mode 100644 internal/link/enrol_reply_probe_test.go delete mode 100644 internal/link/enrol_shape_test.go delete mode 100644 internal/link/enrol_tunnel_key_test.go delete mode 100644 internal/link/enrolment.go delete mode 100644 internal/link/events.go delete mode 100644 internal/link/handacts.go delete mode 100644 internal/link/handacts_test.go delete mode 100644 internal/link/handover.go delete mode 100644 internal/link/handover_integration_test.go delete mode 100644 internal/link/handover_test.go delete mode 100644 internal/link/health.go delete mode 100644 internal/link/health_test.go delete mode 100644 internal/link/heard_order_integration_test.go delete mode 100644 internal/link/heard_order_test.go delete mode 100644 internal/link/heard_test.go delete mode 100644 internal/link/judge_directories_test.go delete mode 100644 internal/link/merges.go delete mode 100644 internal/link/meshcli.go delete mode 100644 internal/link/meshcli_integration_test.go delete mode 100644 internal/link/meshcli_test.go delete mode 100644 internal/link/movesecret.go delete mode 100644 internal/link/movesecret_integration_test.go delete mode 100644 internal/link/movesecret_test.go delete mode 100644 internal/link/order.go delete mode 100644 internal/link/order_test.go delete mode 100644 internal/link/pages.go delete mode 100644 internal/link/pages_integration_test.go delete mode 100644 internal/link/pages_test.go delete mode 100644 internal/link/profile_report_test.go delete mode 100644 internal/link/protocol.go delete mode 100644 internal/link/protocol_node_key_test.go delete mode 100644 internal/link/protocol_test.go delete mode 100644 internal/link/reads-outside delete mode 100644 internal/link/receive.go delete mode 100644 internal/link/receive_fake_test.go delete mode 100644 internal/link/receive_nats.go delete mode 100644 internal/link/receive_nats_integration_test.go delete mode 100644 internal/link/receive_nats_test.go delete mode 100644 internal/link/rekey_test.go delete mode 100644 internal/link/replay314_test.go delete mode 100644 internal/link/replay330_test.go delete mode 100644 internal/link/report_retry_test.go delete mode 100644 internal/link/retirement.go delete mode 100644 internal/link/seat_as_test.go delete mode 100644 internal/link/seatask.go delete mode 100644 internal/link/seattools.go delete mode 100644 internal/link/serve.go delete mode 100644 internal/link/setuid_search_test.go delete mode 100644 internal/link/signer.go delete mode 100644 internal/link/source_inputs_test.go delete mode 100644 internal/link/source_moved_held_test.go delete mode 100644 internal/link/standby_nats_test.go delete mode 100644 internal/link/standing.go delete mode 100644 internal/link/standing_integration_test.go delete mode 100644 internal/link/standing_test.go delete mode 100644 internal/link/store_window_test.go delete mode 100644 internal/link/vault_changed_test.go delete mode 100644 internal/link/watched.go delete mode 100644 internal/link/window.go delete mode 100644 internal/link/window_test.go delete mode 100644 internal/lint/emptyonerror.go delete mode 100644 internal/lint/emptyonerror_test.go delete mode 100644 internal/lint/reads-outside delete mode 100644 internal/lint/removed_test.go delete mode 100644 internal/outward/outward.go delete mode 100644 internal/outward/outward_test.go delete mode 100644 internal/ownmanifest/controller_is_a_process_test.go delete mode 100644 internal/ownmanifest/doc.go delete mode 100644 internal/ownmanifest/genesis_image_test.go delete mode 100644 internal/ownmanifest/helpers_test.go delete mode 100644 internal/ownmanifest/image_place_test.go delete mode 100644 internal/ownmanifest/own_module_json_test.go delete mode 100644 internal/ownmanifest/reads-outside delete mode 100644 internal/ownmanifest/seats_test.go delete mode 100644 internal/recordmirror/mirror.go delete mode 100644 internal/recordmirror/mirror_integration_test.go delete mode 100644 internal/recordmirror/mirror_store_test.go delete mode 100644 internal/recordmirror/mirror_test.go delete mode 100644 internal/recordmirror/provisioning_live_test.go delete mode 100644 internal/recordmirror/readers_test.go delete mode 100644 internal/recordmirror/reads-outside delete mode 100644 internal/secrets/operator_test.go delete mode 100644 internal/secrets/seal.go delete mode 100644 internal/secrets/seal_test.go delete mode 100644 internal/secrets/sealedbox_xcheck_test.go delete mode 100644 internal/secrets/testdata/module-sealedbox-fixture.json delete mode 100644 internal/store/fortest.go delete mode 100644 internal/store/fortest_test.go delete mode 100644 internal/store/live_test.go delete mode 100644 internal/store/manifest_test.go delete mode 100644 internal/store/migrate.go delete mode 100644 internal/store/migrate_test.go delete mode 100644 internal/store/reads-outside delete mode 100644 internal/store/ready_test.go delete mode 100644 internal/store/settings_test.go delete mode 100644 internal/store/store.go delete mode 100644 internal/testbus/testbus.go delete mode 100644 internal/testbus/testbus_integration_test.go delete mode 100644 internal/testbus/testbus_test.go delete mode 100644 internal/token/token.go delete mode 100644 internal/token/token_test.go delete mode 100644 lab/adopt-the-tunnel/README.md delete mode 100644 lab/adopt-the-tunnel/adopt-the-tunnel.test.ts delete mode 100644 lab/adopt-the-tunnel/adopt-the-tunnel.yml delete mode 100644 module.json delete mode 100644 testdata/beside/CAPTURED delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/cursor-index.theme delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk-theme delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-gtk2-rc-files delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-qpa-platformtheme delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-select delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-qt-style-override delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-size delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/env-xcursor-theme delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/gtk-settings.ini delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/portals.conf delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/qt6ct.conf delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/xinitrc-normal delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/files/xresources-normal delete mode 100644 testdata/beside/mesh-catalog/modules/adwaita/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-address delete mode 100644 testdata/beside/mesh-catalog/modules/artifact-store-tools/files/store-names delete mode 100644 testdata/beside/mesh-catalog/modules/artifact-store-tools/module.json.captured delete mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight delete mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-display delete mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-kbd-notify delete mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-media delete mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-notify delete mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-session delete mode 100755 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/after-wake delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/display-session delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/hotkeys-triggers delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/g14-nvidia-power.conf delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/modprobe.d/video-brightness-switch.conf delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf delete mode 100644 testdata/beside/mesh-catalog/modules/asus-zephyrus-g14/templates/udev/rules.d/90-backlight.rules delete mode 100644 testdata/beside/mesh-catalog/modules/audit-logger/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/avahi/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/baserow/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/baserow/files/server.env delete mode 100644 testdata/beside/mesh-catalog/modules/baserow/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/blueman/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/bluetooth/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/brother/files/bucket.json delete mode 100644 testdata/beside/mesh-catalog/modules/brother/files/fetch.json delete mode 100644 testdata/beside/mesh-catalog/modules/brother/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/brother/files/store.json delete mode 100644 testdata/beside/mesh-catalog/modules/brother/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/buildah/module.json.captured delete mode 100755 testdata/beside/mesh-catalog/modules/ca-trust/files/anchor delete mode 100644 testdata/beside/mesh-catalog/modules/ca-trust/files/mesh-ca-trust.service delete mode 100644 testdata/beside/mesh-catalog/modules/ca-trust/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/agent-account delete mode 100755 testdata/beside/mesh-catalog/modules/claude-code/files/claude-agent delete mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/facts.json delete mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/claude-code/files/zsh-normal delete mode 100644 testdata/beside/mesh-catalog/modules/claude-code/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/claude-licence-manager/files/database.url delete mode 100644 testdata/beside/mesh-catalog/modules/claude-licence-manager/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/claude-licence-manager/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/bin/clipmenud-session delete mode 100755 testdata/beside/mesh-catalog/modules/clipmenu/files/bin/xsel delete mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-histlength delete mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-max-clips delete mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/env-cm-selections delete mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/i3/50-clipmenu.conf delete mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/files/systemd/clipmenud-mesh.conf delete mode 100644 testdata/beside/mesh-catalog/modules/clipmenu/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/confluence/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/confluence/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/cups/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/dbus/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/desk-channel/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/desk-channel/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/dev-tools/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/disk-load/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/distribution/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/dmenu/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/dnsmasq/files/dnsmasq.conf delete mode 100644 testdata/beside/mesh-catalog/modules/dnsmasq/files/mesh.conf delete mode 100644 testdata/beside/mesh-catalog/modules/dnsmasq/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/docker-compose/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/docker/files/daemon.json delete mode 100644 testdata/beside/mesh-catalog/modules/docker/files/docker-prune.service delete mode 100644 testdata/beside/mesh-catalog/modules/docker/files/docker-prune.timer delete mode 100644 testdata/beside/mesh-catalog/modules/docker/files/log-rotation.conf delete mode 100644 testdata/beside/mesh-catalog/modules/docker/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/dunst/files/dunstrc delete mode 100644 testdata/beside/mesh-catalog/modules/dunst/files/i3/50-dunst.conf delete mode 100644 testdata/beside/mesh-catalog/modules/dunst/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/fail2ban delete mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/iptables-allports-dualchain.conf delete mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/jail.local delete mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/mesh-own-addresses.json delete mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/own-addresses delete mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/recidive.conf delete mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/files/sshd.conf delete mode 100644 testdata/beside/mesh-catalog/modules/fail2ban/module.json.captured delete mode 100755 testdata/beside/mesh-catalog/modules/feh/files/fehbg delete mode 100644 testdata/beside/mesh-catalog/modules/feh/files/i3/50-feh.conf delete mode 100644 testdata/beside/mesh-catalog/modules/feh/files/xinitrc-normal delete mode 100644 testdata/beside/mesh-catalog/modules/feh/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/flatpak/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/fonts/files/50-mesh-fonts.conf delete mode 100644 testdata/beside/mesh-catalog/modules/fonts/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/forticlient/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/agent-teams.json delete mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/consumers.json delete mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/origins delete mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/repositories.json delete mode 100644 testdata/beside/mesh-catalog/modules/gitea/files/server.env delete mode 100644 testdata/beside/mesh-catalog/modules/gitea/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/gitlab/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/gitlab/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/login delete mode 100644 testdata/beside/mesh-catalog/modules/gnome-keyring/files/pam/passwd delete mode 100644 testdata/beside/mesh-catalog/modules/gnome-keyring/files/xinitrc-first delete mode 100644 testdata/beside/mesh-catalog/modules/gnome-keyring/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/go/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/admin.secret delete mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/influxdb-api delete mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/mesh-influxdb.yaml delete mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/oidc-client.secret delete mode 100644 testdata/beside/mesh-catalog/modules/grafana/files/oidc.env delete mode 100644 testdata/beside/mesh-catalog/modules/grafana/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/hello-web/files/index.html delete mode 100644 testdata/beside/mesh-catalog/modules/hello-web/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/home-assistant/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/home-assistant/files/provisions.env delete mode 100644 testdata/beside/mesh-catalog/modules/home-assistant/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/hostname/files/hostname delete mode 100644 testdata/beside/mesh-catalog/modules/hostname/files/hosts delete mode 100644 testdata/beside/mesh-catalog/modules/hostname/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/i3/files/config delete mode 100644 testdata/beside/mesh-catalog/modules/i3/files/env-xdg-current-desktop delete mode 100644 testdata/beside/mesh-catalog/modules/i3/files/env-xdg-session-desktop delete mode 100755 testdata/beside/mesh-catalog/modules/i3/files/wms/i3 delete mode 100644 testdata/beside/mesh-catalog/modules/i3/files/xinitrc-last delete mode 100644 testdata/beside/mesh-catalog/modules/i3/module.json.captured delete mode 100755 testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3bar-watchdog delete mode 100755 testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-updates delete mode 100755 testdata/beside/mesh-catalog/modules/i3status-rust/files/bin/i3status-value delete mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/bottom-bar.toml delete mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/i3/60-i3status-rust.conf delete mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/icons/custom-icons.toml delete mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/top-bar.toml delete mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/files/xinitrc-normal delete mode 100644 testdata/beside/mesh-catalog/modules/i3status-rust/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/icecast/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/icecast/files/icecast.xml delete mode 100644 testdata/beside/mesh-catalog/modules/icecast/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/influxdb/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/influxdb/files/server.env delete mode 100644 testdata/beside/mesh-catalog/modules/influxdb/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/invoicing/files/api.env delete mode 100644 testdata/beside/mesh-catalog/modules/invoicing/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/jira/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/jira/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/kernel/files/90-mesh-kernel-boot-entries.hook delete mode 100644 testdata/beside/mesh-catalog/modules/kernel/files/boot-entries.sh delete mode 100644 testdata/beside/mesh-catalog/modules/kernel/files/huge-pages.sh delete mode 100644 testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-boot-entries.service delete mode 100644 testdata/beside/mesh-catalog/modules/kernel/files/mesh-kernel-huge-pages.service delete mode 100644 testdata/beside/mesh-catalog/modules/kernel/files/modules-load.conf delete mode 100644 testdata/beside/mesh-catalog/modules/kernel/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/admin.env delete mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/consumers.json delete mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/database.env delete mode 100644 testdata/beside/mesh-catalog/modules/keycloak/files/hostname.env delete mode 100644 testdata/beside/mesh-catalog/modules/keycloak/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/lab/files/lab.env delete mode 100644 testdata/beside/mesh-catalog/modules/lab/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/lemurs/files/config.toml delete mode 100755 testdata/beside/mesh-catalog/modules/lemurs/files/xserver delete mode 100644 testdata/beside/mesh-catalog/modules/lemurs/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/letta/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/letta/files/pgpass delete mode 100644 testdata/beside/mesh-catalog/modules/letta/files/server.env delete mode 100644 testdata/beside/mesh-catalog/modules/letta/files/start.sh delete mode 100644 testdata/beside/mesh-catalog/modules/letta/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/local-model-consumer/files/openai.env delete mode 100644 testdata/beside/mesh-catalog/modules/local-model-consumer/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/localization/files/locale.conf delete mode 100644 testdata/beside/mesh-catalog/modules/localization/files/vconsole.conf delete mode 100644 testdata/beside/mesh-catalog/modules/localization/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/logrotate/files/logrotate.conf delete mode 100644 testdata/beside/mesh-catalog/modules/logrotate/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/admin.env delete mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/database.env delete mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/mailu.env delete mode 100644 testdata/beside/mesh-catalog/modules/mailu/files/secret.env delete mode 100644 testdata/beside/mesh-catalog/modules/mailu/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/marrytts/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/matrix/files/conduit.toml delete mode 100644 testdata/beside/mesh-catalog/modules/matrix/files/element.json delete mode 100644 testdata/beside/mesh-catalog/modules/matrix/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/oomd.conf delete mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/root-slice.conf delete mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/sysctl.conf delete mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/user-manager.conf delete mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/files/zram-generator.conf delete mode 100644 testdata/beside/mesh-catalog/modules/memory-pressure/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/Z.txt delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/a.txt delete mode 100755 testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/bin/run delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/testdata/repos/archive/modules/demo/files/sub/b.conf delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/files/forge delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/files/gitconfig delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/files/known_hosts delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/files/registry delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-go/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-image/files/forge delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-image/files/gitconfig delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-image/files/known_hosts delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-image/files/registry delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-image/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-image/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/files/forge delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/files/gitconfig delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/files/known_hosts delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/files/registry delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-build-node/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-catalog/files/database.url delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-catalog/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-cli/files/zsh-normal delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-cli/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-conditions/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-config/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-delivery-gate/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-delivery-gate/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-files/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-hand-acts/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-healers/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-issues/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-issues/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-knowledge/files/database.url delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-knowledge/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-knowledge/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-notifier/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/callers.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-reader/files/database-url delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-reader/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-web/files/oidc.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-web/files/operators.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail-web/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/accounts.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/database-url delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/grants.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/files/store.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-mail/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-provisioner-client/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-provisioner/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-reads/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-self-check/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-vault/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-watcher/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/mesh-watcher/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/messenger/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/messenger/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/minio/files/object-store.json delete mode 100644 testdata/beside/mesh-catalog/modules/minio/files/root.env delete mode 100644 testdata/beside/mesh-catalog/modules/minio/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/model-usage/files/database.url delete mode 100644 testdata/beside/mesh-catalog/modules/model-usage/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mongodb/files/server-root.secret delete mode 100644 testdata/beside/mesh-catalog/modules/mongodb/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mosquitto/files/bootstrap.env delete mode 100644 testdata/beside/mesh-catalog/modules/mosquitto/files/mosquitto.conf delete mode 100644 testdata/beside/mesh-catalog/modules/mosquitto/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mounts/files/mounts.conf delete mode 100644 testdata/beside/mesh-catalog/modules/mounts/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/mssql/files/backup.sql delete mode 100644 testdata/beside/mesh-catalog/modules/mssql/files/sa.env delete mode 100644 testdata/beside/mesh-catalog/modules/mssql/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/n8n/files/n8n-database.secret delete mode 100644 testdata/beside/mesh-catalog/modules/n8n/files/server.env delete mode 100644 testdata/beside/mesh-catalog/modules/n8n/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/nats/files/nats.conf delete mode 100644 testdata/beside/mesh-catalog/modules/nats/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/netcheck/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/networkmanager/files/50-mesh.conf delete mode 100644 testdata/beside/mesh-catalog/modules/networkmanager/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/nextcloud-client/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/nextcloud/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/nextcloud/files/server.env delete mode 100644 testdata/beside/mesh-catalog/modules/nextcloud/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/nfs-server/files/50-mesh.conf delete mode 100644 testdata/beside/mesh-catalog/modules/nfs-server/files/shares.conf delete mode 100644 testdata/beside/mesh-catalog/modules/nfs-server/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/nftables/files/mesh-filter.service delete mode 100644 testdata/beside/mesh-catalog/modules/nftables/files/mesh.conf delete mode 100644 testdata/beside/mesh-catalog/modules/nftables/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/nm-applet/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/node-env/files/50-mesh.conf delete mode 100644 testdata/beside/mesh-catalog/modules/node-env/files/environment.sh delete mode 100644 testdata/beside/mesh-catalog/modules/node-env/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/nodejs/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/mqtt.env delete mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/settings.js delete mode 100644 testdata/beside/mesh-catalog/modules/nodered/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/nodered/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/ollama/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/only-office/files/server.env delete mode 100644 testdata/beside/mesh-catalog/modules/only-office/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/openai-consumer/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/openrazer/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/pacman/files/keeper.json delete mode 100644 testdata/beside/mesh-catalog/modules/pacman/files/pacman.conf delete mode 100644 testdata/beside/mesh-catalog/modules/pacman/files/reflector.conf delete mode 100644 testdata/beside/mesh-catalog/modules/pacman/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/photos-eef/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/photos-filip/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/picom/files/picom.conf delete mode 100644 testdata/beside/mesh-catalog/modules/picom/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/polychromatic/files/display-session delete mode 100644 testdata/beside/mesh-catalog/modules/polychromatic/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/postgres/files/consumers.json delete mode 100644 testdata/beside/mesh-catalog/modules/postgres/module.json.captured delete mode 100755 testdata/beside/mesh-catalog/modules/power/files/bin/power-moment delete mode 100644 testdata/beside/mesh-catalog/modules/power/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/bar-battery delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/bar-power-draw delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-boot delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/after-wake delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-shutdown delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/before-sleep delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-battery delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/mesh-power/moments/on-mains delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/logind.conf.d/power.conf delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/halt.target.d/mesh-power.conf delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hibernate.target.d/mesh-power.conf delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/hybrid-sleep.target.d/mesh-power.conf delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-boot.service delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-after-wake.service delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-shutdown.service delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-before-sleep.service delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/mesh-power-supply.service delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/poweroff.target.d/mesh-power.conf delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/reboot.target.d/mesh-power.conf delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/sleep.target.d/mesh-power.conf delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/systemd/system/suspend.target.d/mesh-power.conf delete mode 100644 testdata/beside/mesh-catalog/modules/power/templates/udev/rules.d/90-mesh-power.rules delete mode 100644 testdata/beside/mesh-catalog/modules/powerlevel10k/files/zsh-normal delete mode 100644 testdata/beside/mesh-catalog/modules/powerlevel10k/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/records/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/records/files/origin delete mode 100644 testdata/beside/mesh-catalog/modules/records/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/redis/files/redis.conf delete mode 100644 testdata/beside/mesh-catalog/modules/redis/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/restic/files/backups.conf delete mode 100644 testdata/beside/mesh-catalog/modules/restic/files/data.conf delete mode 100644 testdata/beside/mesh-catalog/modules/restic/files/retention.conf delete mode 100644 testdata/beside/mesh-catalog/modules/restic/module.json.captured delete mode 100755 testdata/beside/mesh-catalog/modules/rofi/files/bin/dmenu delete mode 100755 testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-launch delete mode 100755 testdata/beside/mesh-catalog/modules/rofi/files/bin/rofi-powermenu delete mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/config.rasi delete mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/i3/50-rofi.conf delete mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh-powermenu.rasi delete mode 100644 testdata/beside/mesh-catalog/modules/rofi/files/themes/mesh.rasi delete mode 100644 testdata/beside/mesh-catalog/modules/rofi/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/route-adapter/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/route-adapter/module.json.captured delete mode 100755 testdata/beside/mesh-catalog/modules/screen-lock/files/bin/screen-lock delete mode 100644 testdata/beside/mesh-catalog/modules/screen-lock/files/xinitrc-normal delete mode 100644 testdata/beside/mesh-catalog/modules/screen-lock/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/searxng/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/searxng/files/settings.yml delete mode 100644 testdata/beside/mesh-catalog/modules/searxng/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/sensors/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/showcase/files/README.md delete mode 100644 testdata/beside/mesh-catalog/modules/showcase/files/marker.txt delete mode 100644 testdata/beside/mesh-catalog/modules/showcase/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/showcase/templates/showcase.env delete mode 100644 testdata/beside/mesh-catalog/modules/slack/files/display-session delete mode 100644 testdata/beside/mesh-catalog/modules/slack/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/snapd/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/config delete mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/forge delete mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/gitconfig delete mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/files/known_hosts delete mode 100644 testdata/beside/mesh-catalog/modules/ssh-client/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/sshd/files/10-mesh.conf delete mode 100644 testdata/beside/mesh-catalog/modules/sshd/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/step-ca/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/step-ca/files/init.env delete mode 100644 testdata/beside/mesh-catalog/modules/step-ca/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/sudo/files/10-mesh-operator delete mode 100644 testdata/beside/mesh-catalog/modules/sudo/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/analytics-env.env delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/analytics-reconcile.exs delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/analytics-start.sh delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/jwt.sql delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/logs.sql delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/pooler.sql delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/realtime.sql delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/roles.sql delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/supabase.sql delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/db-init/webhooks.sql delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/functions-env.env delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/functions-main/index.ts delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/gotrue.env delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/kong.yml delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/meta-env.env delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/pooler-env.env delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/pooler.exs delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/postgrest.conf delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/realtime-env.env delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/settings.json delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/storage-env.env delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/studio-env.env delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/files/vector.yml delete mode 100644 testdata/beside/mesh-catalog/modules/supabase/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/systemd-networkd/files/00-mesh0.network delete mode 100644 testdata/beside/mesh-catalog/modules/systemd-networkd/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/systemd-resolved/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/systemd/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/telegram/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/time-sync/files/50-mesh.conf delete mode 100644 testdata/beside/mesh-catalog/modules/time-sync/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/triggerhappy/files/service.conf delete mode 100644 testdata/beside/mesh-catalog/modules/triggerhappy/files/triggers.conf delete mode 100644 testdata/beside/mesh-catalog/modules/triggerhappy/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/umami/files/server.env delete mode 100644 testdata/beside/mesh-catalog/modules/umami/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/unifi/files/config.json delete mode 100644 testdata/beside/mesh-catalog/modules/unifi/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/website/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/xclip/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/mimeapps.list delete mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.conf delete mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.dirs delete mode 100644 testdata/beside/mesh-catalog/modules/xdg/files/user-dirs.locale delete mode 100644 testdata/beside/mesh-catalog/modules/xdg/module.json.captured delete mode 100755 testdata/beside/mesh-catalog/modules/xorg/files/xinitrc delete mode 100644 testdata/beside/mesh-catalog/modules/xorg/files/xresources delete mode 100644 testdata/beside/mesh-catalog/modules/xorg/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/xterm/files/env-terminal delete mode 100644 testdata/beside/mesh-catalog/modules/xterm/files/xresources-normal delete mode 100644 testdata/beside/mesh-catalog/modules/xterm/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/zfs/files/scrub.conf delete mode 100644 testdata/beside/mesh-catalog/modules/zfs/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/zsh-autosuggestions/files/zsh-normal delete mode 100644 testdata/beside/mesh-catalog/modules/zsh-autosuggestions/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/zsh-syntax-highlighting/files/zsh-last delete mode 100644 testdata/beside/mesh-catalog/modules/zsh-syntax-highlighting/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-editor delete mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-path-local-bin delete mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-path-scripts delete mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-path-scripts-bin delete mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-visual delete mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/env-xdg-config-home delete mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/tools.conf delete mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/zshenv delete mode 100644 testdata/beside/mesh-catalog/modules/zsh/files/zshrc delete mode 100644 testdata/beside/mesh-catalog/modules/zsh/module.json.captured delete mode 100644 testdata/beside/mesh-catalog/predefined.json delete mode 100644 testdata/beside/mesh-host/examples/foundation-first-node.lock delete mode 100644 testdata/beside/mesh-host/internal/apply/placement_guard.go.captured delete mode 100644 testdata/beside/mesh-sdk/conformance/events/module-event.json diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index f4fe65b4..00000000 --- a/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -# The Go it builds with, pinned here because genesis builds this file with no arguments (novox/hq -# issue 223) — the Makefile passes the same digest. A tag older than go.mod asks for is how -# `make image` broke once before (issue 146). -ARG GO_BASE=golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c -# The control plane's image — for genesis and the lab only. The mesh runs the controller as a Go -# bundle the host starts as a process (module.json; novox/hq issue 213), and builds no image of it. -# Genesis builds this file and raises it as the container the process replaces on the first push -# (mesh-host internal/bootstrap, novox/hq issue 223). -# -# novox/hq ADR 0006: this image is pinned by digest in the bundle the host carries, fetched on a -# machine where no mesh exists yet, and run before there is anything to check it against. So it -# holds the program and nothing else — no shell, no package manager, no libc, nothing with a CVE -# feed of its own. What a person has to audit before trusting a first node is one binary. -# -# There are no CA certificates in here on purpose. Nothing it does today makes an outbound TLS -# connection to a public name: it reaches PostgreSQL on the machine it was raised on, and the -# broker is verified against a fingerprint pinned in a token rather than against a public root -# (novox/hq ADR 0004). Adding them "just in case" would put a trust store in the one image whose -# whole argument is that it contains nothing to reason about. - -FROM ${GO_BASE} AS build -WORKDIR /src - -# **No dependency is committed** (issue 1305): go.sum names each one's version and checksum, and the build -# resolves them with -mod=mod. Dependencies first, so a change to the source does not re-copy them. -COPY go.mod go.sum ./ - -# `COPY . .` brings `.git` along, so Go is told not to stamp the commit: the version is said by -X, and a -# rebuild of an unchanged program is the same bytes (novox/hq ADR 0267 rule 6, issue 363). -COPY . . -ARG VERSION=development -RUN CGO_ENABLED=0 GOFLAGS=-mod=mod go build -trimpath -buildvcs=false \ - -ldflags "-s -w -X main.version=${VERSION}" \ - -o /mesh-controller ./cmd/mesh-controller - -FROM scratch -COPY --from=build /mesh-controller /mesh-controller - -# Numeric because there is no /etc/passwd to look a name up in. Nothing here needs to be root: -# it opens outbound connections and writes nothing to its own filesystem. -USER 65534:65534 - -ENTRYPOINT ["/mesh-controller"] diff --git a/Makefile b/Makefile deleted file mode 100644 index 9636d6e0..00000000 --- a/Makefile +++ /dev/null @@ -1,112 +0,0 @@ -# novox/hq ADR 0006 — the control plane, in Go. -# -# The image the bundle pins holds the program and nothing else, so the build is static and the -# container is built FROM scratch. That is not a size optimisation: this image is fetched by -# digest and run on a machine where no mesh exists to check anything, and everything in it is -# something a person would have to audit. - -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo development) -LDFLAGS := -s -w -X main.version=$(VERSION) - -# Where `make check` raises PostgreSQL. A high port and a throwaway container: nothing here -# touches a database anybody else is using. Override PG_PORT if this one is taken -- the first -# port chosen was already serving something that had been up for six days. -PG_PORT ?= 55532 -PG_CONTAINER ?= mesh-controller-check -PG_IMAGE ?= postgres:17-alpine -export MESH_TEST_POSTGRES ?= postgres://postgres:check@127.0.0.1:$(PG_PORT)/postgres?sslmode=disable - -.PHONY: build image check test vet fmt postgres postgres-stop clean - -build: - CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o build/mesh-controller ./cmd/mesh-controller - -# Tagged 'development' as well as by version, because the lab places images by name and a -# scenario naming a version would have to be edited on every build. The version tag is what a -# real bundle pins. -IMAGE ?= mesh-controller:$(VERSION) -DEV_TAG ?= mesh-controller:development - -# The Go base the image is built on. -# -# **`make image` was broken and stayed broken**, because the Dockerfile's fallback base was a Go -# older than go.mod asks for: every build died at `go mod download` with "go.mod requires go >= -# 1.26.0", and the pipeline never saw it because the pipeline passes the declared base in. Anybody -# building the image by hand hit it and had to find the digest themselves (novox/hq 04-ISSUES/146, -# what it cost). -# -# **Pinned here since the manifest stopped building an image** (novox/hq issue 213): the mesh builds -# the controller as a Go bundle with its own toolchain, and only `make image` — genesis and the lab — -# still needs a Go base. The digest is the one the manifest declared until then. -GO_BASE ?= golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c - -image: - @test -n "$(GO_BASE)" || { echo "no GO_BASE; pass GO_BASE="; exit 1; } - docker build --build-arg GO_BASE=$(GO_BASE) --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) . - @echo - @docker image inspect $(IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes' - -# The provisioner ships as an image too, because it is the thing that makes a sealed credential -# true on a machine -- and the mesh cannot, having discarded the plaintext. -PROVISIONER_IMAGE ?= mesh-provision-postgres:$(VERSION) -PROVISIONER_DEV_TAG ?= mesh-provision-postgres:development - -provisioner-image: - docker build --build-arg GO_BASE=$(GO_BASE) -f examples/postgres-provisioner/Dockerfile \ - -t $(PROVISIONER_IMAGE) -t $(PROVISIONER_DEV_TAG) . - @echo - @docker image inspect $(PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes' - -# The object store's provisioner, for the same reason: a bucket and a policy are not files, and -# the mesh cannot make them -- it discarded the credential it would have to use. -OBJECTSTORE_IMAGE ?= mesh-provision-objectstore:$(VERSION) -OBJECTSTORE_DEV_TAG ?= mesh-provision-objectstore:development - -objectstore-image: - docker build --build-arg GO_BASE=$(GO_BASE) -f examples/objectstore-provisioner/Dockerfile \ - -t $(OBJECTSTORE_IMAGE) -t $(OBJECTSTORE_DEV_TAG) . - @echo - @docker image inspect $(OBJECTSTORE_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes' - -# The cache's provisioner, for the same reason as the database's: an ACL user is not a file, -# and the mesh cannot make one -- it discarded the credential it would have to use. -REDIS_PROVISIONER_IMAGE ?= mesh-provision-redis:$(VERSION) -REDIS_PROVISIONER_DEV_TAG ?= mesh-provision-redis:development - -redis-provisioner-image: - docker build --build-arg GO_BASE=$(GO_BASE) -f examples/redis-provisioner/Dockerfile \ - -t $(REDIS_PROVISIONER_IMAGE) -t $(REDIS_PROVISIONER_DEV_TAG) . - @echo - @docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes' - -# The unit level alone (untagged: no bus, no store, no other process), which a developer runs continuously. The tests -# that need a bus or the store carry `//go:build integration` and run in `check`, not here; without a database the -# store's tests would skip rather than fail anyway. -test: - go test -timeout 15m ./... - -vet: - go vet ./... - -# A quick step for a developer: the checker runs the build module's own formatting and vet (ADR 0501 §5, D7). -fmt: - @unformatted=$$(gofmt -l cmd internal examples) ; \ - if [ -n "$$unformatted" ] ; then echo "not gofmt'd:" ; echo "$$unformatted" ; exit 1 ; fi - -postgres: - @docker rm -f -v $(PG_CONTAINER) >/dev/null 2>&1 || true - @docker run -d --name $(PG_CONTAINER) -e POSTGRES_PASSWORD=check \ - -p 127.0.0.1:$(PG_PORT):5432 $(PG_IMAGE) >/dev/null - @printf 'waiting for postgres' - @for i in $$(seq 1 60) ; do \ - if docker exec $(PG_CONTAINER) pg_isready -U postgres >/dev/null 2>&1 ; then \ - echo ' — ready' ; exit 0 ; fi ; \ - printf '.' ; sleep 1 ; \ - done ; \ - echo ' — never came up' ; docker logs $(PG_CONTAINER) | tail -20 ; exit 1 - -postgres-stop: - @docker rm -f -v $(PG_CONTAINER) >/dev/null 2>&1 || true - -clean: - rm -rf build/ diff --git a/README.md b/README.md index 56695985..a1de1323 100644 --- a/README.md +++ b/README.md @@ -1,218 +1,7 @@ # mesh-controller -**Tier 2 of Novox Mesh — the control plane.** Everything that needs to know about more than one -node. +The controller ended under issue 1126. Its jobs moved to the parts that ADR 0590 and ADR 0593 name: the indexer, the +assigner, the provisioner, the grants part and the node-engine. Its last module, route-proxy, moved to mesh-networking. -That is the whole test, and it draws the line the host cannot: the host applies and does not -decide, *because deciding needs knowledge one machine does not have*. Which nodes should run the -store, which peers belong in an overlay, whether a node has been unreachable for a week — nobody -on a single machine can answer any of them. - -The reasoning lives in [novox/hq](https://git.novox.be/novox/hq); this repository carries no -argument that is not settled there. - -## What it is not - -- **Not the thing that changes machines.** It decides; the host applies. It never reaches into a - node except through the host, over the link, in a bounded vocabulary. -- **Not a database.** There is no mesh database. Each context owns its own store and nothing - outside a context touches it — including nodes, which hold no credential to any of them. -- **Not privileged.** It has no more access to a machine than a declaration can express. - -## What exists today - -**One context of seven, and one of the things it will do.** - -| | | -|---|---| -| `inventory` | node records and enrolment tokens — **built, as far as identity** | -| `identity` | the control plane's signing key, and the keys nodes are known by — **built** | -| `config`, `connectivity`, `provisioning`, `delivery`, `observability` | not built | -| the interface every surface speaks to | not built; its shape is not decided | - -``` -mesh-controller migrate bring each context's schema up to date -mesh-controller node add create a node record -mesh-controller node list the nodes this mesh knows about -mesh-controller token issue --node a one-time right to join, for an existing record -mesh-controller token issue --new create the record and issue for it -mesh-controller identity show this control plane's signing key -mesh-controller broker show where the broker is, and what to expect there -mesh-controller version what this binary is -``` - -`migrate` is **step 3 of the foundation bootstrap** — the step the first node cannot get past, run -against a database raised moments earlier from the bundle the host carries. - -### Tokens, and what they are missing - -A token is **a one-time right to join, issued for a node record** — which is where re-enrolment is -decided, since what an identity binds to is settled when the token is made rather than when it is -presented. - -What is built: the secret is 256 bits from the system's random source, shown once, and **stored -only as a hash**, so a copy of this database is not a set of working credentials. It is usable -exactly once and only before it expires, and both are read from the row rather than from a status -something would have had to write. Issuing again for the same node invalidates the outstanding -one — two live tokens are two machines able to join as the same node. - -Redemption is a single statement that both finds a live token and spends it, so eight concurrent -attempts on one secret produce exactly one winner. There is a test that runs them. - -**A token now carries three of its four parts**, and is one line of base64 a person can copy. The -signing key is real: an Ed25519 key this control plane generates once and keeps, whose public half -travels in every token. A node believes a declaration because it carries a signature that key made -— and pinning only the broker would not do, because it would make the control plane's authority -transitive, so a compromised broker could forge declarations, and since the host applies whatever -the link delivers that is the whole machine. - -**All four parts are built.** Given `MESH_BROKER_ADDRESS` and `MESH_BROKER_CERTIFICATE`, a token -carries everything ADR 0004 requires. Without them it carries two, and `token issue` prints it -**and names what is missing** rather than producing something that looks complete and cannot be -used. - -**The fingerprint is derived from the certificate, never configured.** A configured one can drift -from the certificate it describes, and a drifted pin is worse than none: every node issued a token -during the drift refuses to connect, and the failure looks like an attack. It is computed over the -DER bytes — what a client actually sees on the wire — so the same certificate re-wrapped with -different line endings still produces the same pin. - -### Two contexts, and the rule between them is real - -`identity` is the second context and it exists partly to test a claim this repository had made and -never checked: that a context reaches only its own store. It holds `MESH_STORE_IDENTITY`; -`inventory` holds `MESH_STORE_INVENTORY`; there is no setting that reaches both and no way to ask -for one. Run `migrate` with only one and it stops, naming the grant it does not have. - -A token needs a node record from one and a signing key from the other. Neither reads the other's -store — the process holding both grants asks each for its part. - -### How a node is known - -**The node generates a keypair; the mesh records the public half.** The same principle as SSH, and -the same rule `08-connectivity` already applies to the overlay keys — which is where this was -settled all along, though it took being asked directly to notice. - -Only the public half is ever stored, and that is the property worth having: **a copy of this -database grants nothing.** It is a list of who to believe, not a set of credentials, which is what -makes *compromise of a node is compromise of that node* literally true. - -Exactly one key is live per node. Re-enrolment revokes the one it replaced, in the same -transaction — two live identities for one node record is the stolen-laptop case, with the replaced -machine still believed. The database enforces it as well as the code, and there is a test running -six concurrent enrolments that fails when the constraint is removed. - -**Not the machine's SSH host key**, though that was the obvious economy. Host keys are regenerated -by reinstalls and image clones, which would silently un-enrol a node; their lifecycle belongs to -sshd rather than the mesh; and a partial host has no SSH daemon at all. - -## Reaching a store - -**A context is granted only what it exclusively owns.** No shared writes, no read-only role on -another context's store, and no connection string that reaches more than one. - -That is a rule about credentials, so it is built as one. There is no mesh-wide connection setting -and no way to ask for one: - -``` -MESH_STORE_INVENTORY=postgres://…/inventory -``` - -A process granted `inventory` holds that variable and no other. Reaching another context's store -is not a matter of restraint — it has no address for it and no credential to present. And it is -how the rule is *checked*: what a context can reach is visible in the declaration that runs it, -as the list of variables it was given. - -Each context's database is named after the context. There is deliberately no database named for -the mesh as a whole. - -## Migrations - -Numbered, embedded in the binary, applied in order, each in a transaction with the row recording -it. The applying is four lines; the rest is refusals, and the refusals are the point: - -| it stops when | because | -|---|---| -| a migration that ran has since been edited | the database holds the old version, the repository holds the new one, and nothing holds the difference | -| a migration is numbered below one that already ran | usually two branches taking the same next number — applying it now runs the schema in an order nobody tested | -| two migrations share a number | order is the entire guarantee, and two files with one number have none | -| a file in the directory is not a valid migration name | a misnamed migration would otherwise never run and nothing would say so | -| a migration is empty | it records that something happened and changes nothing, which cannot be told from a mistake | - -All of them stop rather than warn. At the moment any of them is true, nobody knows what the -database contains, and there is no correct guess about a schema. - -Running it again does nothing. Two copies running at once take an advisory lock, so a restart -during a slow migration does not become two runners racing. - -## Building - -``` -make build the binary -make image the container image -make check gofmt, vet, and every test against a real PostgreSQL -``` - -`make check` raises a throwaway PostgreSQL in a container and takes it down afterwards, including -when the tests fail. Without one the tests that need a database **skip and say so** rather than -passing quietly — `make test` is the honest subset, not the gate. - -Tests run against a real database rather than a fake because what is being tested *is* the -database's behaviour: that DDL is transactional, that an advisory lock serialises, that a -checksum mismatch is caught against a record PostgreSQL actually kept. A fake would assert that -the fake behaves as expected. - -Every test here has been confirmed to fail when the behaviour it defends is removed. Two did not, -when first written, and both are now commented with what they were missing. - -## Checks that cross into the host's repository - -Two things are agreed between this repository and `novox/mesh-host`, and each is a separate struct -on each side. A field renamed on one of them fails **silently** — the crossing succeeds and -something is simply absent — so both are checked by handing one side's real output to the other's -real parser. Neither runs by default; each skips with a reason, because a repository that fails -without its neighbour checked out is a repository nobody can build. - -**What this mesh sends, read by the host that receives it:** - -``` -mesh-controller: ./build/mesh-controller plan --json > /tmp/d.json -mesh-host: MESH_EMITTED=/tmp/d.json go test ./internal/declaration/ -v -``` - -**What a node says when it joins, read by this mesh:** - -``` -mesh-host: MESH_ENROL_OUT=/tmp/enrol.json go test ./internal/link/ -mesh-controller: MESH_ENROL=/tmp/enrol.json make check -``` - -The second does more than compare shapes: it seals something to the key that arrived and opens it -with the private half the host kept. A key that is present, correctly named and simply *wrong* -passes every check that only looks at the message. - -## The image - -**The mesh no longer runs the controller from it** (novox/hq issue 213). The module declares a Go -bundle, `controller`, which the host on the controller's machine unpacks and runs as the process -`mesh-controller` under the account of the same name (ADR 0188 §1, §3). The image stays for what -still runs a container of the controller: genesis, which raises the first controller from it and -installs the module from its manifest (mesh-host `internal/bootstrap`), and the lab. Neither is the -mesh's own build any more — `make image` builds it. - -`FROM scratch`, holding one statically linked binary and nothing else — no shell, no package -manager, no libc, no CA certificates. - -Not a size optimisation. The bundle a host carries pins this image by digest, and it is fetched -and run on a machine where no mesh exists to check anything and a person is expected to have read -the bundle and believed it. Everything in the image is something that person would have to audit. - -## Where the reasoning lives - -| | | -|---|---| -| what the control plane is | novox/hq ADR 0006 | -| what it takes to run one, and why Go | novox/hq ADR 0006 | -| a context owns its store, exclusively | novox/hq ADR 0008 | -| schema changes are numbered migrations | novox/hq ADR 0013 | -| a test defends a decision | novox/hq ADR 0017 | +This repository holds no code and is archived once the delivery of the group that emptied it (release sdk1) is done. +Its history stays here for reading. diff --git a/cmd/mesh-controller/accept_outside.go b/cmd/mesh-controller/accept_outside.go deleted file mode 100644 index dd83125e..00000000 --- a/cmd/mesh-controller/accept_outside.go +++ /dev/null @@ -1,149 +0,0 @@ -package main - -import ( - "context" - "fmt" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/inventory" -) - -// A provision whose credential only a person outside the mesh can give (novox/hq ADR 0337, issue 164, issue 165): -// given once for its provider, at the controller's terminal, and sealed in one act to every consumer that binds the -// provision from that provider now. The value is never read back, printed or kept. - -// outsideOffer is the provider module's own name when `secret accept ` names a provision that -// module offers with a credential issued outside the mesh, and "" when it names anything else (an own secret, or a -// pair with --provider). -func outsideOffer(ctx context.Context, module, name, provider string) (string, error) { - if provider != "" { - return "", nil - } - open, err := openStores(ctx) - if err != nil { - return "", err - } - defer open.Close() - shelf, err := open.inventory.Catalogue(ctx) - if err != nil { - return "", err - } - if m, known := shelf[module]; known && m.IssuedOutsideOf(name) { - return module, nil - } - return "", nil -} - -// offeredOutsideBy is a module that offers the provision with a credential issued outside the mesh, or "". -func offeredOutsideBy(ctx context.Context, provision string) (string, error) { - open, err := openStores(ctx) - if err != nil { - return "", err - } - defer open.Close() - shelf, err := open.inventory.Catalogue(ctx) - if err != nil { - return "", err - } - module, _ := catalogue.OffersIssuedOutside(shelf, provision) - return module, nil -} - -// consumersFrom is every consumer that binds a provision from one provider now, found by resolving every machine as -// its next send would, and the machines that could not be resolved, with why. -func consumersFrom(ctx context.Context, open *stores, provider, providerModule, provision string) ( - []inventory.PairEnd, []string, error) { - // One reading of the mesh for every machine this looks at. - ctx = withInputCache(ctx) - nodes, err := open.inventory.Nodes(ctx) - if err != nil { - return nil, nil, err - } - var out []inventory.PairEnd - var unresolved []string - for _, n := range nodes { - plan, _, err := resolveNode(ctx, open, n.Name) - if err != nil { - unresolved = append(unresolved, fmt.Sprintf("%s: %s", n.Name, oneLine(err.Error()))) - continue - } - out = append(out, bindsFrom(plan, n.Name, provider, providerModule, provision)...) - } - return out, unresolved, nil -} - -// bindsFrom is every consumer on one machine whose resolution answers the provision from that provider. Pure. -func bindsFrom(plan catalogue.Resolution, machine, provider, providerModule, provision string) []inventory.PairEnd { - var out []inventory.PairEnd - for _, need := range plan.Needs { - if need.ByRecord || need.Name != provision || need.From != provider { - continue - } - if need.Module != "" && need.Module != providerModule { - continue - } - out = append(out, inventory.PairEnd{Consumer: machine, ConsumerModule: need.For, Local: need.Local}) - } - sort.Slice(out, func(i, j int) bool { - return out[i].ConsumerModule+"\x00"+out[i].Local < out[j].ConsumerModule+"\x00"+out[j].Local - }) - return out -} - -// acceptForProvision gives a provision's credential issued outside the mesh, once for its provider (ADR 0337 §3). -func acceptForProvision(ctx context.Context, provider, providerModule, provision, value string) error { - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - consumers, unresolved, err := consumersFrom(ctx, open, provider, providerModule, provision) - if err != nil { - return err - } - accepted, err := open.inventory.AcceptSecretForProvision(ctx, provision, provider, providerModule, value, consumers) - value = "" - if err != nil { - return err - } - fmt.Print(acceptedSaid(provider, providerModule, provision, accepted, unresolved)) - if len(accepted.Sealed) == 0 { - return nil - } - // Composed into each consumer's composition (novox/hq ADR 0539 clause 5): a new revision its want then names. - if _, err := composeModules(ctx, open, accepted.Machines); err != nil { - return fmt.Errorf("%s is sealed and its compositions on %s could not be written: %w", provision, - strings.Join(accepted.Machines, ", "), err) - } - fmt.Printf(" composed for %s; each consumer starts again on it once its want names the new composition\n", - strings.Join(accepted.Machines, ", ")) - return nil -} - -// acceptedSaid is what one accept for a provider did, in words that never carry the value. Pure. -func acceptedSaid(provider, providerModule, provision string, a inventory.ProvisionAccepted, unresolved []string) string { - var b strings.Builder - if len(a.Sealed) == 0 { - fmt.Fprintf(&b, "%s from %s on %s: no consumer binds it now, so nothing was sealed; a consumer that binds "+ - "later waits for it, and this line given again seals it to every consumer then\n", provision, - providerModule, provider) - } else { - var names []string - for _, c := range a.Sealed { - names = append(names, fmt.Sprintf("%s on %s%s", c.ConsumerModule, c.Consumer, asLocal(c.Local))) - } - fmt.Fprintf(&b, "%s from %s on %s is given, sealed to %s and to %s\n", provision, providerModule, provider, - strings.Join(names, ", "), provider) - fmt.Fprintf(&b, " the mesh cannot read it back, never makes one of its own for it, and will not rotate it\n") - } - for _, c := range a.Unsealable { - fmt.Fprintf(&b, " not sealed to %s on %s: no sealing key on that machine; give it again once it has one\n", - c.ConsumerModule, c.Consumer) - } - for _, u := range unresolved { - fmt.Fprintf(&b, " not known whether a consumer on %s binds it, which did not resolve; give it again once it does\n", u) - } - return b.String() -} diff --git a/cmd/mesh-controller/acting.go b/cmd/mesh-controller/acting.go deleted file mode 100644 index ebf60696..00000000 --- a/cmd/mesh-controller/acting.go +++ /dev/null @@ -1,414 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "os" - "strings" - "sync" - "time" - - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" - - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/lease" - "github.com/novox/mesh-controller/internal/link" -) - -// Acting under the lease (novox/hq to-be 45 §6, ADR 0227 rule 1). -// -// **Only the instance holding the lease acts**: sends a declaration, writes a plan, a condition or a -// call. Every one of those passes theLease.epoch, which answers the epoch the act carries or why it may -// not happen. Three ways a process stands to the lease: -// -// - **The serving controller** takes it before it does anything else — before it asserts the bus's -// objects, which are the controller's to write — waiting while another holds it, and renews it. -// Another holder seen, or the key's age passed with no renewal made, is the lease lost: the gate -// closes at once and the process exits, so its service manager restarts it as a candidate (serve, -// in push.go). A renewal the bus does not answer in time is not a loss (novox/hq issue 477): the -// lease is kept, no new act starts once half the key's age passed unrenewed, and the next renewal -// made opens the gate again. -// - **A command run at a shell** — `push` in the installer, the lab, a person repairing a mesh whose -// controller is down (issue 201) — acts **under the holder's epoch** when a controller holds the -// lease: it is the same mesh's word, composed and sent under the store's hold of each machine like -// the serving controller's, and the epoch it carries is read at the moment it acts, so a handover -// between makes it stale and refused like any other. **When nobody holds the lease, the command -// takes it** for as long as it runs and gives it back; a controller starting meanwhile waits for -// it, as it would for another controller. -// - **A process with no bus** — a test, a command that only reads — acts with no epoch and is -// refused nothing: there is nothing to order against, and nothing it does reaches a machine. -// -// **Unleased, said and temporary.** A serving controller whose bus refuses it the lease's key — the bus's -// user list is older than this build and does not grant the bucket yet — and that sees no other holder -// serves without one, as every controller did before the lease: declarations carry no epoch, which no -// node-engine refuses. Said once, kept as a condition (S12), and tried again every renewal interval; the -// first push that sends the bus its new user list grants it, and the next try takes it. Refusing to act -// instead would be a controller that can never send the user list that lets it act. - -// actor is this process's standing to the lease. -type actor struct { - mu sync.Mutex - // held is the lease this process holds: the serving controller's, or a command's own. - held *lease.Lease - // unleased is why a serving controller acts without the lease; empty while it holds it or is not - // serving. - unleased string - // serving is a serving controller, which never borrows another's epoch. - serving bool - // kv is the lease bucket, for a command to read the holder's epoch from. - kv jetstream.KeyValue - close func() - // noBus is a process with no bus configured. - noBus bool - // reset is when the lease bucket was found raised again from nothing and its revisions moved past - // the highest epoch issued, and what was said of it; zero when it was not (S12). - reset time.Time - resetSaid string - // renewEvery and poll, when set, are how often the serving controller renews the lease it holds and - // looks again at one another holds, in place of the lease's own: a test's, so two controllers hand the - // lease over in a second rather than in a renewal of five (novox/hq issue 591). A renewal is given - // the lease's own RenewEvery to answer however often it is made, so a slow bus does not lose it. - renewEvery, poll time.Duration - // releasing is held for the whole of a release, so a second caller — the stop signal's and serve's own - // deferred one — waits until the lease is given back and its epoch recorded, never past it to the store's - // close (novox/hq issue 906). - releasing sync.Mutex - // keeper and inv are where a takeover is said (sayTakeover): set once the serving controller's condition - // store opens; nil before, and in a command. - keeper *conditions.Keeper - inv *inventory.Inventory -} - -// theLease is this process's standing to the lease. -var theLease = &actor{} - -// instance names this process among controller instances: its machine, its process and when it -// started. The lease's holder and every call this process keeps carry it. -var instance = func() string { - host, _ := os.Hostname() - return fmt.Sprintf("controller@%s pid %d since %s", host, os.Getpid(), time.Now().UTC().Format(time.RFC3339)) -}() - -// epoch is the gate: the epoch an act carries — zero for none — or why it may not happen. -func (a *actor) epoch(ctx context.Context) (uint64, error) { - a.mu.Lock() - held, serving, unleased, noBus := a.held, a.serving, a.unleased, a.noBus - a.mu.Unlock() - switch { - case held != nil: - return held.Epoch() - case serving && unleased != "": - return 0, nil - case serving: - return 0, lease.ErrNotHeld - case noBus: - return 0, nil - } - return a.forACommand(ctx) -} - -// forACommand is a command's epoch: the holder's, or a lease of its own when nobody holds one. -func (a *actor) forACommand(ctx context.Context) (uint64, error) { - a.mu.Lock() - defer a.mu.Unlock() - if a.held != nil { - return a.held.Epoch() - } - if a.kv == nil { - address, err := broker.BusAddress() - if err != nil { - // No bus: this process reaches no machine, and has nothing to order against. - a.noBus = true - return 0, nil - } - js, err := broker.Dial(address) - if err != nil { - return 0, fmt.Errorf("the bus cannot be reached, so whether a controller holds the lease cannot be "+ - "read and nothing is done: %w", err) - } - api, err := jetstream.New(js.Conn()) - if err != nil { - js.Close() - return 0, err - } - reading, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - if err := broker.EnsureLeaseBucket(reading, api); err != nil { - js.Close() - return 0, err - } - kv, err := api.KeyValue(reading, broker.LeaseBucket) - if err != nil { - js.Close() - return 0, err - } - a.kv, a.close = kv, js.Close - if _, found, err := lease.Current(reading, kv); err == nil && !found { - // Nobody: this command takes it for as long as it runs. - l, err := lease.Open(reading, api, broker.LeaseBucket, lease.Options{Holder: holderOf(instance), - Say: func(format string, args ...any) { fmt.Printf(format+"\n", args...) }}) - if err != nil { - return 0, err - } - epoch, err := l.TryTake(reading) - if err != nil { - return 0, fmt.Errorf("no controller holds the lease and this command could not take it: %w", err) - } - keeping, stop := context.WithCancel(context.Background()) - go l.Keep(keeping) - a.held = l - closeBus := a.close - a.close = func() { - stop() - l.Release(context.Background()) - closeBus() - } - return epoch, nil - } - } - reading, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - holder, found, err := lease.Current(reading, a.kv) - if err != nil { - return 0, fmt.Errorf("who holds the controller lease cannot be read, so nothing is done: %w", err) - } - if !found { - return 0, errors.New("the controller that held the lease while this command ran let go of it; nothing " + - "more is done under an epoch nobody holds — run the command again") - } - return holder.Epoch, nil -} - -// release gives back what this process holds, at its end — or at the stop signal, before the rest of the -// process is taken down (serveUnderTheLease). A second call waits for the first to finish. -func (a *actor) release() { - a.releasing.Lock() - defer a.releasing.Unlock() - a.mu.Lock() - closing := a.close - a.close = nil - a.mu.Unlock() - if closing != nil { - closing() - } -} - -// holderOf is this process as the lease's holder. -func holderOf(instance string) lease.Holder { - host, _ := os.Hostname() - build := runningBuild() - if build == "" { - build = version - } - return lease.Holder{Instance: instance, Host: host, Build: build} -} - -// RunningBuildVar is where the declaration tells this process which build it is (module.json, the -// controller process's env): the version its bundle is delivered as, `${version}` composed by the -// catalogue from the bundle's digest. Empty for a process placed by hand. -const RunningBuildVar = "MESH_CONTROLLER_VERSION" - -// runningBuild is the version of the build this process is, or empty when the declaration did not say. -func runningBuild() string { - return strings.TrimSpace(os.Getenv(RunningBuildVar)) -} - -// serveUnderTheLease takes the lease for the serving controller, waiting while another holds it, and -// keeps it until ctx ends. Lost is closed when it is lost; the caller exits on it. -func (a *actor) serveUnderTheLease(ctx context.Context, inv *inventory.Inventory, address string) (lost <-chan struct{}, err error) { - a.mu.Lock() - a.serving = true - a.mu.Unlock() - // Its own connection, held as long as the lease, and named so (novox/hq issue 327). - js, err := broker.Dial(address, nats.Name(broker.ConnectionName+" lease")) - if err != nil { - return nil, fmt.Errorf("the mesh is on the bus at %s and this control plane cannot reach it to take the "+ - "lease: %w", broker.BareAddress(address), err) - } - api, err := jetstream.New(js.Conn()) - if err != nil { - js.Close() - return nil, err - } - asserting, cancel := context.WithTimeout(ctx, 10*time.Second) - err = broker.EnsureLeaseBucket(asserting, api) - cancel() - if err != nil { - js.Close() - return nil, err - } - say := func(format string, args ...any) { fmt.Printf(format+"\n", args...) } - l, err := lease.Open(ctx, api, broker.LeaseBucket, lease.Options{Holder: holderOf(instance), - RenewEvery: a.renewEvery, RenewWithin: lease.RenewEvery, Poll: a.poll, Floor: inv.HighestEpoch, Say: say, Health: controllerHealth, Moved: func(was, floor uint64) { - a.mu.Lock() - defer a.mu.Unlock() - a.reset = time.Now() - a.resetSaid = fmt.Sprintf("the lease bucket was at revision %d with epoch %d already issued: it was "+ - "raised again from nothing (a bus whose data was replaced), and its revisions were moved past %d so "+ - "no machine refuses the next epoch", was, floor, floor) - }}) - if err != nil { - js.Close() - return nil, err - } - gone := make(chan struct{}) - epoch, err := l.Take(ctx) - switch { - case ctx.Err() != nil: - js.Close() - return nil, ctx.Err() - case err != nil && !errors.Is(err, lease.ErrUnwritable): - // Whether another controller acts cannot be told: this one does not act, and exits to try again. - js.Close() - return nil, err - case err != nil: - // Nobody holds it and the bus will not let it be written: unleased, said, tried again (see above). - a.mu.Lock() - a.unleased = err.Error() - a.mu.Unlock() - say("this controller serves WITHOUT the lease: %v. Its declarations carry no epoch; it tries again "+ - "every %s, and the first push that sends the bus its user list grants it", err, lease.RenewEvery) - go a.takeWhenGranted(ctx, l, inv, gone) - default: - a.took(ctx, l, inv, epoch, gone) - } - a.mu.Lock() - a.close = func() { - // Given back first, then recorded: an epoch is recorded released only when its key is gone, and - // whatever the gate says by then — a shutdown longer than half the key's age closed it, and the epoch - // was left open for the next holder to find "expired" (novox/hq issue 906, 2026-10-26). - if given := l.Release(context.Background()); given != 0 { - ending, cancel := context.WithTimeout(context.Background(), 5*time.Second) - if err := inv.EndEpoch(ending, given, inventory.EpochReleased); err != nil { - say("how epoch %d ended could not be recorded: %v", given, err) - } - cancel() - } - js.Close() - } - a.mu.Unlock() - // **The stop signal gives the lease back first** (novox/hq issue 906): the moment ctx ends, before the rest - // of the process is taken down, the gate closes and the key goes, so the next controller takes the lease - // at once and the epoch is recorded released. Waiting for the process's own end left the key unrenewed for - // as long as the shutdown took — ten seconds on 2026-10-26, past the gate's half-age. - go func() { - <-ctx.Done() - if a.holds(l) { - say("stopping: the controller lease is given back first") - } - a.release() - }() - return gone, nil -} - -// holds is whether this process holds l now. -func (a *actor) holds(l *lease.Lease) bool { - a.mu.Lock() - defer a.mu.Unlock() - return a.held == l -} - -// took is the lease taken: recorded, earlier epochs nobody gave back ended as expired, kept. -func (a *actor) took(ctx context.Context, l *lease.Lease, inv *inventory.Inventory, epoch uint64, gone chan struct{}) { - a.mu.Lock() - a.held, a.unleased = l, "" - a.mu.Unlock() - h := holderOf(instance) - recording, cancel := context.WithTimeout(ctx, 10*time.Second) - expired, err := inv.TookEpoch(recording, inventory.Epoch{Epoch: epoch, Instance: h.Instance, Host: h.Host, - Build: h.Build, Taken: time.Now()}, l.GivenBack()) - cancel() - if err != nil { - fmt.Printf("epoch %d could not be recorded as taken, so a stale refusal from it will not name it: %v\n", epoch, err) - } - if given := l.GivenBack(); given != 0 { - fmt.Printf("the lease was given back by the controller of epoch %d: taken at once, at epoch %d\n", given, epoch) - } - for _, e := range expired { - fmt.Printf("the controller of epoch %d (%s) stopped renewing the lease without giving it back: it is "+ - "taken over at epoch %d\n", e.Epoch, e.Instance, epoch) - } - a.sayTakeover(ctx, epoch) - go l.Keep(ctx) - go func() { - <-l.Lost() - if ctx.Err() == nil { - why := l.LostWhy() - ending, cancel := context.WithTimeout(context.Background(), 5*time.Second) - _ = inv.EndEpoch(ending, epoch, inventory.EpochLost) - cancel() - fmt.Printf("the controller lease was lost (epoch %d): %v — this controller stops and exits, to "+ - "be started again as a candidate\n", epoch, why) - } - close(gone) - }() -} - -// takeWhenGranted tries the lease again every renewal interval while serving unleased, and stops this -// controller if another took it meanwhile: two serving at once is what the lease is for. -func (a *actor) takeWhenGranted(ctx context.Context, l *lease.Lease, inv *inventory.Inventory, gone chan struct{}) { - tick := time.NewTicker(lease.RenewEvery) - defer tick.Stop() - for { - select { - case <-ctx.Done(): - return - case <-tick.C: - } - epoch, err := l.TryTake(ctx) - if errors.Is(err, lease.ErrTaken) { - fmt.Printf("another controller took the lease while this one served without it: %v — this one "+ - "stops and exits\n", err) - close(gone) - return - } - if err != nil { - // Still not written, or not readable this time: unleased, said by S12, tried again. - a.mu.Lock() - a.unleased = err.Error() - a.mu.Unlock() - continue - } - a.took(ctx, l, inv, epoch, gone) - return - } -} - -// standing is what `status` and the self-check say of this process and the lease. -type standing struct { - Epoch uint64 - Held bool - Renewed time.Time - Unleased string - // Reset is when the lease bucket was found raised again from nothing, and ResetSaid what of it. - Reset time.Time - ResetSaid string -} - -func (a *actor) standing() standing { - a.mu.Lock() - held, unleased, reset, resetSaid := a.held, a.unleased, a.reset, a.resetSaid - a.mu.Unlock() - st := standing{Unleased: unleased, Reset: reset, ResetSaid: resetSaid} - if held == nil { - return st - } - epoch, err := held.Epoch() - st.Epoch, st.Held, st.Renewed = epoch, err == nil, held.Renewed() - return st -} - -// The gates, given to what acts: a declaration's send (link) and a plan's write (the inventory). -func init() { - link.ActingGate = func(ctx context.Context) error { - _, err := theLease.epoch(ctx) - if err != nil { - return fmt.Errorf("this controller may not send: %w", err) - } - return nil - } -} diff --git a/cmd/mesh-controller/acts.go b/cmd/mesh-controller/acts.go deleted file mode 100644 index 354e83cc..00000000 --- a/cmd/mesh-controller/acts.go +++ /dev/null @@ -1,479 +0,0 @@ -package main - -import ( - "context" - "fmt" - "github.com/novox/mesh-controller/internal/broker" - "io" - "slices" - "sort" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/inventory" -) - -// The things the mesh can be asked to do, separated from how it was asked. -// -// **A surface is an adapter with no decisions in it** (novox/hq ADR 0035). The command line and -// the command API call the same functions here, so an assignment refused at one is refused at the -// other for the same reason and in the same words. The moment a surface can accept something -// another would reject, the mesh has two answers to one question and people learn which to trust. -// -// Each returns what happened as text a person can read and a caller can pass on. Neither surface -// composes its own explanation, because two explanations of one refusal drift. - -// assign puts a module on a node, and says at once what in the mesh no longer works out. -// -// The assignment is kept even when the node does not resolve: it is what a person meant, and -// assignment is not an ordering. A consumer assigned before its provider does not resolve for as -// long as it takes to assign the provider, and refusing the first half of a pair would make the -// order somebody types two commands in part of the mesh's rules. -// -// **What is checked is the mesh, not the one machine** — because that is what the assignment -// changes. novox/hq 04-ISSUES/017 names the shape: an action can succeed into a state its own -// verify rejects, and it happens when the action's test is not the test the verify uses. Here the -// action tested one node and the verify is resolution over all of them, so an assignment could be -// reported as fine while it took a provision away from every other machine — a module offering a -// mesh-scoped provision stops offering it the moment its own node stops resolving, and every -// consumer elsewhere is then told *nothing in this mesh provides it*, with a remedy that names a -// module already assigned. That was found on a four-node raise and read as a version bump breaking -// provider recognition; it was neither the version nor the provider. -// -// It costs a resolution per machine. Assignment is a person typing a command, and being told which -// machines this just blocked is worth more than the milliseconds. -func assign(ctx context.Context, open *stores, node string, modules ...string) (string, error) { - return assignWith(ctx, open, node, assignOptions{}, modules...) -} - -// assignWith is assign asked with its options: build, for a module known and not built (novox/hq issue 325). -func assignWith(ctx context.Context, open *stores, node string, opts assignOptions, modules ...string) (string, error) { - if len(modules) == 0 { - return "", fmt.Errorf("assign %s names no module", node) - } - if err := refusedMovingTheController(modules); err != nil { - return "", err - } - // Every line of the answer comes from the one resolution after the write (novox/hq issue 237): - // what the reads before it would say about a machine left out is not the answer's. - ctx = leftOutSaidBy(ctx, io.Discard) - // **A module the catalogue does not hold is looked for further before it is refused** (novox/hq issue - // 325): a build in flight keeps the assignment pending, a module known and not built is said with how to - // build it, and only a name the mesh never heard of is refused as unknown. Several modules in one act - // are judged together (ADR 0207), so an act naming one not registered is not made in part. - if missing, err := notInCatalogue(ctx, open, modules); err != nil { - return "", err - } else if len(missing) > 0 { - if len(modules) == 1 { - return notRegistered(ctx, open, node, modules[0], opts) - } - var lines []string - for _, m := range missing { - where, err := whereIs(ctx, open.inventory, m, time.Now()) - if err != nil { - return "", err - } - lines = append(lines, where.said) - } - return "", fmt.Errorf("%w: nothing was assigned, since modules assigned together are judged together "+ - "(ADR 0207) and %s not registered:\n %s\n assign them together once each is registered, or each "+ - "alone to keep it pending on its build", inventory.ErrNoSuchModule, strings.Join(missing, ", "), - strings.Join(lines, "\n ")) - } - // **The one assignment refused for what the node lacks** (novox/hq ADR 0207). Everything else - // an assignment leaves unresolved is kept, because assignment is not an ordering; a module whose - // resources are applied through a seat nothing on the node holds is refused, because that order - // — the service manager, the package manager and the runtime before anything that installs, - // runs or contains — is the mesh's to keep. Several modules in one act are judged together, so - // holders that depend on each other go on in one command. - shelf, before, err := seatDependenciesOnAssign(ctx, open, node, modules) - if err != nil { - return "", err - } - // **Before the new assignment can unsettle a seat somebody holds only by being alone** - // (novox/hq 04-ISSUES/170): what the mesh derived so far is written down, and then the - // assignment resolves against a record rather than against a coincidence. - settled, err := recordDerivedHolders(ctx, open) - if err != nil { - return "", err - } - var lines []string - var added []string - for _, module := range modules { - fresh, err := open.inventory.Assign(ctx, node, module) - if err != nil { - return strings.Join(lines, "\n"), err - } - if !fresh { - // Nothing changed, and saying "is assigned" would read as an action. One node runs one - // of each — the module's name is the assignment's identity (novox/hq ADR 0115). - lines = append(lines, fmt.Sprintf( - "%s already runs %s — one node runs one of each (ADR 0115); nothing changed", node, module)) - continue - } - added = append(added, module) - lines = append(lines, fmt.Sprintf("%s is assigned %s", node, module)) - } - if len(added) == 0 { - return strings.Join(lines, "\n"), nil - } - answer := strings.Join(lines, "\n") - for _, line := range settled { - answer += "\n " + line - } - // Its bus credential, in the same act (novox/hq issue 203): an assignment pushed before its - // credential exists delivers a process that cannot authenticate and crash-loops until somebody - // runs a second verb and a second push. Issued here when the module speaks on the bus and has - // no credential yet; kept when it has one, so re-assigning rotates nothing. - var issued []string - for _, module := range added { - if line := issueOnAssign(ctx, open, node, module); line != "" { - issued = append(issued, line) - } - } - // **The one resolution the rest of the answer is said from** (novox/hq issue 237), taken after - // the write: what this act changed about this node's unmet seat dependencies, and nothing else - // (novox/hq ADR 0207) — a dependency of a module just assigned, or one this assignment met; the - // rest of the node's list, and every other node's, is `status`'s — and whether the node resolves. - after := resolvedAfter(ctx, open, shelf, node) - for _, line := range unheldChangeTo(shelf, node, before, after.modules, after.unheld) { - answer += "\n " + line - } - for _, line := range issued { - answer += "\n " + line - } - plan, err := after.plan, after.err - if err != nil { - // Kept, and still refused. Both halves are the answer, and the rest of the mesh is still - // worth reporting: this machine's refusal is rarely the only consequence. - return answer + blockedElsewhere(ctx, open, node), err - } - // Kept, and cannot be hosted here. Said at once rather than discovered at push: a module whose - // capability the machine lacks is on the wrong machine, and the assignment records what a person - // meant while this line says it will not run until it moves. The rest of the node still pushes. - isAdded := map[string]bool{} - for _, m := range added { - isAdded[m] = true - } - for _, u := range plan.Unhostable { - if !isAdded[u.Module] { - continue - } - for _, c := range u.Missing { - answer += "\n but " + catalogue.WrongMachine(u.Module, c, node) - } - } - return answer + composedAfterAct(ctx, open, node) + blockedElsewhere(ctx, open, node), nil -} - -// composedAfterAct composes the node an assignment or its end changed (novox/hq ADR 0539 clause 5): what the -// answer says of it. The assigner then writes the node's wants by its rules; nothing is sent. -func composedAfterAct(ctx context.Context, open *stores, node string) string { - wrote, err := composeModules(ctx, open, []string{node}) - if err != nil { - return fmt.Sprintf("\n but its compositions on %s could not all be written: %v", node, firstLine(err.Error())) - } - if len(wrote[node]) == 0 { - return fmt.Sprintf("\n %s's compositions were already as composed now", node) - } - return fmt.Sprintf("\n composed for %s (%d composition(s) written): its wants follow by the assigner's rules", node, - len(wrote[node])) -} - -// seatDependenciesOnAssign is the refusal ADR 0207 makes at assignment, or nothing, with the -// catalogue and the node's assignments it was judged against. Modules already assigned are not new -// and are not judged again. -func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, modules []string) ( - map[string]catalogue.Manifest, []string, error) { - shelf, err := open.inventory.Catalogue(ctx) - if err != nil { - return nil, nil, err - } - assigned, err := open.inventory.Assigned(ctx, node) - if err != nil { - return nil, nil, err - } - already := map[string]bool{} - for _, a := range assigned { - already[a] = true - } - var adding []string - for _, m := range modules { - if !already[m] { - adding = append(adding, m) - } - } - // The lines AssignRefusal says beside an assignment it lets through are said by unheldChange - // with everything else this act changed, so they are not said twice. - if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil { - return nil, nil, err - } - // Two modules declaring one path or unit, or one package present and absent, is refused before anything - // is recorded (novox/hq ADR 0210, ADR 0303, 04-ISSUES/235): kept, the node would not resolve until one came - // off again. Two modules declaring one package present is not a collision (ADR 0303, issue 628). - if err := catalogue.CollisionRefusal(shelf, node, assigned, adding); err != nil { - return nil, nil, err - } - return shelf, assigned, nil -} - -// unassign takes modules off a node. What they leave behind is the host's business: a directory -// holding anything the mesh did not put there is kept (novox/hq ADR 0030). -// -// It reports the rest of the mesh for the same reason assign does, and more sharply: taking a -// module off one machine is the ordinary way to stop providing something to another, and nothing -// about the command's own output would ever have said so. -// -// **Refused when it takes away the last holder of a seat a module left on the node depends on** -// (novox/hq ADR 0207) — the other side of refusing that module's assignment without one. Several -// modules in one act are judged together, so a holder and its dependents come off in one command. -func unassign(ctx context.Context, open *stores, node string, modules ...string) (string, error) { - if err := refusedMovingTheController(modules); err != nil { - return "", err - } - if len(modules) == 0 { - return "", fmt.Errorf("unassign %s names no module", node) - } - // One resolution after the write, as assign's answer (novox/hq issue 237). - ctx = leftOutSaidBy(ctx, io.Discard) - shelf, err := open.inventory.Catalogue(ctx) - if err != nil { - return "", err - } - assigned, err := open.inventory.Assigned(ctx, node) - if err != nil { - return "", err - } - // Every one checked before any is taken off, so a refusal leaves the node as it was. - runs := map[string]bool{} - for _, a := range assigned { - runs[a] = true - } - // A module only pending on the node (novox/hq issue 325) is withdrawn, when it is the act's one module: - // that is the undo of an assignment kept while its build runs. - if len(modules) == 1 && !runs[modules[0]] { - said, withdrawn, err := withdrawPending(ctx, open.inventory, node, modules[0]) - if err != nil { - return "", err - } - if withdrawn { - return said, nil - } - } - for _, module := range modules { - if !runs[module] { - return "", fmt.Errorf("%s is not assigned to %s", module, node) - } - } - if err := catalogue.UnassignRefusal(shelf, node, assigned, modules); err != nil { - return "", err - } - for _, module := range modules { - if err := open.inventory.Unassign(ctx, node, module); err != nil { - return "", err - } - } - answer := fmt.Sprintf("%s no longer runs %s: its want is written removed by the assigner", - node, strings.Join(modules, ", ")) - after := resolvedAfter(ctx, open, shelf, node) - for _, line := range unheldChangeTo(shelf, node, assigned, after.modules, after.unheld) { - answer += "\n " + line - } - // Whether the node itself still resolves, from the same resolution: before, only the rest of the - // mesh's reads said it, on standard error and once per other machine. - if after.err != nil { - if unresolvable(after.err) { - answer += fmt.Sprintf("\n but %s does not resolve now, so nothing will be sent to it:", node) - } else { - answer += fmt.Sprintf("\n but whether %s still resolves could not be read:", node) - } - for _, line := range strings.Split(strings.TrimRight(after.err.Error(), "\n"), "\n") { - answer += "\n " + strings.TrimSpace(line) - } - } - // Its bus accounts on the node go with it once it no longer resolves there (a module another requires keeps running - // and keeps them), so an assignment again issues them afresh (issue 1945). The node's own provisioner account stays. - if after.err == nil { - for _, module := range modules { - if slices.Contains(after.modules, module) { - continue - } - forgot, err := open.inventory.ForgetBusAccountsOf(ctx, node, module, false) - switch { - case err != nil: - answer += fmt.Sprintf("\n its bus accounts on %s are not forgotten: %v", node, err) - case len(forgot) > 0: - answer += fmt.Sprintf("\n its bus accounts on %s forgotten: %s", node, strings.Join(forgot, ", ")) - } - } - } - // What it leaves behind that is irreplaceable is kept and retired, never removed (novox/hq ADR 0233). - for _, line := range keptOnUnassign(ctx, open.inventory, node, modules) { - answer += "\n " + line - } - return answer + composedAfterAct(ctx, open, node) + blockedElsewhere(ctx, open, node), nil -} - -// afterAct is the one resolution of a node an act's answer is said from, taken after its write -// (novox/hq issue 237): the plan or why there is none, the modules it has on the node, and its unmet -// seat dependencies. -type afterAct struct { - plan catalogue.Resolution - err error - modules []string - unheld []catalogue.Unheld -} - -// resolvedAfter resolves the node as `plan` does, once, after the act's write. When the node resolves, -// its modules and unmet seat dependencies are the resolution's — the closure, so a holder pulled in by -// a requirement counts as `plan` counts it. When it does not, they are judged over the assignments read -// after the write, which can only find more unmet than the closure would: a seat said held there is -// held in the closure too. -func resolvedAfter(ctx context.Context, open *stores, shelf map[string]catalogue.Manifest, node string) afterAct { - plan, _, err := resolveNode(ctx, open, node) - if err == nil { - out := afterAct{plan: plan, unheld: plan.Unheld} - for _, m := range plan.Modules { - out.modules = append(out.modules, m.Module) - } - return out - } - out := afterAct{err: err} - assigned, readErr := open.inventory.Assigned(ctx, node) - if readErr != nil { - return out - } - out.modules, out.unheld = assigned, unheldOver(shelf, node, assigned) - return out -} - -// splitModules is a surface's one `module` field as the modules it names: several, comma-separated, -// are one act (novox/hq ADR 0207), so the holders that depend on each other go on together from the -// command API and the controller seat's verbs as they do from the command line. -func splitModules(field string) []string { - var out []string - for _, m := range strings.Split(field, ",") { - if m = strings.TrimSpace(m); m != "" { - out = append(out, m) - } - } - return out -} - -// blockedElsewhere is every OTHER machine that cannot be worked out as things now stand. -// -// **The state, not the cause.** Saying "this assignment broke laptop" would mean resolving the -// whole mesh twice and would still be a guess about which of several changes did it; saying -// "laptop cannot be worked out, and here is what it says" is true, is what somebody has to fix, -// and cannot mislead. The machine that was just changed is left out because its own refusal is -// already the answer beside this one. -// -// Nothing here can fail the act it reports on. A mesh that cannot be read is worth saying and is -// not a reason to claim the assignment did not happen — it did. -func blockedElsewhere(ctx context.Context, open *stores, except string) string { - // One reading of the mesh for every machine this looks at. - ctx = withInputCache(ctx) - nodes, err := open.inventory.Nodes(ctx) - if err != nil { - return "\n\nThe rest of the mesh could not be checked: " + err.Error() - } - blocked := map[string]string{} - for _, n := range nodes { - if n.Name == except { - continue - } - if _, _, err := resolveNode(ctx, open, n.Name); err != nil { - blocked[n.Name] = err.Error() - } - } - if len(blocked) == 0 { - return "" - } - names := make([]string, 0, len(blocked)) - for name := range blocked { - names = append(names, name) - } - sort.Strings(names) - - var out strings.Builder - fmt.Fprintf(&out, "\n\nAND %d other machine(s) cannot be worked out as things stand, so "+ - "nothing will be sent to them:\n", len(names)) - for _, name := range names { - fmt.Fprintf(&out, " %s\n", name) - for _, line := range strings.Split(strings.TrimRight(blocked[name], "\n"), "\n") { - fmt.Fprintf(&out, " %s\n", strings.TrimSpace(line)) - } - } - out.WriteString("\nThis may or may not be what just changed — it is what is true now.") - return out.String() -} - -// issueOnAssign gives a newly assigned module its bus credential, the way `module issue` does, and -// says what it did in one line. Nothing for a module that declares no broker secret and runs no -// provisioner client; nothing for one whose user is already minted (a credential is rotated on -// purpose, never by re-assigning); and when the bus cannot be reached from here, the line names the -// verb and the push that would refuse the module until it is run — never a silent placeholder (novox/hq issue 203). -func issueOnAssign(ctx context.Context, open *stores, node, module string) string { - // Mint only what no row names: the assignments' watch hears the same write and may issue at once (ADR 0543). - ctx = onlyIfAbsent(ctx) - inv := open.inventory - shelf, err := inv.Catalogue(ctx) - if err != nil { - return "" - } - m, known := shelf[module] - if !known || mayIssue(m) != nil { - return "" - } - user := broker.Principal{Kind: broker.KindModule, Node: node, Module: module}.Username() - _, reads := m.OwnSecrets["broker"] - if !reads && !runsTheProvisionerClient(m) { - // A module whose bus accounts are its processes' alone has none of its own: only the processes that - // are not issued yet are issued (issue 1226). - return processAccountsOnAssign(ctx, inv, m, node) - } - if !reads { - // A module running the provisioner client alone is minted the client's account (issue 917). - user = broker.Principal{Kind: broker.KindProvisioner, Node: node, Module: module}.Username() - } - if _, sealed, err := inv.BusAccountIssued(ctx, user); err != nil { - return "" - } else if sealed { // a row whose credential is not sealed to the node is not issued (issue 1945) - // Its own account stands and is kept; a process it gained is not issued yet (issue 1226). - return processAccountsOnAssign(ctx, inv, m, node) - } - busAddress, err := broker.BusAddress() - if err == nil { - err = issueOnTheNewBus(ctx, inv, m, node, busAddress) - } - if err != nil { - return fmt.Sprintf("its bus credential is not issued (%v): `module issue %s --node %s` first — "+ - "`push %s` refuses to send %s until it is", err, module, node, node, module) - } - return fmt.Sprintf("its bus credential is issued and sealed to %s, and arrives with the push", node) -} - -// processAccountsOnAssign issues the process accounts of an assigned module not issued yet, and says what it did. -func processAccountsOnAssign(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest, node string) string { - line, err := issueMissingProcessAccounts(ctx, inv, m, node) - if err != nil { - return fmt.Sprintf("its process accounts are not issued (%v): `module issue %s --node %s` first — "+ - "`push %s` refuses to send %s until it is", err, m.Module, node, node, m.Module) - } - return line -} - -// refusedMovingTheController refuses assigning or unassigning the controller's module through a verb (review of -// novox/hq ADR 0272): the node it is assigned to is the control-node, and the control-node's operator account is -// the controller's terminal, so whoever moves the module chooses the terminal. At the terminal alone, as every -// change that says who may change what. -func refusedMovingTheController(modules []string) error { - verb, through := throughAVerb() - if !through || !slices.Contains(modules, controllerModule) { - return nil - } - return fmt.Errorf("%s is assigned and unassigned at the controller's terminal only (mesh-cli on the control-node), "+ - "never through a verb (this came through %q): the node it runs on is the control-node, whose operator is the "+ - "terminal (novox/hq ADR 0272). Nothing was assigned", controllerModule, verb) -} diff --git a/cmd/mesh-controller/acts_test.go b/cmd/mesh-controller/acts_test.go deleted file mode 100644 index a7c6f819..00000000 --- a/cmd/mesh-controller/acts_test.go +++ /dev/null @@ -1,135 +0,0 @@ -//go:build integration - -package main - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// What an assignment says about the machines it was not about. - -// **An assignment that blocks another machine says so.** -// -// The shape found on a four-node raise: a module offering a mesh-scoped provision stops offering it -// the moment its own node stops resolving, so an assignment to the provider's machine silently took -// a provision away from every consumer elsewhere. Those consumers were then told *nothing in this -// mesh provides it*, naming as the remedy a module that was already assigned — which read, on the -// way back, as a version bump breaking provider recognition. It was neither the version nor the -// provider: it was one machine's set of assignments, and nothing said so. -// -// novox/hq 04-ISSUES/017 names the general shape — an action succeeding into a state its own verify -// rejects, because the action's test is not the test the verify uses. `assign` tested one node; the -// verify is resolution over all of them. -func TestAnAssignmentThatBlocksAnotherMachineSaysWhichAndWhy(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - - // A provider on the hub and a consumer on the other machine, both resolving. - register(t, open, catalogue.Manifest{Module: "step-ca", Version: "1", - Provides: []catalogue.Offer{{Name: "acme-ca", Scope: catalogue.ScopeMesh}}, - Serves: map[string]map[string]any{"acme-ca": {"path": "/acme/directory"}}}) - register(t, open, catalogue.Manifest{Module: "route-proxy", Version: "1", - Requires: []string{"acme-ca"}}) - if _, err := assign(ctx, open, "anchor", "step-ca"); err != nil { - t.Fatal(err) - } - said, err := assign(ctx, open, "laptop", "route-proxy") - if err != nil { - t.Fatalf("a mesh that should resolve did not: %v\n%s", err, said) - } - if strings.Contains(said, "cannot be worked out") { - t.Fatalf("a well mesh was reported as blocked:\n%s", said) - } - - // Now break the hub's own set, with nothing but the command a person has. - one, two := rivals() - register(t, open, one) - register(t, open, two) - if _, err := assign(ctx, open, "anchor", "rival-one"); err != nil { - t.Fatal(err) - } - said, err = assign(ctx, open, "anchor", "rival-two") - if err == nil { - t.Fatal("an assignment that makes its own node incoherent was not reported at all") - } - - // The node's own refusal is the error, as it always was. What is new is that the machines this - // just took a provision away from are named in the same breath. - if !strings.Contains(said, "laptop") { - t.Fatalf("the machine this blocked is not named:\n%s\n\n%v", said, err) - } - if !strings.Contains(said, "acme-ca") { - t.Fatalf("what laptop is now missing is not said:\n%s", said) - } - if !strings.Contains(said, "cannot be worked out as things stand") { - t.Fatalf("the report does not say what state the mesh is in:\n%s", said) - } - // And the assignment is kept: it is what a person meant, and assignment is not an ordering. - assigned, err := open.inventory.Assigned(ctx, "anchor") - if err != nil { - t.Fatal(err) - } - if !contains(assigned, "rival-two") { - t.Fatalf("the assignment was not kept: %v", assigned) - } -} - -// A consumer assigned before its provider is refused for itself and kept, because assignment is not -// an ordering — refusing the first half of a pair would make the order somebody types two commands -// in part of the mesh's rules. -func TestAConsumerAssignedBeforeItsProviderIsStillAssigned(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - register(t, open, catalogue.Manifest{Module: "route-proxy", Version: "1", - Requires: []string{"acme-ca"}}) - - said, err := assign(ctx, open, "laptop", "route-proxy") - if err == nil { - t.Fatalf("a consumer with nothing to consume resolved:\n%s", said) - } - assigned, err := open.inventory.Assigned(ctx, "laptop") - if err != nil { - t.Fatal(err) - } - if !contains(assigned, "route-proxy") { - t.Fatalf("assignment became an ordering: %v", assigned) - } -} - -// Unassigning is the ordinary way to stop providing something to another machine, and it reports -// the same way for the same reason. -func TestUnassigningAProviderNamesWhoIsNowBlocked(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - register(t, open, catalogue.Manifest{Module: "step-ca", Version: "1", - Provides: []catalogue.Offer{{Name: "acme-ca", Scope: catalogue.ScopeMesh}}, - Serves: map[string]map[string]any{"acme-ca": {"path": "/acme/directory"}}}) - register(t, open, catalogue.Manifest{Module: "route-proxy", Version: "1", - Requires: []string{"acme-ca"}}) - if _, err := assign(ctx, open, "anchor", "step-ca"); err != nil { - t.Fatal(err) - } - if _, err := assign(ctx, open, "laptop", "route-proxy"); err != nil { - t.Fatal(err) - } - - said, err := unassign(ctx, open, "anchor", "step-ca") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(said, "laptop") || !strings.Contains(said, "acme-ca") { - t.Fatalf("taking the provider away said nothing about who was consuming it:\n%s", said) - } -} - -func contains(all []string, one string) bool { - for _, s := range all { - if s == one { - return true - } - } - return false -} diff --git a/cmd/mesh-controller/addresses_integration_test.go b/cmd/mesh-controller/addresses_integration_test.go deleted file mode 100644 index 5042cd91..00000000 --- a/cmd/mesh-controller/addresses_integration_test.go +++ /dev/null @@ -1,231 +0,0 @@ -//go:build integration - -package main - -import ( - "encoding/json" - "os" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/licences" -) - -func TestTheRegistryTrustAndEveryImageFollowThePortTheNodeGaveTheStore(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - register(t, open, aStore()) - if _, err := assign(ctx, open, "anchor", "distribution"); err != nil { - t.Fatal(err) - } - if err := open.inventory.SetSettings(ctx, "anchor", "distribution", - map[string]any{catalogue.PortsSetting: map[string]any{"5000": 5101}}); err != nil { - t.Fatal(err) - } - // A module the mesh built, recorded by digest and path, running on the other machine. - if err := open.inventory.RecordBuild(ctx, inventory.Build{ - ID: "b1", Repository: "r", Module: "app", Commit: "abc", - Made: []inventory.Artifact{{Name: "server", Kind: "image", - Reference: catalogue.ArtifactStoreScheme + "app/server@" + aDigest}}, - }); err != nil { - t.Fatal(err) - } - register(t, open, catalogue.Manifest{Module: "app", Version: "1", - Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-app", - "image": catalogue.ArtifactStoreScheme + "app/server@" + aDigest}}}) - if _, err := assign(ctx, open, "laptop", "app"); err != nil { - t.Fatal(err) - } - // The runtime's trust is the runtime's module's to write (novox/hq ADR 0222): a stand-in for it - // asks where this machine reaches the store, as the docker module does. - register(t, open, catalogue.Manifest{Module: "runtime", Version: "1", - Resources: []map[string]any{{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", - "into": "json", "content": `{"insecure-registries": ["${seat:mesh-artifact-store:reach}"]}` + "\n"}}}) - if _, err := assign(ctx, open, "laptop", "runtime"); err != nil { - t.Fatal(err) - } - - on := map[string]bool{"anchor": true, "laptop": true} - node, port, found, err := artifactStoreOnNetwork(ctx, open.inventory, on) - if err != nil || !found || node != "anchor" || port != "5101" { - t.Fatalf("the store is found on %q:%q (%v, %v); the node put it on 5101", node, port, found, err) - } - - var trust string - for _, r := range composed(t, open, "laptop").Resources { - if r["path"] == "/etc/docker/daemon.json" { - trust, _ = r["content"].(string) - } - if r["id"] == "app.server" && r["image"] != "anchor.internal:5101/app/server@"+aDigest { - t.Errorf("the image the mesh built is fetched as %v", r["image"]) - } - } - if !strings.Contains(trust, "anchor.internal:5101") || strings.Contains(trust, ":5000") { - t.Fatalf("the runtime is told to trust %q; the node put the store on 5101", trust) - } - - // And a replay to the catalogue says where the store is now. - announced, err := following{open: open}.Announceable(ctx) - if err != nil { - t.Fatal(err) - } - if len(announced) != 1 || announced[0].Made[0].Reference != "anchor.internal:5101/app/server@"+aDigest { - t.Fatalf("the replay announces %+v", announced) - } -} - -func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T) { - open := aMesh(t) - // Issue 1126: the placement export over this fresh mesh, before anything below changes it. - placementExportIsEachNodesPlacementFromTheStore(t, open) - ctx := t.Context() - raw, err := os.ReadFile("../../module.json") - if err != nil { - t.Fatal(err) - } - m, err := catalogue.Decode(raw) - if err != nil { - t.Fatal(err) - } - control, err := withSeatPorts(m).Resolve([]catalogue.Built{{Name: "controller", Kind: catalogue.ArtifactBundle, - Reference: "https://registry.example/mesh-controller/controller.tar.gz", Digest: aDigest}}) - if err != nil { - t.Fatal(err) - } - register(t, open, control) - register(t, open, catalogue.Manifest{Module: "postgres", Version: "1", - Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}, - Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}}, - Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store", - "ports": []any{"5432:5432"}, "image": "pg@" + aDigest}}}) - register(t, open, catalogue.Manifest{Module: "lavinmq", Version: "1", - Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}, - Listens: []catalogue.Listening{{Port: 5671, From: catalogue.FromMesh}, - {Port: 5672, From: catalogue.FromMesh}}, - Guards: []int{15672}, - Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker", - "ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}, "image": "mq@" + aDigest}}}) - // The control plane's own bus user is the installer's, seeded at genesis before the controller - // runs (SeedBusUser); without it a push now refuses the credential nobody issued (issue 203). - if err := open.inventory.SeedBusUser(ctx, inventory.BusUser{Username: "anchor.mesh-controller", - Kind: inventory.BusController, Node: "anchor", Module: "mesh-controller"}, "bootstrap"); err != nil { - t.Fatal(err) - } - // Seeded with the credential the controller already uses, so it is sealed: an account counts as issued only once - // sealed (ADR 0543 §2, PR #467), and genesis's seed is the credential in use. - if err := open.inventory.MarkBusAccountSealed(ctx, "anchor.mesh-controller"); err != nil { - t.Fatal(err) - } - if _, err := assign(ctx, open, "anchor", "mesh-controller"); err != nil { - t.Fatal(err) - } - // The store's module is registered and given its port, and NOT assigned: the state genesis - // leaves a given-port node in before the foundation is adopted as modules (04-ISSUES/085). - if err := open.inventory.SetSettings(ctx, "anchor", "postgres", - map[string]any{catalogue.PortsSetting: map[string]any{"5432": 6852}}); err != nil { - t.Fatal(err) - } - if _, err := assign(ctx, open, "anchor", "lavinmq"); err != nil { - t.Fatal(err) - } - if err := open.inventory.SetSettings(ctx, "anchor", "lavinmq", - map[string]any{catalogue.PortsSetting: map[string]any{"5672": 5679}}); err != nil { - t.Fatal(err) - } - - var env map[string]any - for _, r := range composed(t, open, "anchor").Resources { - if r["id"] == "mesh-controller.controller" { - env, _ = r["env"].(map[string]any) - } - } - if env == nil { - t.Fatal("the control plane's process is not in its own node's declaration") - } - for key, want := range map[string]string{ - "MESH_STORE_INVENTORY_PORT": "6852", - "MESH_STORE_IDENTITY_PORT": "6852", - "MESH_STORE_LICENCES_PORT": "6852", - "MESH_BROKER_AMQP_PORT": "5679", - // Neither given nor assigned by the mesh: the manifest's own number is NOT the answer, - // because the sealed value beside it carries the port genesis wrote (finding F3). - "MESH_BROKER_ADDRESS_PORT": "", - "MESH_BROKER_MANAGEMENT_PORT": "", - } { - if env[key] != want { - t.Errorf("the control plane is told %s=%v; the node says %q", key, env[key], want) - } - } -} - -func aLoneNode(t *testing.T) *stores { - t.Helper() - inventory.ForTest(t) - licences.ForTest(t) - open, err := openStores(t.Context()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(open.Close) - for _, m := range provided { - if err := open.inventory.Provide(t.Context(), m); err != nil { - t.Fatal(err) - } - } - record, err := open.inventory.AddNode(t.Context(), "anchor") - if err != nil { - t.Fatal(err) - } - reported, _ := json.Marshal(map[string]any{"capabilities": []map[string]any{ - {"name": "container-runtime", "present": true}}}) - var profile map[string]any - _ = json.Unmarshal(reported, &profile) - if err := open.inventory.RecordProfile(t.Context(), record.ID, profile); err != nil { - t.Fatal(err) - } - if err := open.inventory.RecordSealingKey(t.Context(), record.ID, aPublicKey(t)); err != nil { - t.Fatal(err) - } - return open -} - -func TestOnANodeWithNoNetworkTheStoreIsReachedByLoopback(t *testing.T) { - open := aLoneNode(t) - ctx := t.Context() - register(t, open, aStore()) - register(t, open, catalogue.Manifest{Module: "builder", Version: "1", - Requires: []string{catalogue.ArtifactStoreProvision}, - Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-builder", - "image": "registry.example/mesh-builder@" + aDigest}}}) - if err := open.inventory.RecordBuild(ctx, inventory.Build{ - ID: "b1", Repository: "r", Module: "postgres", Commit: "abc", - Made: []inventory.Artifact{{Name: "runtime", Kind: "image", - Reference: catalogue.ArtifactStoreScheme + "postgres/runtime@" + aDigest}}, - }); err != nil { - t.Fatal(err) - } - register(t, open, catalogue.Manifest{Module: "postgres", Version: "1", - Resources: []map[string]any{{"id": "runtime", "type": "container", "name": "mesh-postgres", - "image": catalogue.ArtifactStoreScheme + "postgres/runtime@" + aDigest}}}) - for _, module := range []string{"distribution", "builder", "postgres"} { - if _, err := assign(ctx, open, "anchor", module); err != nil { - t.Fatal(err) - } - } - if err := open.inventory.SetSettings(ctx, "anchor", "distribution", - map[string]any{catalogue.PortsSetting: map[string]any{"5000": 5100}}); err != nil { - t.Fatal(err) - } - - var image any - for _, r := range composed(t, open, "anchor").Resources { - if r["id"] == "postgres.runtime" { - image = r["image"] - } - } - if image != "127.0.0.1:5100/postgres/runtime@"+aDigest { - t.Fatalf("on the store's own node, off any network, the image is fetched as %v", image) - } -} diff --git a/cmd/mesh-controller/addresses_test.go b/cmd/mesh-controller/addresses_test.go deleted file mode 100644 index 09ed3438..00000000 --- a/cmd/mesh-controller/addresses_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package main - -import ( - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// An address is read from the node's settings where it is used, never recorded with a port -// (novox/hq 04-ISSUES/102). Three readers did not follow the setting; each is held to it here. - -var aDigest = "sha256:" + strings.Repeat("e", 64) - -// aStore is a module offering the artifact store on 5000, published the long way as the -// distribution module does, so a node may be given another number for it. -func aStore() catalogue.Manifest { - return catalogue.Manifest{Module: "distribution", Version: "1", - Provides: []catalogue.Offer{{Name: catalogue.ArtifactStoreProvision, Scope: catalogue.ScopeMesh}}, - Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": float64(5000)}}, - Listens: []catalogue.Listening{{Port: 5000, From: catalogue.FromMesh}}, - Resources: []map[string]any{{"id": "store", "type": "container", "name": "mesh-registry", - "ports": []any{"5000:5000"}, "image": "registry@" + aDigest}}} -} - -// **The trust a machine writes for the store, and the address every built image is fetched -// through, say the port the node gave the store** — not the catalogue's number. - -// **The control plane's own connections say the port the node gave the store and the broker.** -// -// Composed from the control plane's own manifest against a real inventory: the store's module is -// given 6852 on this node the way genesis or an operator gives it, and the control plane's -// process is told so beside the sealed connection genesis wrote. - -// withSeatPorts is the control plane's manifest with the seat placeholders in its environment — -// added here until module.json carries them (the manifest lands one commit after the code that -// fills it, so a control plane one build behind never sees a placeholder it cannot fill). -func withSeatPorts(m catalogue.Manifest) catalogue.Manifest { - seatPorts := map[string]string{ - "MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}", - "MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}", - "MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}", - "MESH_BROKER_AMQP_PORT": "${seat:mesh-broker:5672}", - "MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}", - "MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}", - } - out := m - out.Resources = nil - for _, r := range m.Resources { - if r["type"] != "container" && r["type"] != "process" { - out.Resources = append(out.Resources, r) - continue - } - copied := map[string]any{} - for k, v := range r { - copied[k] = v - } - env := map[string]any{} - if had, ok := r["env"].(map[string]any); ok { - for k, v := range had { - env[k] = v - } - } - for k, v := range seatPorts { - if _, said := env[k]; !said { - env[k] = v - } - } - copied["env"] = env - out.Resources = append(out.Resources, copied) - } - return out -} - -// aLoneNode is one capable machine with nothing placed on any network — the control-node during -// genesis, before the "network" step, which is after the store, the broker, the vault and the -// catalogue have each been built and pushed (finding F2). - -// **Before the network exists, the store's own node reaches it by loopback** — never refused, -// never handed the scheme: a genesis pushes the store, the broker, the vault and the catalogue to -// a node on no network, and builds the catalogue on a base it must be able to pull. diff --git a/cmd/mesh-controller/adopting_test.go b/cmd/mesh-controller/adopting_test.go deleted file mode 100644 index 9a95be52..00000000 --- a/cmd/mesh-controller/adopting_test.go +++ /dev/null @@ -1,660 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "encoding/json" - "errors" - sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" - "github.com/nats-io/nats.go/jetstream" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/testbus" - "net/http" - "reflect" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// novox/hq ADR 0100: taking a module is its cutover; converging a node is one act, previewed, and -// refused while a found container is held; returning to adopted keeps what was taken. - -// anAdoptedAnchor is aMesh with the anchor adopted, running a served module the predecessor also -// runs and a module with only a file, and a filter module in the catalogue. -func anAdoptedAnchor(t *testing.T) (*stores, *[]string) { - t.Helper() - open := aMesh(t) - ctx := t.Context() - register(t, open, catalogue.Manifest{Module: "hello-web", Version: "1", - Listens: []catalogue.Listening{{Port: 8080, From: catalogue.FromEverywhere}}, - Resources: []map[string]any{ - {"id": "page", "type": "file", "path": "/var/lib/hello-web/index.html", "content": "hi"}, - {"id": "server", "type": "container", "name": "hello-web", "ports": []any{"8080:80"}, - "image": "registry.example/hello@sha256:" + strings.Repeat("a", 64)}, - }}) - register(t, open, catalogue.Manifest{Module: "notes", Version: "1", - Resources: []map[string]any{ - {"id": "conf", "type": "file", "path": "/etc/notes.conf", "content": "x"}, - }}) - register(t, open, catalogue.Manifest{Module: "nftables", Version: "1", - Filtering: &catalogue.Filtering{Into: "/etc/nftables.conf"}, - Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service", - "state": "running", "restart-on": []any{"filtering"}}}}) - if err := open.inventory.SetAdopted(ctx, "anchor", true); err != nil { - t.Fatal(err) - } - for _, m := range []string{"hello-web", "notes"} { - if _, err := assign(ctx, open, "anchor", m); err != nil { - t.Fatal(err) - } - } - onTheBusWithItsHaves(t, open) - sent := &[]string{} - saved := sendNodes - sendNodes = func(_ context.Context, _ *stores, names []string) error { - *sent = append(*sent, names...) - return nil - } - t.Cleanup(func() { sendNodes = saved }) - return open, sent -} - -// reportsHolding has the anchor report, on what it was last sent, holding what is given. -func reportsHolding(t *testing.T, open *stores, held ...link.Held) { - t.Helper() - reportsReaching(t, open, []link.Reach{ - {Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"}, - {Protocol: "tcp", Address: "0.0.0.0", Port: 8080, By: "hello-web", Published: true, - ContainerPort: 80}, - {Protocol: "tcp", Address: "0.0.0.0", Port: 5000, By: "predecessor-registry", - Published: true, ContainerPort: 5000}, - {Protocol: "tcp", Address: "127.0.0.1", Port: 15672, By: "mesh-broker", - Published: true, ContainerPort: 15672}, - }, held...) -} - -// reportsReaching has the anchor report, on what it was last sent, what is reachable on it and -// holding what is given. -func reportsReaching(t *testing.T, open *stores, reachable []link.Reach, held ...link.Held) { - t.Helper() - ctx := t.Context() - body, err := composed(t, open, "anchor").Body() - if err != nil { - t.Fatal(err) - } - if _, err := (link.Enrolment{Inventory: open.inventory}).Heard(ctx, link.Report{ - Node: "anchor", Applied: []string{"hello-web.x"}, Declared: digestOf(body), - Firewall: "ufw", Held: held, Reachable: reachable, - // A machine says which of its links face outside on every apply (novox/hq ADR 0140), and a - // filter is not sent to one that has not. The anchor reports one, as a real host does; this - // fixture lacked it from 2026-09-28 and nothing ran the test (issue 177). - Outward: []string{"eth0"}, - // And what filters it (ADR 0168): its front end, the runtime's own, and a chain a - // predecessor left in the runtime's user chain. - Filters: anchorFilters, - }); err != nil { - t.Fatal(err) - } -} - -// anchorFilters is what the adopted anchor says filters it: ufw's chains, the runtime's, and a -// predecessor's chain the mesh did not write. -var anchorFilters = []link.Filter{ - {Where: "table ip filter, chain ufw-reject-input", Owner: "found-firewall", Refuses: "reject"}, - {Where: "table ip filter, chain DOCKER", Owner: "runtime", Refuses: `iifname != "docker0" oifname "docker0" drop`}, - {Where: "table ip filter, chain DOCKER-USER", Owner: "other", Refuses: `iifname "eth0" tcp dport 6000 drop`}, -} - -var ( - heldContainer = link.Held{ID: "hello-web.server", Module: "hello-web", Kind: "container", - Target: "hello-web", Since: time.Now()} - heldFile = link.Held{ID: "notes.conf", Module: "notes", Kind: "file", - Target: "/etc/notes.conf", Since: time.Now(), Kept: "/var/lib/mesh-host/kept/abc-notes.conf"} -) - -func TestTakingAModuleNotOnTheNodeIsRefused(t *testing.T) { - open, _ := anAdoptedAnchor(t) - if _, err := take(t.Context(), open, "anchor", "nftables", takeOptions{Yes: true}); !errors.Is(err, inventory.ErrNotAssigned) { - t.Fatalf("taking an unassigned module gave %v", err) - } - if _, err := take(t.Context(), open, "laptop", "network", takeOptions{Yes: true}); !errors.Is(err, inventory.ErrNotAdopted) { - t.Fatalf("taking on a converged node gave %v", err) - } -} - -func TestConvergingIsRefusedOnAPreviewThatWouldBeStale(t *testing.T) { - open, sent := anAdoptedAnchor(t) - _, err := converge(t.Context(), open, "anchor", false, "", "") - if err == nil || !strings.Contains(err.Error(), "has not reported") { - t.Fatalf("a node that never reported was previewed: %v", err) - } - if len(*sent) != 0 { - t.Fatal("a refused converge sent something") - } -} - -func TestTheFlipIsRefusedWhileAFoundContainerIsHeld(t *testing.T) { - open, sent := anAdoptedAnchor(t) - reportsHolding(t, open, heldContainer, heldFile) - _, err := converge(t.Context(), open, "anchor", true, "", "") - if err == nil || !strings.Contains(err.Error(), "take anchor hello-web once its data has moved") { - t.Fatalf("the flip was not refused while hello-web holds its found container: %v", err) - } - if n, _ := open.inventory.NodeByName(t.Context(), "anchor"); !n.Adopted || len(*sent) != 0 { - t.Fatal("a refused flip changed something") - } -} - -func TestTakingNamesWhatItReplaces(t *testing.T) { - open, _ := anAdoptedAnchor(t) - reportsHolding(t, open, heldContainer, heldFile) - ctx := t.Context() - // The machine holds something for the module, so the take acts on the preview the operator - // saw and names its digest (novox/hq ADR 0163). - preview, err := take(ctx, open, "anchor", "hello-web", takeOptions{}) - if err != nil { - t.Fatal(err) - } - saw := takeDigestIn(t, preview) - if !strings.Contains(preview, "nothing taken; `take anchor hello-web --yes "+saw+"`") { - t.Fatalf("the preview does not say how to act on it:\n%s", preview) - } - if taken, _ := open.inventory.Taken(ctx, "anchor"); len(taken) != 0 { - t.Fatal("the preview took something") - } - if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err == nil || !strings.Contains(err.Error(), "name its digest") { - t.Fatalf("--yes without the digest was not refused: %v", err) - } - said, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw}) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(said, "container hello-web (hello-web.server)") || - !strings.Contains(said, "compositions of anchor are written") { - t.Fatalf("taking did not say what it replaces and what to run:\n%s", said) - } -} - -// takeDigestIn is the digest a take's preview printed. -func takeDigestIn(t *testing.T, preview string) string { - t.Helper() - for _, line := range strings.Split(preview, "\n") { - if fields := strings.Fields(line); len(fields) == 2 && fields[0] == "preview" { - return fields[1] - } - } - t.Fatalf("the preview printed no digest:\n%s", preview) - return "" -} - -// A take acts on the preview the operator saw, and on an account of the machine that is still the -// machine: a changed preview and a stale account refuse (novox/hq ADR 0163, rule 1). -func TestATakeIsRefusedOnAChangedPreviewOrAStaleAccount(t *testing.T) { - open, _ := anAdoptedAnchor(t) - ctx := t.Context() - reportsHolding(t, open, heldContainer, heldFile) - preview, err := take(ctx, open, "anchor", "hello-web", takeOptions{}) - if err != nil { - t.Fatal(err) - } - saw := takeDigestIn(t, preview) - - // The machine reports again, and what it holds has changed: the found container now carries - // facts the preview never showed. - changed := heldContainer - changed.Facts = map[string]any{"image": "hello:2", "declared_image": "registry.example/hello"} - reportsHolding(t, open, changed, heldFile) - _, err = take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw}) - if err == nil || !strings.Contains(err.Error(), "has changed since preview "+saw) { - t.Fatalf("a changed preview was acted on: %v", err) - } - if taken, _ := open.inventory.Taken(ctx, "anchor"); len(taken) != 0 { - t.Fatal("a refused take took something") - } - - // And an account older than the flip allows. - preview, err = take(ctx, open, "anchor", "hello-web", takeOptions{}) - if err != nil { - t.Fatal(err) - } - saw = takeDigestIn(t, preview) - saved := reportFreshFor - reportFreshFor = -time.Second - defer func() { reportFreshFor = saved }() - _, err = take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw}) - if err == nil || !strings.Contains(err.Error(), "a take acts only on an account newer than") { - t.Fatalf("a stale account was acted on: %v", err) - } - reportFreshFor = saved - if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw}); err != nil { - t.Fatal(err) - } - // A module the machine holds nothing for has nothing to compare: --yes alone suffices. - if _, err := take(ctx, open, "anchor", "notes", takeOptions{Yes: true}); err == nil { - // notes holds a file, so this one needs the digest too. - t.Fatal("notes holds a found file and was taken without a digest") - } -} - -func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) { - open, sent := anAdoptedAnchor(t) - ctx := t.Context() - if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil { - t.Fatal(err) - } - *sent = nil // the take composed the node; what follows is judged from here - reportsHolding(t, open, heldFile) - - preview, err := converge(ctx, open, "anchor", false, "", "") - if err != nil { - t.Fatal(err) - } - for _, want := range []string{ - "tcp/8080 hello-web (published, container port 80)", - "declared by hello-web (from anywhere)", - "WILL CLOSE — no module assigned here declares it", - // The anchor faces inward and is on the private network: the derived filter admits ssh - // from the mesh only. - "WILL CLOSE to everything outside the private network — ssh stays open from the mesh", - "notes\n replacing the found file /etc/notes.conf (notes.conf), original kept at", - "assigns nftables", - "the found firewall (ufw) is disabled, never flushed", - // What it routes is not a listener: said not to be previewed, and to be dropped. - "not previewed: traffic the machine routes that is not a published port", - "the derived filter drops it unless a module declares it", - } { - if !strings.Contains(preview, want) { - t.Errorf("the preview does not say %q:\n%s", want, preview) - } - } - if strings.Contains(preview, "15672") { - t.Errorf("a loopback listener is in the preview:\n%s", preview) - } - // What filters the machine now, and the fate of each (novox/hq ADR 0168): the predecessor's - // chain is named as not the mesh's and left, so the reader knows before the flip. - for _, want := range []string{ - "table ip filter, chain DOCKER-USER", - "NOT THE MESH'S; left in force", - `iifname "eth0" tcp dport 6000 drop`, - "table ip filter, chain ufw-reject-input", - "the found firewall's; retired with it", - "the container runtime's own; left", - } { - if !strings.Contains(preview, want) { - t.Errorf("the preview does not say %q:\n%s", want, preview) - } - } - for _, line := range strings.Split(preview, "\n") { - if strings.Contains(line, "5000") && !strings.Contains(line, "WILL CLOSE") { - t.Errorf("an undeclared published port is not said to close: %s", line) - } - } - if n, _ := open.inventory.NodeByName(ctx, "anchor"); !n.Adopted || len(*sent) != 0 { - t.Fatal("the preview changed something") - } - - if _, err := converge(ctx, open, "anchor", true, digestIn(t, preview), ""); err != nil { - t.Fatal(err) - } - n, _ := open.inventory.NodeByName(ctx, "anchor") - if n.Adopted { - t.Fatal("converge --yes left the node adopted") - } - taken, _ := open.inventory.Taken(ctx, "anchor") - if !reflect.DeepEqual(taken, []string{"hello-web", overlay.Name, "nftables", "notes"}) { - t.Fatalf("the flip took %v", taken) - } - if !reflect.DeepEqual(*sent, []string{"anchor"}) { - t.Fatalf("the flip sent %v", *sent) - } - declared := composed(t, open, "anchor") - if declared.Adoption != nil { - t.Fatal("a converged node is still sent an adoption envelope") - } - if !hasID(declared.Resources, "nftables.filtering") { - t.Fatal("the converged node is not declared the mesh's filter") - } - - if _, err := adopt(ctx, open, "anchor"); err != nil { - t.Fatal(err) - } - if _, err := adopt(ctx, open, "anchor"); err == nil { - t.Fatal("adopting an adopted node was not refused") - } - again, _ := open.inventory.Taken(ctx, "anchor") - if !reflect.DeepEqual(again, taken) { - t.Fatalf("returning to adopted lost what was taken: %v", again) - } - declared = composed(t, open, "anchor") - if declared.Adoption == nil || len(declared.Adoption.Untaken) != 0 { - t.Fatalf("returned to adopted, the envelope is %+v", declared.Adoption) - } - if hasID(declared.Resources, "nftables.filtering") || hasID(declared.Resources, "nftables.load") { - t.Fatal("returned to adopted, the mesh's filter is still declared") - } -} - -func hasID(resources []map[string]any, id string) bool { - for _, r := range resources { - if r["id"] == id { - return true - } - } - return false -} - -// The command API refuses a flip exactly as the command line does, in the same words. -func TestTheApiRefusesTheFlipInTheCommandLinesWords(t *testing.T) { - open, _ := anAdoptedAnchor(t) - reportsHolding(t, open, heldContainer) - _, direct := converge(t.Context(), open, "anchor", true, "", "") - if direct == nil { - t.Fatal("the flip was not refused") - } - got := asking(t, letIn{}, "POST", "/converge", `{"node":"anchor","yes":true}`) - if got.Code != http.StatusConflict { - t.Fatalf("got %d: %s", got.Code, got.Body.String()) - } - var said map[string]any - if err := json.Unmarshal(got.Body.Bytes(), &said); err != nil { - t.Fatal(err) - } - if said["refused"] != direct.Error() { - t.Fatalf("the API said %q and the command line %q", said["refused"], direct.Error()) - } - if got := asking(t, letIn{}, "POST", "/take", `{"node":"anchor"}`); got.Code != http.StatusBadRequest { - t.Fatalf("a take naming no module got %d", got.Code) - } -} - -// novox/hq ADR 0100: the preview says what the derived filter does, rendered as it is rendered. On -// a machine that faces inward, ssh is admitted from the private network only, and a port a module -// admits from the mesh only closes to everything outside it: both are said to close. -func TestThePreviewSaysWhatNarrowsToTheMeshCloses(t *testing.T) { - open, _ := anAdoptedAnchor(t) - ctx := t.Context() - register(t, open, catalogue.Manifest{Module: "store", Version: "1", - Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}}}) - if _, err := assign(ctx, open, "anchor", "store"); err != nil { - t.Fatal(err) - } - reportsReaching(t, open, []link.Reach{ - {Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"}, - {Protocol: "tcp", Address: "0.0.0.0", Port: 5432, By: "postgres"}, - {Protocol: "tcp", Address: "10.77.0.1", Port: 5432, By: "postgres"}, - {Protocol: "tcp", Address: "0.0.0.0", Port: 8080, By: "hello-web", Published: true, - ContainerPort: 80}, - }) - preview, err := converge(ctx, open, "anchor", false, "", "") - if err != nil { - t.Fatal(err) - } - lines := map[string]string{} - for _, line := range strings.Split(preview, "\n") { - fields := strings.Fields(line) - if len(fields) > 1 && strings.HasPrefix(fields[0], "tcp/") { - lines[fields[0]+" "+fields[1]] += line + "\n" - } - } - if got := lines["tcp/22 sshd"]; !strings.Contains(got, "WILL CLOSE to everything outside "+ - "the private network") { - t.Errorf("ssh on an inward machine is not said to close outside the mesh:\n%s", preview) - } - store := lines["tcp/5432 postgres"] - if strings.Count(store, "WILL CLOSE to everything outside the private network") != 1 || - !strings.Contains(store, "declared by store (from mesh)") { - t.Errorf("the store's narrowing is not said to close, or its mesh address is:\n%s", preview) - } - if got := lines["tcp/8080 hello-web"]; !strings.Contains(got, "declared by hello-web (from anywhere)") { - t.Errorf("a port open to everywhere is not said to stay:\n%s", preview) - } -} - -// digestIn is the digest a converge preview printed. -func digestIn(t *testing.T, preview string) string { - t.Helper() - for _, line := range strings.Split(preview, "\n") { - if fields := strings.Fields(line); len(fields) == 2 && fields[0] == "preview" { - return fields[1] - } - } - t.Fatalf("the preview printed no digest:\n%s", preview) - return "" -} - -// The flip acts on the preview the operator saw: it names that preview's digest, and it is refused -// when the digest is missing, when anything the preview says has changed since, or when the node's -// account of itself is too old to be the machine as it is. -func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) { - open, sent := anAdoptedAnchor(t) - ctx := t.Context() - if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil { - t.Fatal(err) - } - *sent = nil // the take composed the node; what follows is judged from here - reportsHolding(t, open, heldFile) - preview, err := converge(ctx, open, "anchor", false, "", "") - if err != nil { - t.Fatal(err) - } - saw := digestIn(t, preview) - if !strings.Contains(preview, "converge anchor --yes "+saw) { - t.Fatalf("the preview does not say how to act on it:\n%s", preview) - } - unchanged := func() { - t.Helper() - if n, _ := open.inventory.NodeByName(ctx, "anchor"); !n.Adopted || len(*sent) != 0 { - t.Fatal("a refused flip changed something") - } - } - - if _, err := converge(ctx, open, "anchor", true, "", ""); err == nil || - !strings.Contains(err.Error(), "--yes "+saw) { - t.Fatalf("a flip naming no preview was not refused: %v", err) - } - unchanged() - - // Something new is reachable: the preview the operator saw is not what would happen. - reportsReaching(t, open, []link.Reach{ - {Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"}, - {Protocol: "tcp", Address: "0.0.0.0", Port: 8080, By: "hello-web", Published: true, - ContainerPort: 80}, - {Protocol: "tcp", Address: "0.0.0.0", Port: 6000, By: "something-new"}, - }, heldFile) - _, err = converge(ctx, open, "anchor", true, saw, "") - if err == nil || !strings.Contains(err.Error(), "has changed since preview "+saw) { - t.Fatalf("a flip on a changed preview was not refused: %v", err) - } - unchanged() - - // An account older than the flip trusts is refused, whatever digest is named. - again, err := converge(ctx, open, "anchor", false, "", "") - if err != nil { - t.Fatal(err) - } - saved := reportFreshFor - reportFreshFor = time.Nanosecond - _, err = converge(ctx, open, "anchor", true, digestIn(t, again), "") - reportFreshFor = saved - if err == nil || !strings.Contains(err.Error(), "wait for its next report") { - t.Fatalf("a flip on an old account was not refused: %v", err) - } - unchanged() - - if _, err := converge(ctx, open, "anchor", true, digestIn(t, again), ""); err != nil { - t.Fatalf("the flip on the preview just seen was refused: %v", err) - } - if n, _ := open.inventory.NodeByName(ctx, "anchor"); n.Adopted { - t.Fatal("the flip did not converge the node") - } -} - -// novox/hq ADR 0103: the preview names every kind of thing a module the flip takes holds as found, -// not only its files, and the digest changes when any of them does. -func TestThePreviewNamesEveryHeldKind(t *testing.T) { - open, _ := anAdoptedAnchor(t) - ctx := t.Context() - if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil { - t.Fatal(err) - } - since := time.Now() - held := []link.Held{heldFile, - {ID: "notes.data", Module: "notes", Kind: "directory", Target: "/var/lib/notes", Since: since}, - {ID: "notes.daemon", Module: "notes", Kind: "service", Target: "notes.service", Since: since}, - {ID: "notes.seed", Module: "notes", Kind: "archive", Target: "/srv/notes", Since: since}, - {ID: "notes.worker", Module: "notes", Kind: "process", Target: "notes-worker", Since: since}, - {ID: "notes.account", Module: "notes", Kind: "user", Target: "notes", Since: since}, - } - reportsHolding(t, open, held...) - preview, err := converge(ctx, open, "anchor", false, "", "") - if err != nil { - t.Fatal(err) - } - for _, want := range []string{ - "replacing the found directory /var/lib/notes (notes.data)", - "replacing the found service notes.service (notes.daemon)", - "replacing the found archive /srv/notes (notes.seed)", - "replacing the found process notes-worker (notes.worker)", - "replacing the found user notes (notes.account)", - } { - if !strings.Contains(preview, want) { - t.Errorf("the preview does not say %q:\n%s", want, preview) - } - } - reportsHolding(t, open, held[:len(held)-1]...) - fewer, err := converge(ctx, open, "anchor", false, "", "") - if err != nil { - t.Fatal(err) - } - if digestIn(t, fewer) == digestIn(t, preview) { - t.Fatal("the digest does not change with what is held") - } -} - -// novox/hq ADR 0100: the flip acts on what the node said is reachable, so an account naming nothing -// is refused. Every machine that is up answers on ssh; nothing reported means the host's collectors -// did not, and flipping would close ports the preview never named. -func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) { - open, sent := anAdoptedAnchor(t) - ctx := t.Context() - if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil { - t.Fatal(err) - } - *sent = nil // the take composed the node; what follows is judged from here - // Only a loopback listener: nothing off the machine, which is the same silence. - reportsReaching(t, open, []link.Reach{ - {Protocol: "tcp", Address: "127.0.0.1", Port: 15672, By: "mesh-broker"}, - }, heldFile) - preview, err := converge(ctx, open, "anchor", false, "", "") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(preview, "this account looks partial") { - t.Errorf("the preview does not mark a partial account:\n%s", preview) - } - _, err = converge(ctx, open, "anchor", true, digestIn(t, preview), "") - if err == nil || !strings.Contains(err.Error(), "says nothing is reachable on it") { - t.Fatalf("the flip was not refused on an account naming nothing: %v", err) - } - if n, _ := open.inventory.NodeByName(ctx, "anchor"); !n.Adopted || len(*sent) != 0 { - t.Fatal("a refused flip changed something") - } -} - -// onTheBusWithItsHaves gives the controller a bus that holds, for the anchor, a want and a have of its module that agree: the node -// has everything wanted of it, as read from the haves (novox/hq ADR 0539 §9), so a flip is refused for what it is about and -// not because the bus could not be read. -func onTheBusWithItsHaves(t *testing.T, open *stores) { - t.Helper() - ctx := t.Context() - js, err := broker.Dial(testbus.URL(t)) - if err != nil { - t.Fatal(err) - } - t.Cleanup(js.Close) - servingBus.Store(js) - t.Cleanup(func() { servingBus.Store(nil) }) - api, err := jetstream.New(js.Conn()) - if err != nil { - t.Fatal(err) - } - fp := sdkdelivery.SourcePrefix2 + strings.Repeat("a", 64) - composed := "sha256:" + strings.Repeat("b", 64) - put := func(bucket, key string, value []byte) { - kv, err := api.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucket}) - if err != nil { - t.Fatal(err) - } - if _, err := kv.Put(ctx, key, value); err != nil { - t.Fatal(err) - } - } - wantKey, err := sdkdelivery.WantKey("anchor", "hello-web") - if err != nil { - t.Fatal(err) - } - haveKey, err := sdkdelivery.HaveKey("anchor", "hello-web") - if err != nil { - t.Fatal(err) - } - want, err := sdkdelivery.EncodeWant(sdkdelivery.Want{FP: fp, Composed: composed}) - if err != nil { - t.Fatal(err) - } - have, err := sdkdelivery.EncodeHave(sdkdelivery.Have{FP: fp, Composed: composed, Phase: sdkdelivery.PhaseRunning, Health: sdkdelivery.HealthOK}) - if err != nil { - t.Fatal(err) - } - put(sdkdelivery.AssignerWantsBucket, wantKey, want) - put(sdkdelivery.HaveBucket, haveKey, have) -} - -// **When the bus cannot be read, whether the machine runs what is wanted is not known, and the flip says exactly that** (issue -// 1945): not "push", and not "has not reported". -func TestTheFlipIsRefusedWithTheBusUnreachableSayingItCannotTell(t *testing.T) { - open, sent := anAdoptedAnchor(t) - reportsHolding(t, open, heldContainer, heldFile) - servingBus.Store(nil) // this process has no bus now, and no address to dial - _, err := converge(t.Context(), open, "anchor", true, "", "") - if err == nil || !strings.Contains(err.Error(), "cannot tell whether anchor runs what is wanted: the bus is not reachable") || - strings.Contains(err.Error(), "push") { - t.Fatalf("the flip with the bus out of reach: %v", err) - } - if len(*sent) != 0 { - t.Fatal("a refused flip sent something") - } -} - -// **A take composes the node only when it is performed** (issue 1945): a preview, a take that names no digest and a take refused -// for a changed preview compose nothing; the performed take composes the machine once, and says so. -func TestATakeComposesTheNodeOnlyWhenItIsPerformed(t *testing.T) { - open, sent := anAdoptedAnchor(t) - ctx := t.Context() - reportsHolding(t, open, heldContainer, heldFile) - preview, err := take(ctx, open, "anchor", "hello-web", takeOptions{}) - if err != nil { - t.Fatal(err) - } - if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err == nil { - t.Fatal("a take of a held module without the preview's digest was performed") - } - if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: "not-the-digest"}); err == nil { - t.Fatal("a take naming another preview's digest was performed") - } - if len(*sent) != 0 { - t.Fatalf("a preview or a refused take composed %v", *sent) - } - said, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: takeDigestIn(t, preview)}) - if err != nil { - t.Fatal(err) - } - if len(*sent) != 1 || (*sent)[0] != "anchor" || !strings.Contains(said, "compositions of anchor are written") { - t.Fatalf("the performed take composed %v and said:\n%s", *sent, said) - } -} diff --git a/cmd/mesh-controller/adoption.go b/cmd/mesh-controller/adoption.go deleted file mode 100644 index b61a124d..00000000 --- a/cmd/mesh-controller/adoption.go +++ /dev/null @@ -1,1113 +0,0 @@ -package main - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "flag" - "fmt" - "slices" - "sort" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" - "github.com/novox/mesh-controller/internal/inventory" -) - -// A node is adopted or converged (novox/hq ADR 0100), and it is said to be adopted wherever the -// mesh reports a node's state: node list, node show, status and the board. - -// showMode is the node show lines about a node's mode and what was taken on it. -func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node) error { - if !node.Adopted { - fmt.Printf(" mode converged\n") - // A converged machine holds nothing, and can still run what nobody asked for - // (novox/hq ADR 0163): what it reports as strays is said whatever its mode. - if said, err := inv.AdoptionOf(ctx, node.Name); err == nil && len(said.Strays) > 0 { - showStrays(said.Strays) - } - // And what filters it, truthfully (novox/hq ADR 0168): the mesh alone, or not. - if filtering, err := inv.FilteringOf(ctx, node.Name); err == nil { - showFiltering(filtering, false) - } - return nil - } - fmt.Printf(" mode adopted since %s\n", - node.AdoptedSince.Local().Format(time.DateTime)) - taken, err := inv.Taken(ctx, node.Name) - if err != nil { - return err - } - if len(taken) == 0 { - fmt.Printf(" taken nothing yet\n") - } else { - fmt.Printf(" taken %s\n", strings.Join(taken, ", ")) - } - said, err := inv.AdoptionOf(ctx, node.Name) - if err != nil { - return err - } - if said.At.IsZero() { - fmt.Printf(" it has not yet said what it found\n") - return nil - } - fmt.Printf(" firewall found %s\n", orNone(said.Firewall)) - if err := showTunnel(ctx, inv, node.Name); err != nil { - return err - } - if len(said.Held) == 0 { - fmt.Printf(" holding nothing found\n") - } - for _, h := range said.Held { - // A held thing that changed is how a predecessor still writing is caught: said first. - line := fmt.Sprintf(" holds %-11s %s %s, for %s", h.Kind, h.Target, h.ID, h.Module) - if h.Changed != "" { - line += " — " + strings.ToUpper(h.Changed) + " by something other than the mesh" - } - fmt.Println(line) - if h.Kept != "" { - fmt.Printf(" %-17s original kept at %s\n", "", h.Kept) - } - for _, f := range comparisonLines(h) { - fmt.Printf(" %-17s %s\n", "", f) - } - } - showStrays(said.Strays) - if filtering, err := inv.FilteringOf(ctx, node.Name); err == nil { - showFiltering(filtering, true) - } - fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime)) - return nil -} - -// showFiltering says what filters a machine, with owners (novox/hq ADR 0168), and for a converged -// machine the state of the firewall it was found with. A machine that has not said is not said to -// be filtered by anything. -func showFiltering(f inventory.Filtering, adopted bool) { - if len(f.Filters) == 0 && f.FoundFirewall == nil { - return - } - if fw := f.FoundFirewall; fw != nil && !adopted { - switch { - case fw.Active: - fmt.Printf(" found firewall %s is ACTIVE on this converged machine; the next apply retires it again\n", fw.Kind) - case fw.RetiredBy == "removed": - fmt.Printf(" found firewall %s, removed: the mesh's filter is what filters this machine (novox/hq ADR 0180)\n", fw.Kind) - case fw.RetiredBy == inventory.FilterMesh || fw.RetiredBy == "mesh": - fmt.Printf(" found firewall %s, retired by the mesh; its configuration stays on disk\n", fw.Kind) - case fw.RetiredBy != "": - fmt.Printf(" found firewall %s, found inactive — not by the mesh\n", fw.Kind) - default: - fmt.Printf(" found firewall %s, inactive\n", fw.Kind) - } - } - if len(f.Filters) == 0 { - return - } - if f.Alone() { - fmt.Printf(" filtered by the mesh alone (%s)\n", filterSummary(f.Filters)) - return - } - fmt.Printf(" filtered by NOT the mesh alone: %d rule set(s) the mesh did not write refuse traffic here\n", len(f.Others())) - for _, x := range f.Filters { - if x.Owner == inventory.FilterOther || x.Owner == inventory.FilterFoundFirewall { - fmt.Printf(" %-17s %s — %s: %s\n", "", x.Where, x.Owner, x.Refuses) - } - } - fmt.Printf(" %-17s and its own: %s\n", "", filterSummary(f.Filters)) -} - -// filterSummary counts a machine's filters by owner: "mesh 2, runtime 3, ban 1". -func filterSummary(filters []inventory.Filter) string { - counts := map[string]int{} - for _, x := range filters { - counts[x.Owner]++ - } - var parts []string - for _, owner := range []string{inventory.FilterMesh, inventory.FilterRuntime, inventory.FilterBan, inventory.FilterFoundFirewall, inventory.FilterOther} { - if n := counts[owner]; n > 0 { - parts = append(parts, fmt.Sprintf("%s %d", owner, n)) - } - } - return strings.Join(parts, ", ") -} - -// showStrays says what a machine runs that the mesh neither wrote nor holds (ADR 0163). -func showStrays(strays []inventory.Stray) { - if len(strays) == 0 { - return - } - fmt.Printf(" strays %d container(s) the mesh neither wrote nor holds:\n", len(strays)) - for _, s := range strays { - fmt.Printf(" %-17s %s (%s)\n", "", s.Name, s.Detail) - } -} - -// showTunnel is the node show lines about the tunnel an adopted node found and carried (novox/hq -// ADR 0105): what it presented at enrolment, and what it last said about taking it over. -func showTunnel(ctx context.Context, inv *inventory.Inventory, name string) error { - tunnel, err := inv.TunnelOf(ctx, name) - if errors.Is(err, inventory.ErrNoTunnel) { - return nil - } - if err != nil { - return err - } - fmt.Printf(" tunnel found %s on port %d, %s in %s, %d peer(s)\n", - tunnel.Interface, tunnel.Port, tunnel.Address, tunnel.Range, len(tunnel.Peers)) - carried, said, err := inv.CarriedTunnelOf(ctx, name) - if err != nil { - return err - } - switch { - case !said: - fmt.Printf(" %-17s not yet taken over — the node has not said so\n", "") - case carried.State == inventory.CarriedTaken: - fmt.Printf(" %-17s taken over: %s is down and disabled, never flushed; the mesh's interface "+ - "runs with its key, port and %d peer(s)\n", "", carried.Interface, carried.Peers) - case carried.State == inventory.CarriedDown: - fmt.Printf(" %-17s TUNNEL DOWN: %s is stopped and the mesh's interface is not up — the peers "+ - "reach nothing. On the machine: systemctl start %s\n", "", carried.Interface, - "wg-quick@"+carried.Interface) - default: - fmt.Printf(" %-17s NOT taken over: %s is still the interface the peers reach\n", "", carried.Interface) - } - if said && carried.Note != "" { - fmt.Printf(" %-17s %s\n", "", carried.Note) - } - if said && carried.Kept != "" { - fmt.Printf(" %-17s its configuration's original kept at %s\n", "", carried.Kept) - } - return nil -} - -func orNone(s string) string { - if s == "" { - return "none reported" - } - return s -} - -// adoptedNodes are the names of every adopted node, in the order given. -func adoptedNodes(nodes []inventory.Node) []string { - var out []string - for _, n := range nodes { - if n.Adopted { - out = append(out, n.Name) - } - } - return out -} - -// The operator's acts on an adopted node (novox/hq ADR 0100). Called by the command line and the -// command API alike, so a refusal is the same refusal in the same words at both (ADR 0035). - -// sendNodes composes the named machines as they should be now (novox/hq ADR 0539 clause 5): each node's want then -// names what changed. A variable so a test can see what an act would compose without a broker. -var sendNodes = composeAndSay - -// DefaultFilter is the module converging a node assigns to load the mesh's derived filter. -const DefaultFilter = "nftables" - -// take is a module's cutover on an adopted node: the operator's act, done when that module's data -// has moved. From the next push its resources converge there like any other, replacing what the -// node found and holds for it. -// -// **Previewed, and the preview is a comparison** (novox/hq ADR 0163): for every held thing the -// module would replace, what runs beside what the module declares, and the difference; the -// module's secrets on the machine and where each came from; its settings on the machine. Without -// --yes the comparison is printed and nothing changes. `--yes ` cuts over exactly what was -// previewed, the way the flip is confirmed: the preview ends with a digest of what it said, and a -// take naming an older one, or acting on an account of the machine older than the flip allows, is -// refused. A module the machine holds nothing for has nothing to compare, and `--yes` suffices. -// takeOptions is what a take was told about the differences it may pass (novox/hq ADR 0163). -type takeOptions struct { - Yes bool - // Digest is the preview's, named with --yes; required whenever the machine holds something - // for the module. - Digest string - Downgrade bool - Replace map[string]bool - // Mint names the secrets the service shall take a new value for, although the mesh minted - // one and the service already has its own (rule 2). - Mint map[string]bool -} - -func take(ctx context.Context, open *stores, node, module string, opts takeOptions) (string, error) { - inv := open.inventory - assigned, err := inv.Assigned(ctx, node) - if err != nil { - return "", err - } - if !slices.Contains(assigned, module) { - if plan, _, err := resolveNode(ctx, open, node); err == nil { - if why, runs := plan.Because[module]; runs { - return "", fmt.Errorf("%w: %s runs on %s because %s — assign it to %s to take it", - inventory.ErrNotAssigned, module, node, why, node) - } - } - } - // The comparison first (novox/hq ADR 0163): every held thing the module would replace, beside - // what the module declares, and the differences that refuse unless named. - c, err := comparisonFor(ctx, open, node, module) - if err != nil { - return "", err - } - preview, refusals, saw := comparisonOf(module, c, opts) - if len(refusals) > 0 { - return "", fmt.Errorf("taking %s on %s is refused:\n %s\n%s", module, node, - strings.Join(refusals, "\n "), preview) - } - holds := len(heldOf(c.reported, module)) > 0 - if holds { - preview += "\n preview " + saw - } - if !opts.Yes { - if !holds { - return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes` declares it as the mesh's own", node, module), nil - } - return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes %s` cuts it over as previewed", node, module, saw), nil - } - if holds { - // The take acts on the preview the operator saw, and on an account of the machine that - // is still the machine: the same two refusals the flip makes. - if age := time.Since(c.reported.At); age > reportFreshFor { - return preview, fmt.Errorf("%s last said what it holds %s ago, and a take acts only on "+ - "an account newer than %s: wait for its next report, then preview again", - node, age.Round(time.Second), reportFreshFor) - } - if opts.Digest == "" { - return preview, fmt.Errorf("taking %s on %s acts on the preview you saw: name its digest, "+ - "`take %s %s --yes %s`, once you have read it", module, node, node, module, saw) - } - if opts.Digest != saw { - return preview, fmt.Errorf("what taking %s on %s would replace has changed since preview %s "+ - "(it is now %s): read the preview above, and run `take %s %s --yes %s` if it is "+ - "what you want", module, node, opts.Digest, saw, node, module, saw) - } - } - if err := inv.Take(ctx, node, module); err != nil { - return "", err - } - said := fmt.Sprintf("%s is taken on %s", module, node) - if holds { - said += "; its next composition replaces what the node found and holds for it:\n" + preview - } - if err := sendNodes(ctx, open, []string{node}); err != nil { - return said + "\n and its compositions could not be written yet: the next act that composes them carries it", err - } - return said + fmt.Sprintf("\n the compositions of %s are written; its wants then cut it over", node), nil -} - -// comparison is everything a take puts beside what the module declares: the machine's account of -// what it holds and what is reachable on it, the module's secrets on the machine, its settings -// there, and which found networks a setting keeps for each of its containers (by held id). -type comparison struct { - reported inventory.Adoption - secrets []inventory.SecretState - layers []catalogue.Layer - keeps map[string][]string - // settingsRefused is why the module's settings cannot compose with its definition, when - // they cannot — the module would be left out of the declaration (rule 6). - settingsRefused string -} - -func comparisonFor(ctx context.Context, open *stores, node, module string) (comparison, error) { - inv := open.inventory - var c comparison - var err error - if c.reported, err = inv.AdoptionOf(ctx, node); err != nil { - return c, err - } - if c.secrets, err = inv.SecretsOf(ctx, node, module); err != nil { - return c, err - } - if c.layers, err = inv.SettingsFor(ctx, node, module); err != nil { - return c, err - } - shelf, err := inv.Catalogue(ctx) - if err != nil { - return c, err - } - if m, known := shelf[module]; known && len(c.layers) > 0 { - if err := catalogue.JudgeSettings(m, c.layers, true); err != nil { - c.settingsRefused = err.Error() - } - if kept, err := catalogue.KeptNetworks(m, c.layers, true); err == nil && len(kept) > 0 { - c.keeps = map[string][]string{} - for id, networks := range kept { - c.keeps[module+"."+id] = networks - } - } - } - return c, nil -} - -// heldOf is what a node holds for one module. -func heldOf(reported inventory.Adoption, module string) []inventory.Held { - var out []inventory.Held - for _, h := range reported.Held { - if h.Module == module { - out = append(out, h) - } - } - return out -} - -// comparisonOf is a take's preview: for every held thing of the module, what runs beside what the -// module declares; its secrets and its settings on the machine; and the refusals the differences -// earn unless the take named them (novox/hq ADR 0163): an image older than the one running, a -// declared file that differs from the found one, a secret the mesh minted for a service whose data -// was found. A narrowed port and a shared network are said and not refused. The digest is of what -// the preview says, so anything in it changing changes the digest. -func comparisonOf(module string, c comparison, opts takeOptions) (preview string, refusals []string, digest string) { - var b strings.Builder - held := heldOf(c.reported, module) - foundData := false - for _, h := range held { - if h.Kind == "container" || h.Kind == "directory" { - foundData = true - } - fmt.Fprintf(&b, " %s", heldLine(h)) - if h.Kept != "" { - fmt.Fprintf(&b, ", original kept at %s", h.Kept) - } - b.WriteString("\n") - for _, line := range comparisonLinesWith(h, c.keeps[h.ID], c.reported) { - fmt.Fprintf(&b, " %s\n", line) - } - f := factsOf(h) - if f.downgrade && !opts.Downgrade { - refusals = append(refusals, fmt.Sprintf("%s: the module's image (%s, made %s) is older than the one running (%s, made %s) — "+ - "a service that migrated its data forward may not start on it; `--downgrade` to take it anyway", - h.Target, f.declaredImage, day(f.declaredCreated), f.image, day(f.imageCreated))) - } - if f.differs && !opts.Replace[h.Target] && !opts.Replace["*"] { - refusals = append(refusals, fmt.Sprintf("%s: the module's content differs from the file found; the lines above "+ - "marked - are lost by taking it; `--replace %s` to replace it anyway, or declare the file partially", - h.Target, h.Target)) - } - } - // The module's secrets on the machine (rule 2 and 3): a service whose data was found already - // has a value for each, so one the mesh minted and nobody accepted refuses unless --mint says - // the service shall take a new one. - for _, sec := range c.secrets { - name := sec.Name - if sec.Local != "" { - name += " (" + sec.Local + ")" - } - what := "own secret" - accept := fmt.Sprintf("`secret accept %s %s`", module, sec.Name) - if !sec.Own() { - what = "secret from " + sec.Provider - accept = fmt.Sprintf("`secret accept %s %s --provider %s`", module, sec.Name, sec.Provider) - if sec.Local != "" { - accept = strings.TrimSuffix(accept, "`") + " --local " + sec.Local + "`" - } - } - switch { - case sec.Origin == inventory.OriginAccepted: - fmt.Fprintf(&b, " %s %s: accepted from a person, carried in as it is\n", what, name) - case sec.Origin == inventory.OriginIssued: - // Taken from the provider's software (novox/hq ADR 0305): the value the running service uses. - fmt.Fprintf(&b, " %s %s: issued by the provider's software, carried in as it is\n", what, name) - case opts.Mint[sec.Name]: - fmt.Fprintf(&b, " %s %s: minted by the mesh; the service takes the new value, as --mint said\n", what, name) - case foundData: - fmt.Fprintf(&b, " %s %s: MINTED by the mesh and not accepted — the running service already has one\n", what, name) - refusals = append(refusals, fmt.Sprintf("%s: the mesh minted a value and the service whose data was found "+ - "already uses its own; %s carries the existing value in, or `--mint %s` says the service shall take "+ - "the new one", name, accept, sec.Name)) - default: - fmt.Fprintf(&b, " %s %s: minted by the mesh\n", what, name) - } - } - // And its settings on this machine, composed against its definition (rule 1, rule 6). - for _, layer := range c.layers { - said := layer.Whole() - keys := make([]string, 0, len(said)) - for k := range said { - keys = append(keys, k) - } - sort.Strings(keys) - fmt.Fprintf(&b, " settings from %s: %s\n", layer.From, strings.Join(keys, ", ")) - } - if c.settingsRefused != "" { - fmt.Fprintf(&b, " SETTINGS DO NOT COMPOSE with the module's definition, so the composition leaves it out: %s\n", c.settingsRefused) - } - preview = strings.TrimRight(b.String(), "\n") - sum := sha256.Sum256([]byte(preview)) - return preview, refusals, hex.EncodeToString(sum[:])[:12] -} - -// facts is a held thing's facts as the preview reads them. -type facts struct { - image, imageCreated, declaredImage, declaredCreated string - downgrade, differs bool - networks map[string][]string - mounts, ports, declaredPorts, declaredVolumes []string - difference []string -} - -func factsOf(h inventory.Held) facts { - var f facts - if h.Facts == nil { - return f - } - str := func(k string) string { s, _ := h.Facts[k].(string); return s } - list := func(k string) []string { - var out []string - if raw, ok := h.Facts[k].([]any); ok { - for _, x := range raw { - if s, ok := x.(string); ok { - out = append(out, s) - } - } - } - return out - } - f.image, f.imageCreated = str("image"), str("image_created") - f.declaredImage, f.declaredCreated = str("declared_image"), str("declared_image_created") - f.downgrade, _ = h.Facts["downgrade"].(bool) - f.differs, _ = h.Facts["differs"].(bool) - f.mounts, f.ports = list("mounts"), list("ports") - f.declaredPorts, f.declaredVolumes, f.difference = list("declared_ports"), list("declared_volumes"), list("difference") - if raw, ok := h.Facts["networks"].(map[string]any); ok { - f.networks = map[string][]string{} - for name, members := range raw { - var out []string - if ms, ok := members.([]any); ok { - for _, m := range ms { - if s, ok := m.(string); ok { - out = append(out, s) - } - } - } - f.networks[name] = out - } - } - return f -} - -// comparisonLines says a held thing's facts the way a person weighs them. -func comparisonLines(h inventory.Held) []string { - return comparisonLinesWith(h, nil, inventory.Adoption{}) -} - -// comparisonLinesWith is comparisonLines knowing which found networks this machine's setting keeps -// for the container (rule 4) and what the machine reports reachable, so a published port's reach -// is said beside the port (rule 1). -func comparisonLinesWith(h inventory.Held, keeps []string, reported inventory.Adoption) []string { - f := factsOf(h) - var out []string - if f.image != "" || f.declaredImage != "" { - line := fmt.Sprintf("runs %s", orNone(f.image)) - if f.imageCreated != "" { - line += " (made " + day(f.imageCreated) + ")" - } - line += "; the module declares " + orNone(f.declaredImage) - switch { - case f.declaredCreated != "": - line += " (made " + day(f.declaredCreated) + ")" - case f.declaredImage != "": - line += " (not on the machine yet, so its age is unknown)" - } - if f.downgrade { - line += " — DOWNGRADE" - } - out = append(out, line) - } - names := make([]string, 0, len(f.networks)) - for n := range f.networks { - names = append(names, n) - } - sort.Strings(names) - for _, n := range names { - members := f.networks[n] - if len(members) == 0 { - continue - } - if slices.Contains(keeps, n) { - out = append(out, fmt.Sprintf("on the network %s with %s — kept by this machine's setting, so they still reach it by name once taken", - n, strings.Join(members, ", "))) - continue - } - out = append(out, fmt.Sprintf("on the network %s with %s, which may reach it by name and will not once it moves to the module's own network"+ - " (`settings set %s --node ` with {%q: {: [%q]}} keeps it)", - n, strings.Join(members, ", "), h.Module, catalogue.NetworksSetting, n)) - } - for _, n := range keeps { - if _, found := f.networks[n]; !found { - out = append(out, fmt.Sprintf("keeps the network %s by this machine's setting, which the found container is not on", n)) - } - } - if len(f.ports) > 0 || len(f.declaredPorts) > 0 { - out = append(out, fmt.Sprintf("publishes %s; the module declares %s", - orNone(strings.Join(f.ports, " ")), orNone(strings.Join(f.declaredPorts, " ")))) - // How far each published port reaches now, as the machine reported it: the listener the - // runtime publishes for this container. The found firewall's and the guard's rules are - // not read; what they let through is said as what was reported reachable. - var reach []string - for _, r := range reported.Reachable { - if r.By == h.Target && r.Published { - reach = append(reach, fmt.Sprintf("%s:%d (%s, container port %d)", r.Address, r.Port, r.Protocol, r.ContainerPort)) - } - } - switch { - case len(reach) > 0: - line := "reachable now at " + strings.Join(reach, ", ") - if reported.Firewall != "" && reported.Firewall != "none" { - line += ", behind the found firewall (" + reported.Firewall + "), whose rules are not read" - } - out = append(out, line) - case len(f.ports) > 0 && len(reported.Reachable) > 0: - out = append(out, "not reported reachable on the machine") - } - } - if len(f.mounts) > 0 || len(f.declaredVolumes) > 0 { - out = append(out, fmt.Sprintf("mounts %s; the module declares %s", - orNone(strings.Join(f.mounts, " ")), orNone(strings.Join(f.declaredVolumes, " ")))) - } - if f.differs { - out = append(out, "the declared content differs from the file found (- lost, + new):") - for _, d := range f.difference { - out = append(out, " "+d) - } - } - return out -} - -// day is a timestamp as a person reads it in a preview: its date. -func day(stamp string) string { - if len(stamp) >= 10 { - return stamp[:10] - } - return stamp -} - -// reportFreshFor is how old a node's account of itself may be for the flip to act on it. A -// variable so a test can age a report without waiting. -var reportFreshFor = 15 * time.Minute - -// converge previews, and with yes makes, the flip of an adopted node to converged: every module it -// runs is taken, the filter module is assigned to load the mesh's derived filter in place of the -// guard, and the found firewall is retired — disabled, never flushed — by the host. -// -// Refused while an assigned module still holds a found container: each service is taken on its -// own, when its data has moved, never by the flip. And refused on a preview that would be stale: -// what is reachable is the node's last account, so that account must be of what it was last sent. -// -// **The flip acts on the preview the operator saw** and on nothing else. The preview ends with a -// short digest of what it said — every reachable thing and its fate, the modules the flip takes and -// the filter — and yes must name that digest: if anything the preview would say has changed since, -// the flip is refused rather than done on a preview nobody read. And it is refused on an account -// older than reportFreshFor: what was reachable then is not evidence of what is reachable now. -func converge(ctx context.Context, open *stores, node string, yes bool, digest string, - filter string) (string, error) { - inv := open.inventory - if filter == "" { - filter = DefaultFilter - } - record, err := inv.NodeByName(ctx, node) - if err != nil { - return "", err - } - if !record.Adopted { - return "", fmt.Errorf("%s is converged already; there is nothing to flip", node) - } - reports, err := lastReports(ctx, inv) - if err != nil { - return "", err - } - current, unknown := false, false - for _, r := range reports { - if r.Node == node { - current, unknown = r.Current, r.CurrentUnknown - } - } - if unknown { - return "", fmt.Errorf("cannot tell whether %s runs what is wanted: the bus is not reachable", node) - } - reported, err := inv.AdoptionOf(ctx, node) - if err != nil { - return "", err - } - if !current || reported.At.IsZero() { - return "", fmt.Errorf("%s does not run everything that is wanted of it yet, or has not reported, so what it "+ - "says is reachable may not be the machine as it is: converge once its haves match its wants and it has "+ - "reported", node) - } - - assignedWhenPreviewed, err := inv.Assigned(ctx, node) - if err != nil { - return "", err - } - plan, _, err := resolveNode(ctx, open, node) - if err != nil { - return "", err - } - runs := map[string]bool{} - for _, m := range plan.Modules { - runs[m.Module] = true - } - var holding []string - for _, h := range reported.Held { - if h.Kind == "container" && runs[h.Module] { - holding = append(holding, fmt.Sprintf(" %s holds the found container %s — take %s %s "+ - "once its data has moved", h.Module, h.Target, node, h.Module)) - } - } - if len(holding) > 0 { - sort.Strings(holding) - return "", fmt.Errorf("%s still holds what it found, and a service is taken on its own, "+ - "never by the flip:\n%s", node, strings.Join(holding, "\n")) - } - // And refused while a peer of the tunnel this hub took over has not enrolled (novox/hq ADR - // 0105): the flip loads the derived filter and retires the found firewall, and a machine the - // mesh has no record of is not one the filter admits — it would go dark. - if _, hubName, adopted, err := inv.AdoptedTunnel(ctx); err != nil { - return "", err - } else if adopted && hubName == node { - carried, err := inv.CarriedPeers(ctx) - if err != nil { - return "", err - } - var waiting []string - for _, c := range carried { - if c.EnrolledAs == "" { - waiting = append(waiting, fmt.Sprintf(" %s at %s", overlay.CarriedName(c.PublicKey), c.Address)) - } - } - if len(waiting) > 0 { - return "", fmt.Errorf("%s carries peers of the tunnel it took over that have not enrolled, and "+ - "converging would cut them off — enrol each first (`overlay show` says which are enrolled):\n%s", - node, strings.Join(waiting, "\n")) - } - } - - shelf, err := inv.Catalogue(ctx) - if err != nil { - return "", err - } - filterModule, known := shelf[filter] - if !known { - return "", fmt.Errorf("%w: %s — converging assigns it to load the mesh's filter; "+ - "name another with --filter", inventory.ErrNoSuchModule, filter) - } - if filterModule.Filtering == nil { - return "", fmt.Errorf("%s loads no filter of the mesh's; name a module that does with --filter", - filter) - } - - // What the filter is derived from: the rendering of this machine as a question reads it, with the private network. - with, err := renderingOf(ctx, open, node) - if err != nil { - return "", err - } - rules, err := plan.Rules(with) - if err != nil { - return "", err - } - taken, err := inv.Taken(ctx, node) - if err != nil { - return "", err - } - derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh, - outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks, local: with.LocalNetworks} - filtering, err := inv.FilteringOf(ctx, node) - if err != nil { - return "", err - } - preview, saw := previewOf(node, reported, filtering, derived, plan, taken, filter, runs[filter]) - preview += "\n\n preview " + saw - if !yes { - return preview + fmt.Sprintf("\n\nNothing has changed. Run `converge %s --yes %s` to do "+ - "it.", node, saw), nil - } - // An account naming nothing reachable is not an account of a machine: every machine answers - // on ssh, and the host's collectors failing — `ss` refusing, or the container runtime not - // answering, which drops every published port at once — leaves exactly this. Flipping on it - // would close ports the preview never named. - if yes && countReachable(reported) == 0 { - return preview, fmt.Errorf("%s says nothing is reachable on it, which no machine that is "+ - "up ever is: its account looks partial — whatever reads what is listening, or what "+ - "the container runtime publishes, did not answer. Fix that on the machine, wait for its next report, "+ - "then preview again", node) - } - if age := time.Since(reported.At); age > reportFreshFor { - return preview, fmt.Errorf("%s last said what is reachable on it %s ago, and the flip acts "+ - "only on an account newer than %s: wait for its next report, then preview again", node, - age.Round(time.Second), reportFreshFor) - } - if digest == "" { - return preview, fmt.Errorf("converging %s acts on the preview you saw: name its digest, "+ - "`converge %s --yes %s`, once you have read it", node, node, saw) - } - if digest != saw { - return preview, fmt.Errorf("what converging %s would do has changed since preview %s "+ - "(it is now %s): read the preview above, and run `converge %s --yes %s` if it is "+ - "what you want", node, digest, saw, node, saw) - } - - // The flip. The filter first, and only kept if the node still resolves with it: a node that - // cannot be worked out would be sent nothing, and would sit with its guard and no filter. - assigned, err := inv.Assigned(ctx, node) - if err != nil { - return "", err - } - // And nothing assigned since the preview was composed: the flip takes every module the node - // runs, and one assigned in between would be taken without ever having been previewed. - if !slices.Equal(assigned, assignedWhenPreviewed) { - return preview, fmt.Errorf("what %s runs changed while this was converging (it is now %s): "+ - "the flip takes every module on the node, so read the preview again", node, - strings.Join(assigned, ", ")) - } - if !slices.Contains(assigned, filter) { - if _, err := inv.Assign(ctx, node, filter); err != nil { - return "", err - } - if _, _, err := resolveNode(ctx, open, node); err != nil { - _ = inv.Unassign(ctx, node, filter) - return "", fmt.Errorf("%s cannot run %s, so it was not converged: %w", node, filter, err) - } - } - took, err := inv.Converge(ctx, node) - if err != nil { - return "", err - } - said := preview + fmt.Sprintf("\n\n%s is converged", node) - if len(took) > 0 { - said += "; took " + strings.Join(took, ", ") - } - if err := sendNodes(ctx, open, []string{node}); err != nil { - return said + "\n and its compositions could not be written yet: the next act that composes them carries it", err - } - return said + "\n sent: the host loads the mesh's filter and disables the firewall it found", nil -} - -// previewOf is what converging a node will change, before it changes it, and a short digest of -// what it said: every reachable thing and its fate, the modules the flip takes and the filter. The -// digest is what the flip is asked to act on, so it changes whenever any of those would. -func previewOf(node string, reported inventory.Adoption, filtering inventory.Filtering, derived derivedFilter, - plan catalogue.Resolution, taken []string, filter string, filterAssigned bool) (string, string) { - var said []string - var b strings.Builder - fmt.Fprintf(&b, "converging %s\n", node) - fmt.Fprintf(&b, "\n reachable on the machine now, as it reported at %s:\n", - reported.At.Local().Format(time.DateTime)) - for _, r := range reported.Reachable { - if loopback(r.Address) { - continue - } - what := fmt.Sprintf("%s/%d", r.Protocol, r.Port) - if r.By != "" { - what += " " + r.By - } - if r.Published { - what += fmt.Sprintf(" (published, container port %d)", r.ContainerPort) - } - fate := derived.fate(r) - fmt.Fprintf(&b, " %-44s %s\n", what, fate) - said = append(said, fmt.Sprintf("reach %s %s %s", r.Address, what, fate)) - } - if countReachable(reported) == 0 { - // Said as what it is: no machine that is up is reachable on nothing, so this is an - // account that did not come back, not a machine with nothing on it. - b.WriteString(" nothing reported — this account looks partial, and the flip is " + - "refused on it\n") - said = append(said, "reach nothing reported") - } - // What the machine routes for others is not a listener and not a published port, so nothing - // above can show it; the derived filter's forward chain drops it all the same. - b.WriteString(" not previewed: traffic the machine routes that is not a published port " + - "(a tunnel, NAT in the found firewall) — the derived filter drops it unless a module " + - "declares it\n") - // Which links the filter constrains, said rather than left to the sentence above (novox/hq ADR - // 0140). Everything arriving anywhere else is this machine's own guest and keeps working — which - // is what a reader most wants to know, because the previous shape of this filter cut a machine's - // guests off at the flip without saying so, and that is how this was found. - if len(derived.outwardLinks) > 0 { - b.WriteString(fmt.Sprintf(" it filters what arrives on: %s, and on the private network "+ - "— everything its own guests send keeps working\n", - strings.Join(derived.outwardLinks, ", "))) - } else { - b.WriteString(" it has reported no link facing outside, so no filter can be composed " + - "for it — the flip is refused until it reports one\n") - } - - isTaken := map[string]bool{} - for _, m := range taken { - isTaken[m] = true - } - var takes []string - for _, m := range plan.Modules { - if !isTaken[m.Module] { - takes = append(takes, m.Module) - } - } - if !filterAssigned && !isTaken[filter] { - takes = append(takes, filter) - } - sort.Strings(takes) - b.WriteString("\n the flip takes:\n") - if len(takes) == 0 { - b.WriteString(" nothing — every module is taken already\n") - } - for _, m := range takes { - fmt.Fprintf(&b, " %s\n", m) - said = append(said, "take "+m) - // Every kind it holds — a directory, a service, an archive, a process, a user as well as a - // file (novox/hq ADR 0103) — each said, and each part of what the flip is asked to act on. - for _, h := range reported.Held { - if h.Module != m { - continue - } - fmt.Fprintf(&b, " replacing the found %s", heldLine(h)) - if h.Kept != "" { - fmt.Fprintf(&b, ", original kept at %s", h.Kept) - } - b.WriteString("\n") - said = append(said, "replace "+m+" "+heldLine(h)+" "+h.Kept) - } - } - if !filterAssigned { - fmt.Fprintf(&b, "\n and assigns %s, which loads the mesh's filter in place of its guard\n", filter) - } - fw := reported.Firewall - if fw == "" || fw == "none" { - b.WriteString(" no firewall was found on the machine; the mesh's filter is its first\n") - } else { - fmt.Fprintf(&b, " the found firewall (%s) is disabled, never flushed: its configuration stays on disk\n", fw) - } - said = append(said, fmt.Sprintf("filter %s assigned=%t firewall=%s", filter, filterAssigned, fw)) - // What filters the machine now, and the fate of each (novox/hq ADR 0168): the found firewall - // retired, the runtime's own and bans left, and what the mesh did not write left and named — - // so the reader knows before the flip that the machine will not be filtered by the mesh alone. - if len(filtering.Filters) > 0 { - b.WriteString("\n what filters the machine now, and what the flip does to each:\n") - for _, x := range filtering.Filters { - fate := "left: " + x.Owner + "'s" - switch x.Owner { - case inventory.FilterMesh: - fate = "the mesh's guard; replaced by its filter" - case inventory.FilterFoundFirewall: - fate = "the found firewall's; retired with it" - case inventory.FilterRuntime: - fate = "the container runtime's own; left" - case inventory.FilterBan: - fate = "a ban list; left" - case inventory.FilterOther: - fate = "NOT THE MESH'S; left in force — the machine is not filtered by the mesh alone until you remove it" - } - fmt.Fprintf(&b, " %-50s %s\n", x.Where, fate) - fmt.Fprintf(&b, " %-50s %s\n", "", x.Refuses) - said = append(said, "filter "+x.Owner+" "+x.Where) - } - } - // Sorted: the same account, reported in another order, is the same preview. - sort.Strings(said) - sum := sha256.Sum256([]byte(strings.Join(said, "\n"))) - return strings.TrimRight(b.String(), "\n"), hex.EncodeToString(sum[:])[:12] -} - -// derivedFilter is what the filter the flip loads is rendered from, as AsNftables renders it. -type derivedFilter struct { - rules []catalogue.Rule - foundation []int - // mesh is every address on the private network; outward says the machine faces outside. - mesh []string - outward bool - // outwardLinks is the links this machine reported as facing outside it (novox/hq ADR 0140). - // The filter constrains what arrives on them; everything arriving elsewhere is this machine's - // own guest and is not filtered. - outwardLinks []string - // local is the machine's local networks, which a port from `local-network` stays open to (novox/hq - // ADR 0340). - local []string -} - -// closesOutside is what a narrowing from everywhere to the private network is called: it closes. -const closesOutside = "WILL CLOSE to everything outside the private network" - -// fate is what the derived filter does to one reachable thing: which module declares it and from -// where, or that it will close — wholly, or to everything outside the private network. Rendered -// exactly as AsNftables admits it, ssh included. -func (d derivedFilter) fate(r inventory.Reach) string { - // Bound to an address on the private network, it was never reachable from outside it, so - // admitting it from the mesh narrows nothing. - onMesh := slices.Contains(d.mesh, strings.Trim(r.Address, "[]")) - if r.Protocol == "tcp" && r.Port == catalogue.SSHPort { - // From everywhere only when the machine faces outward or the mesh has no addresses to - // narrow it to; otherwise from the private network only. - if d.outward || len(d.mesh) == 0 || onMesh { - return "stays open — ssh is never closed" - } - return closesOutside + " — ssh stays open from the mesh, never closed there" - } - for _, port := range d.foundation { - if r.Protocol == "tcp" && r.Port == port { - return "stays open — the mesh's own, from anywhere" - } - } - // This machine's own guests ask it for an address and for names, and those two arrive here - // (novox/hq ADR 0140). Admitted by the link they arrive on, so a listener bound anywhere but an - // outward link keeps answering them. - if (r.Protocol == "udp" && (r.Port == 53 || r.Port == 67)) || (r.Protocol == "tcp" && r.Port == 53) { - return "stays open — this machine's own guests asking it for an address and for names" - } - for _, rule := range d.rules { - if rule.Port != r.Port || rule.Protocol != r.Protocol { - continue - } - by := strings.Join(rule.Because, ", ") - switch rule.From { - case catalogue.FromMachine: - return fmt.Sprintf("WILL CLOSE to the network — declared by %s for this machine only", by) - case catalogue.FromMesh: - if len(d.mesh) == 0 { - return fmt.Sprintf("WILL CLOSE — declared by %s from the mesh, and this node "+ - "knows no mesh addresses", by) - } - if !onMesh { - return fmt.Sprintf("%s — declared by %s from the mesh only", closesOutside, by) - } - case catalogue.FromLocalNetwork: - // The private network and this machine's local networks (novox/hq ADR 0340): it closes to - // everything else outside, and says which local networks stay. - if !onMesh { - if len(d.local) == 0 { - return fmt.Sprintf("%s — declared by %s from the local network, and this machine "+ - "reported none", closesOutside, by) - } - return fmt.Sprintf("WILL CLOSE to everything outside the private network and %s — declared "+ - "by %s from the local network", strings.Join(d.local, ", "), by) - } - } - return fmt.Sprintf("declared by %s (from %s)", by, rule.From) - } - return "WILL CLOSE — no module assigned here declares it" -} - -// countReachable is how much of a node's account of itself names something off the machine. -// Loopback is left out for the same reason the preview leaves it out: nothing outside reaches it, -// so a report of loopback alone says nothing about what the filter would close. -func countReachable(reported inventory.Adoption) int { - n := 0 - for _, r := range reported.Reachable { - if !loopback(r.Address) { - n++ - } - } - return n -} - -// heldLine is one thing a node holds as found, as take and the converge preview both say it. -func heldLine(h inventory.Held) string { - return fmt.Sprintf("%s %s (%s)", h.Kind, h.Target, h.ID) -} - -// loopback is an address nothing off the machine reaches. -func loopback(address string) bool { - a := strings.Trim(address, "[]") - return strings.HasPrefix(a, "127.") || a == "::1" || a == "localhost" -} - -// adopt returns a converged node to adopted: the mesh's filter is unloaded, the guard restored, -// the found firewall enabled again and the openings converged through it once more. What was -// taken stays taken. -func adopt(ctx context.Context, open *stores, node string) (string, error) { - inv := open.inventory - record, err := inv.NodeByName(ctx, node) - if err != nil { - return "", err - } - if record.Adopted { - return "", fmt.Errorf("%s is adopted already", node) - } - if err := inv.SetAdopted(ctx, node, true); err != nil { - return "", err - } - said := fmt.Sprintf("%s is adopted; what was taken on it stays taken", node) - if err := sendNodes(ctx, open, []string{node}); err != nil { - return said + "\n and its compositions could not be written yet: the next act that composes them carries it", err - } - return said + "\n sent: the host unloads the mesh's filter and enables the firewall it found", nil -} - -// takeCommand, convergeCommand and adoptCommand are the command line's adapters to the acts above. -func takeCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("take", flag.ContinueOnError) - yes := set.Bool("yes", false, "cut over as previewed, naming the digest the preview printed after it; "+ - "without it the comparison is printed and nothing is taken") - downgrade := set.Bool("downgrade", false, "take it although the module's image is older than the one running") - var replace, mint stringList - set.Var(&replace, "replace", "a found file's path whose content the module may replace although it differs (repeatable; * for every one)") - set.Var(&mint, "mint", "a secret the service shall take the mesh's minted value for, although it already has its own (repeatable)") - positionals, err := parseAround(set, args) - if err != nil { - return err - } - if len(positionals) < 2 || len(positionals) > 3 || (len(positionals) == 3 && !*yes) { - return errors.New("take [--yes ] [--downgrade] [--replace ]... [--mint ]...") - } - opts := takeOptions{Yes: *yes, Downgrade: *downgrade, Replace: map[string]bool{}, Mint: map[string]bool{}} - if len(positionals) == 3 { - opts.Digest = positionals[2] - } - for _, r := range replace { - opts.Replace[r] = true - } - for _, m := range mint { - opts.Mint[m] = true - } - return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, positionals[0], positionals[1], opts) }) -} - -// stringList is a repeatable flag. -type stringList []string - -func (l *stringList) String() string { return strings.Join(*l, ",") } -func (l *stringList) Set(v string) error { *l = append(*l, v); return nil } - -func convergeCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("converge", flag.ContinueOnError) - yes := set.String("yes", "", "do it, naming the digest the preview printed; without it, only "+ - "the preview") - filter := set.String("filter", DefaultFilter, "the module that loads the mesh's filter") - positionals, err := parseAround(set, args) - if err != nil { - return err - } - if len(positionals) != 1 { - return errors.New("converge [--yes ] [--filter nftables]") - } - return runAct(ctx, func(open *stores) (string, error) { - return converge(ctx, open, positionals[0], *yes != "", *yes, *filter) - }) -} - -func adoptCommand(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("adopt ") - } - return runAct(ctx, func(open *stores) (string, error) { return adopt(ctx, open, args[0]) }) -} - -func runAct(ctx context.Context, act func(*stores) (string, error)) error { - open, err := openStores(ctx) - if err != nil { - return err - } - defer open.Close() - said, err := act(open) - if said != "" { - fmt.Println(said) - } - if err != nil && said != "" { - fmt.Println() - } - return err -} diff --git a/cmd/mesh-controller/advisories_counted_test.go b/cmd/mesh-controller/advisories_counted_test.go deleted file mode 100644 index 0a999eb8..00000000 --- a/cmd/mesh-controller/advisories_counted_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/link" -) - -// **A refusal the bus said once is counted once** (novox/hq issue 402). The bus refused the controller -// one publish at 18:52:59 UTC on 2026-10-10 and never again, yet S9 looked at the one remembered -// advisory every half minute for the hour it is kept, and the condition said "observed 15 time(s), last -// 13s ago" with a refusal in its evidence every 30 seconds: two of us read it as a refusal going on and -// went looking for a missing grant. The condition counts what the bus said, with when it last said it, -// never how often the controller looked. -func TestARefusalSaidOnceIsCountedOnceHoweverOftenItIsLookedAt(t *testing.T) { - refused := time.Date(2026, 10, 10, 18, 52, 59, 0, time.UTC) - now := refused.Add(10 * time.Second) - store := conditions.NewInMemory() - k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store, - Teller: &conditions.Told{}, Now: func() time.Time { return now }}) - t.Cleanup(func() { k.Close(t.Context()) }) - - // The words the live condition bus.controller.refused carried on 2026-10-10. - said := "the bus refused the controller: nats: permissions violation: Permissions Violation for " + - `Publish to "mesh.seat.mesh-delivery.tool.times"` - advisory := link.Advisory{Kind: link.AdvisoryRefused, ID: "controller", Said: said, - First: refused, Last: refused, Count: 1} - look := func() conditions.Condition { - t.Helper() - f := &signalFacts{now: now, host: "novox", advisories: []link.Advisory{advisory}} - if err := k.Reconcile(t.Context(), "S9", watchAdvisories(f)); err != nil { - t.Fatal(err) - } - c, found, err := conditions.ReadOne(t.Context(), store, "bus.controller.refused") - if err != nil || !found { - t.Fatalf("bus.controller.refused: found %v, %v", found, err) - } - return c - } - - var c conditions.Condition - for range 15 { - c = look() - now = now.Add(30 * time.Second) - } - if c.Observations != 1 || len(c.Evidence) != 1 { - t.Fatalf("one refusal, looked at 15 times, reads as observed %d time(s) with %d evidence lines: %+v", - c.Observations, len(c.Evidence), c.Evidence) - } - if !c.LastObserved.Equal(refused) || !c.Evidence[0].At.Equal(refused) { - t.Fatalf("last observed %s, evidence at %s: the refusal was at %s", c.LastObserved, c.Evidence[0].At, refused) - } - - // The bus refuses it three times more between two looks: the condition counts four refusals, says - // the newest, and adds one line of evidence for the look that saw them. - again := now.Add(-5 * time.Second) - advisory.Last, advisory.Count = again, 4 - c = look() - if c.Observations != 4 || len(c.Evidence) != 2 || !c.LastObserved.Equal(again) || !c.Evidence[0].At.Equal(again) { - t.Fatalf("four refusals read as observed %d time(s), last %s, evidence %+v", c.Observations, c.LastObserved, c.Evidence) - } - if !strings.Contains(c.Summary, "(4 times since 18:52 UTC)") { - t.Fatalf("summary %q", c.Summary) - } - now = now.Add(30 * time.Second) - if c = look(); c.Observations != 4 || len(c.Evidence) != 2 { - t.Fatalf("a look with nothing new counted: observed %d time(s), evidence %+v", c.Observations, c.Evidence) - } -} - -// **A condition that looks at the present still counts its looks** (novox/hq issue 402 changes only -// what reads a record): a machine silent for three looks was observed three times, and says so. -func TestAConditionOfThePresentCountsItsLooks(t *testing.T) { - now := time.Date(2026, 10, 10, 19, 0, 0, 0, time.UTC) - store := conditions.NewInMemory() - k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store, - Teller: &conditions.Told{}, Now: func() time.Time { return now }}) - t.Cleanup(func() { k.Close(t.Context()) }) - var c conditions.Condition - for range 3 { - var err error - if c, err = k.Observe(t.Context(), conditions.Observation{Scope: conditions.ScopeMachine, ID: "ace", - Kind: "silent", Machine: "ace", Severity: conditions.Warning, Summary: "ace has not been heard from", - Source: "S1"}); err != nil { - t.Fatal(err) - } - now = now.Add(30 * time.Second) - } - if c.Observations != 3 || len(c.Evidence) != 3 || !c.LastObserved.Equal(now.Add(-30*time.Second)) { - t.Fatalf("observed %d time(s), %d evidence lines, last %s", c.Observations, len(c.Evidence), c.LastObserved) - } -} - -// **One key, one watcher** (novox/hq issue 440). A consumer's max-deliveries condition is said from -// DEAD_LETTERS while it holds a message the consumer gave up on (issue 330). A max-deliveries advisory -// without a token names the same key; read beside it, each look added an observation and a line of -// evidence through the dead-letter half, and the two summaries overwrote each other on every look. The -// dead-letter row alone says that key, and counts the messages given up on, never the looks. -func TestAHeldDeadLetterIsCountedByWhatWasGivenUpNotByTheLooks(t *testing.T) { - gaveUp := time.Date(2026, 10, 10, 21, 4, 0, 0, time.UTC) - now := gaveUp.Add(20 * time.Second) - store := conditions.NewInMemory() - k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store, - Teller: &conditions.Told{}, Now: func() time.Time { return now }}) - t.Cleanup(func() { k.Close(t.Context()) }) - - const key = "bus.EVENTS.media_sonarr.max-deliveries" - held := map[string]int{"EVENTS.media_sonarr": 1} - newest := map[string]time.Time{"EVENTS.media_sonarr": gaveUp} - advisory := link.Advisory{Kind: link.AdvisoryMaxDeliveries, ID: "EVENTS.media_sonarr", Stream: "EVENTS", - Consumer: "media_sonarr", Said: "sonarr on media handed message 7 over 5 times and gave up on it", - First: gaveUp, Last: gaveUp, Count: 1} - look := func() conditions.Condition { - t.Helper() - f := &signalFacts{now: now, host: "novox", advisories: []link.Advisory{advisory}, - deadLetters: held, deadLettersNewest: newest} - if err := k.Reconcile(t.Context(), "S9", watchAdvisories(f)); err != nil { - t.Fatal(err) - } - c, found, err := conditions.ReadOne(t.Context(), store, key) - if err != nil || !found { - t.Fatalf("%s: found %v, %v", key, found, err) - } - if !strings.Contains(c.Summary, "dead-letters verb") { - t.Fatalf("the summary is not the dead-letter row's: %q", c.Summary) - } - return c - } - - var c conditions.Condition - for range 5 { - c = look() - now = now.Add(30 * time.Second) - } - if c.Observations != 1 || len(c.Evidence) != 1 || !c.LastObserved.Equal(gaveUp) { - t.Fatalf("one message given up on, looked at five times, reads as observed %d time(s), last %s, "+ - "with %d evidence lines: %+v", c.Observations, c.LastObserved, len(c.Evidence), c.Evidence) - } - - // The consumer gives up on a second message between two looks: two given up on, one more line. - again := now.Add(-10 * time.Second) - held["EVENTS.media_sonarr"], newest["EVENTS.media_sonarr"] = 2, again - c = look() - if c.Observations != 2 || len(c.Evidence) != 2 || !c.LastObserved.Equal(again) { - t.Fatalf("two given up on read as observed %d time(s), last %s, evidence %+v", c.Observations, - c.LastObserved, c.Evidence) - } - now = now.Add(30 * time.Second) - if c = look(); c.Observations != 2 || len(c.Evidence) != 2 { - t.Fatalf("a look with nothing new counted: observed %d time(s), evidence %+v", c.Observations, c.Evidence) - } -} - -// The advisory still says the max-deliveries it alone knows: a message given up on that could not be -// kept, under a key of its own (issue 330), which issue 440's change leaves as it was. -func TestAMessageThatCouldNotBeKeptIsStillSaidFromTheAdvisory(t *testing.T) { - at := time.Date(2026, 10, 10, 21, 4, 0, 0, time.UTC) - f := &signalFacts{now: at, host: "novox", advisories: []link.Advisory{{Kind: link.AdvisoryMaxDeliveries, - ID: "EVENTS.media_sonarr", Stream: "EVENTS", Consumer: "media_sonarr", Token: link.AdvisoryNotKept, - Said: "sonarr on media gave up on message 7, and it could not be kept", First: at, Last: at, Count: 1}}} - said := watchAdvisories(f) - if len(said) != 1 || said[0].Key() != "bus.EVENTS.media_sonarr.not-kept" { - t.Fatalf("%+v", said) - } -} - -// A max-deliveries advisory without a token names a consumer's dead-letter key, which only DEAD_LETTERS -// says (novox/hq issues 330 and 440): with nothing held, the advisory alone raises nothing. -func TestAMaxDeliveriesAdvisoryAloneRaisesNothing(t *testing.T) { - at := time.Date(2026, 10, 10, 21, 4, 0, 0, time.UTC) - f := &signalFacts{now: at, host: "novox", advisories: []link.Advisory{{Kind: link.AdvisoryMaxDeliveries, - ID: "EVENTS.media_sonarr", Stream: "EVENTS", Consumer: "media_sonarr", - Said: "sonarr on media handed message 7 over 5 times and gave up on it", First: at, Last: at, Count: 1}}} - if said := watchAdvisories(f); len(said) != 0 { - t.Fatalf("said %+v", said) - } -} diff --git a/cmd/mesh-controller/agent_account.go b/cmd/mesh-controller/agent_account.go deleted file mode 100644 index 90c1a7e3..00000000 --- a/cmd/mesh-controller/agent_account.go +++ /dev/null @@ -1,253 +0,0 @@ -package main - -// The account agents run as (novox/hq ADR 0266). -// -// On the control node every agent session ran as the operator's account, which may become root without a -// password — so any agent there could become root without a person, and ADR 0259 §8 (an answer from the -// operator's phone authorises an act) rests on that being false where the router and its channels run. The -// decision: a node may name an account its agents run as, of their own and without sudo; the operator's -// account keeps its sudo. -// -// - **Named at the controller's terminal only** (`node agent-account`): not a verb, not a setting, so no -// agent can name itself another account. Empty is a real state: agents run as the operator there. -// - **Composed** as `${machine:agent-account}`, `${machine:agent-home}` and `${machine:agent-root}` for the -// agent's module, which declares the account with `root: never`, and as MESH_AGENT_ACCOUNT and -// MESH_AGENT_HOME for its tools (catalogue/machine_into_files.go, runtime.go). -// - **Judged by the machine itself.** The node-engine reads, on every look, whether an account declared -// `root: never` can become root without a person — uid 0, a group that grants root, a sudo rule, a -// secret of the mesh it may read — and says it as the declaring module's account verdict, marked -// Root "never". agentConfined reads that verdict; the self-check (probe DA) raises -// `agent-can-become-root` while it does not hold, and `node show` says it. -// -// A verdict not given is never a pass: an engine older than the judging, an account not yet declared, a -// statement that says nothing of it — each is "not judged", and fails. - -import ( - "context" - "fmt" - "sort" - "strings" - "time" - - "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// kindAgentCanBecomeRoot is the condition raised while a machine's agent account can become root without -// a person, or is not judged (ADR 0266). -const kindAgentCanBecomeRoot = "agent-can-become-root" - -// agentAccountProbe is the self-check's probe of it. -const agentAccountProbe = "DA" - -// agentConfined says whether the agents of a machine that names an agent account are confined: the -// machine's newest statement holds a healthy account verdict, judged for root, on that account. named is -// false for a machine that names none — agents run as the operator account there, which this does not -// judge. why is said either way, in the mesh's words; err is a store that could not be read. -// -// The one judgement: `node show`, the self-check, and ADR 0259's router honouring a verified sender read -// it here. -// now is the judging clock, threaded so a caller judging several things at one instant judges them all at it. -func agentConfined(ctx context.Context, inv *inventory.Inventory, node string, now time.Time) (named, confined bool, why string, err error) { - n, err := inv.NodeByName(ctx, node) - if err != nil { - return false, false, "", err - } - if n.AgentAccount == "" { - return false, false, fmt.Sprintf("%s names no agent account: agents run as the operator account (%s)", - node, orNoneKnown(n.Account)), nil - } - h, had, err := inv.HealthOf(ctx, node) - if err != nil { - return true, false, "", err - } - confined, why = judgedConfined(n.AgentAccount, h, had, now) - return true, confined, why, nil -} - -// verdictFreshFor is how old the statement holding the verdict may be, by this controller's clock. A -// node-engine states its health on every change and at least every five minutes (mesh-host's sayAnyway), so -// three statements missed is a node-engine stopped, or a machine away. **A stale verdict is not a pass**: an -// agent that stopped the node-engine must not leave "cannot become root" standing from before. -const verdictFreshFor = 15 * time.Minute - -// judgedConfined is the judgement over one statement, without the store, at now. -func judgedConfined(agent string, h inventory.NodeHealth, had bool, now time.Time) (bool, string) { - if !had { - return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine has stated "+ - "nothing of what it runs", agent) - } - if age := now.Sub(h.HeardAt); age > verdictFreshFor { - return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement was heard at "+ - "%s, more than %d minutes ago, and a verdict that old is not a verdict on now", agent, - h.HeardAt.Local().Format("2006-01-02 15:04"), int(verdictFreshFor.Minutes())) - } - if h.Contract < link.RootContract { - return false, fmt.Sprintf("the agent account %s is not judged: the machine's node-engine is older than "+ - "the judging of an account's root (its statement's contract is %d, the judging is %d)", - agent, h.Contract, link.RootContract) - } - var verdicts []inventory.ResourceHealth - for _, r := range h.Resources { - if r.Kind == link.KindAccount && r.Target == agent && r.Root == link.RootNever { - verdicts = append(verdicts, r) - } - } - if len(verdicts) == 0 { - return false, fmt.Sprintf("the agent account %s is not judged: the machine's newest statement holds no "+ - "verdict on it — no module there declares it never to become root, or the declaration naming it "+ - "has not been applied", agent) - } - sort.Slice(verdicts, func(i, j int) bool { - return verdicts[i].Module+verdicts[i].Resource < verdicts[j].Module+verdicts[j].Resource - }) - for _, v := range verdicts { - switch v.State { - case link.StateHealthy: - case link.StateUnhealthy: - // The engine's own words, which start with link.ReasonRoot when it found a way to root. - return false, fmt.Sprintf("the agent account %s %s (said by %s's %s)", agent, - orNoneKnown(v.Reason), v.Module, v.Resource) - default: - return false, fmt.Sprintf("the agent account %s is not judged: %s (%s's %s, %s)", agent, - orNoneKnown(v.Reason), v.Module, v.Resource, v.State) - } - } - return true, fmt.Sprintf("the agent account %s cannot become root without a person (judged %s)", agent, - h.SaidAt.Local().Format("2006-01-02 15:04")) -} - -// searchQuietFor is how long the controller lets an agent account's verdict wait for the node-engine's setuid -// search before that is itself the urgent condition: the longest a search is expected to take (link.RootSearchQuiet, -// the engine's own value; novox/hq issue 361 — the search runs to completion, with no bound of its own), counted -// from when this controller first saw it waiting, never from the engine's start. Quiet raises nothing; it never -// makes the agent account confined, which only a healthy verdict from a complete, fresh search does. -const searchQuietFor = link.RootSearchQuiet - -// The kinds of an agent account's verdict, for the quiet a search earns. -const ( - verdictOther = iota // anything else: a stale statement, an older engine, no verdict, another unknown - verdictPending // waiting for the search, and otherwise healthy - verdictComplete // judged: healthy, or a way to root found -) - -// rootVerdictKind reads a statement for the agent account (novox/hq ADR 0266): pending when every verdict on it -// is healthy or not judged yet because the engine's setuid search runs, at least one of them that; complete when -// every verdict is healthy or one found a way to root. The engine's own "since" is not read: it starts again at -// every restart of the engine. -func rootVerdictKind(agent string, h inventory.NodeHealth, had bool, now time.Time) int { - if !had || now.Sub(h.HeardAt) > verdictFreshFor || h.Contract < link.RootContract { - return verdictOther - } - pending, any := false, false - for _, r := range h.Resources { - if r.Kind != link.KindAccount || r.Target != agent || r.Root != link.RootNever { - continue - } - any = true - switch { - case r.State == link.StateHealthy: - case r.State == link.StateUnhealthy: - return verdictComplete - case r.State == link.StateUnknown && strings.HasPrefix(r.Reason, link.ReasonRootPending): - pending = true - default: - return verdictOther - } - } - switch { - case !any: - return verdictOther - case pending: - return verdictPending - } - return verdictComplete -} - -// searchStillRunning says the one thing keeping an agent account from being judged is the node-engine's setuid -// search, and that this controller first saw it waiting less than searchQuietFor ago — kept in the store, so a -// node-engine restarted in a loop does not keep it quiet. A complete verdict forgets when it began. -func searchStillRunning(ctx context.Context, inv *inventory.Inventory, node, agent string, h inventory.NodeHealth, - had bool, now time.Time) (bool, error) { - switch rootVerdictKind(agent, h, had, now) { - case verdictComplete: - return false, inv.RootSearchJudged(ctx, node) - case verdictPending: - since, err := inv.RootSearchPending(ctx, node, now) - if err != nil { - return false, err - } - return now.Sub(since) <= searchQuietFor, nil - } - return false, nil -} - -// probeAgentAccounts is DA: every machine that names an agent account has it judged, on its node-engine's -// newest statement, unable to become root without a person (ADR 0266). -func probeAgentAccounts(ctx context.Context, d *doctor) ([]conditions.Observation, error) { - inv := d.open.inventory - nodes, err := inv.Nodes(ctx) - if err != nil { - return nil, err - } - var out []conditions.Observation - for _, n := range nodes { - if n.AgentAccount == "" { - continue - } - h, had, err := inv.HealthOf(ctx, n.Name) - if err != nil { - return nil, err - } - now := time.Now() - quiet, err := searchStillRunning(ctx, inv, n.Name, n.AgentAccount, h, had, now) - if err != nil { - return nil, err - } - confined, why := judgedConfined(n.AgentAccount, h, had, now) - if confined { - continue - } - // Not judged yet only because the node-engine's search runs and no complete, fresh one judges: not the - // urgent condition after every restart. The agent is still not confined — ADR 0259's router reads - // agentConfined, not this — and `node show` still says not judged. Loud again once the search fails, - // waits past searchQuietFor, or the statement goes stale. - if quiet { - continue - } - out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: n.Name, Token: "agent-root", - Machine: n.Name, Severity: conditions.Urgent, - Summary: fmt.Sprintf("on %s, %s (ADR 0266): an agent there may become root without a person, and "+ - "no answer from a channel authorises an act there (ADR 0259 §8)", n.Name, why), - Said: why}) - } - return sortedFound(out), nil -} - -// agentAccountLines is what `node show` says of the account agents run as. -func agentAccountLines(ctx context.Context, inv *inventory.Inventory, n inventory.Node) []string { - if n.AgentAccount == "" { - return []string{fmt.Sprintf(" agents run as the operator account (%s); no agent account is named", - orNoneKnown(n.Account))} - } - _, confined, why, err := agentConfined(ctx, inv, n.Name, time.Now()) - if err != nil { - return []string{fmt.Sprintf(" agents run as %s (home %s); whether it can become root could NOT be read: %v", - n.AgentAccount, n.AgentHome(), err)} - } - verdict := "CAN become root, or is not judged: " + why - if confined { - verdict = why - } - return []string{fmt.Sprintf(" agents run as %s (home %s)", n.AgentAccount, n.AgentHome()), - " " + verdict} -} - -// orNoneKnown is a value, or that none is known. -func orNoneKnown(s string) string { - if strings.TrimSpace(s) == "" { - return "none known" - } - return s -} diff --git a/cmd/mesh-controller/agent_account_integration_test.go b/cmd/mesh-controller/agent_account_integration_test.go deleted file mode 100644 index ece0c1f5..00000000 --- a/cmd/mesh-controller/agent_account_integration_test.go +++ /dev/null @@ -1,161 +0,0 @@ -//go:build integration - -package main - -import ( - "strings" - "testing" - "time" - - snapshot "git.novox.be/novox/mesh-sdk/go/facts" - "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" - "github.com/novox/mesh-host/rootsearch" -) - -func TestTheSelfCheckSaysAnAgentAccountThatCanBecomeRoot(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - inv := open.inventory - for _, n := range []string{"anchor", "laptop"} { - if _, err := inv.NodeByName(ctx, n); err != nil { - if _, err := inv.AddNode(ctx, n); err != nil { - t.Fatal(err) - } - } - if err := inv.SetAccount(ctx, n, "ops", ""); err != nil { - t.Fatal(err) - } - } - if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil { - t.Fatal(err) - } - d := &doctor{open: open} - found, err := probeAgentAccounts(ctx, d) - if err != nil { - t.Fatal(err) - } - found = onlyMachine(found, "anchor") - if len(found) != 1 || found[0].Machine != "anchor" || found[0].Severity != conditions.Urgent || - !strings.Contains(found[0].Said, "not judged") { - t.Fatalf("a named agent account with no verdict: %+v", found) - } - w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"}) - if w.Headline == "" || w.Needs == "" || w.Resolved == "" { - t.Errorf("the condition has no plain words: %+v", w) - } - - healthy := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account", - Kind: link.KindAccount, Target: "agent", State: link.StateHealthy, Root: link.RootNever, Account: "agent"} - if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract, - SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{healthy}}); err != nil { - t.Fatal(err) - } - if found, err = probeAgentAccounts(ctx, d); err != nil || len(onlyMachine(found, "anchor")) != 0 { - t.Fatalf("a judged agent account still fails: %+v %v", found, err) - } - if named, confined, why, err := agentConfined(ctx, inv, "anchor", time.Now()); err != nil || !named || !confined { - t.Fatalf("agentConfined on anchor: %v %v %q %v", named, confined, why, err) - } - if named, _, why, err := agentConfined(ctx, inv, "laptop", time.Now()); err != nil || named || - !strings.Contains(why, "operator account") { - t.Fatalf("agentConfined on a machine naming none: %v %q %v", named, why, err) - } -} - -func TestTheFactsCarryTheAgentAccountAsAPseudonym(t *testing.T) { - open, _ := aMeshWithSecrets(t) - ctx := t.Context() - if err := open.inventory.SetAccount(ctx, "anchor", "keeper", ""); err != nil { - t.Fatal(err) - } - if err := open.inventory.SetAgentAccount(ctx, "anchor", "warden", ""); err != nil { - t.Fatal(err) - } - f, err := gatherFacts(ctx, open, "2.11.17") - if err != nil { - t.Fatal(err) - } - body, _ := f.Encode() - if strings.Contains(string(body), "warden") { - t.Error("the agent account's name is in the snapshot") - } - m, ok := f.Machine(snapshot.Pseudonym("machine", "anchor")) - if !ok || m.AgentAccount != snapshot.Pseudonym("account", "warden") || m.Account == m.AgentAccount { - t.Fatalf("the anchor's agent account reads as %q (operator %q)", m.AgentAccount, m.Account) - } -} - -func TestASearchStillRunningAfterARestartIsNotUrgent(t *testing.T) { - if searchQuietFor != rootsearch.Quiet { - t.Fatalf("the quiet is %s and the node-engine's %s: they are one value", searchQuietFor, rootsearch.Quiet) - } - // A daily search that finishes within the quiet never leaves the account unjudged between two of them. - if rootsearch.FreshFor < rootsearch.Every+rootsearch.Quiet { - t.Fatalf("a complete search judges for %s, less than a day's search (%s) and the quiet (%s)", - rootsearch.FreshFor, rootsearch.Every, rootsearch.Quiet) - } - open := aMesh(t) - ctx := t.Context() - inv := open.inventory - if _, err := inv.NodeByName(ctx, "anchor"); err != nil { - if _, err := inv.AddNode(ctx, "anchor"); err != nil { - t.Fatal(err) - } - } - if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil { - t.Fatal(err) - } - d := &doctor{open: open} - say := func(state, reason string) []conditions.Observation { - t.Helper() - // The engine's since is always now: it was just restarted. - v := inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account", - Kind: link.KindAccount, Target: "agent", State: state, Reason: reason, Root: link.RootNever, - Account: "agent", Since: time.Now()} - if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.RootContract, - SaidAt: time.Now(), HeardAt: time.Now(), Resources: []inventory.ResourceHealth{v}}); err != nil { - t.Fatal(err) - } - found, err := probeAgentAccounts(ctx, d) - if err != nil { - t.Fatal(err) - } - return onlyMachine(found, "anchor") - } - running := link.ReasonRootPending + ": the search for setuid programs, started at 19:21, has not finished yet" - healthy := func() { - t.Helper() - if found := say(link.StateHealthy, ""); len(found) != 0 { - t.Fatalf("a healthy verdict raised: %+v", found) - } - } - if found := say(link.StateUnknown, running); len(found) != 0 { - t.Fatalf("a search first seen now was raised: %+v", found) - } - if _, confined, why, _ := agentConfined(ctx, inv, "anchor", time.Now()); confined || !strings.Contains(why, "not judged") { - t.Fatalf("an account whose search runs was read as confined: %q", why) - } - // The engine restarted again and again, each statement's own since fresh: the controller's clock runs on. - if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil { - t.Fatal(err) - } - healthy() // a complete verdict forgets when the waiting began … - if _, err := inv.RootSearchPending(ctx, "anchor", time.Now().Add(-searchQuietFor-time.Minute)); err != nil { - t.Fatal(err) // … and this restart loop began past the bound - } - if found := say(link.StateUnknown, running); len(found) != 1 || found[0].Severity != conditions.Urgent { - t.Fatalf("a search pending past the bound, by the controller's clock, was not urgent: %+v", found) - } - healthy() - incomplete := rootsearch.ReasonIncomplete + ": the search for setuid programs did not finish (last tried at " + - "19:23: timeout); it is tried again later" - if found := say(link.StateUnknown, incomplete); len(found) != 1 || found[0].Severity != conditions.Urgent { - t.Fatalf("a search that did not finish was not urgent: %+v", found) - } - if found := say(link.StateUnhealthy, link.ReasonRoot+": in the group docker; "+running); len(found) != 1 || - found[0].Severity != conditions.Urgent { - t.Fatalf("a way to root found while the search runs was not urgent: %+v", found) - } -} diff --git a/cmd/mesh-controller/agent_account_test.go b/cmd/mesh-controller/agent_account_test.go deleted file mode 100644 index c8db9085..00000000 --- a/cmd/mesh-controller/agent_account_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// The agent account's judgement (novox/hq ADR 0266): confined only on a healthy account verdict judged for -// root, on the very account; every verdict not given — no statement, an older engine, no verdict on it, a -// verdict of unknown — is "not judged" and fails, never a pass. -func TestAnAgentAccountIsConfinedOnlyOnAHealthyVerdictJudgedForRoot(t *testing.T) { - at := time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC) - verdict := func(target, root, state, reason string) inventory.ResourceHealth { - return inventory.ResourceHealth{Module: "claude-code", Resource: "claude-code.agent-account", - Kind: link.KindAccount, Target: target, State: state, Reason: reason, Root: root, Account: target} - } - statement := func(contract int, rs ...inventory.ResourceHealth) inventory.NodeHealth { - return inventory.NodeHealth{Node: "anchor", Contract: contract, SaidAt: at, HeardAt: at, Resources: rs} - } - for _, c := range []struct { - name string - h inventory.NodeHealth - had bool - confined bool - says string - }{ - {"judged and unable", statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, "")), - true, true, "cannot become root without a person"}, - {"judged and able", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnhealthy, - link.ReasonRoot+": in the group docker, which grants root")), true, false, "in the group docker"}, - {"a verdict of unknown", statement(link.RootContract, verdict("agent", link.RootNever, link.StateUnknown, - "sudo could not be read")), true, false, "not judged"}, - {"no statement", inventory.NodeHealth{}, false, false, "not judged"}, - {"an older engine", statement(link.ReadinessContract, verdict("agent", "", link.StateHealthy, "")), - true, false, "older than the judging"}, - {"a verdict on groups only", statement(link.RootContract, verdict("agent", "", link.StateHealthy, "")), - true, false, "no verdict on it"}, - {"a verdict on another account", statement(link.RootContract, verdict("ops", link.RootNever, link.StateHealthy, "")), - true, false, "no verdict on it"}, - } { - confined, why := judgedConfined("agent", c.h, c.had, at.Add(time.Minute)) - if confined != c.confined || !strings.Contains(why, c.says) { - t.Errorf("%s: confined %v, %q; want %v saying %q", c.name, confined, why, c.confined, c.says) - } - } - // A verdict heard longer ago than the bound is no verdict: an agent that stopped the node-engine must not - // leave "healthy" standing. - fresh := statement(link.RootContract, verdict("agent", link.RootNever, link.StateHealthy, "")) - if ok, _ := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor)); !ok { - t.Error("a verdict exactly at the bound is still one") - } - if ok, why := judgedConfined("agent", fresh, true, at.Add(verdictFreshFor+time.Second)); ok || - !strings.Contains(why, "not judged") { - t.Errorf("a stale healthy verdict passed: %q", why) - } -} - -// DA raises an urgent condition, with plain words, on a machine whose agent account is not judged unable to -// become root; a machine that names none is not its to judge. - -func TestTheAgentRootWordsArePlain(t *testing.T) { - w := plainWordings[kindAgentCanBecomeRoot](conditions.Observation{Kind: kindAgentCanBecomeRoot, Machine: "anchor"}) - if why, ok := conditions.PlainWords(w, "anchor"); !ok { - t.Fatalf("not plain: %s: %+v", why, w) - } -} - -func onlyMachine(obs []conditions.Observation, machine string) []conditions.Observation { - var out []conditions.Observation - for _, o := range obs { - if o.Machine == machine { - out = append(out, o) - } - } - return out -} - -// The snapshot a merge check composes from carries the agent account as a pseudonym (novox/hq ADR 0266), -// so a change is judged against machines that name one, and the name never leaves. - -// No verb runs a `node` command that sets something: through the generic `command` verb, `node account`, -// `node agent-account` and every other `node` subcommand but list and show are refused, naming the terminal. -func TestNoVerbSetsANodesAccounts(t *testing.T) { - for _, line := range []string{ - "node agent-account novox --clear", - "node agent-account novox ops", - "node account novox agent", - "node account novox", - "node add intruder", - "node public-domain novox --clear", - "node", - "node frobnicate", - } { - argv, err := argvFor("command", map[string]any{"command": line}) - if err == nil || !strings.Contains(err.Error(), "controller's terminal") || - !strings.Contains(err.Error(), "ADR 0266") { - t.Errorf("%q ran as %v (%v); want a refusal naming the terminal", line, argv, err) - } - } - for _, line := range []string{"node show novox", "node list --json", "status --json"} { - if _, err := argvFor("command", map[string]any{"command": line}); err != nil { - t.Errorf("%q, a read, was refused: %v", line, err) - } - } - if err := terminalOnly([]string{"node", "account", "a", "b"}); err == nil { - t.Error("the refusal is not only the command verb's") - } -} - -// Naming the agent account is the controller's terminal's alone: the `node` verb only shows. -func TestTheNodeVerbOnlyShows(t *testing.T) { - argv, err := argvFor("node", map[string]any{"node": "anchor"}) - if err != nil || strings.Join(argv, " ") != "node show anchor" { - t.Fatalf("the node verb runs %v (%v)", argv, err) - } - for _, v := range catalogue.ControllerVerbs { - if strings.Contains(v.Name, "agent") { - t.Errorf("a verb %q may name the agent account", v.Name) - } - } -} - -// Until a complete search for setuid programs judges — none since the node-engine's state was kept, or the last -// older than the engine lets one judge — the agent account is not judged, and the search runs to its end with no -// bound (novox/hq issue 361). DA does not raise that as urgent within searchQuietFor, counted from when this -// controller first saw it waiting — never from the engine's own "since", which a restart resets, so an engine -// restarted in a loop does not keep it quiet. The account is still not confined, and `node show` still says not -// judged; a search that failed, or a way to root found, is urgent at once. diff --git a/cmd/mesh-controller/api.go b/cmd/mesh-controller/api.go deleted file mode 100644 index e89d2d3f..00000000 --- a/cmd/mesh-controller/api.go +++ /dev/null @@ -1,186 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "net/http" - "strings" - "time" -) - -// The command API: what the mesh can be asked to do, over a network. -// -// **An adapter and nothing else** (novox/hq ADR 0035). Every route here calls the same function -// the command line calls, so a refusal is the same refusal in the same words. Nothing is decided -// in this file — the moment it validates something the command line does not, the mesh has two -// answers to one question. -// -// **It refuses everything unless it was told how to know who is asking.** The board is published -// on a public name, and this is what stands behind it: an unauthenticated command surface reachable -// from the internet is authority over the mesh handed to whoever finds it. So there is no -// permissive default and no flag that removes the check — a mesh that has not been told how to -// authenticate serves nothing, loudly. -// -// The intended authenticator is an OAuth2 provider (ADR 0035), which is an ordinary module. Until -// one is configured this refuses, which is the correct behaviour rather than a placeholder: a -// surface that worked without authentication would be one somebody left running. -func apiCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("api", flag.ContinueOnError) - listen := set.String("listen", "127.0.0.1:8081", "where to serve it") - issuer := set.String("issuer", "", - "the OAuth2 issuer whose tokens this accepts; without it, nothing is served") - if _, err := parseAround(set, args); err != nil { - return err - } - if strings.TrimSpace(*issuer) == "" { - // Refused at start rather than per request, so it is discovered by whoever ran it rather - // than by whoever finds it. - return errors.New( - "--issuer is not set, and this serves commands rather than pages: it will not run " + - "without being told whose tokens to believe. An OAuth2 provider is an ordinary " + - "module (novox/hq ADR 0035)") - } - - server := &http.Server{ - Addr: *listen, - ReadHeaderTimeout: 10 * time.Second, - Handler: commands(mustAuthenticate(*issuer)), - } - fmt.Printf("the command API is on http://%s\n", *listen) - fmt.Printf(" it accepts tokens from %s and refuses everything else\n", *issuer) - fmt.Printf(" every route calls what the command line calls\n") - - go func() { - <-ctx.Done() - closing, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = server.Shutdown(closing) - }() - if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - return err - } - return nil -} - -// Authenticator says whether a request may act, and as whom. -// -// An interface so the check can be driven by a test without an identity provider, and so the one -// real implementation is the only thing that has to be right. -type Authenticator interface { - // Who returns the subject a request is acting as, or an error naming why it may not. - Who(r *http.Request) (string, error) -} - -// mustAuthenticate is the real one: a bearer token from the configured issuer. -// -// **Not yet verifying the signature**, and it says so rather than pretending. Verification needs -// the issuer's keys, which needs an identity provider to exist — so this refuses every request -// until that is built, which is the same answer as having no API at all and is honest about why. -func mustAuthenticate(issuer string) Authenticator { return notYet{issuer: issuer} } - -type notYet struct{ issuer string } - -func (n notYet) Who(*http.Request) (string, error) { - return "", fmt.Errorf( - "this mesh has no way to verify a token from %s yet: the identity provider is a module "+ - "and none is running. Use the command line, which authenticates through nothing "+ - "because it is already behind the machine's own login", n.issuer) -} - -// commands is the routing, separate so a test can drive it without a listener. -func commands(who Authenticator) http.Handler { - mux := http.NewServeMux() - - mux.HandleFunc("POST /assign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) { - return assign(ctx, open, in.Node, splitModules(in.Module)...) - })) - mux.HandleFunc("POST /unassign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) { - return unassign(ctx, open, in.Node, splitModules(in.Module)...) - })) - // Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`. - mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) { - return take(ctx, open, in.Node, in.Module, takeOptions{Yes: in.Yes, Digest: in.Digest}) - })) - mux.HandleFunc("POST /converge", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) { - return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter) - })) - mux.HandleFunc("POST /adopt", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) { - return adopt(ctx, open, in.Node) - })) - - // Anything else is said plainly, because a command surface answering 404 to a verb somebody - // expected is indistinguishable from one that is down. - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - refuse(w, http.StatusNotFound, fmt.Errorf( - "%s %s is not something this mesh can be asked; it accepts POST /assign, "+ - "POST /unassign, POST /take, POST /converge and POST /adopt", r.Method, r.URL.Path)) - }) - return mux -} - -type request struct { - Node string `json:"node"` - Module string `json:"module"` - // Yes, Digest and Filter are converge's and take's: do it rather than preview it, the digest - // of the preview it acts on, and (converge) which module loads the mesh's filter. - Yes bool `json:"yes,omitempty"` - Digest string `json:"digest,omitempty"` - Filter string `json:"filter,omitempty"` -} - -// acting is the shape every route shares: authenticate, read, act, answer. -func acting( - who Authenticator, - needsModule bool, - do func(context.Context, *stores, request) (string, error), -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if _, err := who.Who(r); err != nil { - refuse(w, http.StatusUnauthorized, err) - return - } - var in request - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - refuse(w, http.StatusBadRequest, fmt.Errorf("this is not a request this understands: %w", err)) - return - } - if in.Node == "" || (needsModule && in.Module == "") { - if needsModule { - refuse(w, http.StatusBadRequest, errors.New(`both "node" and "module" are needed`)) - } else { - refuse(w, http.StatusBadRequest, errors.New(`"node" is needed`)) - } - return - } - - open, err := openStores(r.Context()) - if err != nil { - refuse(w, http.StatusServiceUnavailable, err) - return - } - defer open.Close() - - said, err := do(r.Context(), open, in) - if err != nil { - // **The refusal the command line would have given, unchanged.** Carrying `said` with - // it matters: an assignment that was kept and still does not resolve is two facts, - // and dropping either makes the answer wrong. - answer(w, http.StatusConflict, map[string]any{"said": said, "refused": err.Error()}) - return - } - answer(w, http.StatusOK, map[string]any{"said": said}) - } -} - -func answer(w http.ResponseWriter, status int, body map[string]any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(body) -} - -func refuse(w http.ResponseWriter, status int, err error) { - answer(w, status, map[string]any{"refused": err.Error()}) -} diff --git a/cmd/mesh-controller/api_test.go b/cmd/mesh-controller/api_test.go deleted file mode 100644 index 9cc5d99a..00000000 --- a/cmd/mesh-controller/api_test.go +++ /dev/null @@ -1,79 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -type letIn struct{} - -func (letIn) Who(*http.Request) (string, error) { return "somebody", nil } - -func asking(t *testing.T, who Authenticator, method, path, body string) *httptest.ResponseRecorder { - t.Helper() - recorded := httptest.NewRecorder() - commands(who).ServeHTTP(recorded, httptest.NewRequest(method, path, strings.NewReader(body))) - return recorded -} - -// **Nothing is served to somebody the mesh cannot identify**, and that is the default rather than -// a setting. The board this stands behind is published on a public name (novox/hq 11-a-board), so -// an unauthenticated command surface is authority over the mesh handed to whoever finds it. -func TestAnUnauthenticatedRequestIsRefused(t *testing.T) { - got := asking(t, mustAuthenticate("https://identity.example/realms/mesh"), - "POST", "/assign", `{"node":"anchor","module":"umami"}`) - if got.Code != http.StatusUnauthorized { - t.Fatalf("an unidentified caller got %d", got.Code) - } - // And it says what to do instead, because the answer is not "give up". - if !strings.Contains(got.Body.String(), "command line") { - t.Errorf("the refusal does not say what still works: %s", got.Body.String()) - } -} - -// The API refuses to start at all without being told whose tokens to believe. -// -// At start rather than per request, so it is found by whoever ran it rather than by whoever -// finds it. -func TestTheApiWillNotRunWithoutAnIssuer(t *testing.T) { - err := apiCommand(context.Background(), []string{}) - if err == nil { - t.Fatal("it served commands without being told who may give them") - } - if !strings.Contains(err.Error(), "issuer") { - t.Errorf("the refusal does not name what is missing: %v", err) - } -} - -// A request missing what it acts on is refused before anything is opened. -func TestARequestThatNamesNothingIsRefused(t *testing.T) { - // **The exact refusal, not merely "not accepted".** Asserting non-200 passes even when the - // request got as far as opening a store and failing there, which proves nothing about whether - // anything was checked — that is what the first version of this test did. - for _, body := range []string{`{}`, `{"node":"anchor"}`, `{"module":"umami"}`, `not json`} { - got := asking(t, letIn{}, "POST", "/assign", body) - if got.Code != http.StatusBadRequest { - t.Errorf("%s got %d, and a request naming nothing is a bad request", body, got.Code) - } - } -} - -// A verb nobody implemented is said plainly. A command surface answering 404 to something -// somebody expected is indistinguishable from one that is down. -func TestAnUnknownRouteSaysWhatIsAccepted(t *testing.T) { - got := asking(t, letIn{}, "POST", "/rotate", `{}`) - if got.Code != http.StatusNotFound { - t.Fatalf("got %d", got.Code) - } - var said map[string]any - _ = json.Unmarshal(got.Body.Bytes(), &said) - if !strings.Contains(said["refused"].(string), "/assign") { - t.Errorf("it does not say what it does accept: %v", said) - } -} diff --git a/cmd/mesh-controller/apply_failing.go b/cmd/mesh-controller/apply_failing.go deleted file mode 100644 index 5f76ef80..00000000 --- a/cmd/mesh-controller/apply_failing.go +++ /dev/null @@ -1,164 +0,0 @@ -package main - -import ( - "fmt" - "sort" - "strings" - "time" - - "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/inventory" -) - -// kindApplyFailing is S21's kind: a machine whose declarations have been refused, or applied only in part, -// for longer than one apply interval (novox/hq issue 233). -const kindApplyFailing = "apply-failing" - -// applyEvery is how often a node-engine applies what it was last told when nothing new arrives: its -// ReconcileEvery. A failure still there after one more of them is not one the next apply clears. -const applyEvery = 5 * time.Minute - -// packageManagerKey is how a node-engine names a package manager that does not answer among what failed -// (mesh-host's probe.go): "the package manager (pacman)". -const packageManagerKey = "the package manager (" - -// watchApplyFailing is S21: **a machine that does not apply what it was sent is said by the mesh, not only -// to someone who asks for `status`** (novox/hq issue 233, issue 187's family). A workstation refused every -// declaration for an hour and a half because its package manager did not answer, and the mesh's only word -// on it was a line in `status`. Raised once the machine's first report of this failure is older than one apply -// interval **while the machine is heard** — judged by time, not by how many reports said it: a node-engine -// says a re-apply only when how it went changed, so the same failure is one report however long it lasts. -// Names the refusal's or the failure's words; cleared by the clean apply the node-engine then says. Not while -// the machine is silent, asleep or not known: S1 says silent, and asleep is not lost. -func watchApplyFailing(f *signalFacts) []conditions.Observation { - heard := map[string]bool{} - for _, m := range f.machines { - if !m.asleep() && !m.lastHeard.IsZero() && !m.silent(f.now) { - heard[m.name] = true - } - } - var out []conditions.Observation - said := map[string]bool{} - for _, d := range f.doing { - if !d.Wrong() || d.Since == nil || f.now.Sub(*d.Since) <= applyEvery || !heard[d.Node] { - continue - } - out = append(out, applyFailing(d, f.now)) - said[d.Node] = true - } - // **A module's files the provisioner failed to place are a failed apply too** (novox/hq ADR 0362 §3): its client's - // newest word, judged by the same bound — the client says a failure once, and fills again on every change — - // on a machine whose node-engine applied what it was sent. - failed := map[string][]inventory.ProvReport{} - for _, r := range f.provFailed { - if r.Status != inventory.ProvFailed || f.now.Sub(r.At) <= applyEvery || !heard[r.Node] || said[r.Node] { - continue - } - failed[r.Node] = append(failed[r.Node], r) - } - for _, node := range sortedNames(failed) { - out = append(out, provisioningFailing(node, failed[node], f.now)) - } - return out -} - -// provisioningFailing is one machine's S21 condition for the modules whose files its provisioner client failed to -// place. -func provisioningFailing(m string, failed []inventory.ProvReport, now time.Time) conditions.Observation { - sort.Slice(failed, func(a, b int) bool { return failed[a].Module < failed[b].Module }) - var names, words []string - since := failed[0].At - for _, r := range failed { - names = append(names, r.Module) - words = append(words, r.Module+": "+oneLine(orNoReason(r.Reason))) - if r.At.Before(since) { - since = r.At - } - } - o := conditions.Observation{Scope: conditions.ScopeMachine, ID: m, Kind: kindApplyFailing, Machine: m, - Severity: conditions.Warning, Said: strings.Join(words, "; "), - Resolved: m + " applies what it is sent again"} - o.Summary = fmt.Sprintf("%s's provisioner has failed to place the files of %s since %s (bound %s)", m, - strings.Join(names, ", "), since.UTC().Format("2006-01-02 15:04 MST"), ago(applyEvery)) - o.Headline = m + " is not applying its update" - o.Explanation = fmt.Sprintf("The provisioner on %s has not been able to write the files of %s for %s. The "+ - "details say why.", m, strings.Join(names, ", "), humanDuration(now.Sub(since))) - return o -} - -func orNoReason(s string) string { - if s == "" { - return "no reason given" - } - return s -} - -// applyFailing is one machine's S21 condition. -func applyFailing(d inventory.Doing, now time.Time) conditions.Observation { - m := d.Node - since := d.Since.UTC().Format("2006-01-02 15:04 MST") - o := conditions.Observation{Scope: conditions.ScopeMachine, ID: m, Kind: kindApplyFailing, Machine: m, - Severity: conditions.Warning, Said: doingWords(d), - Resolved: m + " applies what it is sent again"} - how := "refused its declaration whole" - if d.Outcome != inventory.OutcomeRefused { - how = "applied its declaration only in part" - } - o.Summary = fmt.Sprintf("%s has %s since %s (bound %s): %s", m, how, since, ago(applyEvery), failedNames(d)) - o.Headline = m + " is not applying its update" - o.Explanation = fmt.Sprintf("%s has %s for %s, and trying again has not helped. The details say what fails.", - m, plainHow(d), humanDuration(now.Sub(*d.Since))) - if tool := unansweringTool(d); tool != "" { - o.Summary = fmt.Sprintf("%s: %s does not answer, so no package reaches it; everything else it was sent "+ - "is applied, since %s (bound %s)", m, tool, since, ago(applyEvery)) - o.Headline = fmt.Sprintf("%s cannot install packages", m) - o.Explanation = fmt.Sprintf("The package manager on %s (%s) has not answered for %s, so no package reaches "+ - "it. Everything else is still applied, so an update that repairs it can land; if none does, it needs a "+ - "look on the machine.", m, tool, humanDuration(now.Sub(*d.Since))) - } - return o -} - -// plainHow is how a machine failed, as the operator reads it. -func plainHow(d inventory.Doing) string { - if d.Outcome == inventory.OutcomeRefused { - return "refused every update" - } - return "applied its updates only in part" -} - -// unansweringTool is the package manager a machine said does not answer, or "". -func unansweringTool(d inventory.Doing) string { - for _, r := range d.Failed { - if rest, ok := strings.CutPrefix(r.ID, packageManagerKey); ok { - return strings.TrimSuffix(rest, ")") - } - } - return "" -} - -// failedNames is what failed, by name: the refusal, or the failed resources' ids. -func failedNames(d inventory.Doing) string { - if d.Outcome == inventory.OutcomeRefused { - return "refused" - } - ids := make([]string, 0, len(d.Failed)) - for _, r := range d.Failed { - ids = append(ids, r.ID) - } - sort.Strings(ids) - return "failed " + strings.Join(ids, ", ") -} - -// doingWords is the machine's own words on what failed: the evidence, kept inside the mesh. -func doingWords(d inventory.Doing) string { - if d.Outcome == inventory.OutcomeRefused { - return "refused: " + oneLine(d.Refused) - } - var parts []string - for _, r := range d.Failed { - parts = append(parts, r.ID+": "+oneLine(r.Error)) - } - sort.Strings(parts) - return strings.Join(parts, "; ") -} diff --git a/cmd/mesh-controller/apply_failing_test.go b/cmd/mesh-controller/apply_failing_test.go deleted file mode 100644 index bb76f910..00000000 --- a/cmd/mesh-controller/apply_failing_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package main - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/inventory" -) - -// unansweringPackageManager is anchor's last report as the workstation's read on 2026-10-04, once its -// node-engine applies the rest (novox/hq issue 233): the package failed and the package manager did not answer, -// the same failure on times reports since for before now. -func unansweringPackageManager(now time.Time, forHowLong time.Duration, times int) inventory.Doing { - since := now.Add(-forHowLong) - return inventory.Doing{Node: "anchor", Outcome: inventory.OutcomeFailed, At: now.Add(-time.Second), - Since: &since, Times: times, Applied: 41, Failed: []inventory.FailedResource{ - {ID: "apply", Error: `applying "pacman.base": nothing can be said about "base": this is the arch host and ` + - "pacman does not answer here. Either this machine is not Arch, or its package database is broken: " + - "pacman exited 1: error: config file /etc/pacman.conf could not be read: No such file or directory"}, - {ID: "the package manager (pacman)", Error: "pacman does not answer here, so every package in this " + - "declaration fails and the rest of it is applied (novox/hq issue 233): pacman exited 1: error: config " + - "file /etc/pacman.conf could not be read: No such file or directory"}}} -} - -// **A machine whose package manager does not answer is raised by the mesh itself, naming the tool and the -// probe's words** (novox/hq issue 233): the words in the evidence, the path kept out of the summary. -func TestAPackageManagerThatDoesNotAnswerIsRaisedNamingItsWords(t *testing.T) { - now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC) - f := calm(now) - f.doing = []inventory.Doing{unansweringPackageManager(now, 90*time.Minute, 18)} - got := watchApplyFailing(f) - if len(got) != 1 { - t.Fatalf("raised %d conditions, want one: %+v", len(got), got) - } - o := got[0] - if o.Key() != "machine.anchor.apply-failing" || o.Machine != "anchor" { - t.Errorf("the condition is not the machine's: %s", o.Key()) - } - if !strings.Contains(o.Summary, "pacman does not answer") || !strings.Contains(o.Summary, "since 2026-10-04 12:30") { - t.Errorf("the summary does not name the tool and since when: %s", o.Summary) - } - if strings.Contains(o.Summary, "/etc/") { - t.Errorf("the summary carries a path, which belongs in the evidence: %s", o.Summary) - } - if !strings.Contains(o.Said, "could not be read") { - t.Errorf("the evidence does not carry the probe's words: %s", o.Said) - } - if !strings.Contains(o.Headline, "cannot install packages") || o.Explanation == "" || o.Resolved == "" { - t.Errorf("the plain words do not say it: %+v", o) - } -} - -// **A whole refusal is raised too**, with the node-engine's words: what the workstation's report said before the -// node-engine applied the rest. -func TestARefusalThatGoesOnIsRaised(t *testing.T) { - now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC) - since := now.Add(-time.Hour) - f := calm(now) - f.doing = []inventory.Doing{{Node: "anchor", Outcome: inventory.OutcomeRefused, Since: &since, Times: 12, - Refused: "this is the arch host and pacman does not answer here", At: now.Add(-time.Minute)}} - got := watchApplyFailing(f) - if len(got) != 1 || !strings.Contains(got[0].Summary, "refused its declaration whole") || - !strings.Contains(got[0].Said, "pacman does not answer") { - t.Fatalf("a refusal that went on for an hour was not raised with its words: %+v", got) - } -} - -// **Judged by time while the machine is heard, not by how many reports said it** (review of novox/hq issue 233): -// a node-engine says the same failure once, so one report an apply interval old is a fault that goes on; one -// younger is not yet; and a silent or sleeping machine is S1's. -func TestOneReportOlderThanAnApplyIsRaisedAndAQuietMachineIsNot(t *testing.T) { - now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC) - f := calm(now) - f.doing = []inventory.Doing{unansweringPackageManager(now, 6*time.Minute, 1)} - if got := watchApplyFailing(f); len(got) != 1 { - t.Errorf("one report of a failure six minutes old was not raised: %+v", got) - } - f.doing = []inventory.Doing{unansweringPackageManager(now, 4*time.Minute, 1)} - if got := watchApplyFailing(f); len(got) != 0 { - t.Errorf("a failure four minutes old was raised before one apply could clear it: %+v", got) - } - f.doing = []inventory.Doing{unansweringPackageManager(now, time.Hour, 1)} - f.machines[0].lastHeard = now.Add(-time.Hour) - if got := watchApplyFailing(f); len(got) != 0 { - t.Errorf("a silent machine was raised for its last report: %+v", got) - } - f = calm(now) - f.doing = []inventory.Doing{unansweringPackageManager(now, time.Hour, 1)} - f.doing[0].Node = "a-machine-the-mesh-does-not-know" - if got := watchApplyFailing(f); len(got) != 0 { - t.Errorf("a machine not heard of was raised: %+v", got) - } -} - -// **A failed re-apply followed by a clean one clears S21** (review of novox/hq issue 233): the node-engine says -// the clean re-apply, the store keeps it as applied with no failure since, and the condition the failure raised -// is cleared through the keeper on the next tick. -func TestACleanReapplyAfterAFailedOneClearsTheCondition(t *testing.T) { - now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC) - store := conditions.NewInMemory() - k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store, - Teller: &conditions.Told{}, Now: func() time.Time { return now }}) - defer k.Close(context.Background()) - w := &watchdogs{keeper: k, started: now.Add(-time.Hour)} - - failing := calm(now) - failing.doing = []inventory.Doing{unansweringPackageManager(now, 20*time.Minute, 1)} - w.see(t.Context(), failing) - open, err := k.Open(t.Context()) - if err != nil || len(open) != 1 || open[0].Kind != kindApplyFailing { - t.Fatalf("the failed re-apply raised %+v (%v), want apply-failing", open, err) - } - - clean := calm(now.Add(5 * time.Minute)) - clean.doing = nil // the clean re-apply recorded: NotDoingWhatTheyWereTold no longer lists the machine - w.see(t.Context(), clean) - if open, _ := k.Open(t.Context()); len(open) != 0 { - t.Fatalf("the condition stayed open after the clean re-apply: %+v", open) - } -} diff --git a/cmd/mesh-controller/ask.go b/cmd/mesh-controller/ask.go deleted file mode 100644 index 9006b791..00000000 --- a/cmd/mesh-controller/ask.go +++ /dev/null @@ -1,62 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "os" - "time" - - "github.com/novox/mesh-controller/internal/link" -) - -// ask calls one of a module's tools, through the control plane's own broker connection. -// -// A module serves tools under an account scoped to exactly that (novox/hq ADR 0047), and nothing -// else in the mesh held an account that could ask one — not an operator at a terminal, not an agent -// acting for one (novox/hq 04-ISSUES/049). The control plane does, so it is the way in: one -// process, one connection, one place a question can be seen to have been asked (ADR 0095). -func askCommand(ctx context.Context, args []string) error { - positionals, flags := split(args) - set := flag.NewFlagSet("ask", flag.ContinueOnError) - wait := set.Duration("wait", 60*time.Second, "how long to wait for the module's answer") - if err := set.Parse(flags); err != nil { - return err - } - if len(positionals) < 2 || len(positionals) > 3 { - return errors.New("ask [json arguments] [--wait 60s]") - } - module, tool := positionals[0], positionals[1] - var arguments json.RawMessage - if len(positionals) == 3 { - if !json.Valid([]byte(positionals[2])) { - return fmt.Errorf("the arguments are not JSON: %s", positionals[2]) - } - arguments = json.RawMessage(positionals[2]) - } - - server, err := connectLink(ctx, nil, nil, nil) - if err != nil { - return err - } - defer server.Close() - - answer, err := link.Ask(ctx, server.Bus(), module, tool, arguments, *wait) - if err != nil { - return err - } - // The answer as the module gave it, to standard output, for a person or a program. A tool - // that answered with an error has still answered: printed the same way, and the exit status - // says which. - body, err := json.Marshal(answer) - if err != nil { - return err - } - fmt.Fprintln(os.Stdout, string(body)) - if answer.Error != "" { - return fmt.Errorf("%s.%s answered with an error: %s", module, tool, answer.Error) - } - return nil -} diff --git a/cmd/mesh-controller/asker.go b/cmd/mesh-controller/asker.go deleted file mode 100644 index 68f3a24a..00000000 --- a/cmd/mesh-controller/asker.go +++ /dev/null @@ -1,1009 +0,0 @@ -package main - -// The controller asks, and acts on the operator's warrant (novox/hq ADR 0259 §6). It holds no channel, no -// identity and no factor: it asks the router like any other module, and performs the answer chosen with its -// own grant. -// -// - **For every open, unsilenced condition that needs the operator and names its answers**, one ask is -// published on the `operator-channel` seat under the controller's own name: the condition's words, its -// actions as options at their levels (Silence acknowledges; Release, Stop, Start and Restart approve), -// answered by the operator, expiring after a day (a week when every option only acknowledges). A -// condition that clears, is silenced, or changes its answers has its ask cancelled; an ask that expired -// unanswered is asked again while the condition lasts; one the router refused is asked again after a wait -// that grows with each refusal in a row, at most half an hour, so a refusal is never believed longer. Each ask is kept in the controller's bucket -// `asked`, so a restart neither asks twice nor forgets. -// - **On a warrant**, heard on the seat's event under the controller's own name (which only the router may -// say), the controller acts once per ask: only for an ask it holds, only for the option it offered at -// that option's level, and only while the condition is still open. It performs the action as itself — -// a silence through its own conditions, any other through the verb the action names — with the warrant's -// words as its why, and records it in the hand-act log as the operator's decision, naming the channel, -// the ask and the proofs. An ask that ended without a choice is recorded and nothing is done. -// - **A warrant it missed** while away is read from the router's record of its asks, under its own name. - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - "sync" - "time" - - "git.novox.be/novox/mesh-sdk/go/asks" - - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/link" -) - -// The asker's name on the seat: the controller's module. -const askerName = broker.ControllerSeat - -// How long an ask lasts: a day when an answer approves, a week when every answer only acknowledges. -const ( - // askApproveFor is a day less a margin, so an ask is never refused at the router for lasting a day and - // a moment (the SDK's bound is a day). - askApproveFor = 24*time.Hour - 10*time.Minute - askAcknowledgeFor = 7 * 24 * time.Hour - // askEvery is how often what is open is asked about again, beside every change. - askEvery = time.Minute - // askCatchUpAfter is how old an open ask is before the router's record of it is read: a warrant heard - // on the event needs no reading. - askCatchUpAfter = 2 * time.Minute - // askAgainAfterAnswer is how long a condition the operator answered is not asked about again with the - // same answers: what was chosen takes a while to clear it, and asking again at once would ask twice. - askAgainAfterAnswer = time.Hour - // askMostOpen is how many asks the controller holds open at once (the router refuses a fourth): the - // most urgent conditions first, then the oldest. - askMostOpen = asks.MostOpen - // askRefusedRetryMost is the longest a refusal is believed without asking again (novox/hq issue 369): the - // router refused while its channels had not yet said they could send, the channels could send twenty - // minutes later, and the controller repeated that refusal for eleven hours. A refused ask is asked again - // after askEvery, then twice as long after each refusal in a row, never longer than this — and at once when - // the channels change. - askRefusedRetryMost = 30 * time.Minute - // askRefusedCounted is how far back refusals in a row are counted for that wait. - askRefusedCounted = 6 * time.Hour - // askVerdictWait is how long an ask made again after a refusal waits for the router's word before it is - // taken as taken: the router refuses an ask as it reads it, so a refusal comes within seconds. Meanwhile - // the condition saying it was not delivered stands as it was, neither cleared nor raised again. - askVerdictWait = 2 * time.Minute -) - -// refusedRetryAfter is how long a part refused n times in a row waits before it is asked again. -func refusedRetryAfter(n int) time.Duration { - wait := askEvery - for i := 1; i < n && wait < askRefusedRetryMost; i++ { - wait *= 2 - } - return min(wait, askRefusedRetryMost) -} - -// What became of an ask, as the controller keeps it. -const ( - askOpen = "open" - askCancelled = "cancelled" -) - -// asked is one ask the controller made, as it keeps it. -type asked struct { - ID string `json:"id"` - Condition string `json:"condition"` - // Channels is what the channels were when it was asked (asker.channels): an ask the router refused is asked - // again at once when the condition's answers or the channels change, and otherwise after a wait that grows - // with each refusal in a row (refusedRetryAfter, novox/hq issue 369). - Channels string `json:"channels,omitempty"` - Ask asks.Ask `json:"ask"` - Actions []conditions.Action `json:"actions"` - // Options are the actions by option id. - Options map[string]int `json:"options"` - State string `json:"state"` - Opened time.Time `json:"opened"` - Ended time.Time `json:"ended,omitempty"` - Warrant *asks.Warrant `json:"warrant,omitempty"` - // Acted is what the controller did on the warrant: empty before it did anything, "acting" while it acts, - // then "done", "failed: …" or "nothing: …". Anything but empty is never acted on again. - Acted string `json:"acted,omitempty"` - // Part is which ask of its condition this is (askPart): empty for the one that carries the condition's - // answers, or the authorising ones where it has both; "acknowledge" for its acknowledging answers asked - // apart (the review of 2026-10-09, M1). - Part string `json:"part,omitempty"` - // Rehearsal is an ask started at the controller's terminal (rehearse.go): about no condition, its answers - // perform nothing, and the reconciling of conditions leaves it alone. - Rehearsal bool `json:"rehearsal,omitempty"` - // Proposal is a settings layer proposed through a verb (proposals.go, novox/hq ADR 0277): about no - // condition, set on Approve by the serving controller itself, and left alone by the reconciling of conditions. - Proposal *settingsProposal `json:"proposal,omitempty"` -} - -// ofACondition says an ask is one of a condition's: not a rehearsal, not a proposal. -func (r asked) ofACondition() bool { return !r.Rehearsal && r.Proposal == nil } - -// partKey is an ask's place among what is asked: its condition and its part. -func partKey(condition, part string) string { return condition + "#" + part } - -// partAcknowledge is the part of a condition asked apart for its acknowledging answers. -const partAcknowledge = "acknowledge" - -// askPart is one ask a condition is asked with: its part, what it is about, and its answers. -type askPart struct { - name string - about string - actions []conditions.Action -} - -// levelOf is an action's level as an option offers it: one that says none is never taken for less than -// approve. -func levelOf(act conditions.Action) asks.Level { - if act.Level == "" { - return asks.Approve - } - return asks.Level(act.Level) -} - -// partsOf is the asks a condition is asked with (the review of 2026-10-09, M1): one, when its answers are all -// of one kind; else its authorising answers (Release, Stop, Restart) in one ask, about the condition, and its -// acknowledging ones (Silence) in another. **An acknowledgement never shares an ask with an approval**: a -// channel that only acknowledges would otherwise answer the ask, and end the approval with it. -func partsOf(c conditions.Condition) []askPart { - var ack, auth []conditions.Action - for _, act := range c.Actions { - if levelOf(act) == asks.Acknowledge { - ack = append(ack, act) - } else { - auth = append(auth, act) - } - } - if len(ack) == 0 || len(auth) == 0 { - return []askPart{{about: c.Key, actions: c.Actions}} - } - return []askPart{{about: c.Key, actions: auth}, - {name: partAcknowledge, about: c.Key + "." + partAcknowledge, actions: ack}} -} - -// askedStore keeps the asks (broker.AskedBucket). **Every write after the first is a compare-and-set** (the -// review of 2026-10-09, L2): an ask is created once, and changed only over the revision it was read at, the -// change decided again on what is read — so two controllers, or two deliveries of one warrant, never write -// over each other, and of two that would act only the one whose write stands does. -type askedStore interface { - Get(ctx context.Context, id string) (*asked, error) - // Create keeps a new ask, and refuses one already kept under its id. - Create(ctx context.Context, a asked) error - // Change applies change to the ask kept under id, by compare-and-set, and says whether its write stood. - // change says whether to write at all; on a write that came between, it is asked again on what is read. - Change(ctx context.Context, id string, change func(*asked) bool) (bool, error) - All(ctx context.Context) ([]asked, error) -} - -// askChangeTries is how often a change is read and tried again when another write came between. -const askChangeTries = 5 - -// asker is the controller asking the operator and acting on the answer. -type asker struct { - open func(ctx context.Context) ([]conditions.Condition, error) - silence func(ctx context.Context, key string, d time.Duration, by, why string) error - store askedStore - // publish puts a message on a subject's stream, de-duplicated by id. - publish func(ctx context.Context, subject string, body []byte, id string) error - // call performs an action's verb with its arguments, as the controller. - call func(ctx context.Context, a conditions.Action, args map[string]string) error - // setLayer sets a proposed settings layer on the operator's warrant (novox/hq ADR 0277); nil cannot. - setLayer setOnWarrant - // record writes the hand-act log. - record func(ctx context.Context, act link.HandAct) error - // routerRecord reads the router's record of an ask for a warrant missed; nil reads nothing. - routerRecord func(ctx context.Context, id string) (*asks.Warrant, error) - // routerHere says whether a router holds the seat and takes asks under the asker's name; nil is yes. - routerHere func(ctx context.Context) (bool, error) - // grantHeld says whether the bus holds the controller's grant to ask: the user list the bus's machine was - // last sent is the one the mesh composes now (novox/hq issue 353). The grant is composed from the router's - // assignment and reaches the bus only when that machine is next pushed, so between `assign` and `push` - // the record says a router is here and the bus refuses every ask. why says what to do; nil is yes. - grantHeld func(ctx context.Context) (held bool, why string, err error) - // channels is what the channels are now, as a fingerprint: who holds which kind, promising what. - channels func(ctx context.Context) string - // raise keeps the asker's own condition (sourceAsker): which conditions needing the operator could not be - // asked, and why. Nil raises nothing (a test that does not look). - raise func(ctx context.Context, obs []conditions.Observation) error - // tell tells the operator one plain notice that needs no answer (operator-channel.tell, novox/hq ADR 0380): - // the refusal of an answer to a plan's ask (ADR 0449 §3). Nil tells nothing (a test that does not look). - tell func(ctx context.Context, headline string, lines []string) error - now func() time.Time - logf func(string, ...any) - - saidNoRouter bool - saidNoGrant bool - // saidGrantCaveat is whether a grant held with a caveat was said, so it is said once. - saidGrantCaveat bool - - mu sync.Mutex - nudged chan struct{} -} - -func (a *asker) nudge() { - if a == nil { - return - } - a.mu.Lock() - if a.nudged == nil { - a.nudged = make(chan struct{}, 1) - } - ch := a.nudged - a.mu.Unlock() - select { - case ch <- struct{}{}: - default: - } -} - -// keep asks until ctx ends: now, on every change of a condition, and every askEvery. -func (a *asker) keep(ctx context.Context) { - a.nudge() - tick := time.NewTicker(askEvery) - defer tick.Stop() - a.mu.Lock() - nudged := a.nudged - a.mu.Unlock() - for { - select { - case <-ctx.Done(): - return - case <-tick.C: - case <-nudged: - } - if err := a.reconcile(ctx); err != nil { - a.logf("what the operator is asked could not be brought up to date: %v", err) - } - } -} - -// wants says whether a condition is one to ask about now. -func wants(c conditions.Condition, now time.Time) bool { - return len(c.Actions) > 0 && c.Needs != "" && !c.SilencedAt(now) -} - -func sameAsked(a []conditions.Action, b []conditions.Action) bool { - x, _ := json.Marshal(a) - y, _ := json.Marshal(b) - return string(x) == string(y) -} - -// reconcile brings what is asked in line with what is open. -func (a *asker) reconcile(ctx context.Context) error { - now := a.now() - if a.routerHere != nil { - here, err := a.routerHere(ctx) - if err != nil { - return err - } - if !here { - if !a.saidNoRouter { - a.logf("no router takes asks under the controller's name (a module declaring %s with its ask "+ - "named by its caller, assigned): the operator is asked nothing until one is", broker.AsksSeat) - a.saidNoRouter = true - } - open, err := a.open(ctx) - if err != nil { - return err - } - var unasked []conditions.Condition - for _, c := range open { - if wants(c, now) { - unasked = append(unasked, c) - } - } - return a.sayUnasked(ctx, unasked, "no router takes the controller's asks: no module holding "+ - broker.AsksSeat+" that takes an ask under its asker's name is assigned") - } - a.saidNoRouter = false - } - if a.grantHeld != nil { - held, why, err := a.grantHeld(ctx) - if err != nil { - return err - } - if !held { - if !a.saidNoGrant { - a.logf("the bus does not hold the controller's grant to ask yet: %s; the operator is asked nothing until it does", why) - a.saidNoGrant = true - } - open, err := a.open(ctx) - if err != nil { - return err - } - var unasked []conditions.Condition - for _, c := range open { - if wants(c, now) { - unasked = append(unasked, c) - } - } - return a.sayUnasked(ctx, unasked, "the bus does not hold the controller's grant to ask yet: "+why) - } - if why != "" && !a.saidGrantCaveat { - // Held, with a caveat worth one line (an older bus module cannot confirm the reload). - a.logf("the controller asks: %s", why) - a.saidGrantCaveat = true - } else if why == "" { - a.saidGrantCaveat = false - } - a.saidNoGrant = false - } - channels := "" - if a.channels != nil { - channels = a.channels(ctx) - } - open, err := a.open(ctx) - if err != nil { - return err - } - all, err := a.store.All(ctx) - if err != nil { - return err - } - byCondition := map[string]asked{} // by partKey - // What is open and not a condition's — a rehearsal, a proposal — still counts toward what the router holds - // open for the controller (askMostOpen); expired unanswered, it is kept so, as the router says it too. - otherOpen := 0 - for _, r := range all { - if r.State == askOpen && !r.ofACondition() && !now.Before(r.Ask.Expires) { - if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool { - if x.State != askOpen || x.Acted != "" { - return false - } - x.State, x.Ended, x.Acted = string(asks.OutcomeExpired), now, "nothing: the ask expired unanswered" - return true - }); err != nil { - return err - } - continue - } - if r.State == askOpen && !r.ofACondition() { - otherOpen++ - } - if r.State == askOpen && r.ofACondition() { - k := partKey(r.Condition, r.Part) - if prior, held := byCondition[k]; !held || r.Opened.After(prior.Opened) { - byCondition[k] = r - } - } - } - // A warrant missed while away, read from the router's record: for a condition's ask, and for a proposal's or a - // rehearsal's alike. - if a.routerRecord != nil { - var lookedUp []asked - for _, r := range byCondition { - lookedUp = append(lookedUp, r) - } - for _, r := range all { - if r.State == askOpen && !r.ofACondition() { - lookedUp = append(lookedUp, r) - } - } - for _, r := range lookedUp { - if now.Sub(r.Opened) < askCatchUpAfter { - continue - } - if w, err := a.routerRecord(ctx, r.ID); err == nil && w != nil { - body, _ := json.Marshal(w) - if err := a.Decided(ctx, body); err != nil { - return err - } - } - } - if all, err = a.store.All(ctx); err != nil { - return err - } - byCondition = map[string]asked{} - for _, r := range all { - if r.State == askOpen && r.ofACondition() { - byCondition[partKey(r.Condition, r.Part)] = r - } - } - } - // What the operator answered lately, by condition: not asked again at once; and what the router refused, - // newest first: not asked again until the answers or the channels change. - answered, refused := map[string]asked{}, map[string]asked{} - for _, r := range all { - k := partKey(r.Condition, r.Part) - if r.State == string(asks.OutcomeChosen) && now.Sub(r.Ended) < askAgainAfterAnswer { - if prior, has := answered[k]; !has || r.Ended.After(prior.Ended) { - answered[k] = r - } - } - if r.State == string(asks.OutcomeRefused) { - if prior, has := refused[k]; !has || r.Opened.After(prior.Opened) { - refused[k] = r - } - } - } - // Refusals in a row, by part: those since the last ask that was not refused, within askRefusedCounted. - // superseded: a part asked since its last refusal, by an ask the router did not refuse — open, answered, - // expired or cancelled. Its refusal is history then, never said again (the review of PR 198: an answered - // retry brought the refusal back). - inRow, superseded := map[string]int{}, map[string]asked{} - for k, last := range refused { - for _, r := range all { - if partKey(r.Condition, r.Part) == k && r.State != string(asks.OutcomeRefused) && r.State != askUnsent && - r.Opened.After(last.Opened) && r.Opened.After(superseded[k].Opened) { - superseded[k] = r - } - } - var since time.Time - for _, r := range all { - if partKey(r.Condition, r.Part) == k && r.State != string(asks.OutcomeRefused) && r.State != askUnsent && - !r.Opened.After(last.Opened) && r.Opened.After(since) { - since = r.Opened - } - } - for _, r := range all { - if partKey(r.Condition, r.Part) == k && r.State == string(asks.OutcomeRefused) && r.Opened.After(since) && - now.Sub(r.Opened) < askRefusedCounted { - inRow[k]++ - } - } - } - wanted := map[string]bool{} - var unasked []conditions.Condition // refused by the router, and nothing it was refused for changed - var refusedWords []string - // The most urgent first, then the oldest: those are asked when no more than askMostOpen may be. - sort.SliceStable(open, func(i, j int) bool { - ui, uj := open[i].Severity == conditions.Urgent, open[j].Severity == conditions.Urgent - if ui != uj { - return ui - } - if !open[i].Raised.Equal(open[j].Raised) { - return open[i].Raised.Before(open[j].Raised) - } - return open[i].Key < open[j].Key - }) - openNow := otherOpen - for _, c := range open { - if !wants(c, now) { - continue - } - for _, p := range partsOf(c) { - if r, held := byCondition[partKey(c.Key, p.name)]; held && sameAsked(r.Actions, p.actions) && now.Before(r.Ask.Expires) { - openNow++ - } - } - } - for _, c := range open { - if !wants(c, now) { - continue - } - saidUnasked := false - for _, p := range partsOf(c) { - key := partKey(c.Key, p.name) - wanted[key] = true - if r, was := refused[key]; was && sameAsked(r.Actions, p.actions) { - cur, held := byCondition[key] - ended := r.Ended - if ended.IsZero() { - ended = r.Opened - } - later, asked := superseded[key] - waiting := !held && !asked && r.Channels == channels && now.Sub(ended) < refusedRetryAfter(inRow[key]) - // Asked again now (the wait over), or lately and the router's word not in yet: said as it was until - // that word, so the condition neither clears nor is raised again at each try. - retrying := !held && !asked && !waiting - verdictDue := held && asked && later.ID == cur.ID && now.Sub(cur.Opened) < askVerdictWait - if waiting || retrying || verdictDue { - if !saidUnasked { - unasked, saidUnasked = append(unasked, c), true - } - if r.Warrant != nil && r.Warrant.Words != "" { - refusedWords = append(refusedWords, r.Warrant.Words) - } - } - if waiting { - continue // refused lately, and nothing it was refused for has changed: asked again after the wait - } - } - // **A condition raised after the answered ask ended is a new one** (novox/hq ADR 0449 §2): a retried - // plan that fails again is asked again, not held back for the hour its earlier answer holds. - if r, done := answered[key]; done && sameAsked(r.Actions, p.actions) && !c.Raised.After(r.Ended) { - if _, held := byCondition[key]; !held { - continue - } - } - if r, held := byCondition[key]; held { - switch { - case !sameAsked(r.Actions, p.actions): - if err := a.cancel(ctx, r, "its answers changed"); err != nil { - return err - } - case !now.Before(r.Ask.Expires): - // Expired unanswered: the router says so too; asked again below while it lasts. - if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool { - if x.State != askOpen { - return false - } - x.State, x.Ended = string(asks.OutcomeExpired), now - return true - }); err != nil { - return err - } - openNow-- - default: - continue - } - } - if openNow >= askMostOpen { - continue // asked when one of the open ones ends, most urgent first - } - if err := a.ask(ctx, c, p, channels); err != nil { - a.logf("the operator could not be asked about %s: %v", c.Key, err) - continue - } - openNow++ - } - } - stillOpen := map[string]conditions.Condition{} - for _, c := range open { - stillOpen[c.Key] = c - } - for key, r := range byCondition { - if wanted[key] { - continue - } - // **A silence never takes an approval back** (the confirmation review of 2026-10-09, M1). Silence is an - // acknowledgement — anyone at the desk may give it — so a condition silenced while its approval is asked - // keeps that ask open, unchanged, until it is answered on a channel that proves who answered, or expires. - // It is not asked again once it ends, while the silence lasts. - if c, open := stillOpen[r.Condition]; open && c.SilencedAt(now) && r.Ask.Highest() != asks.Acknowledge && - now.Before(r.Ask.Expires) && keepsItsAnswers(c, r) { - continue - } - if err := a.cancel(ctx, r, "the condition ended, was silenced or needs nothing now"); err != nil { - return err - } - } - why := "the router refused the ask" - if len(refusedWords) > 0 { - why += ": " + refusedWords[0] - } - return a.sayUnasked(ctx, unasked, why) -} - -// keepsItsAnswers says a condition still offers the answers an ask kept was asked with. -func keepsItsAnswers(c conditions.Condition, r asked) bool { - for _, p := range partsOf(c) { - if partKey(c.Key, p.name) == partKey(r.Condition, r.Part) { - return sameAsked(r.Actions, p.actions) - } - } - return false -} - -// sourceAsker raises the asker's own condition. -const sourceAsker = "asker" - -// sayUnasked keeps the asker's one condition: while a condition that needs the operator could not be asked -// on any channel, said loudly (failure must be loud), cleared when every one could be. -func (a *asker) sayUnasked(ctx context.Context, unasked []conditions.Condition, why string) error { - if a.raise == nil { - return nil - } - var obs []conditions.Observation - if len(unasked) > 0 { - keys := make([]string, 0, len(unasked)) - severity := conditions.Warning - for _, c := range unasked { - keys = append(keys, c.Key) - if c.Severity == conditions.Urgent { - severity = conditions.Urgent - } - } - sort.Strings(keys) - obs = append(obs, conditions.Observation{Scope: conditions.ScopeSeat, ID: broker.AsksSeat, Token: "unasked", - Kind: "asks-undelivered", Severity: severity, Source: sourceAsker, - Summary: fmt.Sprintf("%d condition(s) that need the operator could not be asked on any channel: %s; %s", - len(keys), strings.Join(keys, ", "), why), - Headline: "Questions for you not delivered", - Explanation: "The mesh could not send you its questions on any channel.", - Needs: "answer them from the mesh MCP server, and check why no channel carries them.", - Resolved: "The mesh can ask you again"}) - } - if err := a.raise(ctx, obs); err != nil { - a.logf("whether the operator could be asked could not be kept as a condition: %v", err) - } - return nil -} - -// optionID is an action's label as an option's id: "Silence for a week" is silence-for-a-week. -func optionID(label string) string { - var b strings.Builder - dash := false - for _, r := range strings.ToLower(label) { - switch { - case r >= 'a' && r <= 'z', r >= '0' && r <= '9': - b.WriteRune(r) - dash = false - case !dash && b.Len() > 0: - b.WriteByte('-') - dash = true - } - } - return strings.TrimSuffix(b.String(), "-") -} - -// doesWords is what an action does, in the words an option says it with. -func doesWords(act conditions.Action) string { - switch { - case act.Arguments["silence"] != "": - return "nothing more is said of it for a week" - case act.Verb == "mesh-delivery.release": - return "the delivery goes on" - case act.Verb == "mesh-delivery.stop": - return "the delivery ends" - case act.Verb == broker.ControllerSeat+".plans" && act.Arguments["go"] != "": - return "the delivery starts" - case act.Verb == broker.ControllerSeat+".plans" && act.Arguments["stop"] != "": - return "the delivery is stopped" - case act.Verb == broker.ControllerSeat+".plans" && act.Arguments["retry"] != "": - return "the failed builds are asked again" - case act.Verb == broker.ControllerSeat+".push" && act.Arguments["node"] != "": - return "the held builds are sent to " + act.Arguments["node"] - case act.Verb == broker.ControllerSeat+".upgrade" && act.Arguments["release-backlog"] != "": - return "the builds held after the failed delivery are released" - case strings.HasSuffix(act.Verb, ".restart"): - return "its service is restarted on " + act.Machine - } - return strings.ToLower(act.Label) -} - -// askText is a condition's words as an ask says them: without where an answer is given when no channel can -// give it (FromMeshMCPServer), since the ask is answered on a channel and the router says where else. -func askText(s string) string { - for _, with := range []string{", " + FromMeshMCPServer, " " + FromMeshMCPServer} { - s = strings.ReplaceAll(s, with, ".") - } - return strings.ReplaceAll(s, "..", ".") -} - -// askOf is the ask one part of a condition is asked with. -func askOf(id string, c conditions.Condition, p askPart, now time.Time) (asks.Ask, map[string]int) { - q := asks.Ask{ID: id, Headline: c.Headline, Explanation: askText(c.Explanation), Who: asks.Operator, - OnExpiry: "nothing is done, and you are asked again while it lasts", About: p.about, - Urgent: c.Severity == conditions.Urgent} - options := map[string]int{} - approves := false - for i, act := range p.actions { - level := levelOf(act) // an action that says nothing of its level is never taken for less than approve - approves = approves || level != asks.Acknowledge - oid := optionID(act.Label) - options[oid] = i - // Every option binds the exact act it stands for (novox/hq ADR 0259 §6): the verb, the machine and - // every argument. The warrant then authorises that act and no other. - binds, _ := asks.ActDigest(boundAct(act)) - q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: doesWords(act), Level: level, - Binds: binds}) - } - q.Expires = now.Add(askAcknowledgeFor) - if approves { - q.Expires = now.Add(askApproveFor) - } - return q, options -} - -// boundAct is what an option's Binds digests: the act exactly as the controller will perform it — its verb, -// machine, level, and each argument as "arg." — and never its label or words. -func boundAct(act conditions.Action) asks.Act { - out := asks.Act{"verb": act.Verb, "machine": act.Machine, "level": act.Level} - for k, v := range act.Arguments { - out["arg."+k] = v - } - return out -} - -func newAskID() string { - var b [8]byte - _, _ = rand.Read(b[:]) - return "c" + hex.EncodeToString(b[:]) -} - -// askUnsent is an ask kept and never published: asked again at the next look. -const askUnsent = "unsent" - -// ask publishes one ask about a part of a condition, kept before it is published (the review of 2026-10-09, -// L3): a warrant for it then always finds it, and one whose publishing failed is marked so and asked again. -func (a *asker) ask(ctx context.Context, c conditions.Condition, p askPart, channels string) error { - now := a.now() - id := newAskID() - q, options := askOf(id, c, p, now) - if err := q.Check(now); err != nil { - return err - } - body, err := json.Marshal(q) - if err != nil { - return err - } - if err := a.store.Create(ctx, asked{ID: id, Condition: c.Key, Part: p.name, Ask: q, Actions: p.actions, - Options: options, State: askOpen, Opened: now, Channels: channels}); err != nil { - return fmt.Errorf("the ask could not be kept, so it was not asked: %w", err) - } - if err := a.publish(ctx, asks.AskSubject(askerName), body, "ask."+id); err != nil { - if _, cerr := a.store.Change(ctx, id, func(x *asked) bool { - if x.State != askOpen || x.Acted != "" { - return false - } - x.State, x.Ended, x.Acted = askUnsent, a.now(), "nothing: it could not be published: "+err.Error() - return true - }); cerr != nil { - a.logf("the ask %s could not be published, and could not be marked so: %v", id, cerr) - } - return err - } - a.logf("asked the operator about %s (%s): %d answer(s)", c.Key, id, len(q.Options)) - return nil -} - -// cancel takes an ask back: kept cancelled first, so a warrant that comes after is refused, then said to the -// router; a cancel the router did not hear leaves the ask to expire there, and nothing is done on it here. -func (a *asker) cancel(ctx context.Context, r asked, why string) error { - stood, err := a.store.Change(ctx, r.ID, func(x *asked) bool { - if x.State != askOpen { - return false - } - x.State, x.Ended = askCancelled, a.now() - return true - }) - if err != nil || !stood { - return err - } - body, _ := json.Marshal(map[string]string{"id": r.ID}) - if err := a.publish(ctx, asks.CancelSubject(askerName), body, "cancel."+r.ID); err != nil { - a.logf("the ask %s about %s is taken back here, and the router could not be told (%v): it expires there, "+ - "and no answer to it is acted on", r.ID, r.Condition, err) - return nil - } - a.logf("took back the ask %s about %s: %s", r.ID, r.Condition, why) - return nil -} - -// Decided takes the router's word on one of the controller's asks (link.Decider). An error is returned only -// when what was decided could not be kept, so the word is held and heard again. -func (a *asker) Decided(ctx context.Context, body []byte) error { - var w asks.Warrant - if err := json.Unmarshal(body, &w); err != nil { - a.logf("the router's word on an ask could not be read; ignored: %v", err) - return nil - } - if w.Asker != askerName { - a.logf("REFUSED a warrant for %s's ask %s: the controller acts only on its own", w.Asker, w.Ask) - return nil - } - r, err := a.store.Get(ctx, w.Ask) - if err != nil { - return err - } - if r == nil { - a.logf("REFUSED a warrant for the ask %s, which the controller does not hold", w.Ask) - return nil - } - if r.Acted != "" { - return nil // heard again: acted on once - } - now := a.now() - if w.Outcome != asks.OutcomeChosen { - acted := "nothing: the ask " + string(w.Outcome) - if w.Words != "" { - acted += ": " + w.Words - } - if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool { - if x.Acted != "" { - return false - } - x.State, x.Ended, x.Warrant, x.Acted = string(w.Outcome), now, &w, acted - return true - }); err != nil { - return err - } - a.logf("the ask %s about %s ended %s; nothing is done", r.ID, r.Condition, w.Outcome) - return nil - } - if r.State != askOpen { - // Cancelled, replaced or expired in the controller's own record: no answer to it is acted on. - a.logf("REFUSED a warrant for the ask %s, which is %s in the controller's own record", r.ID, r.State) - return nil - } - option, err := w.For(askerName, r.Ask) - if err != nil { - a.logf("REFUSED a warrant for the ask %s: %v", r.ID, err) - return nil - } - index, offered := r.Options[option.ID] - if !offered || index >= len(r.Actions) { - a.logf("REFUSED a warrant for the ask %s: it chose %s, which no action stands for", r.ID, option.ID) - return nil - } - act := r.Actions[index] - // The act about to be performed is the one the option bound when the controller asked: a record changed - // since is refused, never performed. - if err := option.Performs(boundAct(act)); err != nil { - a.logf("REFUSED a warrant for the ask %s: %v", r.ID, err) - return nil - } - open, err := a.open(ctx) - if err != nil { - return err - } - stillOpen := r.Rehearsal || r.Proposal != nil // a rehearsal and a proposal are about no condition - for _, c := range open { - stillOpen = stillOpen || c.Key == r.Condition - } - if !stillOpen { - // The asker checks the state is still what it asked about before it acts (to-be 46 §10, step 7). - if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool { - if x.State != askOpen || x.Acted != "" { - return false - } - x.State, x.Warrant, x.Ended, x.Acted = string(asks.OutcomeChosen), &w, now, - "nothing: the condition ended before the answer" - return true - }); err != nil { - return err - } - a.logf("%s, for %s, which ended meanwhile: nothing is done", w.Says(), r.Condition) - return nil - } - // Claimed before acting, by compare-and-set: only the delivery whose write stands acts (security review - // of 2026-10-08, finding 9). Not by the warrant's message id, which another publisher could take first: - // the controller's own record decides. - claimed, err := a.store.Change(ctx, r.ID, func(x *asked) bool { - if x.State != askOpen || x.Acted != "" { - return false - } - x.State, x.Warrant, x.Acted = string(asks.OutcomeChosen), &w, "acting" - return true - }) - if err != nil { - return err - } - if !claimed { - a.logf("the warrant for the ask %s was already taken by another delivery; nothing more is done", r.ID) - return nil - } - r.Acted = "acting" - - why := fmt.Sprintf("%s (ask %s)", w.Says(), r.ID) - args := map[string]string{} - for k, v := range act.Arguments { - args[k] = v - } - if v, takes := args["why"]; takes && v == "" { - args["why"] = why - } - var acted error - outcome := "done" - switch { - case r.Rehearsal && act.Verb == rehearsalVerb: - // A rehearsal's answer performs nothing: it is recorded below as the operator's decision. - case r.Proposal != nil && act.Verb == proposalVerb: - // A proposed settings layer, set by this controller itself on Approve (novox/hq ADR 0277): the act's - // digest of the values is held to the record's own values before anything is set. - outcome, acted = a.decideProposal(ctx, *r, act, w) - if acted == nil && outcome != "" && !strings.HasPrefix(outcome, "nothing") { - outcome = "done: " + outcome - } - case act.Arguments["silence"] != "": - acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why) - default: - acted = a.call(ctx, act, args) - } - ended := a.now() - if acted != nil { - outcome = "failed: " + acted.Error() - a.tellRefused(ctx, *r, act, acted) - } - r.Ended, r.Acted = ended, outcome - if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool { - if x.Acted != "acting" { - return false - } - x.Ended, x.Acted = ended, outcome - return true - }); err != nil { - a.logf("%s was acted on (%s), and how it ended could NOT be kept: %v", r.ID, outcome, err) - } - verbArgs := []string{act.Verb} - if act.Machine != "" { - verbArgs = append(verbArgs, "on "+act.Machine) - } - keys := make([]string, 0, len(args)) - for k := range args { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - if k != "why" { - verbArgs = append(verbArgs, k+"="+args[k]) - } - } - if err := a.record(ctx, link.HandAct{Verb: handActWarrant, Args: verbArgs, Why: why, By: byWords(w), - Cause: conditions.CauseOperatorAnswer, Condition: r.Condition, Via: viaWords(w), Ask: r.ID, - Proofs: w.Proofs, RequestedBy: r.Condition, Outcome: r.Acted}); err != nil { - a.logf("%s was done, and could NOT be recorded in the hand-act log: %v", why, err) - } - a.logf("%s: %s", why, r.Acted) - return nil -} - -// tellRefused tells the operator what became of the answer they gave to a plan's ask, when it was not done (novox/hq -// ADR 0449 §3): the controller's `plans` verb refuses a Retry, Start or Stop when the plan moved on between the ask and -// the answer, and the refusal was only recorded, so the operator who pressed the answer heard nothing. **A refusal is -// told as one, and nothing else is**: a verb that said no changed nothing, but a call that got no answer (a timeout, a -// refused grant) may have run, and is told as not known. One plain notice with the ask's headline, beside the hand act -// the caller still records. An answer to any other ask is not told here. A notice that cannot be told is said in the -// log and changes nothing. -func (a *asker) tellRefused(ctx context.Context, r asked, act conditions.Action, failure error) { - if a.tell == nil || act.Verb != broker.ControllerSeat+".plans" { - return - } - var lines []string - var refused verbRefused - if errors.As(failure, &refused) { - lines = []string{ - fmt.Sprintf("Your answer, %s, could not be done: %s.", act.Label, strings.TrimRight(refusalWords(refused.words, act), ".")), - "Nothing was changed.", - } - } else { - lines = []string{ - fmt.Sprintf("Your answer, %s, was given, and it is not known whether it was done.", act.Label), - "Look at the delivery before you answer again.", - } - } - if err := a.tell(ctx, r.Ask.Headline, lines); err != nil { - a.logf("the operator could NOT be told that %s was refused (ask %s): %v", act.Label, r.ID, err) - } -} - -// refusalWords is a verb's refusal as the operator reads it: the plans named as "the plan" (plainRefusal), and, where -// the words still carry an identifier the operator's channel withholds, one sentence of the answer's own. -func refusalWords(words string, act conditions.Action) string { - return plainRefusalOr(words, refusalFallback(act)) -} - -// refusalFallback is what an answer's refusal says when its own words cannot be said: one sentence for each answer -// to a plan's ask, never another answer's. -func refusalFallback(act conditions.Action) string { - switch { - case act.Arguments["retry"] != "": - return "a retry would not run now; the details say why" - case act.Arguments["go"] != "": - return "the delivery could not be started now; the details say why" - case act.Arguments["stop"] != "": - return "the delivery could not be stopped now; the details say why" - } - return "the controller refused it; the details say why" -} - -// handActWarrant is the verb an act the operator chose on a warrant is recorded under: a person's decision, -// never a repair (handActVerbs). -const handActWarrant = "warrant" - -// byWords is who chose, as the hand-act log says it: "the operator, as telegram identity 42". -func byWords(w asks.Warrant) string { - if w.By == nil { - return "the operator" - } - return fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity) -} - -// viaWords is the channel an answer came through: its module and kind, and how the sender was known. -func viaWords(w asks.Warrant) string { - if w.By == nil { - return w.Channel - } - via := w.Channel + " (" + w.By.Kind + ")" - if w.By.Verified != "" { - via += ", " + w.By.Verified - } - return via -} - -// errNotGranted is an action whose verb the controller's grant does not name. -var errNotGranted = errors.New("the controller's grant does not name this verb") diff --git a/cmd/mesh-controller/asker_bus_test.go b/cmd/mesh-controller/asker_bus_test.go deleted file mode 100644 index 32c53642..00000000 --- a/cmd/mesh-controller/asker_bus_test.go +++ /dev/null @@ -1,153 +0,0 @@ -//go:build integration - -package main - -import ( - "context" - "encoding/json" - "sync" - "testing" - "time" - - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" - - "git.novox.be/novox/mesh-sdk/go/asks" - - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/link" - "github.com/novox/mesh-controller/internal/testbus" -) - -// busAsker is an asker on a real bus's `asked` bucket, counting what it performs: two of them are two -// controllers sharing one record. -type busAskerRig struct { - mu sync.Mutex - called int - acts int - open []conditions.Condition - sent [][]byte -} - -func (rig *busAskerRig) asker(t *testing.T, conn *nats.Conn, now time.Time) *asker { - return &asker{ - open: func(context.Context) ([]conditions.Condition, error) { - rig.mu.Lock() - defer rig.mu.Unlock() - return rig.open, nil - }, - silence: func(context.Context, string, time.Duration, string, string) error { return nil }, - store: busAsked{conn: conn}, - publish: func(_ context.Context, subject string, body []byte, _ string) error { - rig.mu.Lock() - defer rig.mu.Unlock() - if subject == asks.AskSubject(askerName) { - rig.sent = append(rig.sent, body) - } - return nil - }, - call: func(context.Context, conditions.Action, map[string]string) error { - time.Sleep(20 * time.Millisecond) // long enough for the other delivery to arrive meanwhile - rig.mu.Lock() - defer rig.mu.Unlock() - rig.called++ - return nil - }, - record: func(context.Context, link.HandAct) error { - rig.mu.Lock() - defer rig.mu.Unlock() - rig.acts++ - return nil - }, - now: func() time.Time { return now }, - logf: t.Logf, - } -} - -func askedBus(t *testing.T) *nats.Conn { - t.Helper() - conn, err := nats.Connect(testbus.URL(t)) - if err != nil { - t.Fatal(err) - } - t.Cleanup(conn.Close) - js, err := jetstream.New(conn) - if err != nil { - t.Fatal(err) - } - if _, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: broker.AskedBucket}); err != nil { - t.Fatal(err) - } - return conn -} - -// The review of 2026-10-09 (L7): two deliveries of one warrant, to two controllers at once, perform its act -// exactly once and record it once — the record's compare-and-set decides, never the warrant's message id. -func TestTwoAnswersAtOnceActOnce(t *testing.T) { - conn := askedBus(t) - now := time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC) - rig := &busAskerRig{open: []conditions.Condition{heldCondition()}} - first, second := rig.asker(t, conn, now), rig.asker(t, conn, now) - if err := first.reconcile(context.Background()); err != nil { - t.Fatal(err) - } - if len(rig.sent) != 1 { - t.Fatalf("asked %d times", len(rig.sent)) - } - var q asks.Ask - _ = json.Unmarshal(rig.sent[0], &q) - release, _ := q.Option("release") - w := asks.Warrant{Ask: q.ID, Asker: askerName, About: q.About, Outcome: asks.OutcomeChosen, Option: release.ID, - Label: release.Label, Level: release.Level, Channel: "telegram", Proofs: []string{"P1"}, At: now, - AskDigest: q.Digest(), By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} - body, _ := json.Marshal(w) - var wg sync.WaitGroup - for _, a := range []*asker{first, second, first, second} { - wg.Add(1) - go func(a *asker) { - defer wg.Done() - if err := a.Decided(context.Background(), body); err != nil { - t.Error(err) - } - }(a) - } - wg.Wait() - if rig.called != 1 || rig.acts != 1 { - t.Fatalf("performed %d time(s), recorded %d time(s)", rig.called, rig.acts) - } - got, err := busAsked{conn: conn}.Get(context.Background(), q.ID) - if err != nil || got == nil || got.Acted != "done" { - t.Fatalf("kept as %+v (%v)", got, err) - } -} - -// The review of 2026-10-09 (L2): a write decided on a record read earlier never lands over one made since. A -// cancel read before the answer was acted on leaves the act's record as it is. -func TestAStaleCancelDoesNotWriteOverAnAct(t *testing.T) { - conn := askedBus(t) - now := time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC) - rig := &busAskerRig{open: []conditions.Condition{heldCondition()}} - a := rig.asker(t, conn, now) - if err := a.reconcile(context.Background()); err != nil { - t.Fatal(err) - } - var q asks.Ask - _ = json.Unmarshal(rig.sent[0], &q) - stale, _ := busAsked{conn: conn}.Get(context.Background(), q.ID) - release, _ := q.Option("release") - w := asks.Warrant{Ask: q.ID, Asker: askerName, About: q.About, Outcome: asks.OutcomeChosen, Option: release.ID, - Label: release.Label, Level: release.Level, Channel: "telegram", At: now, AskDigest: q.Digest(), - By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} - body, _ := json.Marshal(w) - if err := a.Decided(context.Background(), body); err != nil { - t.Fatal(err) - } - if err := a.cancel(context.Background(), *stale, "the condition ended"); err != nil { - t.Fatal(err) - } - got, _ := busAsked{conn: conn}.Get(context.Background(), q.ID) - if got.State != string(asks.OutcomeChosen) || got.Acted != "done" { - t.Errorf("a stale cancel wrote over the act: %+v", got) - } -} diff --git a/cmd/mesh-controller/asker_test.go b/cmd/mesh-controller/asker_test.go deleted file mode 100644 index 4e732bee..00000000 --- a/cmd/mesh-controller/asker_test.go +++ /dev/null @@ -1,1006 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "sync" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/asks" - - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/link" -) - -// novox/hq ADR 0259 §6: the controller asks the operator for the answers its conditions name, and performs -// the one chosen on the router's warrant — once, for its own ask, the option offered, at its level. - -type memAskedStore map[string]asked - -// memAskedMu guards every memAskedStore: Change is a compare-and-set as the bus's is. -var memAskedMu sync.Mutex - -func (m memAskedStore) Get(_ context.Context, id string) (*asked, error) { - memAskedMu.Lock() - defer memAskedMu.Unlock() - r, ok := m[id] - if !ok { - return nil, nil - } - return &r, nil -} -func (m memAskedStore) Create(_ context.Context, r asked) error { - memAskedMu.Lock() - defer memAskedMu.Unlock() - if _, kept := m[r.ID]; kept { - return errors.New("an ask is kept under that id") - } - m[r.ID] = r - return nil -} -func (m memAskedStore) Change(_ context.Context, id string, change func(*asked) bool) (bool, error) { - memAskedMu.Lock() - defer memAskedMu.Unlock() - r, ok := m[id] - if !ok || !change(&r) { - return false, nil - } - m[id] = r - return true, nil -} -func (m memAskedStore) All(context.Context) ([]asked, error) { - memAskedMu.Lock() - defer memAskedMu.Unlock() - var out []asked - for _, r := range m { - out = append(out, r) - } - return out, nil -} - -type published struct { - subject, id string - body []byte -} - -type askerRig struct { - a *asker - open []conditions.Condition - store memAskedStore - sent []published - called []string - silenced []string - acts []link.HandAct - now time.Time - // refuses, when set, is what every verb the asker calls answers; told is every notice the operator was told. - refuses error - told []told -} - -// told is one plain notice the asker told the operator. -type told struct { - headline string - lines []string -} - -func newAskerRig(t *testing.T) *askerRig { - r := &askerRig{store: memAskedStore{}, now: time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)} - r.a = &asker{ - open: func(context.Context) ([]conditions.Condition, error) { return r.open, nil }, - silence: func(_ context.Context, key string, d time.Duration, by, why string) error { - r.silenced = append(r.silenced, key+" for "+d.String()+" by "+by+" because "+why) - // As the controller's conditions do (the confirmation review of 2026-10-09, M1): the condition is - // silenced from now on, so what is asked next sees it silenced. - for i := range r.open { - if r.open[i].Key == key { - r.open[i].Silenced = &conditions.Silence{Until: r.now.Add(d), By: by, Why: why, Since: r.now} - } - } - return nil - }, - store: r.store, - publish: func(_ context.Context, subject string, body []byte, id string) error { - r.sent = append(r.sent, published{subject, id, body}) - return nil - }, - call: func(_ context.Context, a conditions.Action, args map[string]string) error { - raw, _ := json.Marshal(args) - r.called = append(r.called, a.Verb+"@"+a.Machine+" "+string(raw)) - return r.refuses - }, - tell: func(_ context.Context, headline string, lines []string) error { - r.told = append(r.told, told{headline, lines}) - return nil - }, - record: func(_ context.Context, act link.HandAct) error { r.acts = append(r.acts, act); return nil }, - now: func() time.Time { return r.now }, - logf: t.Logf, - } - return r -} - -func heldCondition() conditions.Condition { - release := conditions.Action{Label: "Release", Verb: "mesh-delivery.release", Level: conditions.LevelApprove, - Arguments: map[string]string{"id": "novox/hq@055550802096", "why": ""}} - stop := conditions.Action{Label: "Stop", Verb: "mesh-delivery.stop", Level: conditions.LevelApprove, - Arguments: map[string]string{"id": "novox/hq@055550802096", "why": ""}} - o := conditions.Observation{Scope: conditions.ScopeDelivery, ID: "novox/hq@055550802096", Kind: "delivery-stalled", - Severity: conditions.Warning, Resolver: conditions.ResolverOperator, - Headline: "Delivery of hq held for 36 hours", - Explanation: "A delivery of hq has been held for 36 hours, past its limit.", - Needs: "release it, or stop it, " + FromMeshMCPServer, Actions: []conditions.Action{release, stop}} - return conditions.Condition{Key: o.Key(), Kind: o.Kind, Severity: conditions.Warning, Headline: o.Headline, - Explanation: conditions.Verdict(o.Needs, o.Explanation), Needs: o.Needs, Actions: o.Actions} -} - -func unitsCondition() conditions.Condition { - key := "machine.shanks.units" - return conditions.Condition{Key: key, Kind: "machine-units", Severity: conditions.Warning, - Headline: "3 failed services on shanks", Explanation: "Needs you: mend or remove them on shanks, or silence this.", - Needs: "mend or remove them on shanks, or silence this.", Actions: []conditions.Action{conditions.SilenceAction(key)}} -} - -func (r *askerRig) asksSent(t *testing.T) []asks.Ask { - t.Helper() - var out []asks.Ask - for _, p := range r.sent { - if p.subject != asks.AskSubject("mesh-controller") { - continue - } - var q asks.Ask - if err := json.Unmarshal(p.body, &q); err != nil { - t.Fatal(err) - } - out = append(out, q) - } - return out -} - -func TestAnAskIsMadeForEachConditionThatNamesItsAnswers(t *testing.T) { - r := newAskerRig(t) - quiet := conditions.Condition{Key: "machine.ace.silent", Headline: "ace silent", Explanation: "Nothing for you to do. x"} - r.open = []conditions.Condition{heldCondition(), unitsCondition(), quiet} - if err := r.a.reconcile(context.Background()); err != nil { - t.Fatal(err) - } - sent := r.asksSent(t) - if len(sent) != 2 { - t.Fatalf("asked %d times: %+v", len(sent), sent) - } - byAbout := map[string]asks.Ask{} - for _, q := range sent { - byAbout[q.About] = q - if err := q.Check(r.now); err != nil { - t.Errorf("%s: %v", q.About, err) - } - } - held := byAbout[heldCondition().Key] - if len(held.Options) != 2 || held.Options[0].Label != "Release" || held.Options[0].Level != asks.Approve || - held.Options[1].ID != "stop" || held.Expires != r.now.Add(askApproveFor) || held.Who != asks.Operator || - held.OnExpiry == "" { - t.Errorf("the held delivery is asked %+v", held) - } - units := byAbout["machine.shanks.units"] - if len(units.Options) != 1 || units.Options[0].Level != asks.Acknowledge || units.Expires != r.now.Add(askAcknowledgeFor) { - t.Errorf("the failed units are asked %+v", units) - } - // No second ask while one is open. - r.now = r.now.Add(time.Minute) - _ = r.a.reconcile(context.Background()) - if n := len(r.asksSent(t)); n != 2 { - t.Errorf("asked again while open: %d", n) - } -} - -func TestAnAskIsTakenBackWhenItsConditionEndsAndAskedAgainAfterItExpires(t *testing.T) { - r := newAskerRig(t) - r.open = []conditions.Condition{heldCondition(), unitsCondition()} - _ = r.a.reconcile(context.Background()) - // The units are silenced, the held delivery lasts past its ask's day. - units := unitsCondition() - units.Silenced = &conditions.Silence{Until: r.now.Add(48 * time.Hour)} - r.open = []conditions.Condition{heldCondition(), units} - r.now = r.now.Add(askApproveFor) - if err := r.a.reconcile(context.Background()); err != nil { - t.Fatal(err) - } - var cancels int - for _, p := range r.sent { - if p.subject == asks.CancelSubject("mesh-controller") { - cancels++ - } - } - if cancels != 1 { - t.Errorf("cancels %d, want the silenced one's", cancels) - } - if sent := r.asksSent(t); len(sent) != 3 || sent[2].About != heldCondition().Key { - t.Errorf("the expired ask was not asked again: %+v", sent) - } -} - -// warrantFor is the router's warrant for the open ask about a condition, choosing an option by label. -func (r *askerRig) warrantFor(t *testing.T, condition, label string) asks.Warrant { - t.Helper() - for _, a := range r.store { - if a.Condition != condition || a.State != askOpen { - continue - } - for _, o := range a.Ask.Options { - if o.Label == label { - return asks.Warrant{Ask: a.ID, Asker: "mesh-controller", About: condition, Outcome: asks.OutcomeChosen, - Option: o.ID, Label: o.Label, Level: o.Level, Channel: "telegram", Proofs: []string{"P1"}, At: r.now, - AskDigest: a.Ask.Digest(), - By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} - } - } - } - t.Fatalf("no open ask about %s offers %s", condition, label) - return asks.Warrant{} -} - -func answerWith(t *testing.T, r *askerRig, w asks.Warrant) { - t.Helper() - body, _ := json.Marshal(w) - if err := r.a.Decided(context.Background(), body); err != nil { - t.Fatal(err) - } -} - -func TestAWarrantIsActedOnOnce(t *testing.T) { - r := newAskerRig(t) - r.open = []conditions.Condition{heldCondition()} - _ = r.a.reconcile(context.Background()) - w := r.warrantFor(t, heldCondition().Key, "Release") - answerWith(t, r, w) - answerWith(t, r, w) // heard again - if len(r.called) != 1 { - t.Fatalf("called %v", r.called) - } - want := `mesh-delivery.release@ {"id":"novox/hq@055550802096","why":"the operator, via telegram (user id verified), chose Release (ask ` + w.Ask + `)"}` - if r.called[0] != want { - t.Errorf("called\n %s\nwant\n %s", r.called[0], want) - } - if len(r.acts) != 1 { - t.Fatalf("hand-acts %+v", r.acts) - } - act := r.acts[0] - if act.Verb != handActWarrant || act.By != "the operator, as telegram identity 42" || - act.Via != "telegram (telegram), user id verified" || act.Ask != w.Ask || strings.Join(act.Proofs, ",") != "P1" || - act.Cause != conditions.CauseOperatorAnswer || act.Condition != heldCondition().Key || act.Outcome != "done" { - t.Errorf("the hand-act %+v", act) - } - if !personsDecision(act) { - t.Error("an act on a warrant counts as a repair") - } - if got := r.store[w.Ask]; got.State != string(asks.OutcomeChosen) || got.Acted != "done" { - t.Errorf("kept %+v", got) - } -} - -func TestAWarrantThatIsNotForItsOwnAskIsRefused(t *testing.T) { - for name, change := range map[string]func(*asks.Warrant){ - "another asker": func(w *asks.Warrant) { w.Asker = "mesh-delivery" }, - "an ask not held": func(w *asks.Warrant) { w.Ask = "c0000000000000000" }, - "an option not offered": func(w *asks.Warrant) { w.Option = "delete" }, - "another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge }, - "no person": func(w *asks.Warrant) { w.By = nil }, - "another ask's digest": func(w *asks.Warrant) { w.AskDigest = "sha256:0000" }, - "no ask's digest": func(w *asks.Warrant) { w.AskDigest = "" }, - } { - t.Run(name, func(t *testing.T) { - r := newAskerRig(t) - r.open = []conditions.Condition{heldCondition()} - _ = r.a.reconcile(context.Background()) - w := r.warrantFor(t, heldCondition().Key, "Stop") - change(&w) - answerWith(t, r, w) - if len(r.called)+len(r.acts)+len(r.silenced) != 0 { - t.Errorf("acted on it: %v %v %v", r.called, r.acts, r.silenced) - } - }) - } -} - -func TestASilenceChosenIsTheControllersOwnAndAnAnswerToAnAsk(t *testing.T) { - r := newAskerRig(t) - r.open = []conditions.Condition{unitsCondition()} - _ = r.a.reconcile(context.Background()) - w := r.warrantFor(t, "machine.shanks.units", "Silence for a week") - w.Level, w.Proofs = asks.Acknowledge, nil - w.By = &asks.Person{Who: asks.Operator, Kind: "desktop", Identity: "g14", - Verified: "a desk click: whoever was at the operator's session on g14"} - w.Channel = "desk-channel" - answerWith(t, r, w) - if len(r.called) != 0 || len(r.silenced) != 1 || !strings.HasPrefix(r.silenced[0], "machine.shanks.units for 168h0m0s by the operator, as desktop identity g14") { - t.Fatalf("silenced %v, called %v", r.silenced, r.called) - } - if len(r.acts) != 1 || r.acts[0].Cause != conditions.CauseOperatorAnswer || len(r.acts[0].Proofs) != 0 { - t.Errorf("%+v", r.acts) - } -} - -func TestAnAskThatEndedWithoutAChoiceDoesNothing(t *testing.T) { - r := newAskerRig(t) - r.open = []conditions.Condition{heldCondition()} - _ = r.a.reconcile(context.Background()) - w := r.warrantFor(t, heldCondition().Key, "Release") - w.Outcome, w.Option, w.Label, w.Level, w.By, w.Words = asks.OutcomeExpired, "", "", "", nil, "nobody answered in time" - answerWith(t, r, w) - if len(r.called)+len(r.acts) != 0 || r.store[w.Ask].State != string(asks.OutcomeExpired) || - !strings.HasPrefix(r.store[w.Ask].Acted, "nothing") { - t.Errorf("called %v acts %v kept %+v", r.called, r.acts, r.store[w.Ask]) - } - // And a choice for a condition that ended meanwhile does nothing either. - r2 := newAskerRig(t) - r2.open = []conditions.Condition{heldCondition()} - _ = r2.a.reconcile(context.Background()) - w2 := r2.warrantFor(t, heldCondition().Key, "Release") - r2.open = nil - answerWith(t, r2, w2) - if len(r2.called) != 0 || r2.store[w2.Ask].Acted != "nothing: the condition ended before the answer" { - t.Errorf("%v %+v", r2.called, r2.store[w2.Ask]) - } -} - -func TestAWarrantMissedWhileAwayIsReadFromTheRoutersRecord(t *testing.T) { - r := newAskerRig(t) - r.open = []conditions.Condition{heldCondition()} - _ = r.a.reconcile(context.Background()) - w := r.warrantFor(t, heldCondition().Key, "Stop") - r.a.routerRecord = func(_ context.Context, id string) (*asks.Warrant, error) { - if id != w.Ask { - return nil, errors.New("another ask") - } - return &w, nil - } - r.now = r.now.Add(askCatchUpAfter) - if err := r.a.reconcile(context.Background()); err != nil { - t.Fatal(err) - } - if len(r.called) != 1 || !strings.HasPrefix(r.called[0], "mesh-delivery.stop@") { - t.Errorf("called %v", r.called) - } - if n := len(r.asksSent(t)); n != 1 { - t.Errorf("asked again after the answer: %d", n) - } -} - -// After review (2026-10-08): a refused ask is not asked again at once; issue 369: nor is the refusal believed -// for ever — it is asked again after a wait that doubles with each refusal in a row, and at once when the -// channels change. -func TestAnAskTheRouterRefusedIsAskedAgainAfterAGrowingWait(t *testing.T) { - r := newAskerRig(t) - channels := "channel/telegram=telegram@anchor[choice]own:true" - r.a.channels = func(context.Context) string { return channels } - r.open = []conditions.Condition{heldCondition()} - refuse := func() { - t.Helper() - sent := r.asksSent(t) - refusal, _ := json.Marshal(asks.Warrant{Ask: sent[len(sent)-1].ID, Asker: "mesh-controller", - Outcome: asks.OutcomeRefused, Words: "no channel can carry any of its answers now", At: r.now}) - if err := r.a.Decided(context.Background(), refusal); err != nil { - t.Fatal(err) - } - } - _ = r.a.reconcile(context.Background()) - first := r.asksSent(t)[0] - refuse() - if got := r.store[first.ID]; got.State != string(asks.OutcomeRefused) || !strings.Contains(got.Acted, "nothing") { - t.Fatalf("the refusal was kept as %+v", got) - } - // Each wait: nothing asked before it ends, asked once when it has. - for i, wait := range []time.Duration{time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute, - 16 * time.Minute, 30 * time.Minute, 30 * time.Minute} { - before := len(r.asksSent(t)) - r.now = r.now.Add(wait - 10*time.Second) - _ = r.a.reconcile(context.Background()) - if n := len(r.asksSent(t)); n != before { - t.Fatalf("refusal %d: asked again before %s", i+1, wait) - } - r.now = r.now.Add(10 * time.Second) - _ = r.a.reconcile(context.Background()) - if n := len(r.asksSent(t)); n != before+1 { - t.Fatalf("refusal %d: not asked again after %s (%d asks)", i+1, wait, n) - } - refuse() - } - before := len(r.asksSent(t)) - channels = "channel/telegram=telegram@anchor[choice,verified-sender]own:true" - _ = r.a.reconcile(context.Background()) - if n := len(r.asksSent(t)); n != before+1 { - t.Errorf("not asked again once the channels changed: %d", n-before) - } -} - -// After review: at most three asks open at once, the most urgent first, then the oldest. -func TestAtMostThreeAsksAreOpenTheMostUrgentFirst(t *testing.T) { - r := newAskerRig(t) - var open []conditions.Condition - for i := 0; i < 4; i++ { - c := unitsCondition() - c.Key = "machine.m" + string(rune('a'+i)) + ".units" - c.Actions = []conditions.Action{conditions.SilenceAction(c.Key)} - c.Raised = r.now.Add(-time.Duration(10-i) * time.Hour) - open = append(open, c) - } - urgent := heldCondition() - urgent.Severity, urgent.Raised = conditions.Urgent, r.now.Add(-time.Minute) - r.open = append(open, urgent) - _ = r.a.reconcile(context.Background()) - sent := r.asksSent(t) - if len(sent) != askMostOpen || sent[0].About != urgent.Key || sent[1].About != "machine.ma.units" || sent[2].About != "machine.mb.units" { - var about []string - for _, q := range sent { - about = append(about, q.About) - } - t.Fatalf("asked %v", about) - } -} - -// After review: nothing is asked while no router takes asks under the controller's name, and that is said once. -func TestNothingIsAskedWithoutARouter(t *testing.T) { - r := newAskerRig(t) - var said []string - r.a.logf = func(f string, a ...any) { said = append(said, f) } - r.a.routerHere = func(context.Context) (bool, error) { return false, nil } - r.open = []conditions.Condition{heldCondition()} - _ = r.a.reconcile(context.Background()) - _ = r.a.reconcile(context.Background()) - if len(r.asksSent(t)) != 0 { - t.Error("asked with no router") - } - n := 0 - for _, s := range said { - if strings.Contains(s, "no router takes asks") { - n++ - } - } - if n != 1 { - t.Errorf("said %d times", n) - } -} - -// After review: the condition's words keep where an answer is given without a channel; the ask's text does not. -func TestTheAskDropsWhereItIsAnsweredAndTheConditionKeepsIt(t *testing.T) { - c := heldCondition() - if !strings.Contains(c.Explanation, FromMeshMCPServer) { - t.Fatalf("the condition lost where it is answered: %q", c.Explanation) - } - q, _ := askOf("x", c, partsOf(c)[0], time.Now()) - if strings.Contains(q.Explanation, "mesh MCP server") || !strings.HasPrefix(q.Explanation, "Needs you: release it, or stop it.") { - t.Errorf("the ask says %q", q.Explanation) - } - if askApproveFor >= 24*time.Hour { - t.Errorf("an approving ask lasts %s, which the SDK may refuse at its bound", askApproveFor) - } -} - -// After review (security finding 9): a warrant is acted on only for an ask open in the controller's own record, -// once the claim stands, and never when given after the ask expired. -func TestAWarrantIsActedOnlyForAnOpenAskItClaimsBeforeItExpired(t *testing.T) { - r := newAskerRig(t) - r.open = []conditions.Condition{heldCondition()} - _ = r.a.reconcile(context.Background()) - w := r.warrantFor(t, heldCondition().Key, "Release") - late := w - late.At = r.store[w.Ask].Ask.Expires.Add(time.Minute) - body, _ := json.Marshal(late) - _ = r.a.Decided(context.Background(), body) - if len(r.called) != 0 { - t.Fatalf("acted on a warrant given after the ask expired: %v", r.called) - } - // Claimed already by another delivery: nothing done here. - kept := r.store[w.Ask] - kept.Acted = "acting" - r.store[w.Ask] = kept - body, _ = json.Marshal(w) - _ = r.a.Decided(context.Background(), body) - if len(r.called) != 0 { - t.Fatalf("acted though the claim was another's: %v", r.called) - } - // Cancelled in its own record: refused. - kept.Acted, kept.State = "", askCancelled - r.store[w.Ask] = kept - _ = r.a.Decided(context.Background(), body) - if len(r.called) != 0 { - t.Errorf("acted on a cancelled ask: %v", r.called) - } -} - -// novox/hq ADR 0259 §6: a warrant authorises the act its option bound when the controller asked, and no -// other. A record of the act changed after the ask — another delivery, another machine, another argument — -// is refused and nothing is performed. -func TestAWarrantPerformsOnlyTheActItsOptionBound(t *testing.T) { - for name, change := range map[string]func(*conditions.Action){ - "another argument": func(a *conditions.Action) { - a.Arguments = map[string]string{"id": "novox/mesh-controller@000000000000"} - }, - "another verb": func(a *conditions.Action) { a.Verb = "mesh-delivery.stop" }, - "another machine": func(a *conditions.Action) { a.Machine = "anchor" }, - } { - t.Run(name, func(t *testing.T) { - r := newAskerRig(t) - r.open = []conditions.Condition{heldCondition()} - _ = r.a.reconcile(context.Background()) - w := r.warrantFor(t, heldCondition().Key, "Release") - kept := r.store[w.Ask] - acts := append([]conditions.Action(nil), kept.Actions...) - i := kept.Options[w.Option] - change(&acts[i]) - kept.Actions = acts - r.store[w.Ask] = kept - answerWith(t, r, w) - if len(r.called)+len(r.acts) != 0 { - t.Errorf("performed an act the option did not bind: %v %v", r.called, r.acts) - } - }) - } - // Every option of an ask binds its act. - q, _ := askOf("x", heldCondition(), partsOf(heldCondition())[0], time.Now()) - for _, o := range q.Options { - if o.Binds == "" { - t.Errorf("the option %s binds nothing", o.ID) - } - } -} - -// Failure is loud (novox/hq ADR 0259, the self-review of 2026-10-09): a condition that needs the operator and -// could not be asked on any channel — no router, or the router refused the ask — is a condition of its own, -// cleared once it can be asked again. -func TestAnAskThatCannotBeDeliveredIsSaid(t *testing.T) { - r := newAskerRig(t) - var raised [][]conditions.Observation - r.a.raise = func(_ context.Context, obs []conditions.Observation) error { - raised = append(raised, obs) - return nil - } - last := func() []conditions.Observation { return raised[len(raised)-1] } - routerHere := false - r.a.routerHere = func(context.Context) (bool, error) { return routerHere, nil } - channels := "channel/telegram=telegram@anchor[choice]own:true" - r.a.channels = func(context.Context) string { return channels } - r.open = []conditions.Condition{heldCondition()} - - _ = r.a.reconcile(context.Background()) - if got := last(); len(got) != 1 || got[0].Kind != "asks-undelivered" || - !strings.Contains(got[0].Summary, heldCondition().Key) || !strings.Contains(got[0].Summary, "no router") { - t.Fatalf("no router, said as %+v", got) - } - - routerHere = true - _ = r.a.reconcile(context.Background()) - if got := last(); len(got) != 0 { - t.Fatalf("asked, and still said undelivered: %+v", got) - } - first := r.asksSent(t)[0] - refusal, _ := json.Marshal(asks.Warrant{Ask: first.ID, Asker: "mesh-controller", Outcome: asks.OutcomeRefused, - Words: "no channel can carry any of its answers now", At: r.now}) - if err := r.a.Decided(context.Background(), refusal); err != nil { - t.Fatal(err) - } - _ = r.a.reconcile(context.Background()) - if got := last(); len(got) != 1 || !strings.Contains(got[0].Summary, "no channel can carry") { - t.Fatalf("the router's refusal, said as %+v", got) - } - if why, ok := conditions.PlainWords(conditions.Words{Headline: last()[0].Headline, Explanation: last()[0].Explanation, - Needs: last()[0].Needs, Resolved: last()[0].Resolved}, ""); !ok { - t.Errorf("not plain: %s", why) - } - r.open = nil - _ = r.a.reconcile(context.Background()) - if got := last(); len(got) != 0 { - t.Errorf("nothing needs asking, and still said: %+v", got) - } -} - -// The review of 2026-10-09 (M1): an acknowledging answer never shares an ask with an authorising one. A -// condition offering Restart and Silence is asked twice — Restart alone, about the condition, and Silence -// alone, apart — so Silence chosen on a channel that only acknowledges leaves the Restart ask open. -func TestAnAcknowledgementNeverSharesAnAskWithAnApproval(t *testing.T) { - r := newAskerRig(t) - key := "module.shanks.plex.down" - c := conditions.Condition{Key: key, Kind: "module-down", Severity: conditions.Urgent, Headline: "Plex down on shanks", - Explanation: "Needs you: restart it, or silence this.", Needs: "restart it, or silence this.", - Actions: []conditions.Action{ - {Label: "Restart", Verb: "node-service-manager.restart", Machine: "shanks", Level: conditions.LevelApprove, - Arguments: map[string]string{"unit": "plex"}}, - conditions.SilenceAction(key)}} - r.open = []conditions.Condition{c} - if err := r.a.reconcile(context.Background()); err != nil { - t.Fatal(err) - } - sent := r.asksSent(t) - if len(sent) != 2 { - t.Fatalf("asked %d time(s): %+v", len(sent), sent) - } - for _, q := range sent { - if err := q.Check(r.now); err != nil { - t.Errorf("%s: %v", q.About, err) - } - levels := map[asks.Level]bool{} - for _, o := range q.Options { - levels[o.Level] = true - } - if len(levels) != 1 { - t.Errorf("the ask about %s mixes levels: %+v", q.About, q.Options) - } - } - byAbout := map[string]asks.Ask{} - for _, q := range sent { - byAbout[q.About] = q - } - if q := byAbout[key]; len(q.Options) != 1 || q.Options[0].Label != "Restart" { - t.Errorf("the condition's own ask: %+v", q) - } - if q := byAbout[key+".acknowledge"]; len(q.Options) != 1 || q.Options[0].Level != asks.Acknowledge { - t.Errorf("the acknowledging ask: %+v", q) - } - // Silence chosen: performed, and the Restart ask stays open, never asked twice. - answerWith(t, r, r.warrantFor(t, key, "Silence for a week")) - if len(r.silenced) != 1 || len(r.called) != 0 { - t.Fatalf("silenced %v called %v", r.silenced, r.called) - } - _ = r.a.reconcile(context.Background()) - open := 0 - for _, a := range r.store { - if a.State == askOpen && a.Condition == key { - open++ - if a.Part != "" || a.Ask.Options[0].Label != "Restart" { - t.Errorf("the open ask is %+v", a) - } - } - } - if open != 1 || len(r.asksSent(t)) != 2 { - t.Errorf("after the silence: %d open, %d asked", open, len(r.asksSent(t))) - } - // And the approval still answers: Restart chosen on a channel that proves who answered is performed. - answerWith(t, r, r.warrantFor(t, key, "Restart")) - if len(r.called) != 1 || !strings.HasPrefix(r.called[0], "node-service-manager.restart@shanks") { - t.Errorf("the approval kept through a silence was not performed: %v", r.called) - } -} - -// novox/hq issue 353: the controller's grant to ask is composed from the router's assignment and reaches the -// bus only when its machine is pushed. Between the two, the bus refuses every ask (measured 2026-10-09, 17:54 to -// 17:56 local: seven refusals of mesh.seat.operator-channel.accept.ask.mesh-controller). So nothing is asked -// while the bus's user list is behind, it is said once, the conditions that need the operator are raised as -// undelivered with what to do, and the asks go out once the bus holds the grant. -func TestNothingIsAskedWhileTheBusLacksTheControllersGrant(t *testing.T) { - r := newAskerRig(t) - var said []string - r.a.logf = func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) } - var raised [][]conditions.Observation - r.a.raise = func(_ context.Context, obs []conditions.Observation) error { - raised = append(raised, obs) - return nil - } - held := false - r.a.grantHeld = func(context.Context) (bool, string, error) { - return held, "the bus's user list on anchor is behind what the mesh composes; `push anchor` carries it", nil - } - r.open = []conditions.Condition{heldCondition()} - _ = r.a.reconcile(context.Background()) - _ = r.a.reconcile(context.Background()) - if len(r.asksSent(t)) != 0 { - t.Error("asked while the bus lacks the grant") - } - n := 0 - for _, s := range said { - if strings.Contains(s, "does not hold the controller's grant") { - n++ - } - } - if n != 1 { - t.Errorf("said %d times: %q", n, said) - } - if len(raised) == 0 || len(raised[len(raised)-1]) != 1 || - !strings.Contains(raised[len(raised)-1][0].Summary, "`push anchor` carries it") || - raised[len(raised)-1][0].Kind != "asks-undelivered" { - t.Fatalf("not said as a condition with what to do: %+v", raised) - } - held = true - _ = r.a.reconcile(context.Background()) - if sent := r.asksSent(t); len(sent) != 1 || sent[0].About != heldCondition().Key { - t.Errorf("not asked once the bus holds the grant: %+v", sent) - } - if last := raised[len(raised)-1]; len(last) != 0 { - t.Errorf("the undelivered condition was not cleared: %+v", last) - } -} - -// novox/hq issue 369: the router refused while its channels had not yet said they could send; they could twenty -// minutes later, and "Questions for you not delivered" repeated that refusal for eleven hours, escalating on it. -// The ask is made again; while the router's word on it is awaited the condition stands unchanged (neither -// cleared nor raised again); once the router took it, the condition clears; a new refusal is said in its own -// words. -func TestARefusalIsAskedAgainAndTheUndeliveredConditionClearsOnceTaken(t *testing.T) { - r := newAskerRig(t) - var raised [][]conditions.Observation - r.a.raise = func(_ context.Context, obs []conditions.Observation) error { - raised = append(raised, obs) - return nil - } - last := func() []conditions.Observation { return raised[len(raised)-1] } - r.a.channels = func(context.Context) string { return "channel/telegram=telegram@anchor[choice]own:true" } - r.open = []conditions.Condition{unitsCondition()} - refuse := func(words string) { - t.Helper() - sent := r.asksSent(t) - refusal, _ := json.Marshal(asks.Warrant{Ask: sent[len(sent)-1].ID, Asker: "mesh-controller", - Outcome: asks.OutcomeRefused, Words: words, At: r.now}) - if err := r.a.Decided(context.Background(), refusal); err != nil { - t.Fatal(err) - } - } - _ = r.a.reconcile(context.Background()) - refuse("no channel can carry any of its answers now: telegram: telegram has not said whether it can send") - _ = r.a.reconcile(context.Background()) - if got := last(); len(got) != 1 || !strings.Contains(got[0].Summary, "telegram has not said") { - t.Fatalf("the refusal, said as %+v", got) - } - // The wait ends: asked again; until the router's word on it is in, the condition stands as it was. - r.now = r.now.Add(askEvery) - _ = r.a.reconcile(context.Background()) - if n := len(r.asksSent(t)); n != 2 { - t.Fatalf("not asked again: %d asks", n) - } - if got := last(); len(got) != 1 { - t.Fatalf("cleared while the router's word on the new ask was awaited: the condition would flap") - } - r.now = r.now.Add(askVerdictWait / 2) - _ = r.a.reconcile(context.Background()) - if got := last(); len(got) != 1 { - t.Fatalf("cleared inside the verdict wait: the condition would flap") - } - // Refused again, now for a reason of today: said in those words. - refuse("no channel can carry any of its answers now: telegram: no account is linked") - _ = r.a.reconcile(context.Background()) - if got := last(); len(got) != 1 || !strings.Contains(got[0].Summary, "no account is linked") || - strings.Contains(got[0].Summary, "has not said") { - t.Fatalf("an old refusal's words repeated: %+v", got) - } - // Asked again after the doubled wait, and taken: no refusal comes, and the condition clears. - r.now = r.now.Add(2 * askEvery) - _ = r.a.reconcile(context.Background()) - if n := len(r.asksSent(t)); n != 3 { - t.Fatalf("not asked again after the doubled wait: %d asks", n) - } - r.now = r.now.Add(askVerdictWait) - _ = r.a.reconcile(context.Background()) - if got := last(); len(got) != 0 { - t.Fatalf("taken, and still said undelivered: %+v", got) - } - if n := len(r.asksSent(t)); n != 3 { - t.Fatalf("an ask taken was asked again: %d asks", n) - } - // The operator answers it; the condition stays open (its fix takes a while): the old refusal is not said again. - taken := r.asksSent(t)[2] - r.store.Change(context.Background(), taken.ID, func(x *asked) bool { - x.State, x.Ended = string(asks.OutcomeChosen), r.now - return true - }) - for i := 0; i < 5; i++ { - r.now = r.now.Add(askEvery) - _ = r.a.reconcile(context.Background()) - if got := last(); len(got) != 0 { - t.Fatalf("an answered ask brought its old refusal back: %+v", got) - } - } - // Its explanation opens with one verdict, not two. - r.open = append(r.open, heldCondition()) - _ = r.a.reconcile(context.Background()) - refuse("no channel can carry any of its answers now") - _ = r.a.reconcile(context.Background()) - if e := conditions.Verdict(last()[0].Needs, last()[0].Explanation); strings.Count(e, "Needs you") != 1 { - t.Errorf("the explanation says its verdict twice: %q", e) - } -} - -// hq issue 353, the review: the bus holds the controller's grant to ask only when both facts hold — the mesh -// composes the controller's own grant, and the bus's module says it is in force on the running server — and -// each fact not known is not held, said with why. -func TestTheGrantIsHeldOnlyWhenComposedAndInForceOnTheBus(t *testing.T) { - yes := func(context.Context) (bool, string, error) { return true, "", nil } - no := func(why string) grantJudge { - return func(context.Context) (bool, string, error) { return false, why, nil } - } - broken := func(context.Context) (bool, string, error) { return false, "", errors.New("the store is away") } - for _, c := range []struct { - name string - composed, inForce grantJudge - held bool - says string - }{ - {"composed and in force", yes, yes, true, ""}, - {"not composed", no("no router is assigned"), yes, false, "no router is assigned"}, - {"composed, written, not reloaded", yes, no("the server has not reloaded"), false, "has not reloaded"}, - {"the composition unknown", broken, yes, false, "could not be worked out: the store is away"}, - {"the bus unknown", yes, broken, false, "could not be worked out: the store is away"}, - {"nothing judges the bus", yes, nil, false, "not judged"}, - } { - held, why, err := grantHeldBy(c.composed, c.inForce, time.Now)(context.Background()) - if err != nil || held != c.held || !strings.Contains(why, c.says) { - t.Errorf("%s: held %v (%q, %v)", c.name, held, why, err) - } - } - // Remembered for grantLookEvery, then judged again. - now := time.Date(2026, 10, 9, 18, 0, 0, 0, time.UTC) - inForce := false - judge := grantHeldBy(yes, func(context.Context) (bool, string, error) { return inForce, "not yet", nil }, - func() time.Time { return now }) - if held, _, _ := judge(context.Background()); held { - t.Fatal("held before the bus enforces it") - } - inForce = true - if held, _, _ := judge(context.Background()); held { - t.Error("judged again inside grantLookEvery") - } - now = now.Add(grantLookEvery) - if held, _, _ := judge(context.Background()); !held { - t.Error("not judged again after grantLookEvery") - } -} - -// The controller's own grant, as composed: present while a router that takes asks is assigned, absent -// otherwise — whatever else in the user list changes. -func TestTheControllersOwnGrantToAskIsReadFromTheComposition(t *testing.T) { - router := broker.Declared{Module: "messenger", Holds: []broker.Seat{{Name: broker.AsksSeat, Scope: "mesh", - Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"}, ByCaller: []string{"ask", "cancel", "decided"}}}} - other := broker.Declared{Module: "dunst"} - with := broker.Records{Nodes: []string{"anchor"}, Assigned: map[string][]broker.Declared{"anchor": {router, other}}} - without := broker.Records{Nodes: []string{"anchor"}, Assigned: map[string][]broker.Declared{"anchor": {other}}} - if may, err := controllerMayAsk(with); err != nil || !may { - t.Errorf("with a router assigned: %v %v", may, err) - } - if may, err := controllerMayAsk(without); err != nil || may { - t.Errorf("with no router assigned: %v %v", may, err) - } -} - -// The bus module's answer, with the record's word on the list sent as the check only where the answer does -// not settle it (reviews 22 and 31 of PR 188); each way it is not held names its own remedy. -func TestTheBusModulesAnswerNamesTheRemedyForEachCase(t *testing.T) { - const ( - reloaded = `{"allowed":true,"in_force":true,"file_written":"2026-10-09T15:55:56Z","server_loaded":"2026-10-09T15:56:05Z"}` - notLoaded = `{"allowed":true,"in_force":false,"why":"the user list on the machine allows it, and the server has not reloaded since it was written","file_written":"2026-10-09T15:55:56Z","server_loaded":"2026-10-09T15:50:00Z"}` - noVarz = `{"allowed":true,"in_force":false,"why":"the server's last configuration load could not be read: refused"}` - noStat = `{"allowed":true,"in_force":false,"why":"when the bus's user list was written could not be read inside mesh-broker-nats: exit 1"}` - notAllowed = `{"allowed":false,"in_force":false,"why":"the user list on the machine does not allow it"}` - ) - current := busListState{Machine: "anchor"} - behind := busListState{Machine: "anchor", Behind: true} - stepped := busListState{Machine: "anchor", Behind: true, BusStep: "sending anchor would replace the bus; `bus upgrade --why …`"} - uncomposed := busListState{Machine: "anchor", Behind: true, Uncomposed: errors.New("module foo requires a provision nobody provides")} - for _, c := range []struct { - name, answer string - askErr error - list busListState - listErr error - held bool - says, not string - }{ - {"in force", reloaded, nil, behind, nil, true, "", "push"}, - {"written, not reloaded", notLoaded, nil, current, nil, false, "within about 5 s", "push"}, - {"in force not known: varz", noVarz, nil, current, nil, false, "is not known: the server's last configuration load", "wait"}, - {"in force not known: stat", noStat, nil, current, nil, false, "is not known: when the bus's user list was written", "wait"}, - {"not allowed, list behind", notAllowed, nil, behind, nil, false, "`push anchor` carries it", "5 s"}, - {"not allowed, bus step pending", notAllowed, nil, stepped, nil, false, "`bus upgrade", "`push anchor`"}, - {"not allowed, list uncomposed", notAllowed, nil, uncomposed, nil, false, "`plan anchor`", "`push anchor`"}, - {"older module, allowed, list current", `{"allowed":true}`, nil, current, nil, true, "not confirmed", "push"}, - {"older module, allowed, list behind", `{"allowed":true}`, nil, behind, nil, true, "not confirmed", "push"}, - {"older module, allowed, list unknown", `{"allowed":true}`, nil, current, errors.New("the store is away"), true, "`bus upgrade`", "push"}, - {"older module, not allowed, list behind", `{"allowed":false}`, nil, behind, nil, false, "`push anchor` carries it", "5 s"}, - {"module cannot answer, list current", "", errors.New("no responders"), current, nil, true, "could not answer", "push"}, - {"module cannot answer, list behind", "", errors.New("no responders"), behind, nil, false, "`push anchor` carries it", ""}, - {"module cannot answer, list unknown", "", errors.New("no responders"), current, errors.New("the store is away"), false, "could not be worked out", ""}, - {"answer unreadable, list behind", `not json`, nil, behind, nil, false, "could not be read", ""}, - } { - judge := busGrantBy( - func(context.Context) (string, json.RawMessage, error) { - return "anchor", json.RawMessage(c.answer), c.askErr - }, - func(context.Context) (busListState, error) { return c.list, c.listErr }) - held, why, _ := judgeGrantOnce(judge) - if held != c.held || !strings.Contains(why, c.says) || (c.not != "" && strings.Contains(why, c.not)) { - t.Errorf("%s: held %v (%q)", c.name, held, why) - } - } - none := busGrantBy(func(context.Context) (string, json.RawMessage, error) { return "", nil, nil }, nil) - if held, why, _ := judgeGrantOnce(none); held || !strings.Contains(why, "no module holds the bus") { - t.Errorf("no bus: held %v (%q)", held, why) - } -} - -// judgeGrantOnce is the whole judgement with the composed grant held, as the asker makes it. -func judgeGrantOnce(bus grantJudge) (bool, string, error) { - yes := func(context.Context) (bool, string, error) { return true, "", nil } - return grantHeldBy(yes, bus, time.Now)(context.Background()) -} - -// The orders reviews 22 and 31 of PR 188 found: this controller live before the bus module that answers -// in_force, with the bus's machine behind on its list — and, after mesh-catalog #166 merged, unpushable until a -// person's bus upgrade. An older module that allows the ask asks (once, with the caveat said); a new module that -// has not reloaded names the wait, not a push; one that cannot say whether it reloaded names what is not known. -func TestAnOlderBusModuleNeitherSilencesTheAsksNorNamesTheWrongRemedy(t *testing.T) { - for _, c := range []struct { - name, answer string - list busListState - asks bool - says, not string - }{ - {"older module, list current", `{"allowed":true}`, busListState{Machine: "anchor"}, true, "not confirmed", ""}, - {"older module, list behind, bus step pending", `{"allowed":true}`, - busListState{Machine: "anchor", Behind: true, BusStep: "a planned step"}, true, "not confirmed", ""}, - {"new module, not reloaded", - `{"allowed":true,"in_force":false,"why":"not reloaded","file_written":"2026-10-09T15:55:56Z","server_loaded":"2026-10-09T15:50:00Z"}`, - busListState{Machine: "anchor"}, false, "within about 5 s", "push"}, - {"new module, reload not known", `{"allowed":true,"in_force":false,"why":"the server's last configuration load could not be read: refused"}`, - busListState{Machine: "anchor"}, false, "is not known", "wait"}, - } { - t.Run(c.name, func(t *testing.T) { - r := newAskerRig(t) - var said []string - r.a.logf = func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) } - var raised [][]conditions.Observation - r.a.raise = func(_ context.Context, obs []conditions.Observation) error { - raised = append(raised, obs) - return nil - } - yes := func(context.Context) (bool, string, error) { return true, "", nil } - r.a.grantHeld = grantHeldBy(yes, busGrantBy( - func(context.Context) (string, json.RawMessage, error) { - return "anchor", json.RawMessage(c.answer), nil - }, - func(context.Context) (busListState, error) { return c.list, nil }), - func() time.Time { return r.now }) - r.open = []conditions.Condition{heldCondition()} - _ = r.a.reconcile(context.Background()) - _ = r.a.reconcile(context.Background()) - sent := r.asksSent(t) - if c.asks { - if len(sent) != 1 || sent[0].About != heldCondition().Key { - t.Fatalf("not asked: %+v", sent) - } - n := 0 - for _, s := range said { - if strings.Contains(s, c.says) { - n++ - } - } - if n != 1 { - t.Errorf("the caveat said %d times: %q", n, said) - } - return - } - if len(sent) != 0 { - t.Fatalf("asked while the bus does not hold the grant: %+v", sent) - } - if len(raised) == 0 || len(raised[len(raised)-1]) != 1 || - raised[len(raised)-1][0].Kind != "asks-undelivered" { - t.Fatalf("not said as undelivered: %+v", raised) - } - summary := raised[len(raised)-1][0].Summary - if !strings.Contains(summary, c.says) || (c.not != "" && strings.Contains(summary, c.not)) { - t.Errorf("the condition names the wrong remedy: %q", summary) - } - }) - } -} - -// The serving asker is wired to the real judgement: with records and a bus it cannot read, the grant is not -// held, and so nothing is asked (a stub saying yes, or no judgement at all, would ask). -func TestTheServingAskerJudgesTheGrantForReal(t *testing.T) { - a := servingAsker(&stores{}, nil, nil, nil) - if a.grantHeld == nil { - t.Fatal("the serving asker does not judge the bus's grant") - } - held, why, err := a.grantHeld(context.Background()) - if err != nil || held || !strings.Contains(why, "could not be worked out") { - t.Errorf("held %v (%q, %v) with nothing to read", held, why, err) - } -} diff --git a/cmd/mesh-controller/asker_wire.go b/cmd/mesh-controller/asker_wire.go deleted file mode 100644 index 7f756226..00000000 --- a/cmd/mesh-controller/asker_wire.go +++ /dev/null @@ -1,613 +0,0 @@ -package main - -// The asker on the bus: its asks in the controller's bucket `asked`, its asks and cancels published on the -// seat under the controller's name, the verbs a warrant chooses called with the controller's grant, and the -// router's record of its asks read under its name (novox/hq ADR 0259). - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/nats-io/nats.go" - "github.com/nats-io/nats.go/jetstream" - - "git.novox.be/novox/mesh-sdk/go/asks" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/conditions" - "github.com/novox/mesh-controller/internal/inventory" - "github.com/novox/mesh-controller/internal/link" -) - -// askerFrom is the serving controller's asker; nil in any other process. -var askerFrom atomic.Pointer[asker] - -// askWithin is how long a verb a warrant chose is given to answer. -const askWithin = time.Minute - -type busAsked struct{ conn *nats.Conn } - -func (b busAsked) kv(ctx context.Context) (jetstream.KeyValue, error) { - js, err := jetstream.New(b.conn) - if err != nil { - return nil, err - } - return js.KeyValue(ctx, broker.AskedBucket) -} - -func (b busAsked) Get(ctx context.Context, id string) (*asked, error) { - kv, err := b.kv(ctx) - if err != nil { - return nil, err - } - e, err := kv.Get(ctx, id) - if errors.Is(err, jetstream.ErrKeyNotFound) { - return nil, nil - } - if err != nil { - return nil, err - } - var r asked - return &r, json.Unmarshal(e.Value(), &r) -} - -// Create keeps a new ask under its id, and only where none is kept: never over another. -func (b busAsked) Create(ctx context.Context, r asked) error { - kv, err := b.kv(ctx) - if err != nil { - return err - } - body, err := json.Marshal(r) - if err != nil { - return err - } - _, err = kv.Create(ctx, r.ID, body) - return err -} - -// Change applies change to the ask kept under id by compare-and-set on its key's revision (the review of -// 2026-10-09, L2): read, changed, and written only over the revision read; when another write came between, -// read again and asked again, at most askChangeTries times. change says whether to write at all. -func (b busAsked) Change(ctx context.Context, id string, change func(*asked) bool) (bool, error) { - kv, err := b.kv(ctx) - if err != nil { - return false, err - } - for try := 0; try < askChangeTries; try++ { - e, err := kv.Get(ctx, id) - if errors.Is(err, jetstream.ErrKeyNotFound) { - return false, nil - } - if err != nil { - return false, err - } - var r asked - if err := json.Unmarshal(e.Value(), &r); err != nil { - return false, err - } - if !change(&r) { - return false, nil - } - body, err := json.Marshal(r) - if err != nil { - return false, err - } - if _, err := kv.Update(ctx, id, body, e.Revision()); err != nil { - var api *jetstream.APIError - if errors.Is(err, jetstream.ErrKeyExists) || (errors.As(err, &api) && api.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence) { - continue - } - return false, err - } - return true, nil - } - return false, fmt.Errorf("the ask %s changed under every one of %d tries", id, askChangeTries) -} - -func (b busAsked) All(ctx context.Context) ([]asked, error) { - kv, err := b.kv(ctx) - if err != nil { - return nil, err - } - lister, err := kv.ListKeys(ctx) - if err != nil { - return nil, err - } - defer func() { _ = lister.Stop() }() - var out []asked - for k := range lister.Keys() { - e, err := kv.Get(ctx, k) - if err != nil { - continue - } - var r asked - if json.Unmarshal(e.Value(), &r) == nil { - out = append(out, r) - } - } - return out, nil -} - -// verbRefused is a verb's own refusal of an act: the seat answered, and said no. Any other error of callAction (a bus -// that did not answer, a grant that is refused) is not one: the verb may have run. -type verbRefused struct{ verb, words string } - -func (e verbRefused) Error() string { return e.verb + " refused: " + e.words } - -// callAction performs an action's verb as the controller, through the grant that names it. -func callAction(conn *nats.Conn) func(ctx context.Context, a conditions.Action, args map[string]string) error { - return func(ctx context.Context, a conditions.Action, args map[string]string) error { - seat, verb, ok := strings.Cut(a.Verb, ".") - if !ok { - return fmt.Errorf("%q names no seat and verb", a.Verb) - } - body := map[string]any{} - for k, v := range args { - body[k] = v - } - if seat == catalogue.DeliverySeat { - _, err := askDeliveryOwner(ctx, conn, verb, body) - return err - } - granted := false - for _, v := range broker.VerbsTheControllerActsOnAWarrant { - granted = granted || (v.Seat == seat && v.Verb == verb) - } - if !granted { - return fmt.Errorf("%s: %w", a.Verb, errNotGranted) - } - var answer link.Answer - var err error - if a.Machine != "" { - answer, err = link.AskSeatTool(ctx, conn, seat, verb, a.Machine, body, askWithin) - } else { - answer, err = link.AskMeshSeatTool(ctx, conn, seat, verb, body, askWithin) - } - if err != nil { - return err - } - if answer.Error != "" { - return verbRefused{verb: a.Verb, words: answer.Error} - } - return nil - } -} - -// tellThrough tells the operator one plain notice through the operator channel's `tell`, over the controller's own -// connection and grant (novox/hq ADR 0380); a holder's refusal is an error naming it. -func tellThrough(conn *nats.Conn) func(ctx context.Context, headline string, lines []string) error { - return func(ctx context.Context, headline string, lines []string) error { - answer, err := link.AskMeshSeatTool(ctx, conn, "operator-channel", "tell", - map[string]any{"headline": headline, "lines": lines}, tellWithin) - if err != nil { - return err - } - if answer.Error != "" { - return fmt.Errorf("operator-channel.tell refused: %s", answer.Error) - } - return nil - } -} - -// routerRecordOf reads the router's record of one of the controller's asks, under its name, and answers -// how it ended when it did: the bucket is the one the asks seat's declarer names as its records. -func routerRecordOf(conn *nats.Conn, inv *inventory.Inventory) func(ctx context.Context, id string) (*asks.Warrant, error) { - return func(ctx context.Context, id string) (*asks.Warrant, error) { - bucket, err := asksRecords(ctx, inv) - if err != nil || bucket == "" { - return nil, err - } - reply, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+askerName+"."+id, nil) - if err != nil { - return nil, err - } - if reply.Header.Get("Status") != "" { - return nil, nil // none, or not readable: the event says it - } - var rec struct { - State string `json:"state"` - Warrant *asks.Warrant `json:"warrant"` - } - if json.Unmarshal(reply.Data, &rec) != nil || rec.State == "open" || rec.Warrant == nil { - return nil, nil - } - return rec.Warrant, nil - } -} - -// asksRecords is the bucket the asks seat's declarer keeps its record of asks in. -func asksRecords(ctx context.Context, inv catalogueReader) (string, error) { - declared, err := inv.Catalogue(ctx) - if err != nil { - return "", err - } - for _, m := range declared { - for _, s := range m.DefinesSeats { - if s.Name == broker.AsksSeat && len(s.Records) > 0 { - return broker.BucketName(m.Module, s.Records[0]), nil - } - } - } - return "", nil -} - -// routerHereIn says whether a module declaring the asks seat, with its ask named by its caller, is assigned: -// without it nothing takes an ask, and asking would only fill a queue nobody reads. -func routerHereIn(inv *inventory.Inventory) func(ctx context.Context) (bool, error) { - return func(ctx context.Context) (bool, error) { - entries, err := inv.Catalogued(ctx) - if err != nil { - return false, err - } - for _, e := range entries { - for _, s := range e.Manifest.DefinesSeats { - if s.Name == broker.AsksSeat && s.NamedByCaller("ask") && len(e.On) > 0 { - return true, nil - } - } - } - return false, nil - } -} - -// grantHeldIn says whether the bus holds the controller's grant to ask (novox/hq issue 353), judged on the two -// facts that make it so, and failing closed on each: -// -// 1. **composed**: the user list the mesh composes now grants the controller itself to publish its ask -// (controllerMayAsk) — the controller's own grant, not whether any user of the list changed; -// 2. **in force**: the bus's own module, on the machine holding the bus, says the user list there allows it -// and the server reloaded after that list was written (the nats module's `nats_user_can`, `in_force`) — a -// list sent is not a list the bus enforces until it reloads. -// -// Anything that cannot be worked out is not held, with why, so the conditions that need the operator are said -// as undelivered rather than asked into a refusal. -func grantHeldIn(open *stores, conn *nats.Conn) func(ctx context.Context) (bool, string, error) { - return grantHeldBy(composedGrantIn(open), busGrantIn(open, conn), time.Now) -} - -// grantJudge is one of the two facts: whether it holds, and why not. -type grantJudge func(ctx context.Context) (bool, string, error) - -// grantHeldBy is the judgement over the two facts, remembered for grantLookEvery: composing the list resolves -// the bus's machine whole, and asking the bus's module is a call. -func grantHeldBy(composed, inForce grantJudge, now func() time.Time) func(ctx context.Context) (bool, string, error) { - var mu sync.Mutex - var at time.Time - var held bool - var why string - return func(ctx context.Context) (bool, string, error) { - mu.Lock() - defer mu.Unlock() - if !at.IsZero() && now().Sub(at) < grantLookEvery { - return held, why, nil - } - held, why = judgeGrant(ctx, composed, inForce) - at = now() - return held, why, nil - } -} - -// judgeGrant is held only when both facts hold; an error is a fact not known, never a yes. A fact that holds -// with a caveat (a reload not confirmed) gives that caveat as the why of a yes. -func judgeGrant(ctx context.Context, composed, inForce grantJudge) (bool, string) { - var caveat []string - for _, fact := range []struct { - judge grantJudge - what string - }{{composed, "whether the mesh composes the controller a grant to ask"}, {inForce, "whether the bus enforces it"}} { - if fact.judge == nil { - return false, fact.what + " is not judged here" - } - ok, why, err := fact.judge(ctx) - if err != nil { - return false, fact.what + " could not be worked out: " + err.Error() - } - if !ok { - return false, why - } - if why != "" { - caveat = append(caveat, why) - } - } - return true, strings.Join(caveat, "; ") -} - -// controllerMayAsk says whether the controller's own grant, as composed from these records, lets it publish its -// ask (ADR 0259 §3: composed while a router that takes asks under its asker's name is assigned). -func controllerMayAsk(records broker.Records) (bool, error) { - users, err := broker.Users(records) - if err != nil { - return false, err - } - for _, u := range users { - if u.Kind != broker.KindController { - continue - } - perms, err := broker.PermissionsFor(u) - if err != nil { - return false, err - } - return broker.MayPublish(perms, asks.AskSubject(askerName)), nil - } - return false, errors.New("the composition holds no controller") -} - -// composedGrantIn is the first fact, from the mesh's records. -func composedGrantIn(open *stores) grantJudge { - return func(ctx context.Context) (bool, string, error) { - if open == nil || open.inventory == nil { - return false, "", errors.New("the mesh's records are not open") - } - records, err := open.inventory.BusRecords(ctx) - if err != nil { - return false, "", err - } - may, err := controllerMayAsk(records) - if err != nil || may { - return may, "", err - } - return false, "the mesh composes the controller no grant to ask: no router that takes asks under its " + - "asker's name is assigned", nil - } -} - -// busGrantIn is the second fact, asked of the bus's own module on the machine holding the bus, with the check -// main made before it (novox/hq issue 353, PR 186: is the user list sent to the bus's machine the one composed -// now) kept for the cases the module's word does not settle. -func busGrantIn(open *stores, conn *nats.Conn) grantJudge { - ask := func(ctx context.Context) (string, json.RawMessage, error) { - if open == nil || open.inventory == nil || conn == nil { - return "", nil, errors.New("the mesh's records or the bus are not open") - } - holders, err := seatHolders(ctx, open.inventory) - if err != nil { - return "", nil, err - } - h, held := holders[theBrokerSeat] - if !held || h.Node == "" || h.Module == "" { - return "", nil, nil - } - a, err := link.AskModuleToolOn(ctx, conn, h.Module, busUserCanTool, h.Node, map[string]any{ - "user": broker.ControllerName, "action": "publish", "subject": asks.AskSubject(askerName)}, 15*time.Second) - if err != nil { - return h.Node, nil, err - } - if a.Error != "" { - return h.Node, nil, errors.New(a.Error) - } - return h.Node, a.Result, nil - } - list := func(ctx context.Context) (busListState, error) { - if open == nil || open.inventory == nil { - return busListState{}, errors.New("the mesh's records are not open") - } - s, err := brokerListState(ctx, open, nil) - if err != nil || s.Machine == "" || !s.Behind { - return s, err - } - step, err := busHeld(ctx, open.inventory, []string{s.Machine}) - if err != nil { - return busListState{}, err - } - s.BusStep = step[s.Machine] - return s, nil - } - return busGrantBy(ask, list) -} - -// busListState is the user list on the bus's machine as the controller's record knows it: whether the list -// sent there is behind the one composed now, why it could not be composed, and the bus step a send there waits -// on (that machine cannot be pushed until a person's `bus upgrade`). -type busListState struct { - Machine string - Behind bool - Uncomposed error - BusStep string -} - -// remedy is the one thing that sends the bus's machine the list composed now: the plan's own reason when it -// cannot be composed, the bus upgrade while sends there wait on it, and otherwise a push of that machine. -func (s busListState) remedy(node string) string { - if s.Machine != "" { - node = s.Machine - } - switch { - case s.Uncomposed != nil: - return fmt.Sprintf("the user list for %s cannot be composed (%v); `plan %s` says what to fix first", - node, s.Uncomposed, node) - case s.BusStep != "": - return fmt.Sprintf("sends to %s wait on the bus's planned step, so a push will not carry it: %s", node, s.BusStep) - } - return fmt.Sprintf("`push %s` carries it", node) -} - -// busGrantBy judges the bus's word, and names for each way it is not held the one thing that makes it held: -// -// - **in force**: held; -// - **allowed, from a module older than `in_force`**: held, with the caveat that the reload is not confirmed. -// The file allows the publish, and the bus reloads within about 5 s of a change to it, so the worst case is -// one ask refused in those seconds, which is asked again (issue 369). The list check is not consulted: -// while a bus step is pending the bus's machine cannot be pushed, so any change to the composed list would -// read as behind for good and silence every ask until a person's bus upgrade (the review of PR 188); -// - **allowed, written, not reloaded** (the module gives both times): wait about 5 s; -// - **allowed, in force not known** (the module could not read a time): what is not known, said; -// - **not allowed, or the module cannot answer**: the list check decides — the remedy that sends the list -// when it is behind (busListState.remedy), and, for a module that cannot answer, held while the list sent -// is current, as main judged it. -// -// ask answers the machine holding the bus and its module's answer ("" when nothing holds the bus); list is the -// record's word on the list sent to that machine. -func busGrantBy(ask func(ctx context.Context) (string, json.RawMessage, error), - list func(ctx context.Context) (busListState, error)) grantJudge { - return func(ctx context.Context) (bool, string, error) { - node, raw, askErr := ask(ctx) - if askErr == nil && node == "" { - return false, "no module holds the bus, so nothing says what it enforces", nil - } - var answer busAnswer - if askErr == nil { - if answer, askErr = readBusAnswer(raw); askErr != nil { - askErr = fmt.Errorf("its answer could not be read: %w", askErr) - } - } - if askErr != nil { - if node == "" { - node = "the bus's machine" - } - s, err := list(ctx) - if err != nil { - return false, "", fmt.Errorf("the bus module on %s could not answer (%v), and whether the list sent "+ - "there is the one composed now could not be worked out: %w", node, askErr, err) - } - if s.Behind { - return false, fmt.Sprintf("the bus module on %s could not answer (%v), and the user list sent there is "+ - "behind what the mesh composes; %s", node, askErr, s.remedy(node)), nil - } - return true, fmt.Sprintf("the bus module on %s could not answer (%v); judged by the user list sent there, "+ - "which is the one composed now", node, askErr), nil - } - notAllowed := func(older string) (bool, string, error) { - s, err := list(ctx) - if err != nil { - return false, "", fmt.Errorf("the user list on %s does not allow the controller to ask%s, and whether "+ - "the list sent there is the one composed now could not be worked out: %w", node, older, err) - } - if s.Behind { - return false, fmt.Sprintf("the user list on %s does not allow the controller to ask%s: it is behind "+ - "what the mesh composes; %s", node, older, s.remedy(node)), nil - } - return false, fmt.Sprintf("the user list on %s does not allow the controller to ask%s, though the list "+ - "sent there is the one composed now; %s again", node, older, s.remedy(node)), nil - } - switch { - case answer.InForce == nil && answer.Allowed: - return true, fmt.Sprintf("the user list on %s allows the controller to ask, but its reload is not "+ - "confirmed: the bus module there is older than the answer whether a grant is in force, and the bus "+ - "upgrade (`bus upgrade`) carries one that confirms it", node), nil - case answer.InForce == nil: - return notAllowed(" (the bus module there is older than the answer whether a grant is in force)") - case !answer.Allowed: - return notAllowed("") - case *answer.InForce: - return true, "", nil - case answer.FileWritten != "" && answer.ServerLoaded != "": - return false, fmt.Sprintf("the bus on %s has the controller's grant to ask written (%s) but has not "+ - "reloaded since (last load %s); it reloads in place within about 5 s, so nothing is to be done but wait", - node, answer.FileWritten, answer.ServerLoaded), nil - } - why := answer.Why - if why == "" { - why = "it gives no reason" - } - return false, fmt.Sprintf("the user list on %s allows the controller to ask, but whether the bus enforces it "+ - "is not known: %s", node, why), nil - } -} - -// busUserCanTool is the bus module's answer to whether a user may do something, and whether it is in force. -const busUserCanTool = "nats_user_can" - -// busAnswer is the bus module's answer to busUserCanTool; InForce is nil from a module older than that field, -// and FileWritten and ServerLoaded are given only when it could read both times (mesh-catalog's nats InForce). -type busAnswer struct { - Allowed bool `json:"allowed"` - InForce *bool `json:"in_force"` - Why string `json:"why"` - FileWritten string `json:"file_written"` - ServerLoaded string `json:"server_loaded"` -} - -func readBusAnswer(raw json.RawMessage) (busAnswer, error) { - var answer busAnswer - err := json.Unmarshal(raw, &answer) - return answer, err -} - -// grantLookEvery is how often the bus's user list is judged against the one its machine was last sent. -const grantLookEvery = 30 * time.Second - -// channelsIn is what the channels are now, as a fingerprint: each module claiming a kind of the channel -// bench, where, promising what, and whether of its own account. An ask the router refused is asked again -// once this changes. -func channelsIn(inv *inventory.Inventory) func(ctx context.Context) string { - return func(ctx context.Context) string { - entries, err := inv.Catalogued(ctx) - if err != nil { - return "" - } - var parts []string - for _, e := range entries { - for _, c := range e.Manifest.Claims { - if c.Kind == "" || !catalogue.KindedBenches[c.Name] { - continue - } - on := append([]string(nil), e.On...) - sort.Strings(on) - caps := append([]string(nil), c.Capabilities...) - sort.Strings(caps) - parts = append(parts, fmt.Sprintf("%s/%s=%s@%s[%s]own:%t", c.Name, c.Kind, e.Manifest.Module, - strings.Join(on, ","), strings.Join(caps, ","), e.Manifest.RunsAs != "")) - } - } - sort.Strings(parts) - return strings.Join(parts, ";") - } -} - -// startAsking makes the serving controller's asker and hands it the router's words. -func startAsking(ctx context.Context, open *stores, server *link.Server, conn *nats.Conn, keeper *conditions.Keeper) { - js, err := jetstream.New(conn) - if err != nil { - fmt.Printf("the operator cannot be asked: %v\n", err) - return - } - a := servingAsker(open, conn, js, keeper) - if err := server.Decides(a); err != nil { - fmt.Printf("the operator's answers cannot be heard, so nothing is asked: %v\n", err) - return - } - askerFrom.Store(a) - goServing(ctx, func() { a.keep(ctx) }) -} - -// servingAsker is the serving controller's asker, wired to the mesh: what it reads, publishes, performs and -// records, and the judgements it asks by — the bus's grant to ask among them (novox/hq issue 353). -func servingAsker(open *stores, conn *nats.Conn, js jetstream.JetStream, keeper *conditions.Keeper) *asker { - return &asker{ - open: keeper.Open, - silence: func(ctx context.Context, key string, d time.Duration, by, why string) error { - _, err := keeper.Silence(ctx, key, d, by, why) - return err - }, - store: busAsked{conn: conn}, - publish: func(ctx context.Context, subject string, body []byte, id string) error { - _, err := js.Publish(ctx, subject, body, jetstream.WithMsgID(id)) - return err - }, - call: callAction(conn), - // A proposed settings layer is set by this controller itself on the warrant (novox/hq ADR 0277). - setLayer: setLayerIn(open), - record: func(ctx context.Context, act link.HandAct) error { - _, err := link.RecordHandAct(ctx, conn, act) - return err - }, - tell: tellThrough(conn), - routerRecord: routerRecordOf(conn, open.inventory), - routerHere: routerHereIn(open.inventory), - grantHeld: grantHeldIn(open, conn), - channels: channelsIn(open.inventory), - raise: func(ctx context.Context, obs []conditions.Observation) error { - return keeper.Reconcile(ctx, sourceAsker, obs) - }, - now: time.Now, - logf: func(format string, args ...any) { fmt.Printf(format+"\n", args...) }, - } -} diff --git a/cmd/mesh-controller/assignment_accounts.go b/cmd/mesh-controller/assignment_accounts.go deleted file mode 100644 index 242853de..00000000 --- a/cmd/mesh-controller/assignment_accounts.go +++ /dev/null @@ -1,489 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "maps" - "slices" - "strings" - "sync" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/chain" - "github.com/novox/mesh-controller/internal/inventory" -) - -// An assignment that stands issues its node's bus accounts and the module's grant (novox/hq ADR 0543, issue 1945; a -// step of research 072's grants: the memberships and credentials heard from placement, not from a send or a build). -// -// ADR 0539 clause 7 issues a module's credential and grant when its build is done. A module already built and assigned -// to one more node never has a build done again, so nothing issued that node's account: on 2026-10-25 at 17:19 -// mesh-build-node and mesh-build-go were assigned to ace, composed for it, and the bus's user list never named -// ace.mesh-build-node nor ace.mesh-build-go; one runtime found no credential, the other was refused by the bus, both -// restarting every 5 s. The assign act composes the assigned node alone (composedAfterAct): never the node holding the -// bus, never the grant. -// -// So the write of an assignments key is the event. When a key `.` of the assignments bucket comes to stand -// (a new key, or one that was ended or pending), the lease holder issues the module's accounts on that node that are not -// minted (idempotent: a minted account is never issued again, a rotation is on purpose), composes the node holding the -// bus so its user list carries them, and writes the grants. A rewrite of an assignment that already stood (the -// provisioner's plan kept in its value) does nothing. A starting controller looks at every standing assignment once, -// from one reading of the catalogue: it issues what is missing, then composes the bus's node and writes the grants -// once. The assign act issues too; both mint only an account no row names, in one statement, so exactly one of them -// mints and seals it (mintBusUser, the review of issue 1945). - -// assignmentAccountSteps are what an assignment that comes to stand does, each a step a test may stand in for. -type assignmentAccountSteps struct { - // holds says this controller holds the lease: only its holder issues. - holds func(ctx context.Context) bool - // forgetStale forgets every module's accounts on a node where the module no longer resolves, and answers a line for - // each (issue 1945: rows of an unassignment before this release, marked sealed by migration 0108). - forgetStale func(ctx context.Context) ([]string, error) - // catalogue reads the catalogue once, for every assignment a look issues for. - catalogue func(ctx context.Context) (map[string]catalogue.Manifest, error) - // issue issues the module's accounts on the node that are not minted, from the catalogue read; issued says it - // minted one, line what it did. - issue func(ctx context.Context, shelf map[string]catalogue.Manifest, node, module string) (issued bool, line string, err error) - // busHolder is the node holding the bus, "" for none. - busHolder func(ctx context.Context) (string, error) - // compose writes the nodes' compositions; grants writes the grants issued. - compose func(ctx context.Context, nodes []string) error - grants func(ctx context.Context) error -} - -// settle issues what the standing assignment of module on node lacks. fresh says the assignment just came to stand: the -// bus's node is composed and the grants written even when every account was minted before (a module assigned again -// keeps its account, and the bus's user list left it out while it was unassigned). It answers what it did, "" for -// nothing. -func (s assignmentAccountSteps) settle(ctx context.Context, node, module string) (string, error) { - if module == controllerModule { - // The controller's own module is issued at the terminal alone: its `broker` secret is the credential it is - // using (rollout.go), and it is assigned and unassigned there only (refusedMovingTheController). - return "", nil - } - if !s.holds(ctx) { - return "", errNotHolding - } - shelf, err := s.catalogue(ctx) - if err != nil { - return "", fmt.Errorf("the catalogue cannot be read: %w", err) - } - _, line, err := s.issue(ctx, shelf, node, module) - if err != nil { - return "", fmt.Errorf("its bus accounts on %s are not issued: %w", node, err) - } - said, err := s.cover(ctx) - prefix := module + " on " + node + ": " - if line != "" { - prefix += line + "; " - } - return prefix + said, err -} - -// look is the start's look: the accounts every standing assignment lacks, issued one after another from one reading of -// the catalogue, then the bus's node composed and the grants written once, when it looked at any assignment, whatever -// it issued (an assignment that came to stand while no controller heard it may have its accounts and still lack its -// place in the bus's user list or its grant). It answers what it did. -func (s assignmentAccountSteps) look(ctx context.Context, standing []chain.Assignment) (string, error) { - var forgot []string - if len(standing) > 0 && s.forgetStale != nil { - // Only from a reading of the assignments that holds some: an empty or failed one would forget every account. - if !s.holds(ctx) { - return "", errNotHolding - } - var err error - if forgot, err = s.forgetStale(ctx); err != nil { - return "", fmt.Errorf("the accounts of modules no longer on their nodes are not forgotten: %w", err) - } - } - var looked []chain.Assignment - for _, a := range standing { - if a.Module != controllerModule { - looked = append(looked, a) - } - } - if len(looked) == 0 { - return strings.Join(forgot, "\n"), nil - } - if !s.holds(ctx) { - return "", errNotHolding - } - shelf, err := s.catalogue(ctx) - if err != nil { - return "", fmt.Errorf("the catalogue cannot be read: %w", err) - } - var lines []string - for _, a := range looked { - if ctx.Err() != nil { - return "", ctx.Err() - } - if _, line, err := s.issue(ctx, shelf, a.Node, a.Module); err != nil { - return "", fmt.Errorf("%s's bus accounts on %s are not issued: %w", a.Module, a.Node, err) - } else if line != "" { - lines = append(lines, a.Module+" on "+a.Node+": "+line) - } - } - said, err := s.cover(ctx) - lines = append(append(forgot, lines...), fmt.Sprintf("%d standing assignment(s) looked at; %s", len(looked), said)) - return strings.Join(lines, "\n"), err -} - -// errNotHolding is a controller that does not hold the lease: it issues nothing, and tries again until it holds it. -var errNotHolding = errors.New("this controller does not hold the lease") - -// cover composes the node holding the bus and writes the grants, and says so. -func (s assignmentAccountSteps) cover(ctx context.Context) (string, error) { - holder, err := s.busHolder(ctx) - if err != nil { - return "", fmt.Errorf("the node holding the bus cannot be read: %w", err) - } - if holder != "" { - if err := s.compose(ctx, []string{holder}); err != nil { - return "", fmt.Errorf("the bus's user list on %s is not composed: %w", holder, err) - } - } - if err := s.grants(ctx); err != nil { - return "", err - } - if holder != "" { - return "the bus's user list composed on " + holder + ", the grants written", nil - } - return "the grants written", nil -} - -// assignmentStands says a value of the assignments bucket holds an assignment that stands: made (a module only pulled in -// by another names no time assigned), not ended and in no state (chain.Assignment.Stands). -func assignmentStands(a chain.Assignment) bool { - return !a.Assigned.IsZero() && a.Stands() && a.Node != "" && a.Module != "" -} - -// assignmentTurns keeps which keys of the assignments bucket stood when last heard, to tell an assignment that comes to -// stand from a rewrite of one that stood. -type assignmentTurns struct { - mu sync.Mutex - stood map[string]bool -} - -// heard records a value of key (nil for a delete or a purge) and answers whether it holds an assignment that just came -// to stand. -func (t *assignmentTurns) heard(key string, value []byte) (chain.Assignment, bool) { - var a chain.Assignment - stands := false - if value != nil && json.Unmarshal(value, &a) == nil { - stands = assignmentStands(a) - } - t.mu.Lock() - defer t.mu.Unlock() - if t.stood == nil { - t.stood = map[string]bool{} - } - was := t.stood[key] - t.stood[key] = stands - return a, stands && !was -} - -// ownAccountOf is the username of the account a module speaks on the bus with on node, its own and no process's: the -// module's when it reads a `broker` own secret, else its provisioner client's when it runs one; false when it has none -// (a module whose accounts are its processes' alone, or one that does not speak on the bus). -func ownAccountOf(m catalogue.Manifest, node string) (string, bool) { - if _, reads := m.OwnSecrets["broker"]; reads { - return broker.Principal{Kind: broker.KindModule, Node: node, Module: m.Module}.Username(), true - } - if runsTheProvisionerClient(m) { - return broker.Principal{Kind: broker.KindProvisioner, Node: node, Module: m.Module}.Username(), true - } - return "", false -} - -// issueAssignmentAccounts issues module's accounts on node that are not minted: its own (issueOnTheNewBus) and its -// processes' (issueMissingProcessAccounts). A module the catalogue does not hold, or one with nowhere to deliver an -// account (mayIssue), has nothing to issue; an account minted before is kept. -func issueAssignmentAccounts(ctx context.Context, inv *inventory.Inventory, shelf map[string]catalogue.Manifest, - node, module string) (bool, string, error) { - // Mint only what no row names: the assign act hears its own write and may issue at once (the review of issue 1945). - ctx = onlyIfAbsent(ctx) - m, known := shelf[module] - if !known || mayIssue(m) != nil { - return false, "", nil - } - if _, own := ownAccountOf(m, node); own { - minted, err := inv.BusMinted(ctx) // sealed accounts alone: a row with no sealed credential is not issued - if err != nil { - return false, "", fmt.Errorf("the issued accounts cannot be read: %w", err) - } - if missingAccounts(m, node, minted) { - busAddress, err := broker.BusAddress() - if err != nil { - return false, "", err - } - // Its processes' and provisioner client's accounts too: issueOnTheNewBus issues each that is not issued. - ctx, got := withIssued(ctx) - if err := issueOnTheNewBus(ctx, inv, m, node, busAddress); err != nil { - return false, "", err - } - if issued := got.list(); len(issued) > 0 { - return true, "issued and sealed to " + node + ": " + strings.Join(issued, ", "), nil - } - return false, "", nil - } - return false, "its bus accounts on " + node + " stood issued and sealed", nil - } - line, err := issueMissingProcessAccounts(ctx, inv, m, node) - if err != nil { - return false, "", err - } - return line != "", line, nil -} - -// missingAccounts says some account of m on node is not issued: its own, a process's, or its provisioner client's. -func missingAccounts(m catalogue.Manifest, node string, minted []string) bool { - if user, own := ownAccountOf(m, node); own && !slices.Contains(minted, user) { - return true - } - if runsTheProvisionerClient(m) && - !slices.Contains(minted, broker.Principal{Kind: broker.KindProvisioner, Node: node, Module: m.Module}.Username()) { - return true - } - return len(processAccountsMissing(m, node, minted)) > 0 -} - -// liveAssignmentAccountSteps are the steps on the controller's own stores and the bus: the composer's passes, as the -// done keys' take-ins use them, so a node asked for by both is composed once a pass. -func liveAssignmentAccountSteps(open *stores) assignmentAccountSteps { - inv := open.inventory - return assignmentAccountSteps{ - holds: func(ctx context.Context) bool { - // The lease's gate, as every act's: a serving controller the bus does not grant the lease yet acts unleased. - _, err := theLease.epoch(ctx) - return err == nil - }, - forgetStale: func(ctx context.Context) ([]string, error) { return forgetStaleAccounts(ctx, open) }, - catalogue: func(ctx context.Context) (map[string]catalogue.Manifest, error) { return inv.Catalogue(ctx) }, - issue: func(ctx context.Context, shelf map[string]catalogue.Manifest, node, module string) (bool, string, error) { - return issueAssignmentAccounts(ctx, inv, shelf, node, module) - }, - busHolder: func(ctx context.Context) (string, error) { - holders, err := seatHolders(ctx, inv) - if err != nil { - return "", err - } - return holders[catalogue.BrokerSeat].Node, nil - }, - compose: func(ctx context.Context, nodes []string) error { - return composerFor(open).compose(ctx, nodes, false) - }, - grants: func(ctx context.Context) error { - return composerFor(open).compose(ctx, nil, true) - }, - } -} - -// assignmentAccountsRetry is how long a settle that failed waits before it tries again. -var assignmentAccountsRetry = 30 * time.Second - -// untilSettled runs settle until it works or ctx ends: a failure, or a controller not holding the lease, is said and -// tried again every assignmentAccountsRetry, never dropped, since no later write of the key may come. -func untilSettled(ctx context.Context, what string, settle func(context.Context) (string, error)) { - for { - said, err := settle(ctx) - if err == nil { - if said != "" { - for _, line := range strings.Split(said, "\n") { - fmt.Printf("assignments: %s\n", line) - } - } - return - } - if ctx.Err() != nil { - return - } - fmt.Printf("assignments: %s: %v; tried again in %s\n", what, err, assignmentAccountsRetry) - select { - case <-ctx.Done(): - return - case <-time.After(assignmentAccountsRetry): - } - } -} - -// watchAssignmentAccounts settles every assignment of the assignments bucket that comes to stand, for as long as ctx: the -// bucket's current values first (the start's look, settled as not fresh), then each write. A watch that ends, or a -// bucket not there yet, is watched again a minute later. -func watchAssignmentAccounts(ctx context.Context, open *stores) { - steps := liveAssignmentAccountSteps(open) - goServing(ctx, func() { - runAssignmentWatches(ctx, open.inventory.WatchAssignments, func(a chain.Assignment) { - inBackground(ctx, func() { - untilSettled(ctx, a.Module+" on "+a.Node, func(ctx context.Context) (string, error) { - return steps.settle(ctx, a.Node, a.Module) - }) - }) - }, func(look []chain.Assignment) { - // The start's look, once in the process: one reading of the catalogue, one assignment after another, - // then one composition of the bus's node and the grants (issues 1945 and 1277). - inBackground(ctx, func() { - untilSettled(ctx, "the start's look", func(ctx context.Context) (string, error) { - return steps.look(ctx, look) - }) - }) - }, func() bool { - select { - case <-ctx.Done(): - return false - case <-time.After(time.Minute): - return true - } - }) - }) -} - -// runAssignmentWatches watches the assignments again each time a watch ends, until ctx ends or pause says stop: each -// assignment that comes to stand is handed to fresh, and the standing ones of the first watch that reads them all (its -// Caught mark) to look, once. A watch that ends, or fails to open, before the start's look ran is no start's look: the -// next one is, with what it hears from nothing (a fresh assignmentTurns), so no key it handed is dropped and no burst of -// fresh settles replaces the look (the review of issue 1945). -func runAssignmentWatches(ctx context.Context, watch func(context.Context) (<-chan chain.Entry, error), - fresh func(chain.Assignment), look func([]chain.Assignment), pause func() bool) { - turns := &assignmentTurns{} - first := true - for { - var atStart []chain.Assignment - if err := watchAssignmentsOnce(ctx, watch, turns, first, func(a chain.Assignment, isFresh bool) { - if !isFresh { - atStart = append(atStart, a) - return - } - fresh(a) - }, func() { - first = false - look(atStart) - atStart = nil - }); err != nil && ctx.Err() == nil { - fmt.Printf("assignments: the assignments are not heard, so a new assignment's bus accounts wait: %v\n", err) - } - if first { - turns = &assignmentTurns{} // the start's look has not run: the next watch hears every standing key anew - } - if ctx.Err() != nil || !pause() { - return - } - } -} - -// watchAssignmentsOnce watches the assignments until the watch or ctx ends, handing each assignment that comes to stand -// to settle. On the process's first watch, the current values are the start's look (not fresh) and read is called once -// they are all handed (the chain's Caught mark); every other value, and every value of a watch opened again after one -// ended, is fresh: an assignment that came to stand while no watch heard it is settled whole, never as the start's look -// (the review of issue 1945). -func watchAssignmentsOnce(ctx context.Context, watch func(context.Context) (<-chan chain.Entry, error), - turns *assignmentTurns, first bool, settle func(a chain.Assignment, fresh bool), read func()) error { - ctx, stop := context.WithCancel(ctx) - defer stop() - entries, err := watch(ctx) - if err != nil { - return err - } - current := true - for { - select { - case <-ctx.Done(): - return nil - case e, ok := <-entries: - if !ok { - if ctx.Err() != nil { - return nil - } - return fmt.Errorf("the watch ended") - } - if e.Caught { - if current { - current = false // the current values are read: what follows is written from now on - if first { - read() - } - } - continue - } - var value []byte - if !e.Deleted { - value = e.Value - } - if a, turned := turns.heard(e.Key, value); turned { - settle(a, !current || !first) - } - } - } -} - -// staleAccounts is, by node, each module some account of which stands on a node where the module no longer resolves: -// the accounts of a module's own, its processes' and its provisioner client's. A node whose resolution is unknown is -// left alone. -func staleAccounts(users map[string]inventory.BusUser, resolved map[string]map[string]bool) map[string][]string { - seen := map[string]map[string]bool{} - for _, u := range users { - switch u.Kind { - case inventory.BusModule, inventory.BusNodeTools, inventory.BusProcess, inventory.BusProvisioner: - default: - continue - } - on, known := resolved[u.Node] - if u.Node == "" || u.Module == "" || !known || on[u.Module] { - continue - } - if seen[u.Node] == nil { - seen[u.Node] = map[string]bool{} - } - seen[u.Node][u.Module] = true - } - out := map[string][]string{} - for node, modules := range seen { - out[node] = slices.Sorted(maps.Keys(modules)) - } - return out -} - -// forgetStaleAccounts forgets the accounts of every module on a node where it no longer resolves (assigned or -// required), from each node's own resolution; a node that does not resolve is left alone. -func forgetStaleAccounts(ctx context.Context, open *stores) ([]string, error) { - inv := open.inventory - users, err := inv.BusUsers(ctx) - if err != nil { - return nil, err - } - ctx = withInputCache(ctx) // one reading of the mesh for every node resolved - resolved := map[string]map[string]bool{} - for _, u := range users { - if _, done := resolved[u.Node]; done || u.Node == "" { - continue - } - plan, _, err := resolveNode(ctx, open, u.Node) - if err != nil { - continue // unknown: nothing of this node is forgotten - } - on := map[string]bool{} - for _, m := range plan.Modules { - on[m.Module] = true - } - resolved[u.Node] = on - } - var lines []string - stale := staleAccounts(users, resolved) - for _, node := range slices.Sorted(maps.Keys(stale)) { - for _, module := range stale[node] { - forgot, err := inv.ForgetBusAccountsOf(ctx, node, module, true) - if err != nil { - return lines, err - } - if len(forgot) > 0 { - lines = append(lines, fmt.Sprintf("%s no longer runs on %s: its bus accounts forgotten: %s", module, node, - strings.Join(forgot, ", "))) - } - } - } - return lines, nil -} diff --git a/cmd/mesh-controller/assignment_accounts_test.go b/cmd/mesh-controller/assignment_accounts_test.go deleted file mode 100644 index 646da68f..00000000 --- a/cmd/mesh-controller/assignment_accounts_test.go +++ /dev/null @@ -1,498 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "reflect" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/broker" - "github.com/novox/mesh-controller/internal/chain" - "github.com/novox/mesh-controller/internal/inventory" -) - -// An assignment that comes to stand issues its node's bus accounts and the module's grant (ADR 0543, issue 1945): on -// 2026-10-25 at 17:19 mesh-build-node and mesh-build-go, built long before, were assigned to ace, and nothing issued -// ace's accounts nor put them in the bus's user list. - -func anAssignmentValue(t *testing.T, a chain.Assignment) []byte { - t.Helper() - raw, err := json.Marshal(a) - if err != nil { - t.Fatal(err) - } - return raw -} - -// Only a write that makes the key stand turns it: a rewrite of a standing assignment (the provisioner's plan kept in its -// value), an ended one, a pending one or a module only pulled in by another does not. The process's first watch hands -// the assignments standing when it starts to the start's look (not fresh), says once that they are all read, and hands -// each assignment written afterwards as fresh; a watch opened again after one ended hands every standing assignment it -// has not seen stand as fresh and never calls the start's look (the review of issue 1945). -func TestAnAssignmentThatComesToStand(t *testing.T) { - at := time.Date(2026, 10, 25, 16, 19, 42, 0, time.UTC) - stands := chain.Assignment{Node: "ace", Module: "mesh-build-node", Assigned: at} - ended := stands - ended.Ended = at.Add(time.Hour) - pending := stands - pending.State = chain.StatePending - pulled := chain.Assignment{Node: "ace", Module: "mesh-build-node", RequiredBy: "mesh-build-go"} - replanned := stands - replanned.Provisioning = json.RawMessage(`{"plan":1}`) - - type write struct { - value *chain.Assignment // nil for a delete - turning bool - } - for _, c := range []struct { - name string - writes []write - }{ - {"a new assignment turns", []write{{&stands, true}}}, - {"a rewrite of one that stands does not", []write{{&stands, true}, {&replanned, false}, {&stands, false}}}, - {"an ended one does not", []write{{&ended, false}}}, - {"assigned again after it ended turns again", []write{{&stands, true}, {&ended, false}, {&stands, true}}}, - {"a pending one does not, and turns once made", []write{{&pending, false}, {&stands, true}}}, - {"a module only pulled in does not", []write{{&pulled, false}}}, - {"one deleted and written again turns again", []write{{&stands, true}, {nil, false}, {&stands, true}}}, - } { - t.Run(c.name, func(t *testing.T) { - turns := &assignmentTurns{} - for i, w := range c.writes { - var value []byte - if w.value != nil { - value = anAssignmentValue(t, *w.value) - } - a, turned := turns.heard("ace.mesh-build-node", value) - if turned != w.turning { - t.Fatalf("write %d: turned %v, want %v", i, turned, w.turning) - } - if turned && (a.Node != "ace" || a.Module != "mesh-build-node") { - t.Fatalf("write %d: the assignment turned is %s on %s", i, a.Module, a.Node) - } - } - }) - } - - type handed struct { - key string - fresh bool - } - for _, first := range []bool{true, false} { - t.Run(fmt.Sprintf("the watch, first %v", first), func(t *testing.T) { - bucket := chain.NewMemory() - put := func(a chain.Assignment) { - t.Helper() - value := anAssignmentValue(t, a) - if e, held, _ := bucket.Get(t.Context(), a.Key()); held { - if _, err := bucket.Update(t.Context(), a.Key(), value, e.Revision); err != nil { - t.Fatal(err) - } - return - } - if _, err := bucket.Create(t.Context(), a.Key(), value); err != nil { - t.Fatal(err) - } - } - put(chain.Assignment{Node: "g14", Module: "mesh-build-go", Assigned: at}) - got := make(chan handed, 8) - read := make(chan struct{}, 8) - ctx, stop := context.WithCancel(t.Context()) - done := make(chan error, 1) - go func() { - done <- watchAssignmentsOnce(ctx, bucket.Watch, &assignmentTurns{}, first, - func(a chain.Assignment, fresh bool) { got <- handed{a.Key(), fresh} }, - func() { read <- struct{}{} }) - }() - next := func() handed { - t.Helper() - select { - case h := <-got: - return h - case <-time.After(5 * time.Second): - t.Fatal("nothing handed") - return handed{} - } - } - // Standing when the watch opened: the start's look on the first watch, fresh on any other. - if h := next(); h != (handed{"g14.mesh-build-go", !first}) { - t.Fatalf("the standing assignment was handed %+v", h) - } - put(chain.Assignment{Node: "g14", Module: "mesh-build-go", Assigned: at, Provisioning: json.RawMessage(`{"plan":2}`)}) - put(chain.Assignment{Node: "ace", Module: "mesh-build-node", Assigned: at}) - if h := next(); h != (handed{"ace.mesh-build-node", true}) { - t.Fatalf("handed %+v, want ace.mesh-build-node as fresh (and nothing for the rewrite before it)", h) - } - stop() - if err := <-done; err != nil { - t.Fatal(err) - } - if want := map[bool]int{true: 1, false: 0}[first]; len(read) != want { - t.Fatalf("the start's look was called %d time(s), want %d", len(read), want) - } - }) - } - // A first watch that fails to open, or ends before its Caught mark, is no start's look: the next watch is, and hands - // every standing assignment to it once, none as fresh (the review of issue 1945: a burst of fresh settles, or keys - // handed and dropped). - for _, firstWatch := range []string{"fails to open", "ends before its Caught mark"} { - t.Run("a first watch that "+firstWatch, func(t *testing.T) { - bucket := chain.NewMemory() - standing := chain.Assignment{Node: "g14", Module: "mesh-build-go", Assigned: at} - if _, err := bucket.Create(t.Context(), standing.Key(), anAssignmentValue(t, standing)); err != nil { - t.Fatal(err) - } - watches := 0 - watch := func(ctx context.Context) (<-chan chain.Entry, error) { - watches++ - if watches > 1 { - return bucket.Watch(ctx) - } - if firstWatch == "fails to open" { - return nil, errors.New("the chain is not open yet") - } - cut := make(chan chain.Entry, 1) - cut <- chain.Entry{Key: standing.Key(), Value: anAssignmentValue(t, standing)} - close(cut) - return cut, nil - } - var looks [][]chain.Assignment - var fresh []chain.Assignment - ctx, stop := context.WithCancel(t.Context()) - defer stop() - runAssignmentWatches(ctx, watch, func(a chain.Assignment) { fresh = append(fresh, a) }, - func(look []chain.Assignment) { looks = append(looks, look); stop() }, - func() bool { return ctx.Err() == nil }) - if len(looks) != 1 || len(looks[0]) != 1 || looks[0][0].Key() != standing.Key() || len(fresh) != 0 { - t.Fatalf("after %d watch(es): looks %v, fresh %v; want one look at %s and nothing fresh", - watches, looks, fresh, standing.Key()) - } - }) - } -} - -// settle issues what is missing and then puts it on the bus: the node holding the bus composed (its user list) and the -// grants written, even when every account was minted before (a module assigned again keeps its account). The start's -// look reads the catalogue once, issues for each standing assignment, and composes the bus's node and writes the grants -// once when it looked at any. Neither acts for a controller that does not hold the lease, nor for the controller's own -// module. -func TestSettlingAndTheStartsLook(t *testing.T) { - ace := func(m string) chain.Assignment { return chain.Assignment{Node: "ace", Module: m} } - cases := []struct { - name string - look []chain.Assignment // nil: settle mesh-build-node on ace - module string - notHolding bool - issued bool - issueErr, composeE error - holder string - wantIssues int - wantComposed []string - wantGrants bool - wantErr string - stale []string // what forgetting the accounts of modules no longer on their nodes says - }{ - {name: "an account not minted", module: "mesh-build-node", issued: true, holder: "novox", wantIssues: 1, - wantComposed: []string{"novox"}, wantGrants: true}, - {name: "an account minted before: the bus and the grants still", module: "mesh-build-go", holder: "novox", - wantIssues: 1, wantComposed: []string{"novox"}, wantGrants: true}, - {name: "no lease: nothing, and tried again", module: "mesh-build-node", notHolding: true, issued: true, - holder: "novox", wantErr: errNotHolding.Error()}, - {name: "the controller's own module is the terminal's", module: controllerModule, issued: true, holder: "novox"}, - {name: "no bus holder: the grants still", module: "mesh-build-node", issued: true, wantIssues: 1, wantGrants: true}, - {name: "an issue that fails is the error, nothing composed", module: "mesh-build-node", - issueErr: errors.New("the bus cannot be reached"), holder: "novox", wantIssues: 1, wantErr: "the bus cannot be reached"}, - {name: "a composition that fails is the error, no grant", module: "mesh-build-node", issued: true, holder: "novox", - composeE: errors.New("store refused"), wantIssues: 1, wantComposed: []string{"novox"}, wantErr: "store refused"}, - {name: "the start's look: each issued, the bus and the grants once", - look: []chain.Assignment{ace("mesh-build-node"), ace(controllerModule), ace("mesh-build-go")}, issued: true, - holder: "novox", wantIssues: 2, wantComposed: []string{"novox"}, wantGrants: true}, - {name: "the start's look with every account minted still covers once", - look: []chain.Assignment{ace("mesh-build-node"), ace("mesh-build-go")}, holder: "novox", wantIssues: 2, - wantComposed: []string{"novox"}, wantGrants: true}, - {name: "the start's look at nothing does nothing", look: []chain.Assignment{ace(controllerModule)}, holder: "novox"}, - {name: "the start's look forgets the accounts of modules no longer on their nodes first", - look: []chain.Assignment{ace("mesh-build-node")}, holder: "novox", wantIssues: 1, wantComposed: []string{"novox"}, - wantGrants: true, stale: []string{"mesh-build-go no longer runs on ace: its bus accounts forgotten: ace.mesh-build-go"}}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - var composed []string - issues, catalogues, grants := 0, 0, 0 - forgets := 0 - steps := assignmentAccountSteps{ - holds: func(context.Context) bool { return !c.notHolding }, - forgetStale: func(context.Context) ([]string, error) { - forgets++ - return c.stale, nil - }, - catalogue: func(context.Context) (map[string]catalogue.Manifest, error) { - catalogues++ - return map[string]catalogue.Manifest{}, nil - }, - issue: func(context.Context, map[string]catalogue.Manifest, string, string) (bool, string, error) { - issues++ - if c.issued { - return true, "its bus credential issued", c.issueErr - } - return false, "", c.issueErr - }, - busHolder: func(context.Context) (string, error) { return c.holder, nil }, - compose: func(_ context.Context, nodes []string) error { - composed = append(composed, nodes...) - return c.composeE - }, - grants: func(context.Context) error { grants++; return nil }, - } - var said string - var err error - if c.look != nil { - said, err = steps.look(t.Context(), c.look) - } else { - said, err = steps.settle(t.Context(), "ace", c.module) - } - if c.wantErr != "" { - if err == nil || !strings.Contains(err.Error(), c.wantErr) { - t.Fatalf("error %v, want one saying %q", err, c.wantErr) - } - } else if err != nil { - t.Fatal(err) - } - if issues != c.wantIssues || catalogues > 1 { - t.Fatalf("%d issue(s) from %d reading(s) of the catalogue, want %d from at most one", issues, catalogues, c.wantIssues) - } - if strings.Join(composed, ",") != strings.Join(c.wantComposed, ",") { - t.Fatalf("composed %v, want %v", composed, c.wantComposed) - } - if want := map[bool]int{true: 1}[c.wantGrants]; grants != want { - t.Fatalf("grants written %d time(s), want %d", grants, want) - } - if want := map[bool]int{true: 1}[len(c.look) > 0 && !c.notHolding]; forgets != want { - t.Fatalf("forgot stale accounts %d time(s), want %d (only from a reading that holds assignments)", forgets, want) - } - for _, line := range c.stale { - if !strings.Contains(said, line) { - t.Fatalf("said %q, want it to say %q", said, line) - } - } - if c.wantGrants && c.wantErr == "" && !strings.Contains(said, "the grants written") { - t.Fatalf("said %q, want it to name the grants", said) - } - }) - } -} - -// The accounts a module speaks with on a node: the module's own when it reads `broker`, its provisioner client's when -// it runs one alone, none when its accounts are its processes' or it does not speak on the bus. A process's account is -// held in the provisioner client's form, named for the process and carrying no claims, and may be issued only when -// the process declares its own secret broker- (ADR 0436 §2; issue 1181). -func TestTheAccountsAModuleSpeaksWithOnANode(t *testing.T) { - client := aProvisioner() - delete(client.OwnSecrets, "broker") - for _, c := range []struct { - name string - m catalogue.Manifest - user string - own bool - }{ - {"a module reading broker", catalogue.Manifest{Module: "mesh-build-node", - OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh-build-node/broker"}}}, - broker.Principal{Kind: broker.KindModule, Node: "ace", Module: "mesh-build-node"}.Username(), true}, - {"a module reading broker and running the client", aProvisioner(), - broker.Principal{Kind: broker.KindModule, Node: "ace", Module: "mesh-provisioner"}.Username(), true}, - {"the provisioner client alone", client, - broker.Principal{Kind: broker.KindProvisioner, Node: "ace", Module: "mesh-provisioner"}.Username(), true}, - {"no broker, no client", catalogue.Manifest{Module: "hello-web"}, "", false}, - } { - t.Run(c.name, func(t *testing.T) { - user, own := ownAccountOf(c.m, "ace") - if user != c.user || own != c.own { - t.Fatalf("got %q %v, want %q %v", user, own, c.user, c.own) - } - }) - } - - t.Run("a process's account", func(t *testing.T) { - user := broker.Principal{Kind: broker.KindProcess, Node: "novox", Module: "mesh-mail", Process: "send"}.Username() - if user != "novox.mesh-mail.send" { - t.Fatalf("the process's username: %q", user) - } - held, err := heldProcessAccount(broker.Broker{Fingerprint: "abc"}, "novox.example:4222", "novox", "mesh-mail", - user, "a-password") - if err != nil { - t.Fatal(err) - } - var got map[string]any - if err := json.Unmarshal(held, &got); err != nil { - t.Fatal(err) - } - want := map[string]any{"url": "nats://novox.example:4222", "fingerprint": "abc", "node": "novox", - "module": "mesh-mail", "user": "novox.mesh-mail.send", "password": "a-password"} - for k, v := range want { - if got[k] != v { - t.Errorf("%s: %v, want %v", k, got[k], v) - } - } - if len(got) != len(want) { - t.Errorf("the held account carries %v; want exactly %v (never claims — a process holds no seat)", got, want) - } - }) - - // An issue mints and seals an account only when it is not issued: no row, or a row with no sealed credential (issue - // 1945, live on 2026-10-25 at 20:04: ace's mesh-build-go had a row from 17:19 and no credential), under the lock on - // the account; a seal that fails leaves it unsealed for the next issue. A rotation always mints. - for _, c := range []struct { - name string - only bool - minted, sealed bool - sealErr error - want string - }{ - {"no row: minted unsealed, sealed, marked", true, false, false, nil, "lock mint-unsealed seal mark release"}, - {"a row sealed: nothing", true, true, true, nil, "lock release"}, - {"a row with no sealed credential: minted and sealed again", true, true, false, nil, "lock mint-unsealed seal mark release"}, - {"a seal that fails: not marked", true, false, false, errors.New("the vault did not take it"), "lock mint-unsealed seal release"}, - {"a rotation mints whatever stands", false, true, true, nil, "mint seal"}, - } { - t.Run("issuing an account: "+c.name, func(t *testing.T) { - acts := &anAccountStore{minted: c.minted, sealed: c.sealed} - ctx, got := withIssued(t.Context()) - if c.only { - ctx = onlyIfAbsent(ctx) - } - err := issueAccount(ctx, acts, inventory.BusUser{Username: "ace.mesh-build-go", Kind: inventory.BusModule}, - func(password string) error { - acts.did("seal") - if password == "" { - t.Error("sealed no password") - } - return c.sealErr - }, func() error { acts.did("after"); return nil }) - if (err != nil) != (c.sealErr != nil) { - t.Fatalf("error %v, want %v", err, c.sealErr) - } - if strings.Join(acts.acts, " ") != c.want { - t.Fatalf("acts %q, want %q", strings.Join(acts.acts, " "), c.want) - } - if issued := len(got.list()) == 1; issued != (c.sealErr == nil && strings.Contains(c.want, "seal")) { - t.Fatalf("said issued %v", got.list()) - } - }) - } - - // Issue 774: where a vault is installed the controller mints nothing; `module issue` and an assignment's issue are - // forwarded to the vault's issue-account, and what the account needs beside its value still runs. A refusal fails - // `module issue` and only says itself on an assignment's issue: the vault mints on the want when it hears it. - for _, c := range []struct { - name string - only bool - answer string - refuse error - want string - fails bool - }{ - {"module issue: forwarded, minted by the vault", false, `{"minted":true,"account":{"secret":"own/ace/mesh-build-go/broker","generation":1}}`, nil, "after", false}, - {"an assignment's issue: forwarded, held already", true, `{"minted":false,"why":"held already"}`, nil, "after", false}, - {"an assignment's issue: refused, said", true, "", errors.New("nobody wants it yet"), "after", false}, - {"module issue: refused, failed", false, "", errors.New("nobody wants it"), "", true}, - } { - t.Run("forwarded to the vault: "+c.name, func(t *testing.T) { - was := vaultIssuer - t.Cleanup(func() { vaultIssuer = was }) - var asked []string - vaultIssuer = func(context.Context, accountIssuer) (vaultAsker, bool, error) { - return func(verb string, args map[string]any) (json.RawMessage, error) { - asked = append(asked, verb+" "+fmt.Sprint(args["user"])) - return json.RawMessage(c.answer), c.refuse - }, true, nil - } - acts := &anAccountStore{} - ctx, got := withIssued(t.Context()) - if c.only { - ctx = onlyIfAbsent(ctx) - } - err := issueAccount(ctx, acts, inventory.BusUser{Username: "ace.mesh-build-go", Kind: inventory.BusModule}, - func(string) error { acts.did("seal"); return nil }, func() error { acts.did("after"); return nil }) - if (err != nil) != c.fails || strings.Join(acts.acts, " ") != c.want { - t.Fatalf("error %v, acts %q; want failed %v, acts %q (nothing minted, locked or sealed here)", err, - strings.Join(acts.acts, " "), c.fails, c.want) - } - if strings.Join(asked, ",") != "issue-account ace.mesh-build-go" { - t.Errorf("the vault was asked %v", asked) - } - if minted := len(got.list()) == 1; minted != strings.Contains(c.answer, `"minted":true`) { - t.Errorf("said issued %v", got.list()) - } - }) - } - - // The accounts of a module that no longer resolves on its node are stale, its provisioner client's among them; a - // module still resolved (assigned or required), an account of another kind, and a node whose resolution is unknown - // are left alone (issue 1945: ace's mesh-build-go rows from 17:19, marked sealed by migration 0108). - t.Run("stale accounts", func(t *testing.T) { - u := func(kind, node, module string) inventory.BusUser { - return inventory.BusUser{Username: node + "." + module + "." + kind, Kind: kind, Node: node, Module: module} - } - users := map[string]inventory.BusUser{} - for _, b := range []inventory.BusUser{u(inventory.BusModule, "ace", "mesh-build-go"), - u(inventory.BusProcess, "ace", "mesh-build-go"), u(inventory.BusProvisioner, "ace", "mesh-provisioner"), - u(inventory.BusModule, "ace", "mesh-build-node"), u(inventory.BusNode, "ace", ""), - u(inventory.BusModule, "g14", "mesh-build-go")} { - users[b.Username] = b - } - got := staleAccounts(users, map[string]map[string]bool{"ace": {"mesh-build-node": true}}) - if want := map[string][]string{"ace": {"mesh-build-go", "mesh-provisioner"}}; !reflect.DeepEqual(got, want) { - t.Fatalf("stale %v, want %v", got, want) - } - }) - - t.Run("a process may be issued only with its own broker secret", func(t *testing.T) { - // mesh-mail's real shape: no `broker` of its own, its process declaring bus (review round 1 of issue 1181). - if err := mayIssue(mailSenderWithProcess()); err != nil { - t.Fatalf("a module with no broker of its own, whose process declares bus, was refused: %v", err) - } - m := mailSenderWithProcess() - delete(m.OwnSecrets, "broker-send") - err := mayIssue(m) - if err == nil { - t.Fatal("a process with no broker- own secret was issued an account") - } - for _, want := range []string{"mesh-mail", "send", "broker-send", "own-secrets"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not say %q: %v", want, err) - } - } - }) -} - -// anAccountStore is an accountIssuer that writes down what was asked of it. -type anAccountStore struct { - minted, sealed bool - acts []string -} - -func (a *anAccountStore) did(act string) { a.acts = append(a.acts, act) } -func (a *anAccountStore) LockBusAccount(context.Context, string) (func(), error) { - a.did("lock") - return func() { a.did("release") }, nil -} -func (a *anAccountStore) BusAccountIssued(context.Context, string) (bool, bool, error) { - return a.minted, a.sealed, nil -} -func (a *anAccountStore) MintBusPassword(context.Context, inventory.BusUser) (string, error) { - a.did("mint") - return "a-password", nil -} -func (a *anAccountStore) MintBusPasswordUnsealed(context.Context, inventory.BusUser) (string, error) { - a.did("mint-unsealed") - return "a-password", nil -} -func (a *anAccountStore) MarkBusAccountSealed(context.Context, string) error { - a.did("mark") - return nil -} diff --git a/cmd/mesh-controller/bindings.go b/cmd/mesh-controller/bindings.go deleted file mode 100644 index 64563d50..00000000 --- a/cmd/mesh-controller/bindings.go +++ /dev/null @@ -1,127 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/conditions" -) - -// A binding to data moves only by a person (novox/hq ADR 0232, issue 273). -// -// The resolver keeps each consumer of a provision that keeps its data at the provider it was last -// sent (catalogue/bound.go) and says what it would have moved. This is where that is said: on the -// push that composed it, and by the self-check's D12 every run, as an urgent condition naming the -// consumer, both providers and the pin that confirms the move. - -// The condition kinds of D12. -const ( - // kindBindingKept is a move the resolver refused: the consumer is still where its data is. - kindBindingKept = "binding-kept" - // kindBindingMoved is a consumer about to be sent another provider than the one on record with - // no pin naming it — what the resolver exists to make impossible, said if it ever is not. - kindBindingMoved = "binding-moved" - // kindBindingMoving is a move a pin asked for, not yet sent: a person's act, said so that the - // data is moved before the push that carries it. - kindBindingMoving = "binding-moving" -) - -// probeBindingsID is the self-check's id for this probe, and the source of what it raises. -const probeBindingsID = "D12" - -// keptObservation is the urgent condition for one refused move. -func keptObservation(k catalogue.KeptBinding) conditions.Observation { - return conditions.Observation{Scope: conditions.ScopeMachine, ID: bindingID(k.Machine, k.Consumer, k.Provision), - Token: kindBindingKept, Kind: kindBindingKept, Machine: k.Machine, Also: otherMachines(k.Machine, k.Bound.Node, k.Would.Node), - Severity: conditions.Urgent, Resolver: conditions.ResolverOperator, - Summary: fmt.Sprintf("on %s, the mesh %s", k.Machine, k.String())} -} - -func bindingID(machine, consumer, provision string) string { - return machine + "." + consumer + "." + provision -} - -func otherMachines(machine string, nodes ...string) []string { - var out []string - seen := map[string]bool{machine: true} - for _, n := range nodes { - if n != "" && !seen[n] { - seen[n] = true - out = append(out, n) - } - } - return out -} - -// probeBindings is D12: every consumer of a provision that keeps its data is bound where it was last -// sent, on every machine — the resolver kept it there (said, urgent, until a person pins), or a pin -// moves it (said, so the data goes first), and never anything else. -func probeBindings(ctx context.Context, d *doctor) ([]conditions.Observation, error) { - // One reading of the mesh for every machine this looks at. - ctx = withInputCache(ctx) - inv := d.open.inventory - nodes, err := inv.Nodes(ctx) - if err != nil { - return nil, err - } - var out []conditions.Observation - for _, n := range nodes { - plan, _, err := resolveNode(ctx, d.open, n.Name) - if err != nil { - if unresolvable(err) { - // D1 says it, with the binding that refused it when that is why. - continue - } - return nil, fmt.Errorf("%s cannot be worked out: %w", n.Name, err) - } - bound, err := inv.BindingsFor(ctx, n.Name) - if err != nil { - return nil, err - } - pins, err := inv.PinsFor(ctx, n.Name) - if err != nil { - return nil, err - } - out = append(out, bindingFindings(plan, bound, pins)...) - } - return out, nil -} - -// bindingFindings is what one machine's resolution says against its record. -func bindingFindings(plan catalogue.Resolution, bound map[string]map[string]catalogue.Chosen, - pins map[string]catalogue.Chosen) []conditions.Observation { - var out []conditions.Observation - for _, k := range plan.Kept { - out = append(out, keptObservation(k)) - } - said := map[string]bool{} - for _, need := range plan.Needs { - if !need.KeepsData || need.ByRecord { - continue - } - was, recorded := bound[need.For][need.Name] - now := catalogue.Chosen{Node: need.From, Module: need.Module} - if !recorded || was == now || (was.Module == "" && was.Node == now.Node) { - continue - } - id := bindingID(plan.Node, need.For, need.Name) - if said[id] { - continue - } - said[id] = true - o := conditions.Observation{Scope: conditions.ScopeMachine, ID: id, Machine: plan.Node, - Also: otherMachines(plan.Node, was.Node, now.Node), Resolver: conditions.ResolverOperator} - if pin, pinned := pins[need.Name]; pinned && pin.Node == now.Node && (pin.Module == "" || pin.Module == now.Module) { - o.Token, o.Kind, o.Severity = kindBindingMoving, kindBindingMoving, conditions.Warning - o.Summary = fmt.Sprintf("on %s, %s's %s moves from %s to %s at the next push, by the pin — its data "+ - "is on %s: move it first", plan.Node, need.For, need.Name, was, now, was) - } else { - o.Token, o.Kind, o.Severity = kindBindingMoved, kindBindingMoved, conditions.Urgent - o.Summary = fmt.Sprintf("on %s, %s's %s would be sent %s, and it is bound to %s, where its data is, "+ - "with no pin naming %s — a move nothing asked for", plan.Node, need.For, need.Name, now, was, now) - } - out = append(out, o) - } - return out -} diff --git a/cmd/mesh-controller/bindings_integration_test.go b/cmd/mesh-controller/bindings_integration_test.go deleted file mode 100644 index da08a10a..00000000 --- a/cmd/mesh-controller/bindings_integration_test.go +++ /dev/null @@ -1,102 +0,0 @@ -//go:build integration - -package main - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/conditions" -) - -func TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T) { - open := aMesh(t) - ctx := t.Context() - inv := open.inventory - if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil { - t.Fatal(err) - } - for _, m := range storeManifests() { - register(t, open, m) - } - assignAll := func(pairs ...[2]string) { - for _, a := range pairs { - if _, err := assign(ctx, open, a[0], a[1]); err != nil { - t.Fatalf("assign %s %s: %v", a[0], a[1], err) - } - } - } - // The anchor's store and resolver hold the mesh's seats, on record; the laptop runs its own of each. - assignAll([2]string{"anchor", "store"}, [2]string{"anchor", "resolver"}) - for _, seat := range [][2]string{{"mesh-store", "store"}, {"mesh-dns-resolver", "resolver"}} { - if err := inv.HoldSeat(ctx, seat[0], catalogue.ScopeMesh, "anchor", seat[1]); err != nil { - t.Fatal(err) - } - } - assignAll([2]string{"laptop", "store"}, [2]string{"laptop", "resolver"}, [2]string{"laptop", "network"}, - [2]string{"laptop", "board"}) - - plan, _, err := resolveNode(ctx, open, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, plan, "board", "postgres-database"); n.From != "laptop" || n.Module != "store" || !n.KeepsData { - t.Fatalf("the consumer was bound to %s/%s (keeps data: %v); its data is beside it", n.From, n.Module, n.KeepsData) - } - if n := need(t, plan, "network", "wildcard-resolution"); n.From != "anchor" || n.KeepsData { - t.Fatalf("the resolver was bound to %s (keeps data: %v); its seat is held on anchor (issue 258)", n.From, n.KeepsData) - } - - // Sent, and recorded: only the binding to data. - bindings := boundToData(plan, nil) - if len(bindings) != 1 || bindings[0].Provider != (catalogue.Chosen{Node: "laptop", Module: "store"}) { - t.Fatalf("recorded %+v", bindings) - } - if err := inv.RecordBindings(ctx, "laptop", bindings); err != nil { - t.Fatal(err) - } - if found, err := probeBindings(ctx, &doctor{open: open}); err != nil || len(found) != 0 { - t.Fatalf("a mesh bound where it was sent: %+v, %v", found, err) - } - - // The laptop's store taken away: refused, not moved to the anchor's empty one. - if err := inv.Unassign(ctx, "laptop", "store"); err != nil { - t.Fatal(err) - } - if _, _, err := resolveNode(ctx, open, "laptop"); err == nil || !unresolvable(err) || - !strings.Contains(err.Error(), "board on laptop is bound to laptop/store") || - !strings.Contains(err.Error(), "pin laptop postgres-database anchor store") { - t.Fatalf("the consumer's store went and it was answered elsewhere: %v", err) - } - - // A person pins the anchor's: it moves, said before it is sent, and the record keeps where it was. - if err := inv.PinProvision(ctx, "laptop", "postgres-database", "anchor", "store"); err != nil { - t.Fatal(err) - } - plan, _, err = resolveNode(ctx, open, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, plan, "board", "postgres-database"); n.From != "anchor" { - t.Fatalf("pinned to the anchor and bound to %s", n.From) - } - found, err := probeBindings(ctx, &doctor{open: open}) - if err != nil { - t.Fatal(err) - } - if len(found) != 1 || found[0].Kind != kindBindingMoving || found[0].Severity != conditions.Warning || - !strings.Contains(found[0].Summary, "board's postgres-database moves from laptop/store to anchor/store") { - t.Fatalf("a pinned move is not said before it is sent: %+v", found) - } - if err := inv.RecordBindings(ctx, "laptop", boundToData(plan, nil)); err != nil { - t.Fatal(err) - } - all, err := inv.Bindings(ctx) - if err != nil || len(all) != 1 || all[0].MovedFrom != "laptop/store" { - t.Fatalf("%+v, %v", all, err) - } - if found, err := probeBindings(ctx, &doctor{open: open}); err != nil || len(found) != 0 { - t.Fatalf("a move sent is still said: %+v, %v", found, err) - } -} diff --git a/cmd/mesh-controller/bindings_test.go b/cmd/mesh-controller/bindings_test.go deleted file mode 100644 index 8cd06d61..00000000 --- a/cmd/mesh-controller/bindings_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "github.com/novox/mesh-controller/internal/conditions" -) - -// novox/hq issue 273, ADR 0232: a consumer of a provision that keeps its data moves only by a pin. - -func storeManifests() []catalogue.Manifest { - return []catalogue.Manifest{ - {Module: "store", Version: "1", - Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}}, - Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}, - Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, - Grants: map[string]string{"postgres-database": "/var/lib/mesh/store/grants"}}, - {Module: "resolver", Version: "1", - Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}, - Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}}, - {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}}, - {Module: "board", Version: "1", Requires: []string{"postgres-database"}}, - } -} - -func need(t *testing.T, plan catalogue.Resolution, consumer, provision string) catalogue.Needed { - t.Helper() - for _, n := range plan.Needs { - if n.For == consumer && n.Name == provision { - return n - } - } - t.Fatalf("no %s for %s: %+v", provision, consumer, plan.Needs) - return catalogue.Needed{} -} - -// The incident through the stores: the laptop runs its own store and a consumer of it, the anchor's -// store holds the mesh's seat. The consumer stays beside its data, the resolver follows its seat, the -// binding is recorded as sent, and a pin — only a pin — moves it, said before the push that carries it. - -// What one machine's resolution says against its record. -func TestBindingFindingsSayAKeptMoveAndAMoveNothingAskedFor(t *testing.T) { - home, anchor := catalogue.Chosen{Node: "home", Module: "store"}, catalogue.Chosen{Node: "anchor", Module: "store"} - plan := catalogue.Resolution{Node: "laptop", - Kept: []catalogue.KeptBinding{{Machine: "laptop", Consumer: "board", Provision: "postgres-database", - Bound: home, Would: anchor}}, - Needs: []catalogue.Needed{ - {Name: "postgres-database", For: "board", From: "home", Module: "store", KeepsData: true}, - {Name: "postgres-database", For: "game", From: "anchor", Module: "store", KeepsData: true}, - {Name: "wildcard-resolution", For: "network", From: "anchor", Module: "resolver"}, - }} - bound := map[string]map[string]catalogue.Chosen{ - "board": {"postgres-database": home}, - "game": {"postgres-database": home}, - "network": {"wildcard-resolution": {Node: "home", Module: "resolver"}}, - } - found := linted(bindingFindings(plan, bound, nil)) - if len(found) != 2 { - t.Fatalf("found %+v", found) - } - kept, moved := found[0], found[1] - if kept.Kind != kindBindingKept || kept.Severity != conditions.Urgent || kept.Machine != "laptop" || - !strings.Contains(kept.Summary, "would move board's postgres-database from home/store to anchor/store") || - !strings.Contains(kept.Summary, "its data is on home/store") || - !strings.Contains(kept.Summary, "`pin laptop postgres-database anchor store` to confirm a move (and move the data first)") { - t.Errorf("kept: %+v", kept) - } - if moved.Kind != kindBindingMoved || moved.Severity != conditions.Urgent || - !strings.Contains(moved.Summary, "game's postgres-database would be sent anchor/store") { - t.Errorf("moved: %+v", moved) - } - if kept.Key() == moved.Key() { - t.Error("two consumers, one condition") - } -} diff --git a/cmd/mesh-controller/board.go b/cmd/mesh-controller/board.go deleted file mode 100644 index 3557b8dc..00000000 --- a/cmd/mesh-controller/board.go +++ /dev/null @@ -1,324 +0,0 @@ -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "html/template" - "net/http" - "sort" - "strings" - "time" -) - -// boardCommand serves the three questions as a page. -// -// **It reads through the same functions everything else does and holds nothing** -// (novox/hq 03-DESIGN/01-to-be/11-a-board.md). The board being replaced is one service that reads -// every context's database directly — [ADR 0008](novox/hq) violated by the one component with a -// reason to violate it, and the cost is that a boundary nothing may cross can move, while one -// thing crossing it is enough to freeze it. A board that reads the provisioning tables is a board -// that breaks when provisioning changes its tables, and the change then gets weighed against the -// board. -// -// **It stores nothing of its own.** No cache that can disagree, no table of what the mesh looked -// like last time. Every request reads the mesh now; if that is slow, the answer belongs in the -// context that owns it, where everything else asking gets it too. -// -// **Reading is the whole of it.** Every action a board could offer already exists as a command, -// and a button that does something no command does is a second implementation of a decision. -func boardCommand(ctx context.Context, args []string) error { - set := flag.NewFlagSet("board", flag.ContinueOnError) - // The private network, not everything. A board says which machines are broken and what they - // are running, which is exactly the map somebody attacking this would like — and there is no - // reason for it to be reachable from further away than the mesh. - listen := set.String("listen", "127.0.0.1:8080", "where to serve it") - if _, err := parseAround(set, args); err != nil { - return err - } - - server := &http.Server{ - Addr: *listen, - ReadHeaderTimeout: 10 * time.Second, - Handler: board(), - } - fmt.Printf("the board is on http://%s\n", *listen) - fmt.Printf(" it reads the mesh on every request and keeps nothing\n") - - go func() { - <-ctx.Done() - closing, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = server.Shutdown(closing) - }() - if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - return err - } - return nil -} - -// board is the handler, separate so a test can drive it without a listener. -func board() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - asked, err := ask(r.Context()) - if err != nil { - // **Said, not blank.** A board that cannot reach the mesh and renders an empty page - // says "nothing is wrong" in the one situation where nobody can know that. - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusServiceUnavailable) - _ = page.Execute(w, view{Unreachable: err.Error()}) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := page.Execute(w, asked); err != nil { - // The page is half-written by now; there is nothing useful left to say to the - // browser, and saying it here is what stops the failure being silent. - fmt.Printf("the board could not render: %v\n", err) - } - }) - // The same answers for something that is not a person, from the same read. A board and a - // script disagreeing about which machine is broken would be worse than either alone. - mux.HandleFunc("/mesh.json", func(w http.ResponseWriter, r *http.Request) { - open, err := openStores(r.Context()) - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - defer open.Close() - asked, err := theThreeQuestions(r.Context(), open) - if err != nil { - http.Error(w, err.Error(), http.StatusServiceUnavailable) - return - } - body, err := statusAsJSON(asked) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(append(body, '\n')) - }) - return mux -} - -// ask reads the mesh for one request. -func ask(ctx context.Context) (view, error) { - open, err := openStores(ctx) - if err != nil { - return view{}, err - } - defer open.Close() - - asked, err := theThreeQuestions(ctx, open) - if err != nil { - return view{}, err - } - return viewOf(asked), nil -} - -// view is what the page is given. Nothing is derived here that the reader could not derive. -type view struct { - Unreachable string - Machines int - Broken []brokenMachine - Quiet []quietMachine - Behind []staleModule - Waiting []waitingMachine - // Unresolved is every machine that cannot be worked out at all. Shown above everything else, - // because a machine here is in none of the other lists: nothing was computed for it, so it is - // not broken, not quiet and not behind — and a page without this said "all well" about a mesh - // where nothing could be sent anywhere. - Unresolved []blockedMachine - // Network is why the private network could not be computed, when it could not. - Network string - // Adopted is every node still adopted (novox/hq ADR 0100). Not broken: nothing forces the - // flip, so a node left adopted is shown rather than read as converged. - Adopted []string - At string -} - -type blockedMachine struct { - Node string - // Said is the mesh's own words, a line at a time. Every unmet requirement, not the first: - // a machine is usually blocked by more than one and fixing one of them changes nothing. - Said []string -} - -type waitingMachine struct { - Node string - // Never told is not out of date: nobody has ever asked this machine to be anything. Same - // remedy, different situation, and the page says which. - Never bool -} - -type brokenMachine struct { - Node string - // Outcome is refused or failed, and stays distinct all the way to the page. **Refused means - // the machine is exactly as it was and what is wrong is in what was sent; failed means it is - // in a state nobody declared and what is wrong is on the machine.** They are fixed in - // different places, so one word for both would send half the readers to the wrong one. - Outcome string - Said []string - When string -} - -type quietMachine struct { - Node string - // Heard is "never" or how long ago. Never heard from is not the same as quiet for a while: - // one may be a machine that was never sent anything. - Heard string -} - -type staleModule struct { - Module string - Holds string - Source string - Running []string -} - -func viewOf(asked answers) view { - out := view{Machines: len(asked.nodes), At: time.Now().Format("15:04:05"), - Network: asked.network, Adopted: adoptedNodes(asked.nodes)} - var blocked []string - for name := range asked.refused { - blocked = append(blocked, name) - } - sort.Strings(blocked) - for _, name := range blocked { - one := blockedMachine{Node: name} - for _, line := range strings.Split(strings.TrimRight(asked.refused[name], "\n"), "\n") { - one.Said = append(one.Said, strings.TrimSpace(line)) - } - out.Unresolved = append(out.Unresolved, one) - } - for _, d := range asked.wrong { - one := brokenMachine{Node: d.Node, Outcome: d.Outcome, - When: d.At.Local().Format("2006-01-02 15:04")} - if d.Refused != "" { - // The host's own words. It says exactly what it could not accept, and nothing - // written here would say it better. - one.Said = append(one.Said, firstLine(d.Refused)) - } - for _, f := range d.Failed { - one.Said = append(one.Said, f.ID+": "+firstLine(f.Error)) - } - out.Broken = append(out.Broken, one) - } - for _, n := range asked.quiet { - out.Quiet = append(out.Quiet, quietMachine{Node: n.Name, Heard: heardFrom(n)}) - } - for _, m := range asked.waiting { - out.Waiting = append(out.Waiting, waitingMachine{Node: m.Node, Never: m.Never}) - } - for module, on := range asked.behind { - from := asked.sources[module] - out.Behind = append(out.Behind, staleModule{ - Module: module, Holds: short(from.BuiltFrom), Source: short(from.Head), Running: on, - }) - } - return out -} - -// The page. Deliberately one file with no assets: a board that cannot render without fetching -// something is a board that is blank exactly when the mesh is unwell. -var page = template.Must(template.New("board").Parse(` - - -the mesh - -{{if .Unreachable}} -

the mesh cannot be read

-

{{.Unreachable}}

-

This says nothing about whether the mesh is well — only that this page could not -find out.

-{{else}} -

{{.Machines}} machine{{if ne .Machines 1}}s{{end}}

- -{{if .Unresolved}} -

Can everything be worked out?

-
    - {{range .Unresolved}} -
  • blocked {{.Node}} - {{range .Said}}
    {{.}}
    {{end}} -
  • - {{end}} -
-

Nothing can be sent to a machine here, and it appears in none of the lists below: -nothing was computed for it, so there is nothing it can be behind.

-{{end}} -{{if .Network}} -

The private network could not be computed: {{.Network}}

-{{end}} - -

Is anything broken?

-{{if .Broken}} -
    - {{range .Broken}} -
  • - {{.Outcome}} - {{.Node}} {{.When}} - {{range .Said}}
    {{.}}
    {{end}} -
  • - {{end}} -
-{{else}}

No. Every machine is doing what it was told.

{{end}} - -

Is anything not answering?

-{{if .Quiet}} -
    {{range .Quiet}}
  • {{.Node}} {{.Heard}}
  • {{end}}
-

Not heard from is not the same as tried and could not — a machine here may be -new, switched off, or unreachable.

-{{else}}

No. Every machine has been heard from.

{{end}} - -

Is anything out of date?

-{{if .Behind}} -
    - {{range .Behind}} -
  • {{.Module}} holds {{.Holds}}, source has {{.Source}} - {{if .Running}}
    running on {{range $i, $n := .Running}}{{if $i}}, {{end}}{{$n}}{{end}}
    - {{else}}
    assigned to nothing
    {{end}} -
  • - {{end}} -
-{{else}}

No. Every module is what its source last had.

{{end}} - -{{if .Waiting}} -
    - {{range .Waiting}} -
  • {{.Node}} - {{if .Never}}has never been sent anything - {{else}}is not running what the mesh would send it{{end}} -
  • - {{end}} -
-

Never told is not out of date: nobody has asked that machine to be anything yet. -Both are sent by push --behind.

-{{else}}

Every machine is running what the mesh would send it.

{{end}} -{{if .Adopted}} -

Which machines are adopted?

-
    {{range .Adopted}}
  • {{.}} adopted — what was found on it is kept until each module is taken
  • {{end}}
-{{end}} -{{end}} - -
Read at {{.At}}. This page holds nothing and changes nothing.
-`)) diff --git a/cmd/mesh-controller/board_test.go b/cmd/mesh-controller/board_test.go deleted file mode 100644 index ea8029dd..00000000 --- a/cmd/mesh-controller/board_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package main - -import ( - "strings" - "testing" - "time" - - "github.com/novox/mesh-controller/internal/inventory" -) - -// Refused and failed stay distinct all the way to the page. -// -// **Refused means the machine is exactly as it was and what is wrong is in what was sent; failed -// means it is in a state nobody declared and what is wrong is on the machine.** They are fixed in -// different places, so a page saying "error" for both sends half its readers to the wrong one. -func TestRefusedAndFailedReachThePageAsDifferentThings(t *testing.T) { - rendered := render(t, viewOf(answers{ - nodes: []inventory.Node{{Name: "anchor"}, {Name: "laptop"}}, - wrong: []inventory.Doing{ - {Node: "anchor", Outcome: "refused", Refused: "not a declaration this host speaks", - At: time.Now()}, - {Node: "laptop", Outcome: "failed", At: time.Now(), - Failed: []inventory.FailedResource{{ID: "web.container", Error: "no such image"}}}, - }, - })) - // The rendered outcome, not the word anywhere on the page: both words appear in the - // stylesheet, so a plain Contains passes whatever the machine actually said. It did. - for _, want := range []string{`refused`, - `failed`} { - if !strings.Contains(rendered, want) { - t.Fatalf("the page does not distinguish the two — missing %s:\n%s", want, rendered) - } - } - // And the host's own words, which say exactly what it could not accept. - for _, want := range []string{"not a declaration this host speaks", "web.container", "no such image"} { - if !strings.Contains(rendered, want) { - t.Fatalf("the page does not say %q, so a reader must go and ask:\n%s", want, rendered) - } - } -} - -// Nothing wrong is said, not left blank. An empty page and a well mesh must not look alike. -func TestAWellMeshSaysSoRatherThanShowingNothing(t *testing.T) { - rendered := render(t, viewOf(answers{nodes: []inventory.Node{{Name: "anchor"}}})) - for _, want := range []string{ - "Every machine is doing what it was told", - "Every machine has been heard from", - "Every module is what its source last had", - } { - if !strings.Contains(rendered, want) { - t.Fatalf("a well mesh does not say %q:\n%s", want, rendered) - } - } -} - -// A board that cannot reach the mesh must not render an empty page: that says "nothing is wrong" -// in the one situation where nobody can know it. -func TestABoardThatCannotReadTheMeshSaysSo(t *testing.T) { - rendered := render(t, view{Unreachable: "the store did not answer"}) - if !strings.Contains(rendered, "the store did not answer") { - t.Fatalf("the reason is not on the page:\n%s", rendered) - } - if strings.Contains(rendered, "Every machine is doing what it was told") { - t.Fatal("a board that could not read the mesh reported that the mesh is well") - } -} - -// Quiet is not broken, and the page says which it is. -func TestQuietIsNotReportedAsBroken(t *testing.T) { - rendered := render(t, viewOf(answers{ - nodes: []inventory.Node{{Name: "laptop"}}, - quiet: []inventory.Node{{Name: "laptop"}}, - })) - if !strings.Contains(rendered, "not the same as tried and could not") { - t.Fatalf("the page does not separate quiet from broken:\n%s", rendered) - } - if !strings.Contains(rendered, "Every machine is doing what it was told") { - t.Fatalf("a quiet machine was counted as broken:\n%s", rendered) - } -} - -// The page renders what a machine said, and a hostile string in it is not markup. -// -// What is on this page comes from machines, and a machine's own words are the whole reason the -// page is useful. They are also the one thing here that nobody in this repository wrote. -func TestWhatAMachineSaidIsNotMarkup(t *testing.T) { - rendered := render(t, viewOf(answers{ - nodes: []inventory.Node{{Name: "anchor"}}, - wrong: []inventory.Doing{{Node: "anchor", Outcome: "refused", - Refused: ``, At: time.Now()}}, - })) - if strings.Contains(rendered, "