diff --git a/go/compose/rendering_test.go b/go/compose/rendering_test.go new file mode 100644 index 0000000..0f6d09b --- /dev/null +++ b/go/compose/rendering_test.go @@ -0,0 +1,323 @@ +package compose + +import ( + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/overlay" +) + +// aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide). +func aWebModule() catalogue.Manifest { + return catalogue.Manifest{Module: "web", Version: "1", + Listens: []catalogue.Listening{{Name: "http", Port: 8080, From: catalogue.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "web", "ports": []any{"8080"}, + "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}} +} + +// aStoreModule is mesh-controller's sendable_integration_test.go TestConsumersAreToldTheGivenPort store (cmd/mesh-controller +// at 6c2a8d62): publishing 5432:5432, offering a database to the mesh. +func aStoreModule() catalogue.Manifest { + return catalogue.Manifest{Module: "store", Version: "1", + Provides: []catalogue.Offer{{Name: "database", Scope: catalogue.ScopeMesh}}, + Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}}, + Guards: []int{5432}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store", + "ports": []any{"5432:5432"}, + "image": "registry.example/pg@sha256:" + strings.Repeat("b", 64)}}} +} + +func placed(t *testing.T, in Input, node string) Placed { + t.Helper() + got, err := Places(in, node) + if err != nil { + t.Fatalf("%v", err) + } + return got +} + +// 1363/AC1 and AC2: a port the Input holds as allocated on the node is the one composed; with none, no number is invented: the +// manifest's own is composed and the port is named foreseen, as the Foreseeing mode does. +func TestAnAllocatedPortIsComposedAndAnUnallocatedOneIsNamedForeseenNotInvented(t *testing.T) { + mk := func() Input { + in := aMesh(t) + in.Catalogue["web"] = aWebModule() + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "web") + return in + } + in := mk() + got := placed(t, in, "laptop") + if p := got.Rendering.Ports["web"][8080]; p != 8080 { + t.Errorf("a port nobody allocated is composed as %d, not as the manifest's own", p) + } + if len(got.ForeseenPorts) != 1 || got.ForeseenPorts[0] != "web:8080" { + t.Errorf("foreseen ports are %v", got.ForeseenPorts) + } + nodeOf(t, &in, "laptop").Ports = map[string]map[int]int{"web": {8080: 20123}} + got = placed(t, in, "laptop") + if p := got.Rendering.Ports["web"][8080]; p != 20123 || len(got.ForeseenPorts) != 0 { + t.Errorf("the allocated port is composed as %d (foreseen %v)", p, got.ForeseenPorts) + } + // A port the module does not publish is not the mesh's to move, so it is never foreseen. + m := aWebModule() + m.Resources = nil + in.Catalogue["web"] = m + nodeOf(t, &in, "laptop").Ports = nil + got = placed(t, in, "laptop") + if got.Rendering.Ports["web"][8080] != 8080 || len(got.ForeseenPorts) != 0 { + t.Errorf("an unpublished port: %v foreseen %v", got.Rendering.Ports, got.ForeseenPorts) + } + unchanged(t, mk, func(in Input) { placed(t, in, "laptop") }) +} + +// 1363/AC3: a port the operator gave is used, over the allocated one, and a consumer elsewhere is told it +// (mesh-controller sendable_integration_test.go TestConsumersAreToldTheGivenPort). +func TestAPortTheOperatorGaveIsUsedAndNotReallocated(t *testing.T) { + in := aMesh(t) + in.Catalogue["store"] = aStoreModule() + in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"database"}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "store") + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "app") + anchor.Ports = map[string]map[int]int{"store": {5432: 20999}} + anchor.Settings = map[string][]catalogue.Layer{"store": {{From: "anchor", + Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}}} + + got := placed(t, in, "anchor") + if p := got.Rendering.Ports["store"][5432]; p != 5433 { + t.Errorf("the store is on %d, not the port it was given", p) + } + if g := got.Rendering.Given["store"][5432]; g != 5433 { + t.Errorf("the given port is %d", g) + } + if len(got.ForeseenPorts) != 0 { + t.Errorf("a given port is foreseen: %v", got.ForeseenPorts) + } + resolved, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if told := need(t, resolved.Plan, "app", "database").Serves["port"]; told != 5433 { + t.Errorf("the consumer is told the store is on %v", told) + } + + // One holder per machine port across the node's modules: two given the same refused, in the controller's words. + in.Catalogue["store2"] = func() catalogue.Manifest { + m := aStoreModule() + m.Module, m.Provides = "store2", nil + m.Resources = []map[string]any{{"id": "server", "type": "container", "name": "other", "ports": []any{"5432:5432"}, + "image": "registry.example/pg@sha256:" + strings.Repeat("c", 64)}} + return m + }() + anchor.Assigned = append(anchor.Assigned, "store2") + anchor.Settings["store2"] = []catalogue.Layer{{From: "anchor", + Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}} + _, err = Places(in, "anchor") + if err == nil || !errors.Is(err, ErrPortTaken) || !strings.Contains(err.Error(), "store and store2 are both given machine port 5433 on anchor") && + !strings.Contains(err.Error(), "store2 and store are both given machine port 5433 on anchor") { + t.Fatalf("two modules given one machine port: %v", err) + } +} + +// 1363/AC4: the seats held and taken reach the Rendering: where each mesh-scoped seat's holder put its ports, from the node's +// given and assigned ports, never the manifest's own; and on an adopted node a holder assigned but not taken counts only by its +// given ports (novox/hq ADR 0100). +func TestSeatsHeldAndTakenReachTheRendering(t *testing.T) { + in := aMesh(t) + holder := aStoreModule() + holder.Claims = []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}} + in.Catalogue["store"] = holder + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "store") + // The shelf is read whole, in this node's set or not: laptop, running none, is told where the anchor's store is only by the + // settings and ports of its own machine, which it has none of. + anchor.Ports = map[string]map[int]int{"store": {5432: 20999}} + got := placed(t, in, "anchor") + if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 { + t.Errorf("the seat's holder is told to be on %d", at) + } + if len(placed(t, in, "laptop").Rendering.Seats) != 0 { + t.Errorf("laptop is told of a holder it has no port for: %v", placed(t, in, "laptop").Rendering.Seats) + } + // Adopted, and the holder assigned but not taken: the found container's ports, not the mesh's assignment. + anchor.Adopted = true + if len(placed(t, in, "anchor").Rendering.Seats) != 0 { + t.Errorf("an untaken holder on an adopted node counts by the mesh's assignment: %v", placed(t, in, "anchor").Rendering.Seats) + } + anchor.Taken = []string{"store"} + got = placed(t, in, "anchor") + if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 || !got.Rendering.Adopted || !got.Rendering.Taken["store"] { + t.Errorf("a taken holder: seats %v adopted %v taken %v", got.Rendering.Seats, got.Rendering.Adopted, got.Rendering.Taken) + } +} + +// 1363/AC4: local networks and outward links (mesh-controller local_network_test.go TestNodeShowsWhichAttachedNetworksCountAndWhy, +// the values): a private network on a link facing outside counts, a public subnet and the private network's own link never do; a +// machine that said none judges to nothing. +func TestLocalNetworksAndOutwardLinksReachTheRendering(t *testing.T) { + in := aMesh(t) + laptop := nodeOf(t, &in, "laptop") + laptop.OutwardLinks = []string{"eth0", "eth1"} + laptop.Networks = []catalogue.AttachedNetwork{ + {Link: "eth0", Network: "192.168.1.10/24"}, {Link: "eth1", Network: "203.0.113.7/24"}, {Link: overlay.Interface, Network: "10.10.0.4/24"}} + laptop.NetworksSaid = true + got := placed(t, in, "laptop").Rendering + if len(got.LocalNetworks) != 1 || got.LocalNetworks[0] != "192.168.1.0/24" { + t.Errorf("the local networks are %v", got.LocalNetworks) + } + if len(got.OutwardLinks) != 2 || got.OutwardLinks[0] != "eth0" { + t.Errorf("the outward links are %v", got.OutwardLinks) + } + laptop.NetworksSaid = false + if got := placed(t, in, "laptop").Rendering; len(got.LocalNetworks) != 0 { + t.Errorf("a machine that never reported has local networks %v", got.LocalNetworks) + } +} + +// 1363/AC4: accounts, the names of the mesh, the mesh range and the operator's export reach the Rendering. +func TestAccountsNamesAndTheOperatorsExportReachTheRendering(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "anchor").Account = "jochens" + in.OperatorExport = &catalogue.KeptExport{} + in.OverlayCIDR = "10.99.0.0/16" + got := placed(t, in, "anchor").Rendering + if got.Accounts["anchor"] != "jochens" || len(got.Accounts) != 1 { + t.Errorf("accounts are %v", got.Accounts) + } + if got.Names["anchor.internal"] != "10.77.0.1" || got.Names["laptop.internal"] != "10.77.0.2" || len(got.Names) != 2 { + t.Errorf("names are %v", got.Names) + } + if len(got.Machines) != 2 || got.Machines["laptop.internal"] != "10.77.0.2" { + t.Errorf("machines are %v", got.Machines) + } + if len(got.Mesh) != 2 || got.Mesh[0] != "10.77.0.1" || got.Mesh[1] != "10.77.0.2" { + t.Errorf("the mesh's addresses are %v", got.Mesh) + } + if got.MeshRange != "10.99.0.0/16" || got.Suffix != "internal" || got.TunnelInterface != overlay.Interface { + t.Errorf("range %q suffix %q tunnel %q", got.MeshRange, got.Suffix, got.TunnelInterface) + } + if got.Kept != nil { + t.Error("the operator's export reaches a node where nothing keeps it") + } + keeper := catalogue.Manifest{Module: "keeper", Version: "1", Keeps: "/var/lib/keeper/export.json"} + in.Catalogue["keeper"] = keeper + nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "keeper") + if got := placed(t, in, "anchor").Rendering; got.Kept != in.OperatorExport { + t.Errorf("a module that keeps the export is not given it: %v", got.Kept) + } + // And the names of a machine that left the network leave with it. + nodeOf(t, &in, "laptop").Assigned = nil + if got := placed(t, in, "anchor").Rendering; len(got.Names) != 1 || got.Names["laptop.internal"] != "" { + t.Errorf("a machine off the network is named: %v", got.Names) + } +} + +// The names the operator gave to peers of an adopted tunnel answer until they enrol (novox/hq issue 112); a node of the mesh owns +// its name. +func TestACarriedPeerIsNamedUntilItEnrols(t *testing.T) { + in := aMesh(t) + in.CarriedPeers = []CarriedPeer{{PublicKey: "K1", Address: "192.0.2.5", Named: "printer"}, + {PublicKey: "K2", Address: "192.0.2.6", Named: "laptop"}, {PublicKey: "K3", Address: "192.0.2.7", Named: "late", EnrolledAs: "late"}} + got := placed(t, in, "anchor").Rendering + if got.Names["printer.internal"] != "192.0.2.5" || got.Names["laptop.internal"] != "10.77.0.2" || got.Names["late.internal"] != "" { + t.Errorf("names are %v", got.Names) + } +} + +// A node-engine older than a contract is not sent the field it would refuse (novox/hq ADR 0240 and after). +func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) { + in := aMesh(t) + laptop := nodeOf(t, &in, "laptop") + if r := placed(t, in, "laptop").Rendering; r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands { + t.Errorf("an engine that never stated anything reads %+v", r) + } + laptop.Stated, laptop.Contract = true, ReadinessContract + if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands { + t.Errorf("an engine at the readiness contract reads %+v", r) + } + laptop.Contract = HandedContract + if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || !r.JudgesRoot || !r.ReadsBusy || !r.Hands { + t.Errorf("an engine at the handed contract reads %+v", r) + } +} + +// The artifact store as a node reaches it: over the private network by its holder's name and the port it put it on, or on loopback +// for the holder alone while nothing is on the network (novox/hq 04-ISSUES/102). +func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) { + in := aMesh(t) + in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1", + Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "artifacts") + if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" { + t.Errorf("laptop reaches the store at %q", got) + } + nodeOf(t, &in, "laptop").Assigned = nil + anchor.Assigned = []string{"artifacts"} // off the network: the holder alone reaches it + if got := placed(t, in, "anchor").Rendering.ArtifactStore; got != "127.0.0.1:5100" { + t.Errorf("the holder reaches the store at %q", got) + } + if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" { + t.Errorf("a machine off the network reaches the store at %q", got) + } + in.Built = []string{"mesh-sdk"} + if got := placed(t, in, "anchor").Rendering.Built; !got["mesh-sdk"] { + t.Errorf("built is %v", got) + } +} + +// A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on +// the port the answering listen is published on there (novox/hq ADR 0199); the same zone declared twice is refused, by name. +func TestAZoneIsPlacedWhereTheMeshPutItAndTheSameZoneTwiceIsRefused(t *testing.T) { + in := aMesh(t) + lab := catalogue.Manifest{Module: "lab", Version: "1", Zone: &catalogue.Zone{Name: "${setting:zone}", Listen: "dns"}, + Listens: []catalogue.Listening{{Name: "dns", Port: 53, Protocol: "udp", From: catalogue.FromMesh}}} + in.Catalogue["lab"] = lab + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "lab") + anchor.Settings = map[string][]catalogue.Layer{"lab": {{From: "anchor", Values: map[string]any{"zone": "Lab.Example."}}}} + anchor.Ports = map[string]map[int]int{"lab": {53: 5353}} + zones := placed(t, in, "laptop").Rendering.Zones + if len(zones) != 1 || zones[0] != (catalogue.ZoneAt{Zone: "lab.example", Node: "anchor", Module: "lab", Address: "10.77.0.1", Port: 5353}) { + t.Fatalf("zones are %+v", zones) + } + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "lab") + laptop.Settings = map[string][]catalogue.Layer{"lab": {{From: "laptop", Values: map[string]any{"zone": "lab.example"}}}} + if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "the mesh's zones cannot be forwarded") { + t.Fatalf("one zone declared twice: %v", err) + } +} + +// Every holder of a replicated seat is listed by internal name at its private address (novox/hq ADR 0223): the holders on record, +// and only the sole claimant when there are none; a holder off the private network is left out. +func TestEveryHolderOfAReplicatedSeatIsListedOnTheNetwork(t *testing.T) { + in := aMesh(t) + resolver := catalogue.Manifest{Module: "resolver", Version: "1", + Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}} + in.Catalogue["resolver"] = resolver + anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") + anchor.Assigned = append(anchor.Assigned, "resolver") + // One claimant and nothing on record: the sole claimant is listed. + if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 1 || got["anchor.internal"] != "10.77.0.1" { + t.Errorf("the sole claimant: %v", got) + } + // Both on record: both are listed (two claimants with nothing on record would be refused at resolution). + laptop.Assigned = append(laptop.Assigned, "resolver") + in.Holdings = []catalogue.Held{ + {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver"}, + {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "resolver"}} + got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"] + if len(got) != 2 || got["anchor.internal"] != "10.77.0.1" || got["laptop.internal"] != "10.77.0.2" { + t.Errorf("both holders on record: %v", got) + } + // A holder off the private network is left out: a resolver named at an address nothing answers waits out its timeout. + in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Address: "10.77.0.3"}, Assigned: []string{"resolver"}}) + in.Holdings = append(in.Holdings, catalogue.Held{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver"}) + if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 2 || got["ace.internal"] != "" { + t.Errorf("a holder off the network is listed: %v", got) + } +}