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.
296 lines
16 KiB
Go
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)
|
|
}
|
|
}
|