diff --git a/cmd/mesh-compose/main.go b/cmd/mesh-compose/main.go index 22a5753..bd30708 100644 --- a/cmd/mesh-compose/main.go +++ b/cmd/mesh-compose/main.go @@ -23,11 +23,17 @@ import ( "flag" "fmt" "io" + "maps" "os" "path/filepath" + "slices" "sort" "strings" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" + "github.com/novox/mesh-host/internal/composer" ) @@ -105,7 +111,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if *declarations { req.Declarations = true } - v, err := composer.Run(req) + v, err := runWithSeats(req) if err != nil { fmt.Fprintln(stderr, "mesh-compose:", err) return 1 @@ -118,6 +124,46 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 0 } +// NoSeatList is what a machine reads, not judged, when the request carries no seat list (novox/hq ADR 0636 §1). +const NoSeatList = "the composer was handed no seat list, so it composes nothing (a reader of the seat list acts on nothing " + + "new until it has read the list, novox/hq ADR 0636 §1): the asker gives the seat definitions it judges against in the request's seats" + +// runWithSeats composes req against the seat list the request carries (Request.Seats: the definitions the asker's seat list +// reader holds, the members' heads' laid over them in a check, ADR 0636 §4), over the foundation's bootstrapped ones +// (seatlist.Bootstrapped), as the node-engine composes against the list it read from the bus (ADR 0635 §1). The command +// compiles no seat list: without one in the request it reads every manifest and judges no machine, each said not judged +// with NoSeatList (ADR 0593 §9), never a refusal or a pass made without the seats' paths, deliveries and holders. +func runWithSeats(req composer.Request) (composer.Verdict, error) { + if len(req.Seats) == 0 { + machines := req.Machines + req.Machines = nil + v, err := composer.Run(req) + if err != nil || len(machines) == 0 { + return v, err + } + for i := range v.Cases { + v.Cases[i].Machines = map[string]composer.Machine{} + for _, node := range machines { + v.Cases[i].Machines[node] = composer.Machine{NotJudged: NoSeatList} + } + } + return v, nil + } + defs := map[string]seatdef.Definition{} + for _, d := range seatlist.Bootstrapped() { + defs[d.Seat] = d + } + for _, raw := range req.Seats { + d, err := seatdef.Parse(raw) + if err != nil { + return composer.Verdict{}, fmt.Errorf("a seat definition of the request: %w", err) + } + defs[d.Seat] = d + } + catalogue.UseSeats(catalogue.SeatsFromDefinitions(slices.Collect(maps.Values(defs)))) + return composer.Run(req) +} + // readCatalogue reads every module.json of a catalogue checkout by its module's name: the root's and each of // modules/*/. A manifest whose name cannot be read is kept under its directory's name, so the reader's refusal is said. func readCatalogue(dir string) (map[string]composer.Manifest, error) { diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go index 0b69063..ae0fcf4 100644 --- a/cmd/mesh-compose/main_test.go +++ b/cmd/mesh-compose/main_test.go @@ -17,6 +17,7 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" "github.com/novox/mesh-host/internal/beside" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/composer" @@ -49,11 +50,25 @@ func meshOf() compose.Input { func request(t *testing.T, depth string) composer.Request { t.Helper() r := composer.Request{V: composer.Version, Depth: depth, HoldingsFromClaims: true, - Machines: []string{"anchor", "laptop", "ace"}, - Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}} + Machines: []string{"anchor", "laptop", "ace"}, Seats: seats(t), + Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}} return withInput(t, r, meshOf()) } +// seats is the seat list a request carries, as the checker hands the one its reader holds (ADR 0635 §1, ADR 0636 §4). +func seats(t *testing.T) []json.RawMessage { + t.Helper() + var out []json.RawMessage + for _, d := range seatlisttest.Definitions() { + raw, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + out = append(out, raw) + } + return out +} + // withInput is r with in as its input, as a caller puts it on the wire: the request's shape carries the input as JSON. func withInput(t *testing.T, r composer.Request, in compose.Input) composer.Request { t.Helper() @@ -124,6 +139,31 @@ func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) { t.Errorf("%s does not compose and says no reason", name) } } + + // Issue 1966, the group try of 2026-11-06 10:07: the composer judges against the seat list its request carries, the + // node-engine's from the bus (ADR 0635 §1). The uplink's holder steps back from the resolver file for the resolver's + // holder by the seat's definition; without the list nothing is judged (ADR 0636 §1), never "both declare the path". + resolverMachine := func(withSeats bool) composer.Machine { + r := composer.Request{V: composer.Version, Depth: composer.DepthCompose, HoldingsFromClaims: true, + Machines: []string{"laptop"}, Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}} + if withSeats { + r.Seats = seats(t) + } + r = withInput(t, r, compose.Input{Nodes: []compose.Node{{Name: "laptop", + Assigned: []string{"networkmanager", "systemd-resolved", "pacman", "systemd", "dnsmasq"}, + Capabilities: map[string]bool{"file-references": true, "privileged": true, "service-manager": true, "package-manager": true, "uplink-networkmanager": true}}}}) + code, v, stderr := runJSON(t, nil, encode(t, r)) + if code != 0 || len(v.Cases) != 1 { + t.Fatalf("exit %d: %s", code, stderr) + } + return v.Cases[0].Machines["laptop"] + } + if m := resolverMachine(true); strings.Contains(m.Reason+strings.Join(m.Problems, " "), "both declare") || m.NotJudged != "" { + t.Errorf("with the seat list, the uplink's and the resolver's holders on one machine: %+v, want the uplink's stepping back", m) + } + if m := resolverMachine(false); m.Composes || m.NotJudged != NoSeatList || m.Reason != "" { + t.Errorf("without a seat list: %+v, want not judged, saying why", m) + } } // 1621/AC3: over the captured catalogue and a mesh of three machines, the command's verdict equals, machine for @@ -196,7 +236,7 @@ func TestACatalogueCheckoutIsComposedForANode(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil { t.Fatal(err) } - in := encode(t, withInput(t, composer.Request{V: composer.Version}, compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}})) + in := encode(t, withInput(t, composer.Request{V: composer.Version, Seats: seats(t)}, compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}})) code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in) if code != 0 { t.Fatalf("exit %d: %s", code, stderr)