diff --git a/go/catalogue/verbs.go b/go/catalogue/verbs.go index 11f0b3e..9f980fb 100644 --- a/go/catalogue/verbs.go +++ b/go/catalogue/verbs.go @@ -126,6 +126,13 @@ var ControllerVerbs = []Verb{ {Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.", Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"}), Replaces: []string{"hostnamectl", "uptime"}}, + {Name: "placement-export", Description: "Each machine's placement as the catalogue's placement file says it (novox/hq ADR 0555 " + + "§6, issue 1126): what a person decided of the machine (endpoint, site, hub, address on the private network, public " + + "domain, accounts, adoption, the tunnel found at adoption) and where its modules run (in order, with host ports, " + + "bindings, config placement layers, pins and holdings), read by the controller's own code from its own store, as JSON. " + + "Read-only; the one verb the controller gains for its exit (the operator, 2026-10-26 19:06:40), read once by the seed " + + "of the placement files.", + Input: schema(map[string]string{"node": "one machine's name; every machine when absent"}, nil)}, {Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " + "and which machines run it.", Input: schema(nil, nil)}, diff --git a/go/compose/frombus/export.go b/go/compose/frombus/export.go new file mode 100644 index 0000000..996bb1e --- /dev/null +++ b/go/compose/frombus/export.go @@ -0,0 +1,100 @@ +package frombus + +import ( + "fmt" + "sort" + "strconv" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Export is one node's placement file as the composition input the controller fills says it (novox/hq ADR 0555 §6, +// issue 1126): the inverse of Input for what a person decides, so the controller's one read-only verb +// `placement-export` answers, from its own store by its own code, exactly what the seed writes into the catalogue. +// What the machine observes (its record) is not in it. +func Export(in compose.Input, node string) (delivery.Placement, error) { + var n *compose.Node + for i := range in.Nodes { + if in.Nodes[i].Name == node { + n = &in.Nodes[i] + } + } + if n == nil { + return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node) + } + p := delivery.Placement{Node: node, Decided: delivery.NodeDecided{ + Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address, + PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount, + AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}} + // The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted + // one whether or not the hub took it; and the names a person gave the hub's carried peers. + if t, ok := in.Tunnels[node]; ok { + rt := recordTunnelOf(t.Tunnel) + p.Decided.Tunnel = &rt + } else if in.Adopted != nil && in.Adopted.Hub == node { + rt := recordTunnelOf(in.Adopted.Tunnel) + p.Decided.Tunnel = &rt + } + if n.Place.Hub { + for _, c := range in.CarriedPeers { + if c.Named != "" { + if p.Decided.PeerNames == nil { + p.Decided.PeerNames = map[string]string{} + } + p.Decided.PeerNames[c.PublicKey] = c.Named + } + } + } + for _, module := range n.Assigned { + m := delivery.PlacedModule{Module: module} + for port, at := range n.Ports[module] { + if m.Ports == nil { + m.Ports = map[string]int{} + } + m.Ports[strconv.Itoa(port)] = at + } + for provision, to := range n.Bound[module] { + if m.Bindings == nil { + m.Bindings = map[string]delivery.Provider{} + } + m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module} + } + for _, layer := range n.Settings[module] { + if layer.From == catalogue.MeshWideLayer { + m.HasMesh, m.MeshWide = true, layer.Placement + } else { + m.HasLayer, m.Layer = true, layer.Placement + } + } + p.Modules = append(p.Modules, m) + } + for provision, to := range n.Pins { + if p.Pins == nil { + p.Pins = map[string]delivery.Provider{} + } + p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module} + } + for _, h := range in.Holdings { + if h.Node == node { + p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind}) + } + } + sort.SliceStable(p.Holds, func(i, j int) bool { + if p.Holds[i].Claim != p.Holds[j].Claim { + return p.Holds[i].Claim < p.Holds[j].Claim + } + return p.Holds[i].Module < p.Holds[j].Module + }) + return p, nil +} + +func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel { + out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU, + Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} + for _, peer := range t.Peers { + out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address}) + } + return out +} diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index 8fdc0d5..5eeaafa 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -77,7 +77,7 @@ func TestInput(t *testing.T) { "anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1", Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}}, Modules: []delivery.PlacedModule{{Module: overlay.Name}}, - Holds: []delivery.Holding{{Claim: "mesh-terminal", Scope: "mesh", Module: "xterm"}}}, + Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}}, "laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: overlay.Name}, {Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}}}, @@ -116,7 +116,7 @@ func TestInput(t *testing.T) { Ports: map[string]map[int]int{"xterm": {80: 8080}}, Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}}}}, }, - Holdings: []catalogue.Held{{Claim: "mesh-terminal", Scope: "mesh", Node: "anchor", Module: "xterm"}}, + Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}}, OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "xterm", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}}, Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true}, @@ -153,6 +153,22 @@ func TestInput(t *testing.T) { t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore)) } + // ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the + // placement the bus holds, and read back it is the store's input again. + for node, placed := range mk().placements { + got, err := Export(want, node) + if err != nil { + t.Fatal(err) + } + if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) { + pj, _ := delivery.EncodePlacement(placed) + t.Errorf("%s exported as %s, want %s", node, gj, pj) + } + } + if _, err := Export(want, "stranger"); err == nil { + t.Error("a node the input lacks was exported") + } + b = mk() if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil { t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets)