diff --git a/cmd/mesh-controller/seats.go b/cmd/mesh-controller/seats.go index 07910266..fd44d2a5 100644 --- a/cmd/mesh-controller/seats.go +++ b/cmd/mesh-controller/seats.go @@ -34,8 +34,10 @@ type seatRow struct { Delivers string `json:"delivers,omitempty"` Decision string `json:"decision"` // Replicated says the seat may be held on several machines at once (novox/hq ADR 0223). - Replicated bool `json:"replicated,omitempty"` - Holders []seatHolder `json:"holders"` + Replicated bool `json:"replicated,omitempty"` + // Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287). + Paths []catalogue.SeatPath `json:"paths,omitempty"` + Holders []seatHolder `json:"holders"` } // seatsHeld is every seat the mesh defines with its holders, and every claim held that names no @@ -49,7 +51,7 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata rows := make([]seatRow, 0, len(seats)) for _, s := range seats { row := seatRow{Seat: s.Name, Scope: s.Scope, Delivers: s.Delivers, Decision: s.Decision, - Replicated: s.Replicated, Holders: []seatHolder{}} + Replicated: s.Replicated, Paths: s.Paths, Holders: []seatHolder{}} seen := map[seatHolder]bool{} for _, h := range held { // Resolve the held claim to a seat rather than comparing names, so a record naming a @@ -255,12 +257,16 @@ func seatsCommand(ctx context.Context, args []string) error { return err } rows, outside := seatsHeld(catalogue.Seats(), world.Held) + // Which module owns each seat's path on each machine (novox/hq ADR 0287): the seat naming it, and + // that seat's holder there, with the stepping back applied. + owners := catalogue.PathOwners(catalogue.Seats(), world.Held) if *asJSON { out := struct { - Seats []seatRow `json:"seats"` - Outside []catalogue.Held `json:"outside,omitempty"` - }{rows, outside} + Seats []seatRow `json:"seats"` + Owners []catalogue.PathOwner `json:"path-owners,omitempty"` + Outside []catalogue.Held `json:"outside,omitempty"` + }{rows, owners, outside} body, err := json.MarshalIndent(out, "", " ") if err != nil { return err @@ -270,7 +276,7 @@ func seatsCommand(ctx context.Context, args []string) error { } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "SEAT\tSCOPE\tDELIVERS\tHELD BY") + fmt.Fprintln(w, "SEAT\tSCOPE\tDELIVERS\tPATHS\tHELD BY") for _, r := range rows { delivers := r.Delivers if delivers == "" { @@ -284,11 +290,22 @@ func seatsCommand(ctx context.Context, args []string) error { } holders = strings.Join(parts, ", ") } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Seat, r.Scope, delivers, holders) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Seat, r.Scope, delivers, seatPathsText(r.Paths), holders) } if err := w.Flush(); err != nil { return err } + if len(owners) > 0 { + fmt.Println("\nwho owns each seat's path, by machine (novox/hq ADR 0287):") + w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "PATH\tMACHINE\tSEAT\tMODULE") + for _, o := range owners { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", o.Path, o.Node, o.Seat, o.Module) + } + 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 { @@ -297,3 +314,19 @@ func seatsCommand(ctx context.Context, args []string) error { } return nil } + +// seatPathsText is a seat's paths in one column: each path, and the seat it steps back for, if any. +func seatPathsText(paths []catalogue.SeatPath) string { + if len(paths) == 0 { + return "—" + } + parts := make([]string, 0, len(paths)) + for _, p := range paths { + if p.StepsBackFor != "" { + parts = append(parts, p.Path+" (steps back for "+p.StepsBackFor+")") + continue + } + parts = append(parts, p.Path) + } + return strings.Join(parts, ", ") +} diff --git a/cmd/mesh-controller/seats_test.go b/cmd/mesh-controller/seats_test.go index 7db64bf8..435eb705 100644 --- a/cmd/mesh-controller/seats_test.go +++ b/cmd/mesh-controller/seats_test.go @@ -74,3 +74,22 @@ func TestAKindedBenchIsNotHandedOver(t *testing.T) { } } } + +// The listing shows each seat's paths, and the seat a path steps back for (novox/hq ADR 0287). +func TestTheSeatsListingShowsEachSeatsPaths(t *testing.T) { + rows, _ := seatsHeld(catalogue.Seats(), nil) + got := map[string]string{} + for _, r := range rows { + got[r.Seat] = seatPathsText(r.Paths) + } + for seat, want := range map[string]string{ + "node-hostname": "/etc/hosts, /etc/hostname", + "node-uplink": "/etc/resolv.conf (steps back for node-resolver)", + "node-resolver": "/etc/resolv.conf", + "node-packet-filter": "—", + } { + if got[seat] != want { + t.Errorf("%s lists its paths as %q, not %q", seat, got[seat], want) + } + } +} diff --git a/internal/catalogue/node_resolver.go b/internal/catalogue/node_resolver.go index 8ba464dc..0f6515da 100644 --- a/internal/catalogue/node_resolver.go +++ b/internal/catalogue/node_resolver.go @@ -66,56 +66,54 @@ func holdsSeat(m Manifest, seat string) bool { return false } -// rendersResolverFile says whether a module asks the mesh to render the machine's resolver file. -func rendersResolverFile(m Manifest) bool { - for _, f := range m.Facts { - if !f.Home && f.Path == ResolverFile { - return true - } - } - return false -} - -// ResolverFileOwner is the module that writes the machine's resolver file among the modules on one -// machine (novox/hq ADR 0247): the holder of node-resolver when one renders it, else nobody is named here -// and the file is whoever's it always was — the uplink holder's (ADR 0223). -func ResolverFileOwner(modules []Manifest) string { - for _, m := range modules { - if holdsSeat(m, ResolverSeat) && rendersResolverFile(m) { - return m.Module - } - } - return "" -} - -// stepsBack is the module as it composes on a machine whose resolver file is the node-resolver holder's: -// **the uplink holder's rendering of that file is left out**, and nothing else of it changes. Only the -// uplink's holder steps back — any other module rendering the file beside the resolver's is still two -// owners of one path, and is refused as before. +// stepsBack is the module as it composes among the modules of one machine (novox/hq ADR 0287, ADR 0247): +// **where it holds a seat that names a path stepping back for another seat, and that other seat's holder +// is on the machine and declares the path, its own declaration of the path is left out**, and nothing else +// of it changes. The stepping back is the seat's definition — node-uplink stepping back from the resolver +// file for node-resolver — so any other module declaring the path beside the owner's is still two owners +// of one path, and is refused as before. func stepsBack(m Manifest, modules []Manifest) Manifest { - if !holdsSeat(m, "node-uplink") || !rendersResolverFile(m) { - return m + drop := map[string]bool{} + for _, c := range m.Claims { + seat, known := SeatNamed(c.Name) + if !known { + continue + } + for _, p := range seat.Paths { + if p.StepsBackFor == "" { + continue + } + for _, o := range modules { + if o.Module != m.Module && holdsSeat(o, p.StepsBackFor) && declaresPath(o, p.Path) { + drop[p.Path] = true + } + } + } } - owner := ResolverFileOwner(modules) - if owner == "" || owner == m.Module { + if len(drop) == 0 { return m } facts := make(map[string]RosterFile, len(m.Facts)) for name, f := range m.Facts { - if !f.Home && f.Path == ResolverFile { + if !f.Home && drop[f.Path] { continue } facts[name] = f } m.Facts = facts + var resources []map[string]any + for _, r := range m.Resources { + if p, ok := r["path"].(string); ok && drop[p] { + continue + } + resources = append(resources, r) + } + m.Resources = resources return m } // steppedBack is every module of one machine as it composes there (stepsBack, each). func steppedBack(modules []Manifest) []Manifest { - if ResolverFileOwner(modules) == "" { - return modules - } out := make([]Manifest, len(modules)) for i, m := range modules { out[i] = stepsBack(m, modules) diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 5cd3aecf..cdd5e208 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -957,7 +957,9 @@ func checkResources(modules []Manifest) []string { // Judged as the machine composes them: where the node-resolver holder writes the resolver file, the // uplink holder's rendering of it is not composed, so it is not a second owner (novox/hq ADR 0247). modules = steppedBack(modules) - var problems []string + // And only a claimant of a seat naming a path declares it — the controller's generated resources + // included, which are judged here at composition (novox/hq ADR 0287). + problems := seatPathsOnMachine(modules) owner := map[string]string{} ownedPath := map[string]string{} // path → owning module, for the access check below packagePresent, packageAbsent := map[string][]string{}, map[string][]string{} diff --git a/internal/catalogue/seat_paths.go b/internal/catalogue/seat_paths.go new file mode 100644 index 00000000..3d68e448 --- /dev/null +++ b/internal/catalogue/seat_paths.go @@ -0,0 +1,307 @@ +package catalogue + +import ( + "fmt" + "path" + "strings" +) + +// The paths a seat's holder owns (novox/hq ADR 0287). +// +// **Who owns a machine-wide file is the role's question, answered once.** Until this, which module owned +// /etc/hosts or /etc/resolv.conf was decided record by record (ADRs 0199, 0222, 0223, 0241) and enforced +// only after the fact, by checkResources finding two modules on one machine declaring the same path: a +// module that did not hold node-hostname could declare /etc/hosts on a machine where nobody held it, and +// nothing refused it. Now a seat's definition names the paths the role owns on any machine, and three +// refusals follow from it, each where the mesh can still say no: +// +// - a claim whose module does not declare every path its seat names, by CanHold, at registration and at +// handover; +// - a module declaring a path a seat names while claiming none of the seats that name it, at +// registration (CatalogueProblems), and what the controller generates for a machine, at composition; +// - a module's own seat naming a path another seat already names, at registration — the second loses. +// +// Reading a seat's path is access, never a resource, so it stays allowed. + +// SeatPath is one path a seat's holder owns: absolute, literal, on the machine where the holder runs. +type SeatPath struct { + Path string `json:"path"` + // StepsBackFor is a node seat whose holder owns the path instead, on a machine where that seat is held + // (novox/hq ADR 0287, ADR 0247): this seat's holder still declares the path, and its declaration is not + // composed there. Only the mesh's own seats step back, each by a record. + StepsBackFor string `json:"steps-back-for,omitempty"` +} + +// seatPathProblem is what is wrong with one path a seat names, judged alone: absolute, written literally +// with no placeholder, and clean, so it compares with what a module declares byte for byte. +func seatPathProblem(seat, scope, p string) string { + switch { + case scope != ScopeNode: + return fmt.Sprintf("the seat %s names the path %q and is a %s seat; only a node seat names paths, "+ + "because a path is a machine's (novox/hq ADR 0287)", seat, p, scope) + case strings.Contains(p, "${"): + return fmt.Sprintf("the seat %s names the path %q, which has a placeholder; a seat's path is written "+ + "literally (novox/hq ADR 0287)", seat, p) + case !strings.HasPrefix(p, "/") || path.Clean(p) != p: + return fmt.Sprintf("the seat %s names the path %q, which is not a clean absolute path (novox/hq ADR 0287)", + seat, p) + } + return "" +} + +// SeatPathProblems is what is wrong with the paths a set of the mesh's own seats names (novox/hq ADR +// 0287): only a node seat names one, each absolute and literal; every path has exactly one seat naming it +// without stepping back; and a seat steps back only for another node seat naming the same path without +// stepping back, so the stepping back cannot cycle. +func SeatPathProblems(set []Seat) []string { + var problems []string + owners := map[string][]string{} // path → the seats naming it without stepping back + stepping := map[string][]SeatPath{} // seat → the paths it names stepping back + byName := map[string]Seat{} + for _, s := range set { + byName[s.Name] = s + for _, p := range s.Paths { + if why := seatPathProblem(s.Name, s.Scope, p.Path); why != "" { + problems = append(problems, why) + } + if p.StepsBackFor == "" { + owners[p.Path] = append(owners[p.Path], s.Name) + } else { + stepping[s.Name] = append(stepping[s.Name], p) + } + } + } + for _, s := range set { + for _, p := range stepping[s.Name] { + other, known := byName[p.StepsBackFor] + switch { + case !known || other.Scope != ScopeNode: + problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %q, which is not a "+ + "node seat of the mesh's (novox/hq ADR 0287)", s.Name, p.Path, p.StepsBackFor)) + case !namesWithoutSteppingBack(other, p.Path): + problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %s, which does not "+ + "own that path itself; a seat steps back only for the one that owns it (novox/hq ADR 0287)", + s.Name, p.Path, other.Name)) + } + } + } + for _, p := range sortedKeys(owners) { + if len(owners[p]) > 1 { + problems = append(problems, fmt.Sprintf("the path %q is named by %s, each without stepping back; "+ + "no two seats name one path (novox/hq ADR 0287)", p, strings.Join(owners[p], " and "))) + } + } + for _, s := range set { + for _, p := range stepping[s.Name] { + if len(owners[p.Path]) == 0 { + problems = append(problems, fmt.Sprintf("the path %q has no seat naming it without stepping back "+ + "(novox/hq ADR 0287)", p.Path)) + } + } + } + return problems +} + +func namesWithoutSteppingBack(s Seat, p string) bool { + for _, sp := range s.Paths { + if sp.Path == p && sp.StepsBackFor == "" { + return true + } + } + return false +} + +// declaredPaths is every machine path a module declares it owns: a resource's literal path, and a file +// the mesh renders for it outside the operator's home. An access is not here — reading is not owning. +func declaredPaths(m Manifest) map[string]bool { + out := map[string]bool{} + for _, r := range m.Resources { + if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") { + out[p] = true + } + } + for _, f := range m.Facts { + if !f.Home && strings.HasPrefix(f.Path, "/") { + out[f.Path] = true + } + } + return out +} + +// declaresPath says whether a module declares one path, as a resource or as a file the mesh renders. +func declaresPath(m Manifest, p string) bool { return declaredPaths(m)[p] } + +// missingPaths is each path a seat names that a module does not declare, in the seat's order. +func missingPaths(m Manifest, paths []SeatPath) []string { + if len(paths) == 0 { + return nil + } + has := declaredPaths(m) + var missing []string + for _, p := range paths { + if !has[p.Path] { + missing = append(missing, p.Path) + } + } + return missing +} + +// pathSeats is which seats name each path: the mesh's own, and then those modules declare, in the order +// given. A module's seat naming a path a seat already names loses it, and the refusal says so (novox/hq +// ADR 0287 — as for a seat's name, the second declaration loses). +func pathSeats(order []string, shelf Shelf, declaredBy map[string]string) (map[string][]string, []string) { + named := map[string][]string{} + for _, s := range Seats() { + for _, p := range s.Paths { + named[p.Path] = append(named[p.Path], s.Name) + } + } + var problems []string + for _, module := range order { + for _, s := range shelf[module].DefinesSeats { + if declaredBy != nil && declaredBy[s.Name] != module { + continue // a second declaration of the seat's name, refused for that already + } + for _, p := range s.Paths { + if others := named[p]; len(others) > 0 { + problems = append(problems, fmt.Sprintf( + "%s declares the seat %s naming the path %q, which %s already names; no two seats "+ + "name one path (novox/hq ADR 0287)", module, s.Name, p, strings.Join(others, " and "))) + continue + } + named[p] = append(named[p], s.Name) + } + } + } + return named, problems +} + +// unclaimedSeatPaths is each path a module declares that a seat names while it claims none of the seats +// naming it (novox/hq ADR 0287): only a claimant declares a seat's path, and a module claiming any one of +// them may. +func unclaimedSeatPaths(m Manifest, named map[string][]string) []string { + claims := map[string]bool{} + for _, c := range m.Claims { + claims[canonicalSeat(c.Name)] = true + } + var problems []string + declared := declaredPaths(m) + for _, p := range sortedKeys(declared) { + seats := named[p] + if len(seats) == 0 { + continue + } + claimsOne := false + for _, s := range seats { + claimsOne = claimsOne || claims[s] + } + if !claimsOne { + problems = append(problems, fmt.Sprintf( + "%s declares the path %q, which the seat %s names, and claims none of them; only a holder of "+ + "a seat naming a path declares it (novox/hq ADR 0287)", m.Module, p, strings.Join(seats, " and "))) + } + } + return problems +} + +// declaredSeatPathProblems is what one module's own seat declaration can be judged on alone: a path +// only on a node seat, absolute, literal and clean. +func declaredSeatPathProblems(m Manifest, s SeatDeclaration) []string { + var problems []string + for _, p := range s.Paths { + if why := seatPathProblem(s.Name, s.At(), p); why != "" { + problems = append(problems, m.Module+": "+why) + } + } + return problems +} + +// asSeatPaths is a module's own seat's paths in the shape the mesh's seats name theirs. A module's seat +// never steps back: only the mesh's own seats do, each by a record. +func asSeatPaths(paths []string) []SeatPath { + out := make([]SeatPath, 0, len(paths)) + for _, p := range paths { + out = append(out, SeatPath{Path: p}) + } + return out +} + +// PathOwner is the module that owns one seat's path on one machine (novox/hq ADR 0287): the seat naming +// the path, and that seat's holder there, with the stepping back applied. +type PathOwner struct { + Path string `json:"path"` + Node string `json:"node"` + Seat string `json:"seat"` + Module string `json:"module"` +} + +// PathOwners is, for every path a seat in the set names and every machine where a seat naming it is held, +// who owns the path there. A seat that steps back for another held on the same machine is passed over. +func PathOwners(set []Seat, held []Held) []PathOwner { + // node → seat → holding module + holders := map[string]map[string]string{} + for _, h := range held { + s, ok := SeatNamed(h.Claim) + if !ok || h.Scope != ScopeNode || s.Scope != ScopeNode { + continue + } + if holders[h.Node] == nil { + holders[h.Node] = map[string]string{} + } + if _, taken := holders[h.Node][s.Name]; !taken { + holders[h.Node][s.Name] = h.Module + } + } + naming := map[string][]Seat{} // path → the seats naming it + steps := map[string]map[string]string{} // path → seat → the seat it steps back for + for _, s := range set { + for _, p := range s.Paths { + naming[p.Path] = append(naming[p.Path], s) + if p.StepsBackFor != "" { + if steps[p.Path] == nil { + steps[p.Path] = map[string]string{} + } + steps[p.Path][s.Name] = p.StepsBackFor + } + } + } + var out []PathOwner + for _, p := range sortedKeys(naming) { + for _, node := range sortedKeys(holders) { + for _, s := range naming[p] { + module, holds := holders[node][s.Name] + if !holds { + continue + } + if forSeat := steps[p][s.Name]; forSeat != "" { + if _, there := holders[node][forSeat]; there { + continue + } + } + out = append(out, PathOwner{Path: p, Node: node, Seat: s.Name, Module: module}) + break + } + } + } + return out +} + +// seatPathsOnMachine is each path the modules of one machine declare that a seat names, for a module +// claiming none of the seats naming it (novox/hq ADR 0287). Registration refuses the same of a manifest; +// this holds what the controller generates for a machine to it too, because a computed module's resources +// exist only once composed (checkResources is asked them there). The seats are the mesh's own and those the +// machine's modules declare. +func seatPathsOnMachine(modules []Manifest) []string { + shelf := Shelf{} + var order []string + for _, m := range modules { + shelf[m.Module] = m + order = append(order, m.Module) + } + named, _ := pathSeats(order, shelf, nil) + var problems []string + for _, m := range modules { + problems = append(problems, unclaimedSeatPaths(m, named)...) + } + return problems +} diff --git a/internal/catalogue/seat_paths_test.go b/internal/catalogue/seat_paths_test.go new file mode 100644 index 00000000..536bcb00 --- /dev/null +++ b/internal/catalogue/seat_paths_test.go @@ -0,0 +1,268 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0287 (issue 412): a seat names the paths its holder owns, a claim without them is +// refused, and only a claimant declares a seat's path. + +// The starting set, exactly: node-hostname names /etc/hosts and /etc/hostname, node-uplink the resolver +// file stepping back for node-resolver, node-resolver the resolver file, and every other seat names none. +func TestTheSeatsThatNamePathsAreTheStartingSet(t *testing.T) { + want := map[string]string{ + "node-hostname": "/etc/hosts /etc/hostname", + "node-uplink": "/etc/resolv.conf>node-resolver", + ResolverSeat: "/etc/resolv.conf", + } + for _, s := range DefaultSeats() { + var got []string + for _, p := range s.Paths { + if p.StepsBackFor != "" { + got = append(got, p.Path+">"+p.StepsBackFor) + } else { + got = append(got, p.Path) + } + } + if strings.Join(got, " ") != want[s.Name] { + t.Errorf("%s names %v; ADR 0287 gives it %q", s.Name, got, want[s.Name]) + } + } +} + +// The compiled set keeps the rules: node seats only, absolute and literal, one seat per path without +// stepping back, and a stepping back that cannot cycle. +func TestTheCompiledSeatPathsKeepTheRules(t *testing.T) { + if p := SeatPathProblems(DefaultSeats()); len(p) != 0 { + t.Fatalf("the compiled set breaks ADR 0287: %v", p) + } + for _, c := range []struct { + why string + set []Seat + want string + }{ + {"a mesh seat naming a path", []Seat{{Name: "mesh-x", Scope: ScopeMesh, Paths: []SeatPath{{Path: "/etc/x"}}}}, + "only a node seat names paths"}, + {"a placeholder", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/${setting:x}"}}}}, + "placeholder"}, + {"a relative path", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "etc/x"}}}}, + "not a clean absolute path"}, + {"two owners", []Seat{ + {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}, + {Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}}, "no two seats name one path"}, + {"a cycle", []Seat{ + {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-b"}}}, + {Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-a"}}}}, + "does not own that path itself"}, + {"stepping back for nobody", []Seat{ + {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-z"}}}}, + "not a node seat"}, + } { + if got := strings.Join(SeatPathProblems(c.set), "; "); !strings.Contains(got, c.want) { + t.Errorf("%s: %q does not say %q", c.why, got, c.want) + } + } +} + +// What the store's rows carry is never the paths: a row read back keeps the compiled ones. +func TestTheSeatPathsSurviveTheStoresRows(t *testing.T) { + before := seats + t.Cleanup(func() { seats = before }) + UseSeats([]Seat{{Name: "node-hostname", Scope: ScopeNode}}) + s, _ := SeatNamed("node-hostname") + if len(s.Paths) != 2 { + t.Fatalf("node-hostname read back from its row names %v", s.Paths) + } +} + +func hostnameSeat(t *testing.T) Seat { + t.Helper() + s, ok := SeatNamed("node-hostname") + if !ok { + t.Fatal("no node-hostname seat") + } + return s +} + +func hostnameHolder(resources ...map[string]any) Manifest { + return Manifest{Module: "names", Version: "1", Claims: []Claim{{Name: "node-hostname", Scope: ScopeNode, + Serves: []string{"entries", "add", "remove"}}}, Resources: resources} +} + +// A holder declares every path its seat names, as a resource or as a file the mesh renders for it, or +// CanHold refuses it naming the seat and each missing path — the judgement registration and handover share. +func TestAClaimWithoutTheSeatsPathsIsRefused(t *testing.T) { + seat := hostnameSeat(t) + hosts := map[string]any{"id": "hosts", "type": "file", "path": "/etc/hosts", "content": "x"} + name := map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"} + + if err := CanHold(hostnameHolder(hosts, name), seat); err != nil { + t.Errorf("a holder declaring both paths as resources was refused: %v", err) + } + rendered := hostnameHolder(hosts) + rendered.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Template: "{{.Node}}\n"}} + if err := CanHold(rendered, seat); err != nil { + t.Errorf("a holder declaring a path as a file the mesh renders was refused: %v", err) + } + err := CanHold(hostnameHolder(), seat) + if err == nil { + t.Fatal("a holder declaring neither path was accepted") + } + for _, want := range []string{"node-hostname", "/etc/hosts", "/etc/hostname", "ADR 0287"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } + // A file under the operator's home is placed per account: it is not the machine's path. + home := hostnameHolder(hosts) + home.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Home: true, Template: "x"}} + if err := CanHold(home, seat); err == nil { + t.Error("a file in the operator's home counted as the machine's /etc/hostname") + } + // At registration, the same refusal. + if got := strings.Join(CatalogueProblems(Shelf{"names": hostnameHolder(hosts)}), "; "); !strings.Contains(got, + "does not declare /etc/hostname") { + t.Errorf("registration let a holder without /etc/hostname through: %q", got) + } +} + +// Only a claimant declares a seat's path: a module claiming no seat that names /etc/hosts and declaring +// it is refused at registration naming the seat; the uplink's holder and the resolver's both declaring +// the resolver file are each a claimant of a seat naming it. Reading a seat's path is access, and allowed. +func TestOnlyAClaimantDeclaresASeatsPath(t *testing.T) { + stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ + {"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}} + got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; ") + if !strings.Contains(got, `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { + t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got) + } + reader := Manifest{Module: "reader", Version: "1", Accesses: []Access{{ID: "hosts", Path: "/etc/hosts"}}} + if got := CatalogueProblems(Shelf{"reader": reader}); len(got) != 0 { + t.Errorf("reading /etc/hosts was refused: %v", got) + } + uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}, + Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}} + resolver := localResolver() + resolver.Claims[0].Serves = []string{"routes", "route", "unroute"} + if got := CatalogueProblems(Shelf{"uplink": uplink, resolver.Module: resolver}); len(got) != 0 { + t.Errorf("the uplink's holder and the resolver's declaring the resolver file were refused: %v", got) + } +} + +// A module's own seat names paths under the same rules: a node seat only, literal, and none another seat +// names — the second declaration loses. Its holder declares them, as a mesh seat's does. +func TestAModulesOwnSeatNamesPathsUnderTheSameRules(t *testing.T) { + meshScoped := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ + {Name: "a-seat", Paths: []string{"/etc/a"}}}} + if got := strings.Join(declaredSeatProblems(meshScoped), "; "); !strings.Contains(got, "only a node seat names paths") { + t.Errorf("a module's mesh seat naming a path was accepted: %q", got) + } + placeholder := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ + {Name: "a-seat", Scope: ScopeNode, Paths: []string{"${dir:state}/a"}}}} + if got := strings.Join(declaredSeatProblems(placeholder), "; "); !strings.Contains(got, "placeholder") { + t.Errorf("a module's seat naming a path with a placeholder was accepted: %q", got) + } + + taken := Manifest{Module: "b", Version: "1", DefinesSeats: []SeatDeclaration{ + {Name: "b-seat", Scope: ScopeNode, Paths: []string{"/etc/hosts"}}}} + if got := strings.Join(CatalogueProblems(Shelf{"b": taken}), "; "); !strings.Contains(got, + `b declares the seat b-seat naming the path "/etc/hosts", which node-hostname already names`) { + t.Errorf("a module's seat naming /etc/hosts was accepted: %q", got) + } + + own := Manifest{Module: "c", Version: "1", + DefinesSeats: []SeatDeclaration{{Name: "c-seat", Scope: ScopeNode, Paths: []string{"/etc/c.conf"}}}, + Claims: []Claim{{Name: "c-seat", Scope: ScopeNode}}} + if got := strings.Join(CatalogueProblems(Shelf{"c": own}), "; "); !strings.Contains(got, + "c claims c-seat but does not declare /etc/c.conf") { + t.Errorf("a holder of a module's seat without its path was accepted: %q", got) + } + own.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}} + if got := CatalogueProblems(Shelf{"c": own}); len(got) != 0 { + t.Errorf("a holder declaring its own seat's path was refused: %v", got) + } + other := Manifest{Module: "d", Version: "1", Resources: []map[string]any{ + {"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}} + if got := strings.Join(CatalogueProblems(Shelf{"c": own, "d": other}), "; "); !strings.Contains(got, + `d declares the path "/etc/c.conf", which the seat c-seat names`) { + t.Errorf("a module not claiming c-seat declared its path: %q", got) + } +} + +type writesHosts struct{} + +func (writesHosts) Resources(string) ([]map[string]any, bool, error) { + return []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", + "content": "10.42.0.1 anchor\n"}}, true, nil +} + +// The controller is bound too: what it generates for a machine on a seat's path is refused at composition. +func TestAGeneratedResourceOnASeatsPathIsRefused(t *testing.T) { + r := Resolution{Node: "workstation", Modules: []Manifest{ + {Module: "mesh-names", Computed: "mesh-names"}, + }} + _, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-names": writesHosts{}}}) + if err == nil { + t.Fatal("a generated resource on /etc/hosts was composed") + } + for _, want := range []string{"mesh-names", "/etc/hosts", "node-hostname"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %s: %v", want, err) + } + } +} + +// Who owns a seat's path on a machine: the seat naming it and its holder there, with the stepping back +// applied — the resolver's holder where node-resolver is held, else the uplink's. +func TestPathOwnersApplyTheSteppingBack(t *testing.T) { + held := []Held{ + {Claim: "node-uplink", Scope: ScopeNode, Node: "laptop", Module: "networkmanager"}, + {Claim: ResolverSeat, Scope: ScopeNode, Node: "laptop", Module: "systemd-resolved"}, + {Claim: "node-uplink", Scope: ScopeNode, Node: "server", Module: "systemd-networkd"}, + {Claim: "node-hostname", Scope: ScopeNode, Node: "server", Module: "hostname"}, + } + var got []string + for _, o := range PathOwners(Seats(), held) { + got = append(got, o.Path+" "+o.Node+" "+o.Seat+" "+o.Module) + } + want := []string{ + "/etc/hostname server node-hostname hostname", + "/etc/hosts server node-hostname hostname", + "/etc/resolv.conf laptop node-resolver systemd-resolved", + "/etc/resolv.conf server node-uplink systemd-networkd", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the owners are\n%s\nnot\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// The stepping back comes from the seat: an uplink holder declaring the resolver file as a resource rather +// than a rendered file steps back too, and keeps everything else. +func TestTheUplinkStepsBackByTheSeatsDefinition(t *testing.T) { + uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}}, + Resources: []map[string]any{ + {"id": "resolvers", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.1\n"}, + {"id": "conf", "type": "file", "path": "/etc/uplink.conf", "content": "x"}}} + got := stepsBack(uplink, []Manifest{uplink, localResolver()}) + if len(got.Resources) != 1 || got.Resources[0]["id"] != "conf" { + t.Errorf("the uplink's holder composes %v beside the resolver", got.Resources) + } + if p := checkResources([]Manifest{uplink, localResolver()}); len(p) != 0 { + t.Errorf("the uplink's holder and the resolver's were refused together: %v", p) + } +} + +// The catalogue as it is passes: no holder of a seat naming a path is missing one, and nobody else +// declares one. +func TestTheCataloguePassesTheSeatPathRules(t *testing.T) { + shelf := Shelf{} + for _, m := range theCatalogue(t) { + shelf[m.Module] = m + } + for _, p := range CatalogueProblems(shelf) { + if strings.Contains(p, "ADR 0287") { + t.Errorf("the catalogue breaks ADR 0287: %s", p) + } + } +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 9e07e635..68c7291a 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -53,6 +53,11 @@ type Seat struct { // ${contribution::}. Compiled, never stored: like the protocol, it is the mesh's // definition of the role, and the store's rows carry no column for it. Receives []Receivable + // Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287): a holder declares + // every one, and a module claiming no seat that names a path may not declare it. Only a node seat + // names paths. Compiled, never stored, like Receives: adding or taking away a path is a decision, + // recorded, and the store's rows carry no column for it. + Paths []SeatPath // Decision is the record that made it a seat. Decision string } @@ -178,6 +183,8 @@ var defaultSeats = append([]Seat{ // holds none of it. Named node-hosts-file until ADR 0223; the former name resolves to it as an // alias on a mesh that knew it. {Name: "node-hostname", Scope: ScopeNode, Decision: "novox/hq ADR 0199, ADR 0223", + // The machine's names are the role's files, whatever program fills it (novox/hq ADR 0287). + Paths: []SeatPath{{Path: "/etc/hosts"}, {Path: "/etc/hostname"}}, Serves: []Verb{ {Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " + "the operator's, or the block of the module or tool that writes it.", @@ -299,12 +306,17 @@ var defaultSeats = append([]Seat{ // resolver file as it is and who wrote it, and every link with its addresses, routes and the resolvers // the manager knows for it — the same for every holder, so a question about a machine's names is asked // the same way whatever manages its network. Optional until both holders serve them. - {Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs()}, + // + // It owns the resolver file, stepping back for node-resolver where that is held (novox/hq ADR 0287, + // ADR 0247). + {Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs(), + Paths: []SeatPath{{Path: ResolverFile, StepsBackFor: ResolverSeat}}}, // The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a // VPN client whose domains must go to its own servers while every other name goes to the mesh's. // Where it is held, its holder writes the resolver file and the uplink's holder steps back from it // (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link. - {Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs()}, + {Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs(), + Paths: []SeatPath{{Path: ResolverFile}}}, // A machine's shares and the shares it mounts (novox/hq ADR 0263): the holder of node-nfs-server // exports a machine's folders to the private network and provides each as `nfs-share`; the holder of // node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go). @@ -364,10 +376,11 @@ func UseSeats(s []Seat) { row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves } } - // What a seat receives and whether it is replicated are never stored (novox/hq ADR 0212, ADR - // 0223), so they are always the compiled ones. + // What a seat receives, whether it is replicated and the paths it names are never stored + // (novox/hq ADR 0212, ADR 0223, ADR 0287), so they are always the compiled ones. if d, known := byName[row.Name]; known { row.Receives = d.Receives + row.Paths = d.Paths row.Replicated = d.Replicated row.Serves = optionalAsCompiled(row.Serves, d.Serves) } @@ -541,6 +554,14 @@ func CanHold(m Manifest, seat Seat) error { return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope", m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope) } + // **A holder owns what the role owns** (novox/hq ADR 0287): every path the seat names, declared as + // a resource or as a file the mesh renders for it, so which module owns /etc/hosts on a machine is + // answered by the seat's definition and not by reading every manifest. + if missing := missingPaths(m, seat.Paths); len(missing) > 0 { + return fmt.Errorf("%s claims %s but does not declare %s, which that seat's holder owns (novox/hq "+ + "ADR 0287) — a holder declares every path its seat names, as a resource or as a file the mesh "+ + "renders for it", m.Module, seat.Name, strings.Join(missing, ", ")) + } // **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that // does not answer what the role promises is every caller's timeout, found at registration and // at handover instead, naming the verbs rather than the fact that something is missing. diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index eee4ebe5..cfda1c45 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -67,6 +67,10 @@ type SeatDeclaration struct { // Records are state buckets of the declaring module that each user reads under its own name — the // keys `.…` and no other (ADR 0259 §3). Records []string `json:"records,omitempty"` + // Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287), under the rules the + // mesh's own seats name theirs by: a node seat only, each absolute and literal, and none another seat + // names. A module's seat never steps back. + Paths []string `json:"paths,omitempty"` } // KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator, @@ -163,6 +167,7 @@ func declaredSeatProblems(m Manifest) []string { } } problems = append(problems, trafficProblems(m, s)...) + problems = append(problems, declaredSeatPathProblems(m, s)...) } for _, u := range m.Uses { @@ -271,6 +276,10 @@ func CatalogueProblems(shelf Shelf) []string { return ok } + // Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses. + named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy) + problems = append(problems, pathProblems...) + // Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2). kindsTaken := map[string]string{} @@ -327,7 +336,15 @@ func CatalogueProblems(shelf Shelf) []string { "%s claims %s but does not serve %s, which that seat's protocol promises", module, c.Name, strings.Join(missing, ", "))) } + // And owns what the seat's holder owns, as a holder of the mesh's own seats does (ADR 0287). + if missing := missingPaths(m, asSeatPaths(s.Paths)); len(missing) > 0 { + problems = append(problems, fmt.Sprintf( + "%s claims %s but does not declare %s, which that seat's holder owns (novox/hq ADR 0287)", + module, c.Name, strings.Join(missing, ", "))) + } } + // Only a claimant declares a seat's path (novox/hq ADR 0287). + problems = append(problems, unclaimedSeatPaths(m, named)...) } // **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or // speaking for a kind that proves its sender, is never carried by a machine's runtime. diff --git a/internal/catalogue/uplink_verbs_test.go b/internal/catalogue/uplink_verbs_test.go index a67fb173..b2f6caf7 100644 --- a/internal/catalogue/uplink_verbs_test.go +++ b/internal/catalogue/uplink_verbs_test.go @@ -33,7 +33,9 @@ func TestTheUplinkSeatPromisesItsVerbsAndRequiresNoneYet(t *testing.T) { if !slices.Equal(names, []string{"resolvers", "links"}) { t.Fatalf("node-uplink promises %v", names) } - today := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}} + // The resolver file is the seat's path (novox/hq ADR 0287), which every holder of today renders. + today := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}, + Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "x"}}} if err := CanHold(today, seat); err != nil { t.Fatalf("a holder that serves no verb yet is refused: %v", err) } @@ -55,7 +57,8 @@ func TestTheUplinkVerbsReadBackFromTheRowStayOptional(t *testing.T) { // The row as seeding stores it: both verbs, the mark not a column. UseSeats([]Seat{{Name: "node-uplink", Scope: ScopeNode, Serves: []Verb{{Name: "resolvers"}, {Name: "links"}}}}) seat := uplinkSeat(t) - holder := Manifest{Module: "systemd-networkd", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}} + holder := Manifest{Module: "systemd-networkd", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}, + Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "x"}}} if err := CanHold(holder, seat); err != nil { t.Fatalf("read back from the row, the verbs are required: %v", err) }