package frombus import ( "context" "encoding/json" "fmt" "os" "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, compose.Foreseeing, 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, _, held, err := Compose(ctx, b, Mesh{}, scope, fps) if err == nil && len(held) > 0 { err = fmt.Errorf("modules held: %v", held) } 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 _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil || !strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") { t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held) } // (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, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps) if err == nil && len(held) > 0 { err = fmt.Errorf("modules held: %v", held) } 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) } // The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue // 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and // files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools' // MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is // composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container). for _, c := range running { if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) { t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths) } } f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json") if err != nil { t.Fatal(err) } f1, err := catalogue.ParseManifest(f1raw) if err != nil { t.Fatal(err) } f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{ json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`), json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`), json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`), json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}} if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) } 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) } // (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs // de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values // for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing // praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is // refused, in the config form's words, and proxy, which receives its routes (what it would compose lacks them: review // of the apply, round 1); the rest is judged. spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json") if err != nil { t.Fatal(err) } proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"},{"name":"smtp","scope":"mesh"}],` + `"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`) b = mk("lemurs", overlay.Name, "proxy", "de-spiegel") for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy} { 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" b.placements["n1"] = p fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f")} a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "h1", 3) refused = nil for _, r := range a.Refusals { refused = append(refused, r.Module) if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) { t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why) } } if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel", "proxy"}) { t.Errorf("a module's own refusal: %+v, want de-spiegel refused with proxy, which receives its routes, the node not", a) } if !slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }) { t.Errorf("lemurs, which composes, is not judged beside de-spiegel's refusal: %+v", a) } // (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It // composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was // marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which // receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it // removes nothing of it. spiegelRuns := []json.RawMessage{ json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`), json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`), json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)} if in, err = Input(ctx, b, Mesh{}, stub); err != nil { t.Fatal(err) } in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])} in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest { m.Contributes, m.ContributesMany = nil, nil return m }(in.Nodes[0].Kept["de-spiegel"]) if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil { t.Fatal(err) } running = nil for _, c := range base { if c.Module == "" { c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...) } running = append(running, c) } got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps) if err != nil { t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err) } if len(held) != 2 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["proxy"], "de-spiegel") { t.Errorf("held %v, want de-spiegel in its config form's words, and proxy for its routes", held) } lemursKey, _ := delivery.ComposedKey("n1", "lemurs", fps["lemurs"]) proxyKey, _ := delivery.ComposedKey("n1", "proxy", fps["proxy"]) if _, ok := got[lemursKey]; !ok { t.Errorf("lemurs, which composes, is not composed beside de-spiegel's refusal: %v", sortedKeys(got)) } if _, ok := got[proxyKey]; ok { t.Errorf("proxy is composed without the routes of held de-spiegel: %v", sortedKeys(got)) } carries := func(c delivery.Composed, what string) { t.Helper() ownRes, _ := json.Marshal(c.Resources) for _, raw := range spiegelRuns { var r struct{ ID string } _ = json.Unmarshal(raw, &r) if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) { t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes) } } } carries(got[ownKey], "folded") // (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form, // so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried // in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its // container, files and units (issue 1945 by a new path). fps["de-spiegel"] = fp("7") got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps) if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") { t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held) } spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"]) if _, ok := got[spiegelKey]; ok { t.Errorf("held de-spiegel's own composition is handed") } carries(got[ownKey], "leaving the fold") // (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others // compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own // secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. A module whose // composition depends on how site is held (here the filter) is held with it. site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` + `"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` + `"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` + `{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`) b = mk("lemurs", overlay.Name, "site", "nft") for m, raw := range map[string][]byte{"site": site, "nft": nft} { 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 == "site" { 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"), "site": fp("8")} siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{ json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`), json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}} got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) if err != nil || held["site"] == "" || held["nft"] == "" { t.Fatalf("site, its own secret never issued: %v, held %v; want site held, and the filter, which depends on it", err, held) } for key, c := range got { for _, raw := range c.Resources { if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) { t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw) } } } // (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its // manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the // build that runs listens on 8080: the filter, the holder of what site listens on, is held with it, and nothing // applied opens 9090. moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1)) b.manifests[delivery.ManifestDigest(moved)] = moved b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)} for i := range p.Modules { p.Modules[i].Ports = nil } b.placements["n1"] = p siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`) got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) if err != nil || held["site"] == "" || held["nft"] == "" { t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held, and the filter with it", err, held) } for key, c := range got { for _, raw := range c.Resources { if strings.Contains(string(raw), "dport 9090") { t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw) } } } } // 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) } }