diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 069e370..89e6e93 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -182,6 +182,10 @@ type Scope struct { // decoder (compose.Input.Holds), with the build it would compose it at, so the node says why it holds it (issue 1921, // the composition verb). Nil: said nowhere. Holds func(map[string]compose.Hold) + // Held is the modules the node holds before anything of their builds is read, each with why (a node that has not read + // the seat list, novox/hq ADR 0636 §1): each is held as it runs, as a module refused alone is, so no take-in or + // artifact reference of it can refuse the node (issue 1966). Nil holds none. + Held map[string]string } // InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs @@ -585,7 +589,10 @@ func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Conte // node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served // elsewhere, a stand-in for the node's own) refuses the node, err. func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]engcomp.Composed, skipped []string, held map[string]string, err error) { - held = map[string]string{} + held = maps.Clone(s.Held) + if held == nil { + held = map[string]string{} + } // A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774, // 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid). defer func() { @@ -933,6 +940,10 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m } } for _, pm := range p.Modules { + if why, held := s.Held[pm.Module]; held && refuse != nil { + stub(pm.Module, errors.New(why)) // nothing of its build is read: it keeps what runs (Scope.Held) + continue + } m, ok := modules[pm.Module] if (!ok || m.Fp2 == "") && s.Unmade != nil && wanted[pm.Module] != "" { s.Unmade(pm.Module, "the indexer's modules hold no build of the new form for it (fingerprint "+m.Fp2+"), so it is composed from the manifest") diff --git a/internal/selfcompose/selfcompose.go b/internal/selfcompose/selfcompose.go index d07bdfa..3810cab 100644 --- a/internal/selfcompose/selfcompose.go +++ b/internal/selfcompose/selfcompose.go @@ -43,6 +43,7 @@ import ( "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/compose/resolution" @@ -997,6 +998,10 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { c.mu.Lock() c.dataScope = frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn} // the data items are worked out as composed c.mu.Unlock() + // A node that has not read the seat list takes nothing of a module whose seats are not all bootstrapped from its build + // (ADR 0636 §1): the module is held as it runs before anything of its build is read, so no take-in or artifact + // reference of it (the artifact store's seat is not bootstrapped) can refuse the pass (issue 1966). + scope.Held = c.heldUntilListed(ctx, r, own) missed := c.missed.Load() compositions, skipped, held, err := frombus.Compose(ctx, r, c.env.Mesh, scope, fps) err = c.missedIn(missed, err) @@ -1021,6 +1026,10 @@ func (c *Composer) compose(ctx context.Context) []agent.Update { } if err != nil { why := fmt.Sprintf("%v", err) + if line, unread := c.seatListUnread(); unread { + // A refusal while the list is unread may be of a seat the node cannot yet know: said first (issue 1966). + why = line + "; " + why + } // A bus that did not answer past every try is no answer: composed again after a bound (issue 1126, 2026-10-28), // and the refusal says when (issue 774, 2026-10-31). A refusal is final until an input moves. if BusFailed(err) { @@ -1869,8 +1878,54 @@ func mapValues(m map[string]compositions.Composed) []compositions.Composed { return out } -// WaitsForTheSeatList is why a module is held while the node has not read the seat list. -const WaitsForTheSeatList = "the node has not read the seat list, so it composes nothing new of this module and keeps what runs of it (novox/hq ADR 0636 §1)" +// WaitsForTheSeatList is why a module is held while the node has not read the seat list, said by a gate that names no +// seat it holds. +const WaitsForTheSeatList = "the node has not read the seat list" + composesNothingNewOfIt + +// WaitsForTheSeatListHeld is why a module is held while the node holds only its bootstrapped seat list (novox/hq ADR 0636 +// §2): the number and the names of the seats it holds, then what it does of the module (issue 1966). +const WaitsForTheSeatListHeld = "the node holds only its bootstrapped seat list (%d seats: %s) and has not read the seat " + + seatlist.Seat + +const composesNothingNewOfIt = ", so it composes nothing new of this module and keeps what runs of it (novox/hq ADR 0636 §1)" + +// seatListUnread is, while the node has not read the seat list, the line that says so, naming the bootstrapped seats it +// holds when its gate says them (the reader's Status); false once the list was read, or with no gate. +func (c *Composer) seatListUnread() (string, bool) { + if c.env.SeatList == nil || c.env.SeatList.MayAct() { + return "", false + } + says, ok := c.env.SeatList.(interface{ Status() seatlist.Status }) + if !ok { + return strings.TrimSuffix(WaitsForTheSeatList, composesNothingNewOfIt), true + } + var seats []string + for _, h := range says.Status().Held { + seats = append(seats, h.Seat) + } + return fmt.Sprintf(WaitsForTheSeatListHeld, len(seats), strings.Join(seats, ", ")), true +} + +// heldUntilListed is, while the node has not read the seat list, every module placed on it whose seats are not all +// bootstrapped, each with why: the pass holds them as they run before reading anything of their builds (frombus.Scope.Held). +// A holder of the seat mesh-catalog is composed whatever the list says, since the list is read from it (holdUntilListed, +// holderPart). Nil once the list was read. +func (c *Composer) heldUntilListed(ctx context.Context, r reader, own meshmoduleassigner.Assignments) map[string]string { + line, unread := c.seatListUnread() + if !unread { + return nil + } + holders := c.claimants(ctx, r, meshcatalog.Seat) + held := map[string]string{} + for _, pm := range own.Modules { + if pm.Module == "" || pm.Module == agent.NodeModule || slices.Contains(holders, pm.Module) || + c.env.SeatList.MayAct(c.seatsOf(ctx, r, pm.Module)...) { + continue + } + held[pm.Module] = line + composesNothingNewOfIt + } + return held +} // WaitsForTheSeatListOwn is why the node's own composition is held: it folds modules and accounts whose seats the node // cannot judge before the list was read, so only the last applied one runs (ADR 0636 §1). @@ -1906,7 +1961,8 @@ func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions m if dig, err := engcomp.DigestOf(comp); err == nil && ran[comp.Module+"/"+dig] { continue } - module, why := comp.Module, WaitsForTheSeatList + line, _ := c.seatListUnread() + module, why := comp.Module, line+composesNothingNewOfIt if module == "" { // the node's own: no seat to judge, so while the list is unread it waits whole if c.env.SeatList.MayAct() { continue diff --git a/internal/selfcompose/selfcompose_test.go b/internal/selfcompose/selfcompose_test.go index 277f4e2..f6d1f38 100644 --- a/internal/selfcompose/selfcompose_test.go +++ b/internal/selfcompose/selfcompose_test.go @@ -32,6 +32,8 @@ import ( "time" "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist" "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/compose" "github.com/novox/mesh-host/internal/compose/frombus" @@ -1842,6 +1844,20 @@ type gate struct{ open atomic.Bool } func (g *gate) MayAct(...string) bool { return g.open.Load() } +// namedGate is a gate that says the seats it holds, as the reader of the seat list does (seatlist.Reader.Status). +type namedGate struct { + *gate + seats []string +} + +func (g *namedGate) Status() seatlist.Status { + st := seatlist.Status{Heard: g.open.Load()} + for _, s := range g.seats { + st.Held = append(st.Held, seatlist.Held{Seat: s, Source: seatlist.SourceBootstrap}) + } + return st +} + // ADR 0636 §1: a node that has not read the seat list composes nothing new of a module whose seats are not bootstrapped, // says it holds the module (so its have says why) and hands it at the read, which the reader says as an event; what // already runs with the same composition is not held. @@ -1852,22 +1868,38 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: lemurs}) key, _ := meshcatalogclient.ManifestsKey(digest) b.kv[buckets.Manifests+"/"+key] = man + // Issue 1966: avahi's archive is in the artifact store, whose seat the bootstrap does not hold, so before the read the + // node reaches no store; avahi is held for the list before its build is read, and the pass is not refused for it. + avahi := `{"module":"avahi","version":"1","capabilities":["systemd"],"resources":[{"id":"tools","type":"archive",` + + `"path":"/opt/avahi","source":"` + catalogue.ArtifactStoreScheme + `avahi/tools/blobs/sha256:` + strings.Repeat("d", 64) + `"}]}` + avahiDigest := meshcatalogclient.ManifestDigest([]byte(avahi)) + avahiMan, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "avahi", Manifest: avahi}) + avahiKey, _ := meshcatalogclient.ManifestsKey(avahiDigest) + b.kv[buckets.Manifests+"/"+avahiKey] = avahiMan ctx, cancel := context.WithCancel(context.Background()) defer cancel() env := b.env() - g := &gate{} + g := &namedGate{gate: &gate{}, seats: []string{"mesh-catalog", "node-engine"}} changed := make(chan struct{}, 1) env.SeatList, env.SeatListChanged = g, changed var mu sync.Mutex heldBy := map[string]string{} - env.Refused = func(_ string, held map[string]string) { mu.Lock(); heldBy = held; mu.Unlock() } + var refusedWhole []string + env.Refused = func(why string, held map[string]string) { + mu.Lock() + defer mu.Unlock() + heldBy = held + if why != "" { + refusedWhole = append(refusedWhole, why) + } + } c := New(env) out, err := c.Watch(ctx, "n1.>") if err != nil { t.Fatal(err) } place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}, - Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}}) + Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}, {Module: "avahi"}}}) have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}}) mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"}) want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true}) @@ -1875,6 +1907,8 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { b.putAssigned(t, place) b.put(t, buckets.Have, "n1.node", have) b.put(t, buckets.Modules, "lemurs", mod) + avahiMod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: avahiDigest, Repository: "novox/x", Commit: "c"}) + b.put(t, buckets.Modules, "avahi", avahiMod) b.put(t, buckets.Wants, "want.n1.lemurs", want) b.put(t, buckets.Wants, "want.n1.node", nodeWant) for _, bucket := range []string{buckets.Wants, buckets.Assignments, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} { @@ -1888,11 +1922,32 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) { } handed[u.Key] = true } + // The reason names the bootstrapped list the node holds (issue 1966: ace's modules said "reaches no artifact store"). + waits := fmt.Sprintf(WaitsForTheSeatListHeld, 2, "mesh-catalog, node-engine") + composesNothingNewOfIt mu.Lock() - why := heldBy["lemurs"] + why, avahiWhy, whole := heldBy["lemurs"], heldBy["avahi"], slices.Clone(refusedWhole) mu.Unlock() - if why != WaitsForTheSeatList { - t.Errorf("lemurs is held with %q, want %q", why, WaitsForTheSeatList) + if why != waits || avahiWhy != waits || len(whole) != 0 { + t.Errorf("lemurs is held with %q, avahi with %q, the pass refused %q; want both %q and no refusal", why, avahiWhy, whole, waits) + } + // A pass refused whole while the list is unread says the list first (issue 1966). + refusing := b.env() + refusing.SeatList = g + refusing.Get = func(context.Context, string, string) ([]byte, error) { + return nil, errors.New("nats says ${dir:mesh-state}, and nats declares no directory") + } + var refusal string + refusing.Refused = func(w string, _ map[string]string) { refusal = w } + c2 := New(refusing) + c.mu.Lock() + maps.Copy(c2.wants, c.wants) + maps.Copy(c2.assignments, c.assignments) + maps.Copy(c2.records, c.records) + maps.Copy(c2.modules, c.modules) + c.mu.Unlock() + c2.compose(ctx) + if line := fmt.Sprintf(WaitsForTheSeatListHeld, 2, "mesh-catalog, node-engine") + "; "; !strings.HasPrefix(refusal, line) { + t.Errorf("a pass refused while the list is unread says %q, want it to begin %q", refusal, line) } for k := range handed { if strings.Contains(k, "lemurs") {