From 58b4fcb8c8784f3473aa0c39f967b82f0f9d3118 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 22:18:20 +0200 Subject: [PATCH] A bundle stands on the toolchain it is compiled in (hq issue 211) A manifest names its toolchain by language, not in build.on, so the planner did not know a bundle depends on the module that publishes its toolchain and built the two in one tier: the bundle against the old toolchain, recorded as built from the new commit. The edge is read from the manifest, so it holds before any build recorded it, and a toolchain that moves rebuilds every bundle compiled in it. --- cmd/mesh-controller/release_plan_test.go | 11 +++++++ cmd/mesh-controller/seatverbs.go | 9 ++++-- cmd/mesh-controller/seatverbs_test.go | 10 +++++-- internal/broker/nats.go | 10 +++++-- internal/inventory/dependencies.go | 16 ++++++++++ internal/inventory/dependencies_test.go | 38 ++++++++++++++++++++++++ internal/link/announce.go | 17 +++++++++-- 7 files changed, 100 insertions(+), 11 deletions(-) diff --git a/cmd/mesh-controller/release_plan_test.go b/cmd/mesh-controller/release_plan_test.go index c2eeb27..5bbccbd 100644 --- a/cmd/mesh-controller/release_plan_test.go +++ b/cmd/mesh-controller/release_plan_test.go @@ -115,3 +115,14 @@ func TestACycleIsOneLastTierAndSaidSo(t *testing.T) { t.Fatalf("a cycle should be one tier of two, said: %v", tiers) } } + +// novox/hq 04-ISSUES/211: a merge moving the toolchain and a bundle compiled in it builds the +// bundle a tier after the toolchain, not beside it. +func TestABundleIsPlannedAfterTheToolchainItIsCompiledIn(t *testing.T) { + edges := []inventory.Edge{{From: "node-tools", To: "mesh-tools", Kind: inventory.EdgeStandsOn}} + p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"}, + []string{"mesh-tools", "node-tools"}, edges) + if len(p.Tiers) != 2 || p.Tiers[0][0] != "mesh-tools" || p.Tiers[1][0] != "node-tools" { + t.Fatalf("the toolchain, then the bundle: %v", p.Tiers) + } +} diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 523ab08..e16601a 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -402,19 +402,22 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info { var endpoints []micro.EndpointInfo for _, verb := range verbs { schema, _ := json.Marshal(about[verb].Input) + // The same shape every tool runtime announces in (node-tools' announce package): the name is + // `__`, as the protocol's characters allow; the metadata is what identifies it. endpoints = append(endpoints, micro.EndpointInfo{ - Name: verb, + Name: catalogue.ControllerSeatName + "__" + verb, Subject: link.SeatToolSubject(catalogue.ControllerSeatName, verb), QueueGroup: "seat." + catalogue.ControllerSeatName, Metadata: map[string]string{ + "kind": "seat", "module": catalogue.ControllerSeatName, "tool": verb, + "seat": catalogue.ControllerSeatName, "scope": "mesh", "interchangeable": "false", "description": about[verb].Description, "schema": string(schema), - "seat": catalogue.ControllerSeatName, "scope": "mesh", }, }) } return micro.Info{ ServiceIdentity: micro.ServiceIdentity{ - Name: catalogue.ControllerSeatName, ID: "controller", Version: "1.0.0", + Name: catalogue.ControllerSeatName, ID: "controller", Version: "0.1.0", Metadata: map[string]string{"seat": catalogue.ControllerSeatName, "scope": "mesh"}, }, Description: "the mesh's own verbs, answered by the holder of the mesh-controller seat", diff --git a/cmd/mesh-controller/seatverbs_test.go b/cmd/mesh-controller/seatverbs_test.go index df3177f..edfdb8e 100644 --- a/cmd/mesh-controller/seatverbs_test.go +++ b/cmd/mesh-controller/seatverbs_test.go @@ -281,10 +281,14 @@ func TestTheControllerAnnouncesTheVerbsItServes(t *testing.T) { t.Fatalf("%d endpoints announced for %d verbs served", len(info.Endpoints), len(handlers)) } for _, e := range info.Endpoints { - if _, served := handlers[e.Name]; !served { - t.Errorf("%s is announced and not served", e.Name) + verb := e.Metadata["tool"] + if _, served := handlers[verb]; !served || e.Name != catalogue.ControllerSeatName+"__"+verb { + t.Errorf("%s (%s) is announced and not served under that name", e.Name, verb) } - if e.Subject != link.SeatToolSubject(catalogue.ControllerSeatName, e.Name) || e.QueueGroup != "seat."+catalogue.ControllerSeatName { + if e.Metadata["kind"] != "seat" || e.Metadata["seat"] != catalogue.ControllerSeatName { + t.Errorf("%s is not announced as the seat's verb: %v", e.Name, e.Metadata) + } + if e.Subject != link.SeatToolSubject(catalogue.ControllerSeatName, verb) || e.QueueGroup != "seat."+catalogue.ControllerSeatName { t.Errorf("%s is announced on %s/%s, not where it is served", e.Name, e.Subject, e.QueueGroup) } if e.Metadata["description"] == "" || e.Metadata["schema"] == "" || e.Metadata["scope"] != "mesh" { diff --git a/internal/broker/nats.go b/internal/broker/nats.go index f547d03..c5c01c8 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -465,7 +465,9 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, invoked...) // It says what it serves and may ask what answers (novox/hq ADR 0197): the runtime answers // discovery for each module and seat it carries, and the console it is asks the bus. - sub = append(sub, announcing(serves...)...) + // One service per runtime process, named for the runtime: the bus lets a principal answer each + // request once, so the runtime announces everything it carries under its own name. + sub = append(sub, announcing(append([]string{RuntimeModule}, serves...)...)...) pub = append(pub, discovering()...) // Nothing about consumers: it consumes nothing. A module's reactions to events are its // own long-lived process, which ADR 0175 leaves where it is; what moves here is tools. @@ -793,12 +795,14 @@ func invokedSubjects(invokes []string) ([]string, error) { // protocol's discovery (novox/hq ADR 0197): the questions asked of every service, and those asked of // each name it serves — its own and no other's, so it cannot answer for a service it is not. func announcing(names ...string) []string { - out := []string{"$SRV.PING", "$SRV.INFO"} + out := []string{"$SRV.PING", "$SRV.INFO", "$SRV.STATS"} for _, n := range names { if !safeSubject.MatchString(n) { continue } - out = append(out, "$SRV.PING."+n, "$SRV.PING."+n+".>", "$SRV.INFO."+n, "$SRV.INFO."+n+".>") + for _, verb := range []string{"PING", "INFO", "STATS"} { + out = append(out, "$SRV."+verb+"."+n, "$SRV."+verb+"."+n+".>") + } } return out } diff --git a/internal/inventory/dependencies.go b/internal/inventory/dependencies.go index 580586c..ef38cf6 100644 --- a/internal/inventory/dependencies.go +++ b/internal/inventory/dependencies.go @@ -5,6 +5,7 @@ import ( "sort" "strings" + "github.com/novox/mesh-controller/internal/builder" "github.com/novox/mesh-controller/internal/catalogue" ) @@ -96,6 +97,21 @@ func dependenciesOf(entries []Entry, against map[string][]string, read map[strin add(name, on.Module, EdgeDeclared) } } + // **A bundle stands on the toolchain it is compiled in** (novox/hq 04-ISSUES/211). A + // manifest names its toolchain by language, not in `build.on`, so the edge was implicit + // and a merge that moved the toolchain and a bundle together built both in one tier — + // the bundle against the toolchain as it was, recorded as built from the new commit. Read + // from the manifest, so it holds before any build has recorded what it stood on; and a + // toolchain that moves rebuilds every bundle compiled in it, which is what a toolchain + // carrying a bundle's dependencies requires. + for _, a := range e.Manifest.Build.Artifacts { + if a.Kind != catalogue.ArtifactBundle { + continue + } + if chain, err := builder.ToolchainFor(a.Language); err == nil { + add(name, chain.Base, EdgeStandsOn) + } + } } for _, ref := range against[name] { if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok { diff --git a/internal/inventory/dependencies_test.go b/internal/inventory/dependencies_test.go index 1109d34..144595a 100644 --- a/internal/inventory/dependencies_test.go +++ b/internal/inventory/dependencies_test.go @@ -62,3 +62,41 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) { } } } + +// novox/hq 04-ISSUES/211: a bundle stands on the toolchain it is compiled in, so a merge moving both +// builds the toolchain first — read from the manifest, before any build recorded it. +func TestABundleStandsOnTheToolchainItIsCompiledIn(t *testing.T) { + entries := []Entry{ + {Manifest: catalogue.Manifest{Module: "mesh-tools"}, Source: Source{Repository: "novox/mesh-tools"}}, + {Manifest: catalogue.Manifest{Module: "mesh-tools-go"}, Source: Source{Repository: "novox/mesh-tools-go"}}, + {Manifest: catalogue.Manifest{Module: "node-tools", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{ + {Name: "runtime", Kind: catalogue.ArtifactBundle, Language: "go", System: "arch", From: "cmd/node-tools"}}}}, + Source: Source{Repository: "novox/mesh-tools"}}, + {Manifest: catalogue.Manifest{Module: "nftables", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{ + {Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}}, + Source: Source{Repository: "novox/mesh-catalog"}}, + {Manifest: catalogue.Manifest{Module: "photos", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{ + {Name: "server", Kind: catalogue.ArtifactImage, From: "Dockerfile"}}}}, + Source: Source{Repository: "novox/photos"}}, + } + edges := dependenciesOf(entries, nil, nil) + has := func(from, to string) bool { + for _, e := range edges { + if e.From == from && e.To == to && e.Kind == EdgeStandsOn { + return true + } + } + return false + } + if !has("nftables", "mesh-tools") { + t.Errorf("a TypeScript bundle does not stand on the TypeScript toolchain: %v", edges) + } + if !has("node-tools", "mesh-tools-go") { + t.Errorf("a Go bundle does not stand on the Go toolchain: %v", edges) + } + for _, e := range edges { + if e.From == "photos" && e.Kind == EdgeStandsOn { + t.Errorf("an image stands on a toolchain it is not compiled in: %v", e) + } + } +} diff --git a/internal/link/announce.go b/internal/link/announce.go index f822100..70c039a 100644 --- a/internal/link/announce.go +++ b/internal/link/announce.go @@ -17,7 +17,7 @@ import ( // DiscoverySubjects are where one service instance is asked to say what it is. func DiscoverySubjects(name, id string) []string { var out []string - for _, verb := range []string{"PING", "INFO"} { + for _, verb := range []string{"PING", "INFO", "STATS"} { out = append(out, "$SRV."+verb, "$SRV."+verb+"."+name, "$SRV."+verb+"."+name+"."+id) } return out @@ -35,6 +35,16 @@ func (b OverNATS) Announce(info micro.Info, logger *log.Logger) (func(), error) if err != nil { return nil, err } + // Statistics the protocol asks for; the controller keeps none per verb, so it answers its + // identity and its endpoints with nothing counted — an honest zero, not a refusal. + stats := micro.Stats{ServiceIdentity: info.ServiceIdentity, Type: micro.StatsResponseType} + for _, e := range info.Endpoints { + stats.Endpoints = append(stats.Endpoints, µ.EndpointStats{Name: e.Name, Subject: e.Subject, QueueGroup: e.QueueGroup}) + } + statsBody, err := json.Marshal(stats) + if err != nil { + return nil, err + } var subs []*nats.Subscription done := make(chan struct{}) stop := func() { @@ -46,8 +56,11 @@ func (b OverNATS) Announce(info micro.Info, logger *log.Logger) (func(), error) for _, subject := range DiscoverySubjects(info.Name, info.ID) { subject := subject body := infoBody - if len(subject) >= 9 && subject[:9] == "$SRV.PING" { + switch { + case len(subject) >= 9 && subject[:9] == "$SRV.PING": body = pingBody + case len(subject) >= 10 && subject[:10] == "$SRV.STATS": + body = statsBody } bind := func() (*nats.Subscription, error) { return b.Conn.Subscribe(subject, func(msg *nats.Msg) {