Files
mesh-sdk/go/compose/frombus/answer_rows_test.go
T
jschoubben 16b8d8a1ee Compose as the controller does, so a node's shadow can answer same (issue 1126, C1 C2 C3 C5 C6, T1)
Seals are reused and compared by slot (resource, field), never across resources;
one rule says which resolved config values are laid; a seat path that steps back
is owned by the seat it steps back for, whatever the seat order; a folded module
of an old-form build is composed from what runs of it; the operator export's file
is carried as it runs; a differing resource names its differing JSON paths, found
before its bodies are cut, and an answer is fitted to the node's key.
2026-10-28 09:36:36 +01:00

296 lines
16 KiB
Go

package frombus
import (
"context"
"encoding/json"
"fmt"
"reflect"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
)
// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only;
// and a module folded into its own composition that names a build of the old form (de-spiegel on novox, nats, photos,
// systemd-resolved: their haves name a build-... id and no fingerprint) is left to the node, carried as it runs.
func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
ctx := context.Background()
image := "alpine@sha256:" + strings.Repeat("a", 64)
manifest := func(module string) []byte {
// Its data names its directory, as nats's names ${dir:mesh-state} (issue 1126: novox's own composition refused
// "nats says ${dir:mesh-state}, and nats declares no directory mesh-state" when its stub held no directory).
return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` +
`"own-secrets":{"token":"${dir:state}/token"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/` + module + `","mode":"0700"},` +
`{"id":"server","type":"container","name":"` + module + `","artifact":"site"}],` +
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
}
lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) }
mk := func(placed ...string) *bus {
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
for _, m := range placed {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
}
b := &bus{placements: map[string]delivery.Placement{"n1": p},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}},
manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs},
builds: map[string]*delivery.BuilderBuild{}}
for _, m := range []string{"web", "api", "spiegel"} {
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
}
for _, m := range []string{"web", "api", "spiegel"} {
raw := manifest(m)
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
// web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of
// the new form at all.
for i, m := range []string{"web", "api"} {
mod := b.modules[m]
mod.FP2 = fp(string(rune('1' + i)))
b.modules[m] = mod
b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}}
}
return b
}
given := func(b *bus) map[string]*delivery.Placement {
p := b.placements["n1"]
return map[string]*delivery.Placement{"n1": &p}
}
// Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named.
b := mk("lemurs", "web", "api", "spiegel")
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "h1", 3)
var refused []string
for _, r := range a.Refusals {
refused = append(refused, r.Module)
}
if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) {
t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a)
}
for _, r := range a.Refusals {
want := map[string]string{"api": "waits for the reference of site", "web": "waits for the reference of site",
"spiegel": "spiegel has not been built for this mesh"}[r.Module]
if !strings.Contains(r.Why, want) {
t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want)
}
}
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
t.Errorf("the answer is not one a have holds: %v (%s)", err, raw)
}
if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK {
t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a)
}
// The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as
// they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself.
b = mk("lemurs", overlay.Name, "spiegel")
p := b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
b.placements["n1"] = p
fps := map[string]string{"lemurs": fp("e")}
stub := Scope{Node: "n1"}
in, err := Input(ctx, b, Mesh{}, stub)
if err != nil {
t.Fatal(err)
}
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
base, _, err := compose.Compositions(in, "n1", fps, compose.Reading)
if err != nil {
t.Fatal(err)
}
// A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's
// answer took 7m46s, one compose per module), though here a stub would compose.
if m := culprit(in, "n1", fps, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" {
t.Errorf("a refusal naming no placed module was laid on %s", m)
}
ownKey, _ := delivery.ComposedNodeKey("n1")
own := base[ownKey]
carriedRes := []json.RawMessage{
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
// They run after the node's first own resource.
own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...)
var running []delivery.Composed
for k, c := range base {
if k == ownKey {
c = own
}
running = append(running, c)
}
scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}
got, _, err := Compose(ctx, b, Mesh{}, scope, fps)
if err != nil {
t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err)
}
gotOwn, _ := json.Marshal(got[ownKey].Resources)
wantOwn, _ := json.Marshal(own.Resources)
if string(gotOwn) != string(wantOwn) {
t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn)
}
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "h1", 3)
if a.Verdict != delivery.CandidateSame {
t.Errorf("the fold's answer: %+v, want the same", a)
}
// (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a
// module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer).
keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 {
t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a)
}
// (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed
// the same, not 'in what runs, not composed' (seed #531, novox).
membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`)
withMembership := make([]delivery.Composed, 0, len(running))
for _, c := range running {
if c.Module == "" {
c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership)
}
withMembership = append(withMembership, c)
}
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "h1", 3); a.Verdict != delivery.CandidateSame {
t.Errorf("the node's own with its bus membership is answered %+v, want the same", a)
}
// Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it.
if _, _, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err == nil ||
!strings.Contains(err.Error(), "spiegel has not been built for this mesh") {
t.Errorf("composed spiegel without what runs: %v", err)
}
// (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter
// opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a
// stub had no container, so snake's 80 on ace was opened beside the route proxy's). And C6: the operator export's file
// a module that keeps it runs is carried as it runs (mesh-vault.kept on novox), since the node holds no export.
listening := []byte(`{"module":"spiegel","version":"1","capabilities":["container-runtime"],` +
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"token":"${dir:state}/token"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/spiegel","mode":"0700"},` +
`{"id":"server","type":"container","name":"spiegel","artifact":"site","ports":["8080"]}],` +
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
nft := []byte(`{"module":"nft","version":"1","capabilities":["systemd"],"filtering":{"into":"/etc/nftables.conf"}}`)
vault := []byte(`{"module":"vault","version":"1","capabilities":["systemd"],"keeps":"/var/lib/vault/root",` +
`"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`)
b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault")
for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} {
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
p = b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
for i := range p.Modules {
if p.Modules[i].Module == "spiegel" {
p.Modules[i].Ports = map[string]int{"8080": 20005}
}
}
b.placements["n1"] = p
rec := b.records["n1"]
rec.OutwardLinks = []string{"eth0"}
b.records["n1"] = rec
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "vault": fp("9")}
if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
t.Fatal(err)
}
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
t.Fatal(err)
}
keptFile := json.RawMessage(`{"digest":"sha256:` + strings.Repeat("d", 64) + `","id":"vault.kept","mode":"0600","module":"vault","path":"/var/lib/vault/root/export.json","size":143604,"type":"file"}`)
running = nil
for _, c := range base {
switch c.Module {
case "":
c.Resources = append(append([]json.RawMessage{}, c.Resources...),
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:`+strings.Repeat("c", 64)+`","ports":["10.77.0.1:20005:8080"]}`),
json.RawMessage(`{"id":"spiegel.server-env","type":"file","path":"/var/lib/spiegel/server.env","mode":"0600","content":"A=1\n"}`))
case "vault":
c.Resources = append(append([]json.RawMessage{}, c.Resources...), keptFile)
}
running = append(running, c)
}
got, _, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps)
if err != nil {
t.Fatalf("the node does not compose itself with spiegel carried: %v", err)
}
var rules string
var keptComposed json.RawMessage
for _, c := range got {
for _, raw := range c.Resources {
var r struct {
ID string `json:"id"`
Content string `json:"content"`
}
_ = json.Unmarshal(raw, &r)
switch r.ID {
case "nft." + catalogue.FilteringID():
rules = r.Content
case "vault." + catalogue.KeptID():
keptComposed = raw
}
}
}
if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") {
t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules)
}
for _, c := range running {
if c.Module == "" && !slices.Contains(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).RunsAt, "/var/lib/spiegel/server.env") {
t.Errorf("spiegel carried does not own the path its running file is at")
}
}
var k1, k2 any
_ = json.Unmarshal(keptComposed, &k1)
_ = json.Unmarshal(keptFile, &k2)
if !reflect.DeepEqual(k1, k2) {
t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile)
}
}
// A module another node cannot run in this composition offers nothing in it: the refusal names it and why
// (issue 1126, probe #532: ace and g14 answered "nothing in this mesh provides artifact-store … assign distribution to a
// node" while novox runs it). And a refusal no module's stub changes is answered at once, not after a compose per module
// (ace took 7m46s).
func otherNodeNotResolvedIsNamed(t *testing.T) {
ctx := context.Background()
prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`)
cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`)
// n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here.
b := &bus{placements: map[string]delivery.Placement{
"n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
"n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}},
manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}}
given := map[string]*delivery.Placement{}
for n, p := range b.placements {
p := p
given[n] = &p
}
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "h", 1)
if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
t.Errorf("a refusal for want of n2's provider = %+v", a)
}
}
// A node that reaches no artifact store says what its composition holds of one (issue 1126: ace and g14 answered
// "reaches no artifact store", and nothing said why).
func noStoreReachedSaysWhy(t *testing.T) {
in := compose.Input{Catalogue: map[string]catalogue.Manifest{
"distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}},
Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}}
if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" {
t.Errorf("said %q", got)
}
in.Nodes = in.Nodes[:1]
if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" {
t.Errorf("said %q", got)
}
}