diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 64736de..5ad9ea2 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -286,7 +286,12 @@ var defaultSeats = append([]Seat{ // channel a fix could arrive on. A seat // rather than a condition in the resolver's module, so a machine running two managers is // refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117). - {Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"}, + // + // Its verbs say what the machine resolves through and over which links (novox/hq ADR 0241): the + // 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()}, }, // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. graphicalSessionSeats()...) @@ -655,6 +660,22 @@ func serviceManagerVerbs() []Verb { } } +// uplinkVerbs is the contract every holder of node-uplink serves (novox/hq ADR 0241): read-only answers +// about what the machine resolves through and over which links, whatever program manages its network. +func uplinkVerbs() []Verb { + return []Verb{ + {Name: "resolvers", Description: "The machine's resolver file as it is now: the resolvers, search " + + "domains and options it lists, whether it is the file the mesh declares, and when it is not, who " + + "wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link " + + "in its place, a known writer running.", + Input: schema(nil, nil), Optional: true}, + {Name: "links", Description: "Every network link on the machine: its state, its addresses, whether " + + "the default route leaves through it, and the resolvers and search domains the network manager " + + "knows for it — a VPN's tunnel included.", + Input: schema(nil, nil), Optional: true}, + } +} + // loginShellVerbs is the contract every holder of node-login-shell serves (novox/hq ADR 0176, ADR // 0204): one command, run the way the operator's own terminal would run it, bounded below the // runtime's thirty-second call limit so a hung command answers rather than times the caller out. diff --git a/internal/catalogue/uplink_verbs_test.go b/internal/catalogue/uplink_verbs_test.go new file mode 100644 index 0000000..a67fb17 --- /dev/null +++ b/internal/catalogue/uplink_verbs_test.go @@ -0,0 +1,62 @@ +package catalogue + +import ( + "slices" + "strings" + "testing" +) + +// The node-uplink seat's verbs (novox/hq ADR 0241): promised and routed, optional — not yet a condition of +// holding — so the holders of today still hold it, a holder may name them, and nothing it does not promise; +// and still optional once the store's row is read back (optionalAsCompiled). + +func uplinkSeat(t *testing.T) Seat { + t.Helper() + for _, s := range Seats() { + if s.Name == "node-uplink" { + return s + } + } + t.Fatal("no node-uplink seat") + return Seat{} +} + +func TestTheUplinkSeatPromisesItsVerbsAndRequiresNoneYet(t *testing.T) { + seat := uplinkSeat(t) + var names []string + for _, v := range seat.Serves { + names = append(names, v.Name) + if !v.Optional { + t.Errorf("%s is required already", v.Name) + } + } + 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}}} + if err := CanHold(today, seat); err != nil { + t.Fatalf("a holder that serves no verb yet is refused: %v", err) + } + serving := today + serving.Claims = []Claim{{Name: "node-uplink", Scope: ScopeNode, Serves: []string{"resolvers", "links"}}} + if err := CanHold(serving, seat); err != nil { + t.Fatalf("a holder serving both is refused: %v", err) + } + typo := today + typo.Claims = []Claim{{Name: "node-uplink", Scope: ScopeNode, Serves: []string{"resolver"}}} + if err := CanHold(typo, seat); err == nil || !strings.Contains(err.Error(), "does not promise") { + t.Fatalf("a verb the seat does not promise was accepted: %v", err) + } +} + +func TestTheUplinkVerbsReadBackFromTheRowStayOptional(t *testing.T) { + before := seats + t.Cleanup(func() { seats = before }) + // 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}}} + if err := CanHold(holder, seat); err != nil { + t.Fatalf("read back from the row, the verbs are required: %v", err) + } +}