package selfcompose import ( "context" "strings" "sync" "testing" "time" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" ) const lemurs = `{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}` // fakeBus is the buckets the composer watches, each a channel a test writes updates to, and the keys Get reads. type fakeBus struct { mu sync.Mutex chans map[string]chan agent.Update kv map[string][]byte // bucket/key said []string } func newFakeBus() *fakeBus { return &fakeBus{chans: map[string]chan agent.Update{}, kv: map[string][]byte{}} } func (b *fakeBus) ch(bucket string) chan agent.Update { b.mu.Lock() defer b.mu.Unlock() if b.chans[bucket] == nil { b.chans[bucket] = make(chan agent.Update, 16) } return b.chans[bucket] } func (b *fakeBus) env() Env { return Env{Node: "n1", Watch: func(_ context.Context, bucket, _ string) (<-chan agent.Update, error) { return b.ch(bucket), nil }, Get: func(_ context.Context, bucket, key string) ([]byte, error) { b.mu.Lock() defer b.mu.Unlock() return b.kv[bucket+"/"+key], nil }, Say: func(s string) { b.mu.Lock(); b.said = append(b.said, s); b.mu.Unlock() }} } func (b *fakeBus) put(t *testing.T, bucket, key string, value []byte) { t.Helper() b.ch(bucket) <- agent.Update{Key: key, Value: value} } func next(t *testing.T, out <-chan agent.Update) agent.Update { t.Helper() select { case u := <-out: return u case <-time.After(5 * time.Second): t.Fatal("no update from the composer") return agent.Update{} } } func quiet(t *testing.T, out <-chan agent.Update) { t.Helper() select { case u := <-out: t.Fatalf("the composer handed %s, and nothing changed", u.Key) case <-time.After(50 * time.Millisecond): } } // Issue 1126 (ADR 0547 ยง2): the node composes its wanted modules and its own composition from its inputs on the bus once // every input was read, hands each, then Caught; an input that changes nothing composed hands nothing; a module no want // names any more is taken back; a node that cannot compose says so once and hands nothing. func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) { b := newFakeBus() fp := delivery.SourcePrefix2 + strings.Repeat("a", 64) digest := delivery.ManifestDigest([]byte(lemurs)) man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "lemurs", JSON: lemurs}) key, _ := delivery.ManifestsKey(digest) b.kv[delivery.ManifestsBucket+"/"+key] = man ctx, cancel := context.WithCancel(context.Background()) defer cancel() c := New(b.env()) out, err := c.Watch(ctx, "n1.>") if err != nil { t.Fatal(err) } place, _ := delivery.EncodePlacement(delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []delivery.PlacedModule{{Module: "lemurs"}}}) have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "n1", TunnelKey: "k", Capabilities: []string{"systemd"}}}) mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) want, _ := delivery.EncodeWant(delivery.Want{FP: fp}) nodeWant, _ := delivery.EncodeNodeWant(delivery.NodeWant{}) b.put(t, delivery.PlacementBucket, "placement.n1", place) b.put(t, delivery.HaveBucket, "n1.node", have) b.put(t, delivery.ModulesBucket, "lemurs", mod) b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", want) b.put(t, delivery.AssignerWantsBucket, "want.n1.node", nodeWant) for _, bucket := range []string{delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.HaveBucket, delivery.ModulesBucket, ResolvedBucket} { b.ch(bucket) <- agent.Update{Caught: true} } got := map[string]bool{} for { u := next(t, out) if u.Caught { break } c, err := delivery.DecodeComposed(u.Value) if err != nil { t.Fatalf("%s: %v", u.Key, err) } if c.Node != "n1" { t.Errorf("%s composed for %q", u.Key, c.Node) } got[u.Key] = true } lemursKey, _ := delivery.ComposedKey("n1", "lemurs", fp) nodeKey, _ := delivery.ComposedNodeKey("n1") if !got[lemursKey] || !got[nodeKey] || len(got) != 2 { b.mu.Lock() t.Logf("said: %v", b.said) b.mu.Unlock() t.Fatalf("handed %v, want %s and %s", got, lemursKey, nodeKey) } // A config value of another module, and a value changing nothing composed, hand nothing. b.put(t, ResolvedBucket, "n1.other.k", []byte(`{"value":1}`)) quiet(t, out) // A want removed takes its composition back. removed, _ := delivery.EncodeWant(delivery.Want{Removed: true}) b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", removed) if u := next(t, out); u.Key != lemursKey || !u.Deleted { t.Fatalf("a removed want handed %+v", u) } // A want of a module whose manifest cannot be read: said once, nothing handed, what was handed stands. gone := delivery.SourcePrefix2 + strings.Repeat("b", 64) missing, _ := delivery.EncodeWant(delivery.Want{FP: gone}) b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", missing) quiet(t, out) b.put(t, delivery.AssignerWantsBucket, "want.n1.lemurs", missing) quiet(t, out) b.mu.Lock() n := 0 for _, s := range b.said { if strings.Contains(s, "lemurs is wanted at") { n++ } } b.mu.Unlock() if n != 1 { t.Errorf("the fault was said %d times, want once: %v", n, b.said) } }