Files
mesh-sdk/go/compose/frombus/answer_test.go
T
jschoubben 2fd5ffdd62 A node's answer names every module it cannot compose, a folded module of an old-form build is left to the node as it runs, and no store reached says why (issue 1126, the seed's shadow)
The seed's shadow answers named the first refusal only (ace: baserow's archive,
novox: de-spiegel), so each round showed one more. A build that cannot be read or
taken in, and a module the composition refuses, are now each composed as a stub
and named. A module folded into the node's own composition whose manifest names a
build (de-spiegel, nats, photos, systemd-resolved: haves naming a build-... id)
runs a build only the controller holds: it is composed as a stub and its
resources are carried as the node runs them.
2026-10-27 23:17:05 +01:00

153 lines
7.5 KiB
Go

package frombus
import (
"context"
"encoding/json"
"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 TestTheAnswerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
ctx := context.Background()
image := "alpine@sha256:" + strings.Repeat("a", 64)
manifest := func(module string) []byte {
return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` +
`"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"} {
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)
}
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)
}
// 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)
}
}
// 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 TestNoStoreReachedSaysWhy(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)
}
}