From 64bc1386927e90f6cf8e4e1afcbfb164e19a7468 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 6 Oct 2026 02:21:18 +0200 Subject: [PATCH] Retire the networking bundle and what the control plane stops shipping (hq ADR 0226) networking required mesh-wireguard and nothing else; machines are assigned the network directly. module forget refuses a provided module, so a retired one is removed at start once no machine has it. Guard route-proxy's public account directory against a reissue. --- cmd/mesh-controller/modules.go | 11 ++- cmd/mesh-controller/stores.go | 16 +++ internal/catalogue/provided_test.go | 71 +++++++++----- internal/catalogue/public_issuer_test.go | 119 +++++++++++++++++++++++ internal/catalogue/resolve.go | 6 +- internal/catalogue/two_resolvers_test.go | 3 +- internal/inventory/catalogue.go | 86 ++++++++++++++++ internal/inventory/retire_test.go | 91 +++++++++++++++++ internal/overlay/generator.go | 28 ++---- internal/overlay/seat_test.go | 2 +- 10 files changed, 381 insertions(+), 52 deletions(-) create mode 100644 internal/catalogue/public_issuer_test.go create mode 100644 internal/inventory/retire_test.go diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 6767884..dd2e0bf 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -40,7 +40,12 @@ func providedModules() []catalogue.Manifest { // and neither could be swapped for anything, which is the test of whether a thing is a // module at all (novox/hq ADR 0040). They existed because computed output needed somewhere // to live, and now a module says where it wants it — `facts` in its own manifest. - overlay.Manifest(), overlay.DomainManifest(), + // + // **And the bundle that required it is gone** (novox/hq ADR 0226): `networking` named this + // module's requirement and nothing else, so every machine carried two modules for one + // network. A machine is assigned the private network itself; what a release stops shipping + // is retired at the next start (stores.go, Inventory.RetireUnshipped). + overlay.Manifest(), } { var m catalogue.Manifest b, _ := json.Marshal(raw) @@ -494,8 +499,8 @@ const theBrokerSeat = "mesh-broker" // says. Only when nothing holds the seat yet (genesis raised the broker as plumbing and no module // has adopted it) does the hub stand in, which is where the foundation is by convention. // -// "On the overlay" is what `whereEveryoneIs` answers — a machine that RESOLVED the networking -// module — not "has an address", which is true of every placed machine and says nothing about +// "On the overlay" is what `whereEveryoneIs` answers — a machine that RESOLVED the private network +// — not "has an address", which is true of every placed machine and says nothing about // whether anything can reach it (novox/hq issue 059). A node not on the overlay — at genesis, // before any `overlay place`, which is when the builder's account is issued — keeps the genesis // address, so nothing about bring-up changes. This is issue 055, corrected by 059. diff --git a/cmd/mesh-controller/stores.go b/cmd/mesh-controller/stores.go index 1fa2045..2fbf637 100644 --- a/cmd/mesh-controller/stores.go +++ b/cmd/mesh-controller/stores.go @@ -73,6 +73,22 @@ func migrate(ctx context.Context) error { } fmt.Printf("provided %s\n", m.Module) } + // And what an earlier release shipped and this one does not goes (novox/hq ADR 0226) — unless a + // machine still has it, which is said rather than overridden. + var shipped []string + for _, m := range provided { + shipped = append(shipped, m.Module) + } + retired, kept, err := inv.RetireUnshipped(ctx, shipped) + if err != nil { + return err + } + for _, name := range retired { + fmt.Printf("retired %s: the control plane no longer ships it\n", name) + } + for name, why := range kept { + fmt.Printf("kept %s, which the control plane no longer ships: %s\n", name, why) + } // The seats the mesh ships with, into the table that now holds the set (novox/hq ADR 0122). // Idempotent: fills an empty table on first boot, adds a seat a release ships, and leaves an // operator's changes in the table as they are. diff --git a/internal/catalogue/provided_test.go b/internal/catalogue/provided_test.go index 676321f..a4f9413 100644 --- a/internal/catalogue/provided_test.go +++ b/internal/catalogue/provided_test.go @@ -19,7 +19,7 @@ func provided(t *testing.T) map[string]catalogue.Manifest { t.Helper() out := map[string]catalogue.Manifest{} for _, raw := range []map[string]any{ - overlay.Manifest(), overlay.DomainManifest(), + overlay.Manifest(), } { b, err := json.Marshal(raw) if err != nil { @@ -34,20 +34,16 @@ func provided(t *testing.T) map[string]catalogue.Manifest { return out } -func TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) { - got, err := catalogue.Resolve(provided(t), []string{overlay.Domain}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) - +func TestTheShippedPrivateNetworkResolvesOnItsOwn(t *testing.T) { + // **Assigned directly** (novox/hq ADR 0226): the `networking` bundle that required it is + // retired, so the private network's own module is what a machine is given, and it must need + // nothing the control plane does not ship beside it. + got, err := catalogue.Resolve(provided(t), []string{overlay.Name}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) if err != nil { - t.Fatalf("assigning %s does not work out of the box: %v", overlay.Domain, err) + t.Fatalf("assigning %s does not work out of the box: %v", overlay.Name, err) } - var have []string - for _, m := range got.Modules { - have = append(have, m.Module) - } - for _, want := range []string{overlay.Domain, overlay.Name} { - if !strings.Contains(strings.Join(have, " "), want) { - t.Fatalf("%s did not bring in %s: %v", overlay.Domain, want, have) - } + if len(got.Modules) != 1 || got.Modules[0].Module != overlay.Name { + t.Fatalf("assigning %s brought %v", overlay.Name, got.Modules) } // **The network writes no names** (novox/hq ADR 0199): /etc/hosts is the hosts seat holder's @@ -55,23 +51,52 @@ func TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) { // may name that file. for _, m := range got.Modules { for name, f := range m.Facts { - if m.Module == overlay.Name && f.Path == "/etc/hosts" { + if f.Path == "/etc/hosts" { t.Fatalf("the network's provider still writes /etc/hosts, as its %q fact", name) } } } } -func TestAnotherVPNSatisfiesNetworkingWithoutDraggingWireGuardIn(t *testing.T) { - // **This inverted, and the inversion is the improvement.** The names used to be a module that - // required the mesh's own addressing, which only WireGuard provided — so choosing another VPN - // dragged WireGuard in anyway, and the node-scoped claim existed to at least make that - // collision loud. With the names a fact rather than a provision, a person who chose tailscale - // gets tailscale, and there is nothing left to collide. +func TestTheControlPlaneShipsNoNetworkingBundle(t *testing.T) { + // ADR 0226: one module for the one private network. A bundle shipped beside it again would be + // a second name every machine carries for the same thing. shipped := provided(t) - got, err := catalogue.Resolve( - withTailscale(shipped), - []string{overlay.Domain, "tailscale"}, + if len(shipped) != 1 { + t.Fatalf("the control plane ships %d modules, want only %s", len(shipped), overlay.Name) + } + if _, ok := shipped["networking"]; ok { + t.Fatal("the retired networking bundle is shipped again") + } +} + +func TestARefusalForWantOfThePrivateNetworkNamesItsModule(t *testing.T) { + // The hint in a refusal is a string in the resolver rather than an import of this package. It + // named `networking` until ADR 0226; a hint naming a module nobody ships sends a person to + // assign something that does not exist. + shelf := map[string]catalogue.Manifest{ + "app": {Module: "app", Version: "1", Requires: []string{"postgres-database"}}, + "postgres": {Module: "postgres", Version: "1", Provides: catalogue.FromAnywhere("postgres-database")}, + } + _, err := catalogue.Resolve(shelf, []string{"app"}, catalogue.Node{Name: "workstation"}, + catalogue.World{Offered: map[string][]catalogue.Provider{ + "postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) + if err == nil { + t.Fatal("an app off the private network was pointed at a database on it") + } + if !strings.Contains(err.Error(), "assign "+overlay.Name) { + t.Fatalf("the refusal does not name the private network's module %s: %v", overlay.Name, err) + } +} + +func TestAnotherVPNIsChosenByAssigningItInstead(t *testing.T) { + // What the bundle was for, kept without it: a machine given another VPN answers + // private-network from that VPN, and WireGuard is not dragged in by anything. + shipped := provided(t) + shelf := withTailscale(shipped) + shelf["needs-network"] = catalogue.Manifest{Module: "needs-network", Version: "1", + Requires: []string{overlay.Requirement}} + got, err := catalogue.Resolve(shelf, []string{"needs-network", "tailscale"}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) if err != nil { t.Fatalf("choosing another VPN was refused: %v", err) diff --git a/internal/catalogue/public_issuer_test.go b/internal/catalogue/public_issuer_test.go new file mode 100644 index 0000000..618b076 --- /dev/null +++ b/internal/catalogue/public_issuer_test.go @@ -0,0 +1,119 @@ +package catalogue + +import ( + "os" + "testing" +) + +// 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 +// (examples/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. + for _, res := range m.Resources { + 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 modules ADR 0226 retired stay retired, and nothing asks for what they provided. +func TestTheRetiredNetworkingModulesAreNotInTheCatalogue(t *testing.T) { + if _, err := os.Stat("../../../mesh-catalog/modules"); err != nil { + t.Skipf("the catalogue is not beside this checkout: %v", err) + } + for _, gone := range []string{"public-acme", "dhcpcd", "cloudflare-dns"} { + if _, err := os.Stat("../../../mesh-catalog/modules/" + gone); err == nil { + t.Errorf("%s is in the catalogue again; ADR 0226 retired it", gone) + } + } + entries, err := os.ReadDir("../../../mesh-catalog/modules") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + raw, err := os.ReadFile("../../../mesh-catalog/modules/" + e.Name() + "/module.json") + if err != nil { + continue + } + m, err := ParseManifest(raw) + if err != nil { + continue // judged by the catalogue's own tests + } + for _, r := range m.Requires { + if r == "acme-ca" || r == "public-dns" { + t.Errorf("%s requires %q, which nothing in the catalogue provides since ADR 0226", + m.Module, r) + } + } + } +} diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index b0972a7..1ae8779 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -1019,8 +1019,10 @@ func FromAnywhere(names ...string) []Offer { // // A string here rather than an import, because the private network is a module the control plane // ships and this package must not depend on the thing it resolves. The name being wrong would -// show up as a refusal naming a module nobody can assign, which a test checks. -const meshNetwork = "networking" +// show up as a refusal naming a module nobody can assign, which a test checks +// (provided_test.go). The private network's own module since novox/hq ADR 0226 retired the +// `networking` bundle that required it. +const meshNetwork = "mesh-wireguard" // providersFirst orders a node's modules so that what answers a requirement comes before what // asked for it. diff --git a/internal/catalogue/two_resolvers_test.go b/internal/catalogue/two_resolvers_test.go index 13fb532..f68bb95 100644 --- a/internal/catalogue/two_resolvers_test.go +++ b/internal/catalogue/two_resolvers_test.go @@ -24,7 +24,8 @@ var bothResolvers = []Held{ // uplinks is every module in the catalogue holding node-uplink, and so writing the machine's resolver // file (novox/hq ADR 0223 part 2): the program that would otherwise rewrite it is the one that writes it. -var uplinks = []string{"dhcpcd", "networkmanager", "systemd-networkd"} +// dhcpcd's left the catalogue with ADR 0226, held by no machine. +var uplinks = []string{"networkmanager", "systemd-networkd"} // managing is a machine as each uplink module needs it: able to install, run a service and run the // manager that module is for. diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index c7e5be6..ff95e62 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -284,6 +284,92 @@ func (i *Inventory) Provide(ctx context.Context, m catalogue.Manifest) error { return err } +// RetireUnshipped removes every module the control plane recorded as its own and no longer ships. +// +// **`module forget` refuses a provided module**, rightly: the next start would put it back. So a +// module the control plane stops shipping — `networking`, retired by novox/hq ADR 0226 — could be +// removed by nothing at all, and would sit in the catalogue for ever, assignable and pointing at a +// manifest no release carries. This is the other half of Provide: what this release ships is +// recorded, and what it does not is taken away. +// +// **Never from under a machine.** A retired module still assigned somewhere is kept, and named in +// `kept` with the machines it is on, so the caller can say "unassign it, and it goes at the next +// start". Taking it while assigned would drop whatever it pulled in — for `networking`, the +// private network itself — at the next push, with nobody having asked for that. Its settings, +// secrets and ports are kept the same way: a provided module that holds any is kept and named, +// because discarding them is a person's decision (novox/hq 04-ISSUES/017). +func (i *Inventory) RetireUnshipped(ctx context.Context, shipped []string) (retired []string, kept map[string]string, err error) { + keep := map[string]bool{} + for _, s := range shipped { + keep[s] = true + } + rows, err := i.store.Pool().Query(ctx, + `select name from module where source = 'the control plane' order by name`) + if err != nil { + return nil, nil, err + } + var gone []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + return nil, nil, err + } + if !keep[name] { + gone = append(gone, name) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, nil, err + } + + kept = map[string]string{} + for _, name := range gone { + on, err := i.assignedOn(ctx, name) + if err != nil { + return nil, nil, err + } + if len(on) > 0 { + kept[name] = "still assigned on " + strings.Join(on, ", ") + "; unassign it and it goes at the next start" + continue + } + held, err := i.HeldFor(ctx, name) + if err != nil { + return nil, nil, err + } + if held.Any() { + kept[name] = "the mesh still holds things for it:\n" + strings.Join(held.Lines(), "\n") + continue + } + if err := i.discard(ctx, name); err != nil { + return nil, nil, err + } + retired = append(retired, name) + } + return retired, kept, nil +} + +// assignedOn is the machines a module is assigned to, sorted. +func (i *Inventory) assignedOn(ctx context.Context, name string) ([]string, error) { + rows, err := i.store.Pool().Query(ctx, + `select n.name from assignment a join node n on n.id = a.node where a.module = $1 + order by n.name`, name) + if err != nil { + return nil, err + } + defer rows.Close() + var on []string + for rows.Next() { + var node string + if err := rows.Scan(&node); err != nil { + return nil, err + } + on = append(on, node) + } + return on, rows.Err() +} + // Provided reports whether a module came with the control plane rather than from a repository. func (i *Inventory) Provided(ctx context.Context, name string) (bool, error) { var source *string diff --git a/internal/inventory/retire_test.go b/internal/inventory/retire_test.go new file mode 100644 index 0000000..1956982 --- /dev/null +++ b/internal/inventory/retire_test.go @@ -0,0 +1,91 @@ +package inventory + +import ( + "errors" + "strings" + "testing" + + "github.com/jackc/pgx/v5" +) + +// What a release stops shipping is retired at the next start, and never from under a machine +// (novox/hq ADR 0226). `module forget` refuses a provided module, so without this a module the +// control plane no longer carries could be removed by nothing. + +func TestAModuleTheControlPlaneNoLongerShipsIsRetired(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + for _, m := range []string{"mesh-wireguard", "networking"} { + if err := inv.Provide(ctx, manifest(m, nil, nil)); err != nil { + t.Fatal(err) + } + } + retired, kept, err := inv.RetireUnshipped(ctx, []string{"mesh-wireguard"}) + if err != nil { + t.Fatal(err) + } + if strings.Join(retired, ",") != "networking" || len(kept) != 0 { + t.Fatalf("retired %v, kept %v", retired, kept) + } + if _, err := inv.Provided(ctx, "networking"); !errors.Is(err, pgx.ErrNoRows) { + t.Fatalf("networking is still in the catalogue: %v", err) + } + if provided, err := inv.Provided(ctx, "mesh-wireguard"); err != nil || !provided { + t.Fatalf("what this release ships went too: %v %v", provided, err) + } +} + +func TestARetiredModuleStillAssignedIsKeptAndSaidSo(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + if _, err := inv.AddNode(ctx, "anchor"); err != nil { + t.Fatal(err) + } + if err := inv.Provide(ctx, manifest("networking", nil, nil)); err != nil { + t.Fatal(err) + } + if _, err := inv.Assign(ctx, "anchor", "networking"); err != nil { + t.Fatal(err) + } + retired, kept, err := inv.RetireUnshipped(ctx, nil) + if err != nil { + t.Fatal(err) + } + if len(retired) != 0 { + // Taking it now would drop whatever it pulled in at the next push — for the bundle, the + // private network. + t.Fatalf("a module assigned on a machine was retired: %v", retired) + } + if !strings.Contains(kept["networking"], "anchor") { + t.Fatalf("keeping it does not say where it is still assigned: %v", kept) + } + + // Unassigned, the next start takes it. + if err := inv.Unassign(ctx, "anchor", "networking"); err != nil { + t.Fatal(err) + } + retired, _, err = inv.RetireUnshipped(ctx, nil) + if err != nil { + t.Fatal(err) + } + if strings.Join(retired, ",") != "networking" { + t.Fatalf("unassigned, it was still not retired: %v", retired) + } +} + +func TestAModuleFromARepositoryIsNeverRetiredByThis(t *testing.T) { + // Only what the control plane recorded as its own. A module somebody registered is theirs to + // forget. + inv := fresh(t) + ctx := t.Context() + if err := inv.RegisterModule(ctx, manifest("public-acme", nil, nil), Source{}); err != nil { + t.Fatal(err) + } + retired, kept, err := inv.RetireUnshipped(ctx, nil) + if err != nil { + t.Fatal(err) + } + if len(retired) != 0 || len(kept) != 0 { + t.Fatalf("a registered module was considered: retired %v, kept %v", retired, kept) + } +} diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go index 062de5b..6c829f0 100644 --- a/internal/overlay/generator.go +++ b/internal/overlay/generator.go @@ -61,15 +61,12 @@ const TheNetwork = "the-private-network" // network. A requirement nothing provides is refused at resolution, so leaving the names here // would only have documented a mechanism that does not exist. -// Domain is the module for people who want a network and do not want to choose one. -// -// It has no files of its own — it is requirements and nothing else. Assigning it finds one -// answer to each and takes them silently, so getting a mesh onto a private network is one word. -// The day the catalogue holds a second VPN there are two answers, the resolver refuses and names -// both, and choosing is assigning the one you want. **That is the whole mechanism**: picking an -// implementation is assigning a module, and there is no flavor field, no configuration language, -// and nothing to learn. -const Domain = "networking" +// **There is no bundle any more** (novox/hq ADR 0226). A `networking` module requiring this one and +// nothing else used to be what a machine was assigned, so that "get the network working" was one +// word and a second VPN could be chosen by assigning it instead. The mesh has one private network, +// every machine is on it, and the bundle was a second name for this module that every machine +// carried. A machine is assigned Name directly; another VPN is still chosen by assigning it in its +// place, which is all the bundle ever did. // Generator answers what one node's network configuration is. type Generator struct { @@ -147,19 +144,6 @@ func Manifest() map[string]any { } } -// DomainManifest is the module that means "get the network working". -func DomainManifest() map[string]any { - return map[string]any{ - "module": Domain, - "version": "1", - // **Only the network now.** It used to require name-resolution as well, answered by a - // module that wrote a hosts file and ran nothing. Names are not a provision — they are a - // fact the mesh computes, and whatever puts a machine on the private network writes them, - // because a mesh name IS an address on that network. - "requires": []string{Requirement}, - } -} - // Empty is a network nobody is on. // // A mesh where no machine was given the module. Legitimate rather than broken — every node still diff --git a/internal/overlay/seat_test.go b/internal/overlay/seat_test.go index 37e3b3a..33512e7 100644 --- a/internal/overlay/seat_test.go +++ b/internal/overlay/seat_test.go @@ -13,7 +13,7 @@ import ( // has no module.json for a test reading the catalogue to find. Parsed with the real parser, so a // set that forgot this seat refuses the control plane's own module here rather than on a machine. func TestThePrivateNetworksClaimIsASeatTheMeshDefines(t *testing.T) { - for _, composed := range []map[string]any{Manifest(), DomainManifest()} { + for _, composed := range []map[string]any{Manifest()} { raw, err := json.Marshal(composed) if err != nil { t.Fatal(err)