From 97448194ac2d0705bbc6a38ef994875c87dafd0b Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 20:48:10 +0200 Subject: [PATCH] Seats are a closed set, a seat's holder answers for what it delivers, and a build source may live on the git seat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements novox/hq ADR 0110 and 0111. The seat set lives in internal/catalogue/seats.go: fourteen seats, each with a scope, what occupying it delivers, and the record that made it one. A test asserts the count and a decision per entry, so changing the set means finding the argument, as the host's vocabulary test does. The first set is every seat already claimed — including the-private-network, which the network module claims from a manifest composed in this repository's code, not from any module.json — plus npm-package-registry (ADR 0109) and git (ADR 0111). A test parses every catalogue manifest and this repository's own and fails on any refused claim, so closing the set refuses nothing in use. ParseManifest now refuses a claim on a seat the mesh does not define, a seat claimed at another scope, and a delivering seat claimed by a module that does not provide what it delivers. A malformed claim is refused once, for being malformed. Resolution: among several providers of a mesh provision, a pin still wins; then the holder of the seat that delivers it; then the only provider; otherwise refused as before. ADR 0009's "never guessed" holds — the seat is the choice made once, mesh-wide, rather than a pin per consumer node. A provider now carries the module it came from, because a provider is a (node, module) pair and the pair is what tells a holder from a neighbour on the same machine. The planner's second pass is now given the first pass's holdings. Without them, a node consuming a seat-delivered provision was refused there, and a refused node's own claims dropped out of what the mesh holds — letting a second holder of one of its seats pass unrefused. `seats [--json]` lists every seat, what it delivers, and each holder, derived from assignments every time and never stored. Unheld seats are listed. A stored claim outside the set — possible for a manifest registered before the set closed, since stored manifests are not re-validated — is shown rather than hidden. `build --self /` builds from a repository on the git seat's holder. The clone URL is composed at build time from the holder's node and what it serves for git; the recorded source is the path and the seat (migration 0032), never an address, so a moved forge changes nothing recorded. Nobody holding the seat refuses self-hosted builds and says so; external URLs are unchanged. An address passed with --self is refused rather than recorded as a path. Replaces three foundation tests that defended the builder's carried package binding. The catalogue removed that binding when the builder began requiring the registry through a real grant, so the tests were already failing on main; they now assert the builder requires what the npm seat delivers and carries no copy of its own, and that the forge holds the npm and git seats. Verified: go vet clean; the whole suite passes against a throwaway Postgres (make postgres), the new inventory tests included; gofmt clean apart from cmd/mesh-builder/stdout_test.go, which fails on main too. --- cmd/mesh-controller/build.go | 54 ++++- cmd/mesh-controller/main.go | 3 + cmd/mesh-controller/plan.go | 14 +- cmd/mesh-controller/seats.go | 150 +++++++++++++ cmd/mesh-controller/seats_test.go | 64 ++++++ cmd/mesh-controller/source.go | 136 ++++++++++++ cmd/mesh-controller/source_test.go | 108 +++++++++ .../catalogue/foundation_manifests_test.go | 124 ++++------- internal/catalogue/manifest.go | 8 + internal/catalogue/resolve.go | 13 ++ internal/catalogue/seats.go | 149 +++++++++++++ internal/catalogue/seats_test.go | 208 ++++++++++++++++++ internal/inventory/catalogue.go | 31 ++- internal/inventory/catalogue_test.go | 71 ++++++ .../0032-a-source-may-live-on-a-seat.sql | 12 + internal/overlay/seat_test.go | 29 +++ 16 files changed, 1071 insertions(+), 103 deletions(-) create mode 100644 cmd/mesh-controller/seats.go create mode 100644 cmd/mesh-controller/seats_test.go create mode 100644 cmd/mesh-controller/source.go create mode 100644 cmd/mesh-controller/source_test.go create mode 100644 internal/catalogue/seats.go create mode 100644 internal/catalogue/seats_test.go create mode 100644 internal/inventory/migrations/0032-a-source-may-live-on-a-seat.sql create mode 100644 internal/overlay/seat_test.go diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 98dbf38..582b04c 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -46,25 +46,35 @@ func buildCommand(ctx context.Context, args []string) error { // retype each repository is asking them to be the loop. Naming a repository and asking which // ones need building are different requests, so they are not combined. behind := set.Bool("behind", false, "every module the mesh holds older than its source has") + // A repository on the mesh's own forge, named by its path there (novox/hq ADR 0111). Without it + // the repository is external, cloned exactly as given — see source.go. + self := set.Bool("self", false, "the repository is a path on the forge holding the git seat") positionals, err := parseAround(set, args) if err != nil { return err } if *behind { - if len(positionals) != 0 { + if len(positionals) != 0 || *self { return errors.New("build or build --behind, not both: one names a " + "repository and the other asks which need building") } return buildBehind(ctx, *wait) } if len(positionals) != 1 { - return errors.New("build [--ref R] [--wait D] [--dry-run]") + return errors.New("build [--self] [--path P] [--ref R] [--wait D] [--dry-run]") + } + source := buildSource{Repository: positionals[0]} + if *self { + if err := onASeat(source.Repository); err != nil { + return err + } + source.Seat = gitSeat } if *dryRun { - return buildAndShow(ctx, positionals[0], *path, *ref, *wait) + return buildAndShow(ctx, source, *path, *ref, *wait) } - return buildOne(ctx, positionals[0], *path, *ref, *wait) + return buildOne(ctx, source, *path, *ref, *wait) } // buildFrom turns what a builder said into what the mesh keeps. @@ -325,7 +335,8 @@ func buildBehind(ctx context.Context, wait time.Duration) error { // Its own recorded ref, not its head commit: a module tracking a branch should be built // from that branch, and pinning to the commit the mesh happened to notice would quietly // turn a tracked branch into a pin. - if err := buildOne(ctx, e.Source.Repository, e.Source.Path, e.Source.Ref, wait); err != nil { + source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat} + if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, wait); err != nil { fmt.Printf(" %v\n", err) failed = append(failed, e.Manifest.Module) } @@ -343,7 +354,14 @@ func buildBehind(ctx context.Context, wait time.Duration) error { // buildOne asks a build machine for one repository and records everything that came back. // // Separated from the command so `--behind` can walk a list without a second path to the same act. -func buildOne(ctx context.Context, repository, path, ref string, wait time.Duration) error { +func buildOne(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error { + // Before anything is asked of a builder: a source on a seat nobody holds is refused here, with + // the reason, rather than sent to a machine to fail at `git clone`. + repository, err := cloneFrom(ctx, source) + if err != nil { + return err + } + ident, err := openIdentity(ctx) if err != nil { return err @@ -365,7 +383,10 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat Ref: ref, Held: heldBy(ctx), } - fmt.Printf("asked for %s", request.Repository) + fmt.Printf("asked for %s", source) + if source.Seat != "" { + fmt.Printf(" (%s)", repository) + } if path != "" { fmt.Printf(" at %s", path) } @@ -414,11 +435,18 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat } // Recorded with where it came from, so "is this current?" is answerable without building it - // again (novox/hq ADR 0009). - if err := inv.RegisterModule(ctx, manifest, inventory.Source{ + // again (novox/hq ADR 0009). **For a source on a seat, as the path and the seat, never the URL + // just cloned** (ADR 0111): the URL is where the forge runs today, and recording it would put + // the forge's address back into every module built from it. The build log above keeps the URL, + // because that is what was cloned. + recorded := inventory.Source{ Repository: result.Repository, Path: result.Path, Ref: result.Ref, BuiltFrom: result.Commit, Head: result.Commit, - }); err != nil { + } + if source.Seat != "" { + recorded.Repository, recorded.Seat = source.Repository, source.Seat + } + if err := inv.RegisterModule(ctx, manifest, recorded); err != nil { return err } fmt.Printf("\n%s %s, built on %s from %s\n", @@ -428,7 +456,11 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat } // buildAndShow builds and prints the manifest without recording anything. -func buildAndShow(ctx context.Context, repository, path, ref string, wait time.Duration) error { +func buildAndShow(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error { + repository, err := cloneFrom(ctx, source) + if err != nil { + return err + } ident, err := openIdentity(ctx) if err != nil { return err diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 8442f42..3bcf2c1 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -114,6 +114,8 @@ func run() error { return planCommand(ctx, args[1:]) case "push": return pushCommand(ctx, args[1:]) + case "seats": + return seatsCommand(ctx, args[1:]) case "status": return statusCommand(ctx, args[1:]) case "version": @@ -157,6 +159,7 @@ func usage() { upgrade roll-out [--together] ...send it to the machines running it upgrade record ...record that they are behind, and send nothing status [--json] what is wrong, what is quiet, and what is out of date + seats [--json] every seat this mesh defines, what it delivers, and who holds it board [--listen ADDR] the same three questions, as a page that holds nothing api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here assign put a module on a node diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index b6bd92b..e744e2d 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -217,6 +217,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, } offered := map[string][]catalogue.Provider{} + var firstHeld []catalogue.Held for _, o := range others { got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true}) if err != nil { @@ -224,6 +225,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, // report, and nothing of theirs is running, so it offers nothing. continue } + firstHeld = append(firstHeld, got.Claims...) for _, m := range got.Modules { for _, name := range m.OffersAt(catalogue.ScopeMesh) { // What that module says a consumer needs to know, with that node's settings on @@ -234,7 +236,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, return catalogue.World{}, err } offered[name] = append(offered[name], catalogue.Provider{ - Node: o.node.Name, At: o.node.At, Serves: serves}) + Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module}) } } } @@ -244,14 +246,20 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, }) } - world := catalogue.World{Offered: offered} + // **The second pass is given the first pass's holdings.** A seat's holder answers a requirement + // with several providers (novox/hq ADR 0110), so a node consuming one resolves only once the + // holder is known. Without them its set is refused here, and a refused node's own claims drop + // out of what the mesh holds — so a second holder of one of its seats would pass unrefused. + world := catalogue.World{Offered: offered, Held: firstHeld} + var held []catalogue.Held for _, o := range others { got, err := catalogue.Resolve(shelf, o.assigned, o.node, world) if err != nil { continue } - world.Held = append(world.Held, got.Claims...) + held = append(held, got.Claims...) } + world.Held = held return world, nil } diff --git a/cmd/mesh-controller/seats.go b/cmd/mesh-controller/seats.go new file mode 100644 index 0000000..1a21c6b --- /dev/null +++ b/cmd/mesh-controller/seats.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// What this mesh can have one of, and who fills each (novox/hq ADR 0110). +// +// **Derived every time, never stored.** A seat is held by a module assignment, so the answer is +// computed from assignments by the same resolution that decides what every machine runs. A table +// of holders kept beside the assignments would be a second copy of one fact, and the first thing +// to be wrong about it. + +// seatHolder is one assignment holding a seat. +type seatHolder struct { + Node string `json:"node"` + Module string `json:"module"` +} + +// seatRow is one seat and who holds it. Unheld is an answer — "this mesh has no X" — not a fault. +type seatRow struct { + Seat string `json:"seat"` + Scope string `json:"scope"` + Delivers string `json:"delivers,omitempty"` + Decision string `json:"decision"` + Holders []seatHolder `json:"holders"` +} + +// seatsHeld is every seat the mesh defines with its holders, and every claim held that names no +// seat in the set. +// +// **The second list is not empty by construction.** Manifests are held to the set when they are +// registered, and a mesh can hold one registered before the set closed. Leaving its claim out of the +// overview would make the one thing the overview is for — what does this mesh have — quietly +// incomplete. +func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []catalogue.Held) { + defined := map[string]bool{} + rows := make([]seatRow, 0, len(seats)) + for _, s := range seats { + defined[s.Name] = true + row := seatRow{Seat: s.Name, Scope: s.Scope, Delivers: s.Delivers, Decision: s.Decision, + Holders: []seatHolder{}} + seen := map[seatHolder]bool{} + for _, h := range held { + if h.Claim != s.Name || h.Scope != s.Scope { + continue + } + holder := seatHolder{Node: h.Node, Module: h.Module} + if !seen[holder] { + seen[holder] = true + row.Holders = append(row.Holders, holder) + } + } + sort.Slice(row.Holders, func(i, j int) bool { + if row.Holders[i].Node != row.Holders[j].Node { + return row.Holders[i].Node < row.Holders[j].Node + } + return row.Holders[i].Module < row.Holders[j].Module + }) + rows = append(rows, row) + } + var outside []catalogue.Held + for _, h := range held { + if !defined[h.Claim] { + outside = append(outside, h) + } + } + sort.Slice(outside, func(i, j int) bool { + if outside[i].Claim != outside[j].Claim { + return outside[i].Claim < outside[j].Claim + } + return outside[i].Node < outside[j].Node + }) + return rows, outside +} + +func seatsCommand(ctx context.Context, args []string) error { + set := flag.NewFlagSet("seats", flag.ContinueOnError) + asJSON := set.Bool("json", false, "the same, as JSON") + if err := set.Parse(args); err != nil { + return err + } + + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + inv := open.inventory + shelf, err := inv.Catalogue(ctx) + if err != nil { + return err + } + // Every node, none excluded: the same view of what each machine holds that planning uses. + world, err := theRestOfTheMesh(ctx, inv, shelf, "") + if err != nil { + return err + } + rows, outside := seatsHeld(catalogue.Seats(), world.Held) + + if *asJSON { + out := struct { + Seats []seatRow `json:"seats"` + Outside []catalogue.Held `json:"outside,omitempty"` + }{rows, outside} + body, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Println(string(body)) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "SEAT\tSCOPE\tDELIVERS\tHELD BY") + for _, r := range rows { + delivers := r.Delivers + if delivers == "" { + delivers = "—" + } + holders := "unheld" + if len(r.Holders) > 0 { + parts := make([]string, 0, len(r.Holders)) + for _, h := range r.Holders { + parts = append(parts, h.Module+" on "+h.Node) + } + holders = strings.Join(parts, ", ") + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Seat, r.Scope, delivers, holders) + } + if err := w.Flush(); err != nil { + return err + } + if len(outside) > 0 { + fmt.Println("\nheld, and not a seat this mesh defines (registered before the set closed — novox/hq ADR 0110):") + for _, h := range outside { + fmt.Printf(" %s %s on %s\n", h.Claim, h.Module, h.Node) + } + } + return nil +} diff --git a/cmd/mesh-controller/seats_test.go b/cmd/mesh-controller/seats_test.go new file mode 100644 index 0000000..469d3a3 --- /dev/null +++ b/cmd/mesh-controller/seats_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// The overview of what a mesh has (novox/hq ADR 0110). + +func TestEverySeatIsListedIncludingTheOnesNobodyHolds(t *testing.T) { + // An unheld seat is an answer — "this mesh has no forge" — so it is listed rather than omitted. + rows, _ := seatsHeld(catalogue.Seats(), []catalogue.Held{ + {Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "postgres"}, + }) + if len(rows) != len(catalogue.Seats()) { + t.Fatalf("%d seats listed of %d", len(rows), len(catalogue.Seats())) + } + for _, r := range rows { + switch r.Seat { + case "mesh-store": + if len(r.Holders) != 1 || r.Holders[0].Module != "postgres" || r.Holders[0].Node != "anchor" { + t.Errorf("mesh-store is held by %+v", r.Holders) + } + if r.Delivers != "postgres-database" { + t.Errorf("mesh-store does not say what it delivers: %q", r.Delivers) + } + case "git": + if len(r.Holders) != 0 { + t.Errorf("git is held by %+v in a mesh with no forge", r.Holders) + } + } + } +} + +func TestANodeSeatListsEveryMachineHoldingIt(t *testing.T) { + rows, _ := seatsHeld(catalogue.Seats(), []catalogue.Held{ + {Claim: "the-packet-filter", Scope: catalogue.ScopeNode, Node: "node2", Module: "nftables"}, + {Claim: "the-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"}, + // Resolved twice, reported once: a machine is one holder however many passes saw it. + {Claim: "the-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"}, + }) + for _, r := range rows { + if r.Seat != "the-packet-filter" { + continue + } + if len(r.Holders) != 2 || r.Holders[0].Node != "anchor" || r.Holders[1].Node != "node2" { + t.Fatalf("the packet filter is held by %+v", r.Holders) + } + return + } + t.Fatal("the packet filter is not listed") +} + +func TestAClaimOutsideTheSetIsShownNotHidden(t *testing.T) { + // A manifest registered before the set closed can still hold one. Leaving it out would make + // the overview quietly incomplete, which is the one thing it may not be. + _, outside := seatsHeld(catalogue.Seats(), []catalogue.Held{ + {Claim: "the-controller", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "mesh-controller"}, + }) + if len(outside) != 1 || outside[0].Claim != "the-controller" { + t.Fatalf("a claim outside the set was not shown: %+v", outside) + } +} diff --git a/cmd/mesh-controller/source.go b/cmd/mesh-controller/source.go new file mode 100644 index 0000000..760dda7 --- /dev/null +++ b/cmd/mesh-controller/source.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// where a build's repository is (novox/hq ADR 0111). +// +// A repository is on the mesh's own forge, or it is anywhere else. The first is recorded as its path +// on the forge holding the git seat, and cloned from wherever that forge runs at the moment of +// building; the second is a URL, recorded and cloned exactly as given. The build machine is not told +// the difference — it is handed a URL either way — because only the control plane knows where the +// seat's holder runs. + +// gitSeat is the seat a self-hosted repository lives on. +const gitSeat = "git" + +// buildSource is where a build's repository is: a URL, or a path on a seat's holder. +type buildSource struct { + Repository string + Seat string +} + +// String is the source as a person reads it, which for one on a seat is not the URL: the URL is a +// fact about where the forge happens to run today. +func (s buildSource) String() string { + if s.Seat == "" { + return s.Repository + } + return fmt.Sprintf("%s on the %s seat", s.Repository, s.Seat) +} + +// onASeat refuses an address given as a path on the forge. +// +// **A URL here would be recorded as a path**, and then composed onto the forge's address as one — +// cloning `http://forge:3000/https://github.com/…`. Refused by what an address plainly looks like, +// not repaired: `--self` promises a path, and something that is not one is a mistake to name. +func onASeat(repository string) error { + if strings.Contains(repository, ":") || strings.HasPrefix(repository, "/") || + strings.Trim(repository, "/") == "" { + return fmt.Errorf("--self takes the repository's path on the forge, such as novox/mesh-catalog, "+ + "and %q is not one — without --self it is built from exactly what is given", repository) + } + return nil +} + +// cloneFrom is the URL a build machine clones for a source. +// +// A URL is itself. A path on a seat is composed from the seat's holder as the mesh sees it now — +// the same view planning takes of every machine, so the forge a build clones from is the forge the +// mesh says holds the seat. +func cloneFrom(ctx context.Context, source buildSource) (string, error) { + if source.Seat == "" { + return source.Repository, nil + } + open, err := openStores(ctx) + if err != nil { + return "", err + } + defer open.Close() + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { + return "", err + } + world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "") + if err != nil { + return "", err + } + return clonedFromSeat(world, source.Seat, source.Repository) +} + +// clonedFromSeat composes the clone URL for a repository on a seat's holder. +// +// **Refused, never defaulted, at every step that has no answer.** Nobody holding the seat is a mesh +// without a forge of its own: it builds from external repositories and must say so rather than fail +// to clone. A holder off the private network cannot be reached by any build machine. A holder that +// serves no scheme or port has nothing to compose from — a default port here would be the forge's +// address guessed, which is the thing this exists to stop. +func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) { + seat, known := catalogue.SeatNamed(seatName) + if !known || seat.Delivers == "" { + return "", fmt.Errorf("%q is not a seat a repository can live on", seatName) + } + var holder *catalogue.Held + for i, h := range world.Held { + if h.Claim == seat.Name && h.Scope == seat.Scope { + holder = &world.Held[i] + break + } + } + if holder == nil { + return "", fmt.Errorf("nobody holds the %s seat, so %s cannot be cloned from this mesh's "+ + "forge — assign a module that claims it, or build from the repository's URL without --self", + seat.Name, repository) + } + var provider *catalogue.Provider + for i, p := range world.Offered[seat.Delivers] { + if p.Node == holder.Node && p.Module == holder.Module { + provider = &world.Offered[seat.Delivers][i] + } + } + if provider == nil { + return "", fmt.Errorf("%s on %s holds the %s seat and offers no %q to clone from", + holder.Module, holder.Node, seat.Name, seat.Delivers) + } + if provider.At == "" { + return "", fmt.Errorf("%s on %s holds the %s seat and is not on the private network, so no "+ + "build machine can reach it", holder.Module, holder.Node, seat.Name) + } + scheme, _ := provider.Serves["scheme"].(string) + port := servedPort(provider.Serves["port"]) + if scheme == "" || port == "" { + return "", fmt.Errorf("%s on %s holds the %s seat and does not serve a scheme and a port for %q", + holder.Module, holder.Node, seat.Name, seat.Delivers) + } + path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git") + return fmt.Sprintf("%s://%s:%s/%s.git", scheme, provider.At, port, path), nil +} + +// servedPort is a served port as text, however the manifest and the node's settings carried it. +func servedPort(v any) string { + switch p := v.(type) { + case float64: + return strconv.Itoa(int(p)) + case int: + return strconv.Itoa(p) + case string: + return p + } + return "" +} diff --git a/cmd/mesh-controller/source_test.go b/cmd/mesh-controller/source_test.go new file mode 100644 index 0000000..cd0030b --- /dev/null +++ b/cmd/mesh-controller/source_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// Defends novox/hq ADR 0111: a build source is on the git seat, or it is external. + +func forgeHolding(port any) catalogue.World { + return catalogue.World{ + Held: []catalogue.Held{{Claim: "git", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "gitea"}}, + Offered: map[string][]catalogue.Provider{"git": { + // A second forge that does not hold the seat, so taking the first one found would be wrong. + {Node: "archive", At: "archive.internal", Module: "gitea-mirror", + Serves: map[string]any{"scheme": "http", "port": float64(3000)}}, + {Node: "anchor", At: "anchor.internal", Module: "gitea", + Serves: map[string]any{"scheme": "http", "port": port}}, + }}, + } +} + +func TestARepositoryOnTheSeatIsClonedFromItsHolder(t *testing.T) { + got, err := clonedFromSeat(forgeHolding(float64(3000)), "git", "novox/mesh-catalog") + if err != nil { + t.Fatal(err) + } + if got != "http://anchor.internal:3000/novox/mesh-catalog.git" { + t.Fatalf("cloned from %s", got) + } +} + +func TestAMovedForgeIsFollowedWithoutRewritingAnything(t *testing.T) { + // The whole point: the node gave the forge another port, and the same recorded path clones + // from the new one. Nothing recorded contained the old one to be wrong. + got, err := clonedFromSeat(forgeHolding(float64(3100)), "git", "novox/mesh-catalog") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, ":3100/") { + t.Fatalf("the moved port was not followed: %s", got) + } +} + +func TestWithNobodyHoldingTheSeatASelfHostedBuildIsRefusedAndSaysWhy(t *testing.T) { + _, err := clonedFromSeat(catalogue.World{}, "git", "novox/mesh-catalog") + if err == nil { + t.Fatal("a repository was cloned from a forge the mesh does not have") + } + for _, want := range []string{"nobody holds the git seat", "without --self"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not say %q: %v", want, err) + } + } +} + +func TestAnExternalRepositoryIsClonedExactlyAsGiven(t *testing.T) { + // Unaffected by the seat, held or not: GitHub and GitLab are the ordinary cases. + given := "https://github.com/someone/something.git" + got, err := cloneFrom(t.Context(), buildSource{Repository: given}) + if err != nil { + t.Fatal(err) + } + if got != given { + t.Fatalf("an external repository became %s", got) + } +} + +func TestAHolderOffThePrivateNetworkIsRefused(t *testing.T) { + world := forgeHolding(float64(3000)) + world.Offered["git"][1].At = "" + if _, err := clonedFromSeat(world, "git", "novox/mesh-catalog"); err == nil || + !strings.Contains(err.Error(), "private network") { + t.Fatalf("a forge nothing can reach was cloned from: %v", err) + } +} + +func TestAHolderServingNoPortIsRefusedRatherThanGuessed(t *testing.T) { + // A default port would be the forge's address guessed, which is what this exists to stop. + if _, err := clonedFromSeat(forgeHolding(nil), "git", "novox/mesh-catalog"); err == nil { + t.Fatal("a port was guessed for a forge that serves none") + } +} + +func TestAnAddressGivenAsAPathOnTheForgeIsRefused(t *testing.T) { + for _, bad := range []string{ + "https://github.com/someone/something.git", + "git@anchor:novox/mesh-catalog.git", + "/srv/git/mesh-catalog", + "", + } { + if err := onASeat(bad); err == nil { + t.Errorf("--self accepted %q as a path on the forge", bad) + } + } + if err := onASeat("novox/mesh-catalog"); err != nil { + t.Errorf("a path on the forge was refused: %v", err) + } +} + +func TestASourceOnTheSeatReadsAsAPathNotAnAddress(t *testing.T) { + s := buildSource{Repository: "novox/mesh-catalog", Seat: "git"} + if got := s.String(); got != "novox/mesh-catalog on the git seat" { + t.Fatalf("read as %q", got) + } +} diff --git a/internal/catalogue/foundation_manifests_test.go b/internal/catalogue/foundation_manifests_test.go index 7832aef..5565993 100644 --- a/internal/catalogue/foundation_manifests_test.go +++ b/internal/catalogue/foundation_manifests_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "encoding/json" "fmt" "os" "reflect" @@ -85,9 +84,8 @@ func TestTheFilterModuleNeverFlushesTheRuleset(t *testing.T) { } // The package registry's port is the node's, like every other foundation port (novox/hq -// 04-ISSUES/085, ADR 0100). Two halves, because the forge is reached two ways: through what the -// module that serves it says it serves, and — for the genesis window, before any module provides -// `package-registry` at all — through the one binding the builder carries instead of resolving. +// 04-ISSUES/085, ADR 0100). The forge is reached through what it says it serves, and consumers — +// the builder among them — are told that, rather than carrying a number of their own. func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) { forge := catalogueManifest(t, "gitea") @@ -104,97 +102,67 @@ func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) { // And every consumer of the package registry is told where the machine actually put it, // because that is read from what the forge serves rather than written in the consumer. - if got := ServedOn(forge, "package-registry", given)["port"]; got != 3100 { + if got := ServedOn(forge, "npm-package-registry", given)["port"]; got != 3100 { t.Errorf("the package registry is served on %v, not the port this node gave it", got) } - if got := ServedOn(forge, "package-registry", nil)["port"]; got != float64(3000) { + if got := ServedOn(forge, "npm-package-registry", nil)["port"]; got != float64(3000) { t.Errorf("without a setting the forge serves %v, not the catalogue's port", got) } -} - -// bindingIn is the package binding the builder carries, as the machine would receive it. -func bindingIn(t *testing.T, m Manifest, layers []Layer) map[string]any { - t.Helper() - for _, r := range m.Resources { - if fmt.Sprint(r["id"]) != "package-binding" { - continue - } - settled, err := ApplySettings(r, layers) - if err != nil { - t.Fatalf("the builder's package binding refused %v: %v", layers, err) - } - if settled["merge"] != nil || settled["protected"] != nil { - t.Fatal("the host would be sent fields it does not know") - } - var out map[string]any - if err := json.Unmarshal([]byte(fmt.Sprint(settled["content"])), &out); err != nil { - t.Fatalf("the builder's package binding is not a binding: %v", err) - } - return out + // And so is where a repository on it is cloned from (novox/hq ADR 0111), for the same reason: + // a build composes the URL from what the forge serves, so a given port is a followed port. + if got := ServedOn(forge, "git", given)["port"]; got != 3100 { + t.Errorf("git is served on %v, not the port this node gave the forge", got) } - t.Fatal("the builder carries no package binding") - return nil } -func TestTheBuildersCarriedPackageBindingTakesThePortFromTheNode(t *testing.T) { +// **The builder requires the registry the npm seat delivers, and carries no binding of its own.** +// +// It used to carry a hand-written binding because nothing provided a package registry to resolve +// one from at genesis. The catalogue now requires it like any consumer, and ADR 0110 makes the +// seat's holder the answer when more than one module provides it — so a carried copy would be a +// second answer to the same question, free to drift from the first. Asserted gone, not merely +// unused. +func TestTheBuilderRequiresTheRegistryTheNpmSeatDelivers(t *testing.T) { builder := catalogueManifest(t, "builder") - - // Nothing set: the catalogue's own number, which is what a mesh raised on the defaults uses. - serves := bindingIn(t, builder, nil)["serves"].(map[string]any) - if serves["port"] != float64(3000) { - t.Fatalf("the builder's binding defaults to %v", serves["port"]) + seat, _ := SeatNamed("npm-package-registry") + var requires bool + for _, r := range builder.Requires { + requires = requires || r == seat.Delivers } - - // Given a port, the binding dials it — and the rest of what the forge serves survives, because - // a setting is merged into the module's own values rather than replacing them. - moved := bindingIn(t, builder, []Layer{{From: "anchor", - Values: map[string]any{"serves": map[string]any{"port": float64(3100)}}}}) - got := moved["serves"].(map[string]any) - if got["port"] != float64(3100) { - t.Errorf("the builder dials %v, not the port this node gave the package registry", got["port"]) + if !requires { + t.Fatalf("the builder does not require %q: %v", seat.Delivers, builder.Requires) } - if got["scheme"] != "http" || got["npm-path"] != "/api/packages/novox/npm/" { - t.Errorf("setting the port lost the rest of what the forge serves: %v", got) + if builder.Binds[seat.Delivers] == "" { + t.Errorf("the builder is not told where the registry is: binds %v", builder.Binds) } - if moved["as"] != "mesh-builder" || moved["from"] != "gitea" { - t.Errorf("setting the port changed who the binding is with: %v", moved) - } -} - -// The two halves are one number. The builder carries a binding because at genesis nothing provides -// `package-registry` to resolve one from; the day the forge is a module, the same consumer is told -// what the forge serves. They have to start from the same port, or a mesh raised on the defaults -// dials one number before the forge is assigned and another after. -func TestTheBuildersCarriedBindingStartsWhereTheForgeServes(t *testing.T) { - forge := ServedOn(catalogueManifest(t, "gitea"), "package-registry", nil) - carried := bindingIn(t, catalogueManifest(t, "builder"), nil)["serves"].(map[string]any) - for _, key := range []string{"port", "scheme", "npm-path"} { - if fmt.Sprint(forge[key]) != fmt.Sprint(carried[key]) { - t.Errorf("the forge serves %s %v and the builder's carried binding says %v — the two "+ - "halves of the same registry have drifted apart in the catalogue", - key, forge[key], carried[key]) + for _, r := range builder.Resources { + if fmt.Sprint(r["id"]) == "package-binding" { + t.Fatal("the builder carries its own package binding beside the one the mesh resolves") } } } -func TestTheBuildersPackageBindingKeepsItsIdentity(t *testing.T) { - builder := catalogueManifest(t, "builder") - // `at` above all: a setting that moves it points the builder, and the registry password it - // sends as basic auth, at a host somebody else chose. - for _, key := range []string{"provision", "from", "at", "as"} { - var refused error - for _, r := range builder.Resources { - if fmt.Sprint(r["id"]) != "package-binding" { - continue - } - _, refused = ApplySettings(r, []Layer{{From: "anchor", - Values: map[string]any{key: "something else"}}}) - } - if refused == nil { - t.Errorf("%q can be set on the builder's package binding, which is not a port but who "+ - "the binding is with", key) +// The forge holds the seats it answers for (novox/hq ADR 0110, 0111), parsed by the real parser — +// which refuses a delivering seat claimed by a module that does not provide what it delivers. +func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) { + forge := catalogueManifest(t, "gitea") + holds := map[string]bool{} + for _, c := range forge.Claims { + holds[c.Name] = true + } + for _, seat := range []string{"npm-package-registry", "git"} { + if !holds[seat] { + t.Errorf("gitea does not claim the %s seat: %+v", seat, forge.Claims) } } + git := ServedOn(forge, "git", nil) + if git["scheme"] != "http" || git["port"] != float64(3000) { + t.Errorf("gitea serves nothing a clone URL can be composed from: %v", git) + } + npm := ServedOn(forge, "npm-package-registry", nil) + if npm["npm-path"] != "/api/packages/novox/npm/" { + t.Errorf("gitea no longer says where its npm registry is: %v", npm) + } } // **And the forge's own address follows it**, composed from the manifest in the catalogue beside diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 7ee50bd..ebe2fae 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -950,9 +950,11 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, fmt.Sprintf("%s requires itself", m.Module)) } } + wellFormed := true for _, c := range m.Claims { if !name.MatchString(c.Name) { problems = append(problems, fmt.Sprintf("%q is not a usable claim name", c.Name)) + wellFormed = false } switch c.At() { case ScopeNode, ScopeSite, ScopeMesh: @@ -960,8 +962,14 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, fmt.Sprintf( "%s claims %s at scope %q; a claim is held per node, per site or per mesh", m.Module, c.Name, c.Scope)) + wellFormed = false } } + // Against the seats the mesh defines (novox/hq ADR 0110), once every claim is at least a name + // and a scope — a malformed claim is refused for that, not a second time for being unknown. + if wellFormed { + problems = append(problems, claimProblems(m)...) + } if m.Computed != "" && len(m.Resources) > 0 { // One or the other. A module that both ships files and has them computed would leave // nobody able to say where a given file came from. diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 8f25164..5d3e333 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -87,6 +87,10 @@ type Provider struct { At string // Serves is what the providing module said a consumer needs to know, settled. Serves map[string]any + // Module is which module on that node provides it. A provider is a (node, module) pair + // (novox/hq to-be 23), and the pair is what tells the holder of a seat apart from another module + // providing the same thing (ADR 0110). + Module string } // Held is a claim somebody already has, used for the scopes wider than one node. @@ -381,6 +385,15 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world default: chosenNode, pinned := world.Pinned[want] if !pinned { + // **The seat's holder answers, when a seat delivers this** (novox/hq ADR 0110). + // Not a guess, which ADR 0009 refuses: the choice was made once, mesh-wide, by + // assigning the holder, where a pin makes it again on every consumer's node. A + // pin still wins — it is a consumer coupled to one provider's contents, and has + // said so. + if holder, held := HolderAmong(want, where, world.Held); held { + take(holder) + break + } problems = append(problems, fmt.Sprintf( "%d nodes provide %q, wanted by %s — say which with `pin %s %s `: %s", len(where), want, because[want], node.Name, want, diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go new file mode 100644 index 0000000..2475e06 --- /dev/null +++ b/internal/catalogue/seats.go @@ -0,0 +1,149 @@ +package catalogue + +import ( + "fmt" + "sort" + "strings" +) + +// The seats a mesh can have (novox/hq ADR 0110). +// +// **A closed set, defined here rather than by whoever claims one.** Until this, a well-formed name +// became a seat by being claimed, so nothing could say which seats a mesh has or who fills them: +// `the-showcase` and `the-build-machine` were each invented by the module claiming it. The set is +// what a person reads to learn what a mesh can have, so an entry nobody argued for is an entry +// nobody can explain — the same reason every shape in the host's vocabulary names its decision. +// +// A seat is held by a module assignment. What the mesh knows about a holder is what it knows about +// that assignment; nothing about holders is kept here or anywhere else. + +// Seat is one role the mesh defines. +type Seat struct { + // Name is what a manifest claims. + Name string + // Scope is where there may be only one holder. + Scope string + // Delivers is the provision the seat's holder answers for, or empty. A seat that delivers a + // provision may only be held by a module providing it at the seat's scope, and its holder is + // what a requirement for that provision resolves to when several modules provide it. + Delivers string + // Decision is the record that made it a seat. + Decision string +} + +// seats is the whole set, in the order a person reads it: the mesh's own, then a node's. +var seats = []Seat{ + {Name: "mesh-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079"}, + {Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"}, + {Name: "mesh-broker", Scope: ScopeMesh, Delivers: "amqp", Decision: "novox/hq ADR 0079"}, + {Name: "the-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"}, + {Name: "the-catalogue", Scope: ScopeMesh, Decision: "novox/hq ADR 0110"}, + {Name: "npm-package-registry", Scope: ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"}, + {Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"}, + {Name: "the-build-machine", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, + {Name: "the-dns-port", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, + {Name: "the-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, + {Name: "the-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, + {Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, + {Name: "the-resolver-configuration", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, + {Name: "the-showcase", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, +} + +// Seats is every seat the mesh defines, in reading order. +func Seats() []Seat { + return append([]Seat(nil), seats...) +} + +// SeatNamed is the seat a claim names, if the mesh defines one. +func SeatNamed(name string) (Seat, bool) { + for _, s := range seats { + if s.Name == name { + return s, true + } + } + return Seat{}, false +} + +// SeatDelivering is the seat whose holder answers for a provision, if there is one. +func SeatDelivering(provision string) (Seat, bool) { + if provision == "" { + return Seat{}, false + } + for _, s := range seats { + if s.Delivers == provision { + return s, true + } + } + return Seat{}, false +} + +// claimProblems is what is wrong with a manifest's claims against the set. +// +// Three refusals, each naming the seat: a seat the mesh does not define, a seat claimed at another +// scope, and a seat that delivers a provision claimed by a module that does not provide it — which +// would make the module the mesh's answer for something it cannot answer. +func claimProblems(m Manifest) []string { + var problems []string + for _, c := range m.Claims { + seat, known := SeatNamed(c.Name) + if !known { + problems = append(problems, fmt.Sprintf( + "%s claims %q, which is not a seat this mesh defines (novox/hq ADR 0110) — "+ + "the seats are: %s", m.Module, c.Name, seatNames())) + continue + } + if c.At() != seat.Scope { + problems = append(problems, fmt.Sprintf( + "%s claims %s at scope %q, and %s is a %s seat", + m.Module, c.Name, c.At(), c.Name, seat.Scope)) + } + if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) { + problems = append(problems, fmt.Sprintf( + "%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope", + m.Module, c.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)) + } + } + return problems +} + +func providesAt(m Manifest, provision, scope string) bool { + for _, o := range m.Provides { + if o.Name == provision && o.At() == scope { + return true + } + } + return false +} + +func seatNames() string { + names := make([]string, 0, len(seats)) + for _, s := range seats { + names = append(names, s.Name) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + +// HolderAmong is which of several providers of a provision holds the seat that delivers it. +// +// Found by the (node, module) pair, because a provider is identified by both (novox/hq to-be 23): +// two modules on one node could both provide a provision, and only the one holding the seat +// answers for it. Nothing when no seat delivers the provision, when nobody holds +// it, or when the holder is not among the providers offered. +func HolderAmong(provision string, providers []Provider, held []Held) (Provider, bool) { + seat, delivered := SeatDelivering(provision) + if !delivered { + return Provider{}, false + } + for _, h := range held { + if h.Claim != seat.Name || h.Scope != seat.Scope { + continue + } + for _, p := range providers { + if p.Node == h.Node && p.Module == h.Module { + return p, true + } + } + } + return Provider{}, false +} diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go new file mode 100644 index 0000000..96cdfc2 --- /dev/null +++ b/internal/catalogue/seats_test.go @@ -0,0 +1,208 @@ +package catalogue + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Defends novox/hq ADR 0110: a seat is a module assignment from a closed set. + +// The set is closed, and changing it is a decision. +// +// **The count is asserted, and every entry names the record that made it a seat**, so the next +// person changing the set finds the argument rather than a number to edit — the pattern the host's +// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq +// and a row in to-be 26, not a new number here. +func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { + record := regexp.MustCompile(`^novox/hq ADR \d{4}$`) + seen := map[string]bool{} + delivered := map[string]string{} + for _, s := range Seats() { + if seen[s.Name] { + t.Errorf("%s is in the set twice", s.Name) + } + seen[s.Name] = true + if !record.MatchString(s.Decision) { + t.Errorf("%s names %q as its decision; every seat names the record that made it one", + s.Name, s.Decision) + } + switch s.Scope { + case ScopeNode, ScopeSite, ScopeMesh: + default: + t.Errorf("%s is held per %q, which is not a scope", s.Name, s.Scope) + } + if s.Delivers != "" { + // Two seats answering for one provision would put the question "which one?" back, + // which is the question a seat exists to answer. + if other, twice := delivered[s.Delivers]; twice { + t.Errorf("%s and %s both deliver %q", other, s.Name, s.Delivers) + } + delivered[s.Delivers] = s.Name + } + } + if len(Seats()) != 14 { + t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+ + "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) + } +} + +func claimed(claims string) []byte { + return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh"}],"claims":` + claims + `}`) +} + +func TestAClaimOnASeatTheMeshDoesNotDefineIsRefused(t *testing.T) { + _, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) + if err == nil { + t.Fatal("a module invented a seat by claiming it") + } + if !strings.Contains(err.Error(), "the-anything") || !strings.Contains(err.Error(), "not a seat") { + t.Fatalf("the refusal does not say the seat is unknown: %v", err) + } + // And it says what the seats are, because "no" without the list sends somebody reading code. + if !strings.Contains(err.Error(), "the-packet-filter") { + t.Fatalf("the refusal does not list the seats: %v", err) + } +} + +func TestASeatClaimedAtAnotherScopeIsRefused(t *testing.T) { + _, err := ParseManifest(claimed(`[{"name":"npm-package-registry","scope":"node"}]`)) + if err == nil { + t.Fatal("a mesh seat was held per node") + } + if !strings.Contains(err.Error(), "mesh seat") { + t.Fatalf("the refusal does not say which scope the seat is: %v", err) + } +} + +func TestADeliveringSeatIsOnlyHeldByAModuleThatProvides(t *testing.T) { + // Holding it makes the module the mesh's answer for the provision. A module that cannot answer + // would be the answer anyway, and every consumer would be sent to it. + raw := []byte(`{"module":"thing","version":"1","claims":[{"name":"git","scope":"mesh"}]}`) + _, err := ParseManifest(raw) + if err == nil { + t.Fatal("a module holding the git seat need not provide git") + } + if !strings.Contains(err.Error(), `does not provide "git"`) { + t.Fatalf("the refusal does not say what is missing: %v", err) + } +} + +func TestAClaimThatIsMalformedIsRefusedOnceForThat(t *testing.T) { + // Not a second time for being unknown: one mistake, one line. + _, err := ParseManifest(claimed(`[{"name":"Not A Name","scope":"node"}]`)) + if err == nil { + t.Fatal("a malformed claim was accepted") + } + if strings.Contains(err.Error(), "not a seat") { + t.Fatalf("a malformed claim was also called unknown: %v", err) + } +} + +// Every module in use claims a seat in the set, so closing it refuses nothing that runs. +// +// Read from the catalogue beside this checkout and from this repository's own manifest, the two +// places a manifest lives (ADR 0069). The private-network module's manifest is composed in code, +// and its claim is checked where it is composed. +func TestEveryManifestInUseClaimsASeatTheMeshDefines(t *testing.T) { + paths, _ := filepath.Glob("../../../mesh-catalog/modules/*/module.json") + if len(paths) == 0 { + t.Skip("the catalogue is not beside this checkout") + } + paths = append(paths, "../../module.json") + var checked int + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + // Leniently, so a manifest refused for something unrelated is not reported as a seat + // problem, and the seat check below is the only thing this test holds a module to. + var m Manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("%s: %v", path, err) + } + for _, problem := range claimProblems(m) { + t.Errorf("%s: %s", path, problem) + } + checked += len(m.Claims) + } + if checked == 0 { + t.Fatal("no claims were checked, so this proved nothing") + } +} + +// The holder of a seat answers among several providers. + +func registryShelf() map[string]Manifest { + return shelf( + Manifest{Module: "gitea", Version: "1", Provides: FromAnywhere("npm-package-registry"), + Claims: []Claim{{Name: "npm-package-registry", Scope: ScopeMesh}}}, + Manifest{Module: "verdaccio", Version: "1", Provides: FromAnywhere("npm-package-registry")}, + Manifest{Module: "builder", Version: "1", Requires: []string{"npm-package-registry"}}, + ) +} + +func twoRegistries() map[string][]Provider { + return map[string][]Provider{"npm-package-registry": { + {Node: "anchor", At: "anchor.internal", Module: "gitea"}, + {Node: "archive", At: "archive.internal", Module: "verdaccio"}, + }} +} + +func giteaHoldsTheSeat() []Held { + return []Held{{Claim: "npm-package-registry", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}} +} + +func TestTheSeatsHolderAnswersWhenSeveralProvide(t *testing.T) { + // The whole point: a second registry beside the holder harms nothing, and nobody pins. + got, err := Resolve(registryShelf(), []string{"builder"}, reachable(), + World{Offered: twoRegistries(), Held: giteaHoldsTheSeat()}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "anchor" { + t.Fatalf("the seat's holder did not answer: %v", got.Needs) + } +} + +func TestAPinStillWinsOverTheSeat(t *testing.T) { + // A consumer coupled to one provider's contents has said so, and the seat does not overrule it. + got, err := Resolve(registryShelf(), []string{"builder"}, reachable(), + World{Offered: twoRegistries(), Held: giteaHoldsTheSeat(), + Pinned: map[string]string{"npm-package-registry": "archive"}}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "archive" { + t.Fatalf("the pin was overruled by the seat: %v", got.Needs) + } +} + +func TestWithTheSeatUnheldSeveralProvidersAreStillRefused(t *testing.T) { + // No seat held is no choice made, and ADR 0009's rule stands: never guessed. + _, err := Resolve(registryShelf(), []string{"builder"}, reachable(), + World{Offered: twoRegistries()}) + if err == nil { + t.Fatal("one of two registries was picked with nobody holding the seat") + } + if !strings.Contains(err.Error(), "pin") { + t.Fatalf("the refusal does not say how to choose: %v", err) + } +} + +func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) { + // Two modules on one machine could provide the same thing; only the one holding the seat + // answers. A holder matched by node alone would send consumers to whichever came first. + providers := []Provider{ + {Node: "anchor", At: "anchor.internal", Module: "verdaccio"}, + {Node: "anchor", At: "anchor.internal", Module: "gitea"}, + } + holder, held := HolderAmong("npm-package-registry", providers, giteaHoldsTheSeat()) + if !held || holder.Module != "gitea" { + t.Fatalf("the holder was not told apart from a neighbour: %+v", holder) + } +} diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 5ca6d12..bcf3e55 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -24,7 +24,12 @@ var ErrStillAssigned = errors.New("that module is still assigned to nodes") // Source is where a module comes from and what has been built from it. type Source struct { + // Repository is a URL, cloned exactly as given, unless Seat is set — then it is the + // repository's path on that seat's holder, and never an address (novox/hq ADR 0111). Repository string + // Seat is the seat the repository lives on: `git` for the mesh's own forge, empty for a + // repository anywhere else. + Seat string // Path is the module's directory inside that repository (novox/hq ADR 0069). Empty is the // repository's root, which is a real answer rather than a missing one. Path string @@ -62,8 +67,8 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr // record of where the module normally comes from — which is the only thing that would say, // afterwards, that the machine is running something nobody can rebuild. _, err = i.store.Pool().Exec(ctx, - `insert into module (name, manifest, version, source, source_path, ref, built_from, source_head) - values ($1, $2, nullif($3,''), nullif($4,''), $7, nullif($5,''), nullif($6,''), nullif($6,'')) + `insert into module (name, manifest, version, source, source_path, source_seat, ref, built_from, source_head) + values ($1, $2, nullif($3,''), nullif($4,''), $7, $8, nullif($5,''), nullif($6,''), nullif($6,'')) on conflict (name) do update set manifest = excluded.manifest, version = excluded.version, @@ -71,10 +76,12 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr source = coalesce(excluded.source, module.source), source_path = case when excluded.source is null then module.source_path else excluded.source_path end, + source_seat = case when excluded.source is null then module.source_seat + else excluded.source_seat end, ref = coalesce(excluded.ref, module.ref), built_from = coalesce(excluded.built_from, module.built_from), source_head = coalesce(excluded.built_from, module.source_head)`, - m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom, from.Path) + m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom, from.Path, from.Seat) return err } @@ -99,10 +106,10 @@ func (i *Inventory) SourceMoved(ctx context.Context, module, head string) error func (i *Inventory) SourceOf(ctx context.Context, module string) (Source, error) { var s Source var repo, ref, built, head *string - var path string + var path, seat string err := i.store.Pool().QueryRow(ctx, - `select source, source_path, ref, built_from, source_head from module where name = $1`, - module).Scan(&repo, &path, &ref, &built, &head) + `select source, source_path, source_seat, ref, built_from, source_head from module where name = $1`, + module).Scan(&repo, &path, &seat, &ref, &built, &head) if errors.Is(err, pgx.ErrNoRows) { return Source{}, fmt.Errorf("%w: %s", ErrNoSuchModule, module) } @@ -117,9 +124,10 @@ func (i *Inventory) SourceOf(ctx context.Context, module string) (Source, error) *pair.to = *pair.from } } - // Not in the loop above: the path is never null, because "the repository's root" is an answer - // rather than an absence. + // Not in the loop above: the path and the seat are never null, because "the repository's root" + // and "not on a seat" are answers rather than absences. s.Path = path + s.Seat = seat return s, nil } @@ -917,13 +925,14 @@ type Entry struct { func (i *Inventory) Catalogued(ctx context.Context) ([]Entry, error) { rows, err := i.store.Pool().Query(ctx, `select m.name, m.manifest, - coalesce(m.source, ''), m.source_path, coalesce(m.ref, ''), + coalesce(m.source, ''), m.source_path, m.source_seat, coalesce(m.ref, ''), coalesce(m.built_from, ''), coalesce(m.source_head, ''), coalesce(array_agg(n.name order by n.name) filter (where n.name is not null), '{}') from module m left join assignment a on a.module = m.name left join node n on n.id = a.node - group by m.name, m.manifest, m.source, m.source_path, m.ref, m.built_from, m.source_head + group by m.name, m.manifest, m.source, m.source_path, m.source_seat, m.ref, m.built_from, + m.source_head order by m.name`) if err != nil { return nil, err @@ -936,7 +945,7 @@ func (i *Inventory) Catalogued(ctx context.Context) ([]Entry, error) { var name string var source Source var on []string - if err := rows.Scan(&name, &raw, &source.Repository, &source.Path, &source.Ref, + if err := rows.Scan(&name, &raw, &source.Repository, &source.Path, &source.Seat, &source.Ref, &source.BuiltFrom, &source.Head, &on); err != nil { return nil, err } diff --git a/internal/inventory/catalogue_test.go b/internal/inventory/catalogue_test.go index 1a6cce0..92c61a3 100644 --- a/internal/inventory/catalogue_test.go +++ b/internal/inventory/catalogue_test.go @@ -604,3 +604,74 @@ func TestNeverReportedAndReportedNothingAreDifferentProfiles(t *testing.T) { t.Fatal("a machine that reported nothing looks like one that never reported") } } + +// Defends novox/hq ADR 0111: a source on a seat is recorded as a path and the seat, never an +// address, and a module recorded before the column existed keeps meaning a URL. +func TestASourceOnASeatIsRecordedAsItsPathAndTheSeat(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + if err := inv.RegisterModule(ctx, manifest("gitea-built", nil, nil), Source{ + Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/gitea", Ref: "main", + BuiltFrom: "aaaa1111", + }); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(ctx, manifest("external", nil, nil), Source{ + Repository: "https://example.invalid/someone/something.git", BuiltFrom: "bbbb2222", + }); err != nil { + t.Fatal(err) + } + + own, err := inv.SourceOf(ctx, "gitea-built") + if err != nil { + t.Fatal(err) + } + if own.Seat != "git" || own.Repository != "novox/mesh-catalog" || own.Path != "modules/gitea" { + t.Fatalf("recorded as %+v", own) + } + if strings.Contains(own.Repository, "://") { + t.Fatalf("an address was recorded for a source on a seat: %s", own.Repository) + } + + elsewhere, err := inv.SourceOf(ctx, "external") + if err != nil { + t.Fatal(err) + } + if elsewhere.Seat != "" { + t.Fatalf("an external repository was put on a seat: %+v", elsewhere) + } + + // And the list every rebuild walks carries the seat, or `build --behind` would clone the path + // as though it were a URL. + all, err := inv.Catalogued(ctx) + if err != nil { + t.Fatal(err) + } + for _, e := range all { + if e.Manifest.Module == "gitea-built" && e.Source.Seat != "git" { + t.Fatalf("the rebuild list lost the seat: %+v", e.Source) + } + } +} + +func TestRegisteringWithoutProvenanceKeepsTheSeat(t *testing.T) { + // A manifest handed over by hand keeps the record of where the module normally comes from — + // the seat included, or the next rebuild would treat a path as a URL. + inv := fresh(t) + ctx := t.Context() + if err := inv.RegisterModule(ctx, manifest("thing", nil, nil), Source{ + Repository: "novox/thing", Seat: "git", BuiltFrom: "aaaa1111", + }); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(ctx, manifest("thing", []string{"a-thing"}, nil), Source{}); err != nil { + t.Fatal(err) + } + got, err := inv.SourceOf(ctx, "thing") + if err != nil { + t.Fatal(err) + } + if got.Seat != "git" || got.Repository != "novox/thing" { + t.Fatalf("a hand-registered manifest erased where the module comes from: %+v", got) + } +} diff --git a/internal/inventory/migrations/0032-a-source-may-live-on-a-seat.sql b/internal/inventory/migrations/0032-a-source-may-live-on-a-seat.sql new file mode 100644 index 0000000..707ae0f --- /dev/null +++ b/internal/inventory/migrations/0032-a-source-may-live-on-a-seat.sql @@ -0,0 +1,12 @@ +-- Which seat a module's source lives on, when it lives on one. +-- +-- novox/hq ADR 0111. A module's repository was recorded exactly as a person typed it, so a +-- self-hosted forge's scheme, host and port were written into every module built from it — and +-- moving the forge made every one of those records stale at once, noticed only when a rebuild +-- failed to clone. A source on the `git` seat is now recorded as its path on the seat's holder, and +-- the clone URL is composed from wherever the holder runs at the moment of building. +-- +-- Empty rather than null, and defaulted, because "not on a seat" is a real answer: the repository +-- column is then a URL, cloned exactly as given, which is what every module recorded before this +-- already is. So every existing row keeps exactly the meaning it had. +alter table module add column source_seat text not null default ''; diff --git a/internal/overlay/seat_test.go b/internal/overlay/seat_test.go new file mode 100644 index 0000000..37e3b3a --- /dev/null +++ b/internal/overlay/seat_test.go @@ -0,0 +1,29 @@ +package overlay + +import ( + "encoding/json" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// The private network's claim is a seat the mesh defines (novox/hq ADR 0110). +// +// Checked here because this is where the manifest is composed: it ships with the control plane and +// 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()} { + raw, err := json.Marshal(composed) + if err != nil { + t.Fatal(err) + } + if _, err := catalogue.ParseManifest(raw); err != nil { + t.Errorf("%s, composed by the control plane, is refused: %v", composed["module"], err) + } + } + seat, defined := catalogue.SeatNamed(TheNetwork) + if !defined || seat.Scope != catalogue.ScopeNode { + t.Fatalf("%s is not a node seat the mesh defines: %+v", TheNetwork, seat) + } +}