Give the uplink seat its verbs, staged until its holders serve them (hq ADR 0241)

What a machine resolves through and over which links had no tool: the
resolver file and its writer, and each link with its routes and resolvers,
are now verbs of node-uplink, the same whatever manages the network. A verb
required at once would refuse every holder before the catalogue could serve
it, and a stored one would be required by an older controller, so it is
staged: promised and routed, never stored, and not yet a condition of
holding.
This commit is contained in:
jochen
2026-10-07 20:16:16 +02:00
parent f6aea4bfbb
commit 161e676b97
4 changed files with 153 additions and 2 deletions
+61 -2
View File
@@ -2,6 +2,7 @@ package catalogue
import ( import (
"fmt" "fmt"
"slices"
"sort" "sort"
"strings" "strings"
) )
@@ -53,6 +54,14 @@ type Seat struct {
// ${contribution:<seat>:<kind>}. Compiled, never stored: like the protocol, it is the mesh's // ${contribution:<seat>:<kind>}. 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. // definition of the role, and the store's rows carry no column for it.
Receives []Receivable Receives []Receivable
// Staged names verbs of Serves that are promised and routed but not yet a condition of holding the
// seat: a verb added to a seat whose holders do not serve it yet. Holding requires every verb, and a
// claim may name only promised ones (ADR 0132), so a verb required at once would refuse every holder
// until the catalogue serves it — and the catalogue could not name it until this controller ran. Staged,
// the controller rolls out first, the holders serve the verb next, and a later change requires it.
// Compiled, never stored, and kept out of the stored row's serves: a controller older than the verb
// reads the row strictly and would require what no holder of its time serves.
Staged []string
// Decision is the record that made it a seat. // Decision is the record that made it a seat.
Decision string Decision string
} }
@@ -281,7 +290,13 @@ var defaultSeats = append([]Seat{
// channel a fix could arrive on. A 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 // 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). // 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. Staged until both holders serve them.
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117",
Serves: uplinkVerbs(), Staged: []string{"resolvers", "links"}},
}, },
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
graphicalSessionSeats()...) graphicalSessionSeats()...)
@@ -338,6 +353,17 @@ func UseSeats(s []Seat) {
if d, known := byName[row.Name]; known { if d, known := byName[row.Name]; known {
row.Receives = d.Receives row.Receives = d.Receives
row.Replicated = d.Replicated row.Replicated = d.Replicated
// Staged verbs are never in the row: they come from the binary that promises them.
row.Staged = d.Staged
have := map[string]bool{}
for _, v := range row.Serves {
have[v.Name] = true
}
for _, v := range d.Serves {
if slices.Contains(d.Staged, v.Name) && !have[v.Name] {
row.Serves = append(row.Serves, v)
}
}
} }
merged = append(merged, row) merged = append(merged, row)
} }
@@ -477,7 +503,7 @@ func CanHold(m Manifest, seat Seat) error {
// **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that // **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 // 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. // at handover instead, naming the verbs rather than the fact that something is missing.
if missing := unservedVerbs(claimed.ServesFor(m), seat.Serves); len(missing) > 0 { if missing := unservedVerbs(claimed.ServesFor(m), seat.Required()); len(missing) > 0 {
return fmt.Errorf("%s claims %s but does not serve %s, which that seat's protocol promises "+ return fmt.Errorf("%s claims %s but does not serve %s, which that seat's protocol promises "+
"(novox/hq ADR 0132) — a holder names every verb its seat declares, under the claim's "+ "(novox/hq ADR 0132) — a holder names every verb its seat declares, under the claim's "+
"serves or among its own tools", "serves or among its own tools",
@@ -591,6 +617,39 @@ func serviceManagerVerbs() []Verb {
} }
} }
// Required is the verbs a holder must serve: every one the seat promises but those still staged.
func (s Seat) Required() []Verb {
var out []Verb
for _, v := range s.Serves {
if !slices.Contains(s.Staged, v.Name) {
out = append(out, v)
}
}
return out
}
// Stored is the seat as its row keeps it: its staged verbs left out (see Staged).
func (s Seat) Stored() Seat {
s.Serves = s.Required()
return s
}
// 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)},
{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)},
}
}
// loginShellVerbs is the contract every holder of node-login-shell serves (novox/hq ADR 0176, ADR // 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 // 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. // runtime's thirty-second call limit so a hung command answers rather than times the caller out.
+68
View File
@@ -0,0 +1,68 @@
package catalogue
import (
"slices"
"strings"
"testing"
)
// The node-uplink seat's verbs (novox/hq ADR 0241): promised and routed, staged — 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.
func uplinkSeat(t *testing.T) Seat {
t.Helper()
for _, s := range DefaultSeats() {
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 !slices.Equal(names, []string{"resolvers", "links"}) || len(seat.Required()) != 0 {
t.Fatalf("node-uplink promises %v and requires %v", names, seat.Required())
}
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 TestAStagedVerbIsNeverStoredAndComesBackFromTheBinary(t *testing.T) {
seat := uplinkSeat(t)
if stored := seat.Stored(); len(stored.Serves) != 0 {
t.Fatalf("the row would keep %v", stored.Serves)
}
before := seats
t.Cleanup(func() { seats = before })
// A row as the store keeps it: no protocol of its own. And one an operator gave a verb of its own.
UseSeats([]Seat{{Name: "node-uplink", Scope: ScopeNode}, {Name: "node-service-manager", Scope: ScopeNode}})
for _, s := range Seats() {
if s.Name == "node-uplink" && (len(s.Serves) != 2 || len(s.Staged) != 2) {
t.Fatalf("the working set's node-uplink is %+v", s)
}
}
UseSeats([]Seat{{Name: "node-uplink", Scope: ScopeNode, Serves: []Verb{{Name: "operators-own"}}}})
for _, s := range Seats() {
if s.Name == "node-uplink" && len(s.Serves) != 3 {
t.Fatalf("a row with a verb of its own lost or did not gain the staged ones: %+v", s.Serves)
}
}
}
+2
View File
@@ -62,6 +62,8 @@ func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) { func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) {
var added int var added int
for _, s := range defaults { for _, s := range defaults {
// A verb still staged is never stored (catalogue.Seat.Staged).
s = s.Stored()
accepts, emits, serves, err := protocolJSON(s) accepts, emits, serves, err := protocolJSON(s)
if err != nil { if err != nil {
return added, err return added, err
+22
View File
@@ -156,3 +156,25 @@ func TestTheControllersVerbsInTheRowAreTheBinarys(t *testing.T) {
t.Fatal("a verb this binary adds was not added") t.Fatal("a verb this binary adds was not added")
} }
} }
// A verb still staged (novox/hq ADR 0241) is never in the store's row: a controller older than it reads
// the row and would require what no holder of its time serves.
func TestAStagedVerbIsNotSeeded(t *testing.T) {
inv := ForTest(t)
if _, err := inv.SeedSeats(t.Context(), catalogue.DefaultSeats()); err != nil {
t.Fatal(err)
}
// Seeded twice: the second widens, and must not add it either.
if _, err := inv.SeedSeats(t.Context(), catalogue.DefaultSeats()); err != nil {
t.Fatal(err)
}
got, err := inv.Seats(t.Context())
if err != nil {
t.Fatal(err)
}
for _, s := range got {
if s.Name == "node-uplink" && len(s.Serves) != 0 {
t.Fatalf("the row keeps %v", s.Serves)
}
}
}