Which module owns /etc/hosts or /etc/resolv.conf on a machine was answered only by reading every manifest, and a module not holding node-hostname could declare /etc/hosts where nobody held it. hq ADR 0287 makes the paths part of the seat's definition: node-hostname names /etc/hosts and /etc/hostname, node-uplink names /etc/resolv.conf stepping back for node-resolver, node-resolver names /etc/resolv.conf. CanHold refuses a holder that does not declare every path (registration and handover), registration refuses a module declaring a seat's path while claiming none of the seats naming it, a module's own seat names paths under the same rules (node only, literal, not named by another seat), composition refuses a generated resource on a seat's path, the uplink's step-back is read from the seat instead of a special case, and `seats` shows each seat's paths and who owns each on each machine.
269 lines
12 KiB
Go
269 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|