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) } // However the path is spelled (novox/hq issue 412 review). unclean := Manifest{Module: "unclean", Version: "1", Resources: []map[string]any{ {"id": "hosts", "type": "file", "path": "/etc/../etc/hosts", "content": "10.0.0.1 x\n"}}} if got := strings.Join(CatalogueProblems(Shelf{"unclean": unclean}), "; "); !strings.Contains(got, "node-hostname") { t.Errorf("/etc/../etc/hosts from a module not 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, and is of a rendered file only: an uplink holder declaring the // resolver file as a resource beside a node-resolver holder is two writers of one path, refused when the // machine is resolved — never composed twice — as before the seat named its paths. func TestAnUplinkDeclaringTheResolverFileAsAResourceIsRefusedBesideTheResolver(t *testing.T) { shelf := resolverShelf(t) uplink := shelf["systemd-networkd"] uplink.Facts = nil uplink.Resources = append(append([]map[string]any(nil), uplink.Resources...), map[string]any{"id": "resolvers", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.1\n"}) shelf["systemd-networkd"] = uplink shelf["local-resolver"] = localResolver() _, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{}) if err == nil || !strings.Contains(err.Error(), `both declare the path "/etc/resolv.conf"`) { t.Fatalf("an uplink declaring the resolver file as a resource beside the resolver was not refused: %v", err) } // Its rendered file steps back, by the seat's definition, and nothing else of it does. rendered := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}}, Facts: map[string]RosterFile{"resolvers": {Path: "/etc//resolv.conf", Template: "x"}, "other": {Path: "/etc/uplink.conf", Template: "x"}}} got := stepsBack(rendered, []Manifest{rendered, localResolver()}) if len(got.Facts) != 1 || got.Facts["other"].Path == "" { t.Errorf("the uplink's holder renders %v beside the resolver", got.Facts) } } // A path is compared as the machine writes it: cleaned. Each spelling of /etc/hosts is /etc/hosts. func TestAnUncleanSpellingOfASeatsPathIsTheSeatsPath(t *testing.T) { for _, spelled := range []string{"/etc//hosts", "/etc/./hosts", "/etc/../etc/hosts", "/etc/hosts/"} { stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ {"id": "hosts", "type": "file", "path": spelled, "content": "10.0.0.1 x\n"}}} if got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; "); !strings.Contains(got, `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { t.Errorf("%s from a module not claiming node-hostname was not refused: %q", spelled, got) } holder := hostnameHolder(map[string]any{"id": "hosts", "type": "file", "path": spelled, "content": "x"}, map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"}) if err := CanHold(holder, hostnameSeat(t)); err != nil { t.Errorf("a holder declaring %s was refused: %v", spelled, err) } } } // A seat names a role's own path, never the root or a top-level directory. func TestASeatNamesNoRootOrTopLevelDirectory(t *testing.T) { for _, p := range []string{"/", "/etc"} { m := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ {Name: "a-seat", Scope: ScopeNode, Paths: []string{p}}}} if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "top-level directory") { t.Errorf("a module's seat naming %s was accepted: %q", p, got) } } } // 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) } } }