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/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 45d607f..4c7b32e 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -25,7 +25,7 @@ accounts { users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused", "mesh.seat.node-build-agent.accept.>"] } - subscribe: { allow: ["$JS.API.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] } + subscribe: { allow: ["$JS.API.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { @@ -38,17 +38,17 @@ accounts { } } { user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: { publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] } - subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] } + subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: { publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] } - subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] } + subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: { publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] } - subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] } + subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] } allow_responses: { max: 1, ttl: "1m" } } } ] 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) {