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 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) } 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 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) } }