diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 56f7afb..452a74e 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -530,6 +530,15 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu if result.Source != nil && result.Source.Seat != "" { recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat } + // **A build at a commit does not change the branch a module follows** (novox/hq 04-ISSUES/215): + // the commit is built and recorded as what it was built from, and the module keeps following + // what it followed before — the repository's default branch for one new to the catalogue. + if followedBranch(result.Ref) == "" && result.Ref != "" { + recorded.Ref = "" + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil { + recorded.Ref = followedBranch(was.Ref) + } + } if err := namesNoInstallation(manifest); err != nil { return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w", result.On, result.Repository, short(result.Commit), err) diff --git a/cmd/mesh-controller/build_test.go b/cmd/mesh-controller/build_test.go index a39ef6e..29d0d73 100644 --- a/cmd/mesh-controller/build_test.go +++ b/cmd/mesh-controller/build_test.go @@ -63,3 +63,40 @@ func TestABuildHeardIsRecordedAndRegistered(t *testing.T) { t.Fatalf("a failure is said in the builder's words: %v", err) } } + +// novox/hq 04-ISSUES/215: a build asked at a commit is recorded as built from that commit, and the +// module keeps following the branch it followed — a new one, the default branch. +func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + manifest, _ := json.Marshal(map[string]any{"module": "unifi", "version": "1"}) + result := func(id, ref, commit string) link.BuildResult { + return link.BuildResult{ID: id, Repository: "http://forge.internal:20000/novox/mesh-catalog.git", + Path: "modules/unifi", Ref: ref, On: "anchor", Commit: commit, Manifest: manifest, + Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}} + } + if _, _, err := takeIn(ctx, open.inventory, result("b-1", "main", "1111111aaaa")); err != nil { + t.Fatal(err) + } + if _, _, err := takeIn(ctx, open.inventory, result("b-2", "9c97a8a", "9c97a8a1d2c3")); err != nil { + t.Fatal(err) + } + src, err := open.inventory.SourceOf(ctx, "unifi") + if err != nil { + t.Fatal(err) + } + if src.Ref != "main" || src.BuiltFrom != "9c97a8a1d2c3" { + t.Errorf("after a build at a commit the module follows %q, built from %q; want main, 9c97a8a1d2c3", src.Ref, src.BuiltFrom) + } + + // One new to the catalogue, first built at a commit, follows the default branch. + other, _ := json.Marshal(map[string]any{"module": "letta", "version": "1"}) + r := result("b-3", "deadbeef", "deadbeefcafe") + r.Manifest, r.Path = other, "modules/letta" + if _, _, err := takeIn(ctx, open.inventory, r); err != nil { + t.Fatal(err) + } + if src, _ := open.inventory.SourceOf(ctx, "letta"); src.Ref != "" { + t.Errorf("a module first built at a commit follows %q, want the default branch", src.Ref) + } +} diff --git a/cmd/mesh-controller/network_test.go b/cmd/mesh-controller/network_test.go index 6c76bed..adea345 100644 --- a/cmd/mesh-controller/network_test.go +++ b/cmd/mesh-controller/network_test.go @@ -266,6 +266,12 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t if _, err := assign(ctx, open, "anchor", "dnsmasq"); err != nil { t.Fatal(err) } + // Its bus credential, as assigning issues it where the bus is reachable (novox/hq issue 203): + // no bus is known to this test, so it is minted here, or composing refuses the placeholder. + if _, err := open.inventory.MintBusPassword(ctx, inventory.BusUser{ + Username: "anchor.dnsmasq", Kind: inventory.BusModule, Node: "anchor", Module: "dnsmasq"}); err != nil { + t.Fatal(err) + } zones := func() string { t.Helper() for _, r := range composed(t, open, "anchor").Resources { diff --git a/cmd/mesh-controller/order_test.go b/cmd/mesh-controller/order_test.go index be2c4d0..2acac30 100644 --- a/cmd/mesh-controller/order_test.go +++ b/cmd/mesh-controller/order_test.go @@ -211,3 +211,27 @@ func TestWhatAHandedOverModuleRecordsAboutItsSource(t *testing.T) { } } } + +// novox/hq 04-ISSUES/215: a module once built at a commit still follows its branch — a merge into it +// matches the module, and a plan re-asks the branch, not the old commit. +func TestAModuleBuiltAtACommitStillFollowsItsBranch(t *testing.T) { + m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main"} + pinned := inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "9c97a8a"} + if !sourceIs(pinned, m) { + t.Error("a module whose record names a commit is left out of a merge into its branch") + } + full := inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "9c97a8a1d2c3b4a5f60718293a4b5c6d7e8f9012"} + if !sourceIs(full, m) { + t.Error("a full commit hash is read as a branch") + } + if got := followedBranch("9c97a8a"); got != "" { + t.Errorf("a plan would re-ask the old commit %q", got) + } + if got := followedBranch("release"); got != "release" { + t.Errorf("a branch is not followed as named: %q", got) + } + // A module that follows another branch is still not this merge's. + if sourceIs(inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "release"}, m) { + t.Error("a module following another branch was matched") + } +} diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index 438ea38..65c2b01 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -272,7 +272,8 @@ func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) e } source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat} fmt.Printf(" tier %d: ", p.Tier) - if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, 0); err != nil { + // The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215). + if err := buildOne(ctx, source, e.Source.Path, followedBranch(e.Source.Ref), 0); err != nil { state.State = "failed" state.Why = err.Error() p.State = inventory.PlanFailed @@ -399,6 +400,24 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan, } return true, nil } + // **Asked: settle from the build records first** (novox/hq 04-ISSUES/214). An outcome is taken + // in by whichever controller hears it, and a merge to the controller's own repository replaces + // the controller in its first tier: the build that produced the new one is recorded, and the + // plan never hears it. The record is the fact; a build recorded after the ask is that tier's + // outcome, whoever was listening. + recorded := map[string][]inventory.Build{} + for _, m := range tier { + if s := p.Modules[m]; s != nil && s.State == "asked" { + builds, err := inv.Builds(ctx, m, 5) + if err != nil { + return false, err + } + recorded[m] = builds + } + } + if settleFromRecords(p, tier, recorded) { + return true, nil + } // Asked: wait for every build. var latest time.Time for _, m := range tier { @@ -755,3 +774,41 @@ func splitList(s string) []string { } return out } + +// settleFromRecords marks every module of the tier still `asked` built — or failed — from a build +// recorded after it was asked, and says whether it changed anything (novox/hq 04-ISSUES/214). +// Newest first, as Builds answers: the first record after the ask is the outcome of that ask. +func settleFromRecords(p *inventory.Plan, tier []string, recorded map[string][]inventory.Build) bool { + changed := false + for _, m := range tier { + s := p.Modules[m] + if s == nil || s.State != "asked" || s.AskedAt == nil { + continue + } + var outcome *inventory.Build + for i := range recorded[m] { + b := recorded[m][i] + if b.At.Before(*s.AskedAt) { + break + } + outcome = &b + } + if outcome == nil { + continue + } + at := outcome.At + if outcome.Worked() { + s.State = "built" + s.BuiltAt = &at + s.Commit = outcome.Commit + } else { + s.State = "failed" + s.Why = outcome.Failed + p.State = inventory.PlanFailed + p.Note = fmt.Sprintf("%s failed to build in tier %d", m, p.Tier) + } + fmt.Printf("%s: %s settled from the build records as %s (%s)\n", p.ID, m, s.State, outcome.ID) + changed = true + } + return changed +} diff --git a/cmd/mesh-controller/release_plan_test.go b/cmd/mesh-controller/release_plan_test.go index 5bbccbd..c414023 100644 --- a/cmd/mesh-controller/release_plan_test.go +++ b/cmd/mesh-controller/release_plan_test.go @@ -126,3 +126,40 @@ func TestABundleIsPlannedAfterTheToolchainItIsCompiledIn(t *testing.T) { t.Fatalf("the toolchain, then the bundle: %v", p.Tiers) } } + +// novox/hq 04-ISSUES/214: a plan whose build outcome was recorded while no controller followed it — +// the controller rebuilding itself — settles from the build records instead of waiting for ever. +func TestAPlanSettlesAnAskedBuildFromTheRecords(t *testing.T) { + asked := time.Date(2026, 10, 3, 19, 20, 0, 0, time.UTC) + p := inventory.Plan{ID: "plan-1", Tiers: [][]string{{"mesh-controller", "builder"}, {"route-proxy"}}, + Modules: map[string]*inventory.PlanModule{ + "mesh-controller": {State: "asked", AskedAt: &asked}, + "builder": {State: "asked", AskedAt: &asked}, + }} + records := map[string][]inventory.Build{ + // Newest first, as Builds answers: the build after the ask is the outcome. + "mesh-controller": { + {ID: "build-2", Commit: "2ebbb799", At: asked.Add(4 * time.Minute)}, + {ID: "build-1", Commit: "06ea2168", At: asked.Add(-10 * time.Minute)}, + }, + // Only a build from before the ask: not this ask's outcome. + "builder": {{ID: "build-0", Commit: "06ea2168", At: asked.Add(-time.Hour)}}, + } + if !settleFromRecords(&p, p.Tiers[0], records) { + t.Fatal("nothing settled, though the controller's build is recorded after the ask") + } + if s := p.Modules["mesh-controller"]; s.State != "built" || s.Commit != "2ebbb799" || s.BuiltAt == nil { + t.Errorf("the controller's ask is %+v, want built from 2ebbb799", s) + } + if s := p.Modules["builder"]; s.State != "asked" { + t.Errorf("an ask with no record after it was settled: %+v", s) + } + + // A failure recorded after the ask fails the plan, as hearing it would have. + q := inventory.Plan{ID: "plan-2", Tiers: [][]string{{"x"}}, + Modules: map[string]*inventory.PlanModule{"x": {State: "asked", AskedAt: &asked}}} + settleFromRecords(&q, q.Tiers[0], map[string][]inventory.Build{"x": {{ID: "b", Failed: "no", At: asked.Add(time.Minute)}}}) + if q.State != inventory.PlanFailed || q.Modules["x"].State != "failed" { + t.Errorf("a recorded failure did not fail the plan: %+v %+v", q, q.Modules["x"]) + } +} diff --git a/cmd/mesh-controller/upgrades.go b/cmd/mesh-controller/upgrades.go index 0aa9ffd..b9d9cd2 100644 --- a/cmd/mesh-controller/upgrades.go +++ b/cmd/mesh-controller/upgrades.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "fmt" + "regexp" "strings" "time" @@ -283,6 +284,14 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error { if isHistory(m.MergedAt, lastLookAt(entries, m)) { packaging = nil } + // Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows + // another branch is not part of this merge, and whoever is waiting for its change should read why. + for _, e := range entries { + if sameRepository(e.Source.Repository, m) && !sourceIs(e.Source, m) { + fmt.Printf(" %s is built from %s/%s and follows %s, not %s; this merge leaves it out\n", + e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base) + } + } touched := whatTheMergeTouched(from, entries, m) for _, e := range touched { if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil { @@ -345,7 +354,24 @@ func sourceIs(s inventory.Source, m link.SourceMoved) bool { if !sameRepository(s.Repository, m) { return false } - return s.Ref == "" || s.Ref == m.Base + ref := followedBranch(s.Ref) + return ref == "" || ref == m.Base +} + +// commitRef is a ref that names a commit rather than a branch: what `build --ref ` asks for. +var commitRef = regexp.MustCompile(`^[0-9a-f]{7,40}$`) + +// followedBranch is the branch a recorded ref means a module follows (novox/hq 04-ISSUES/215). **A +// commit is never a branch to follow.** A build asked at a commit — to try one, or to pin it during a +// fix — recorded that commit as the module's ref; every merge after it then failed to match the +// module, its plan left it out without saying so, and every plan that rebuilt it asked for that same +// old commit again. A commit recorded so is read as the repository's default branch, which is what +// the module followed before it; a branch is followed as named. +func followedBranch(ref string) string { + if commitRef.MatchString(strings.TrimSpace(ref)) { + return "" + } + return ref } // sameRepository is whether a recorded repository is the one a merge names, in either spelling it diff --git a/internal/broker/nats.go b/internal/broker/nats.go index c5c01c8..86ecace 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -469,8 +469,24 @@ func PermissionsFor(p Principal) (Permissions, error) { // 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. + // **And it consumes for the modules it carries** (novox/hq ADR 0198, which changes ADR 0175's + // "it consumes nothing"): a module's long-running code is a bundle this runtime launches, and + // the runtime is its bus — it reads the module's own durable consumer and acknowledges what + // the module's code took. Exactly the grants the module's own principal has for that consumer, + // on its name and no other's: asking about it, pulling from it, acknowledging it. The + // consumer is still the controller's to make, from the module's own principal. + for _, d := range p.Carries { + own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits, + Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches} + if _, consumes := ConsumerFor(own); !consumes { + continue + } + stream, durable := consumerStream(own), consumerDurable(own) + pub = append(pub, + "$JS.API.CONSUMER.INFO."+stream+"."+durable, + "$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable, + "$JS.ACK."+stream+"."+durable+".>") + } sub = unique(sub) pub = unique(pub) } diff --git a/internal/broker/nats_test.go b/internal/broker/nats_test.go index dad5ed9..404919b 100644 --- a/internal/broker/nats_test.go +++ b/internal/broker/nats_test.go @@ -378,7 +378,7 @@ func TestAModulePullsItsOwnConsumerAndNoOthers(t *testing.T) { // their tools (novox/hq ADR 0175): every carried module's tool namespace, every held seat's verbs // on this node, every module's membership on this node, and a call to anything. Nothing it // consumes, because it reacts to nothing. -func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) { +func TestTheRuntimeServesTheUnionAndConsumesForItsModules(t *testing.T) { filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}} p := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{ {Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}}, @@ -408,22 +408,33 @@ func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) { t.Errorf("the runtime may not publish %s: %v", want, perms.Publish) } } - // Nothing of what a carried module consumes, and no consumer of its own to ack. - for _, s := range perms.Subscribe { - if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") { - t.Errorf("the runtime was granted a delivery it has no consumer for: %s", s) + // It reads the consumer of every carried module that consumes — that module's, by its name, as + // the module's own principal could (novox/hq ADR 0198) — and of no module that consumes nothing. + for _, want := range []string{ + "$JS.API.CONSUMER.INFO.EVENTS.anchor_zsh", + "$JS.API.CONSUMER.MSG.NEXT.EVENTS.anchor_zsh", + "$JS.ACK.EVENTS.anchor_zsh.>", + } { + if !contains(perms.Publish, want) { + t.Errorf("the runtime may not read zsh's consumer: %s missing from %v", want, perms.Publish) } } for _, s := range perms.Publish { - if strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER") { - t.Errorf("the runtime was granted a consumer's subject and has no consumer: %s", s) + if (strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER")) && !strings.Contains(s, "anchor_zsh") { + t.Errorf("the runtime was granted a consumer no carried module of it consumes on: %s", s) + } + } + // It pulls; nothing is pushed to it, and it subscribes no event subject directly. + for _, s := range perms.Subscribe { + if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") { + t.Errorf("the runtime was granted a delivery: %s", s) } } if !perms.AllowResponses { t.Error("the runtime answers what it is asked, and may not reply") } if _, needed := ConsumerFor(p); needed { - t.Error("a consumer would be made for the runtime, which consumes nothing") + t.Error("a consumer would be made for the runtime itself; it reads its modules' consumers, never one of its own") } // Each subject once in each list: the file is read as the mesh's authority model. One subject may // stand in both — the runtime answers discovery on `$SRV.INFO` and, as the console, asks it diff --git a/internal/catalogue/build.go b/internal/catalogue/build.go index 1bc31c6..23d3aa6 100644 --- a/internal/catalogue/build.go +++ b/internal/catalogue/build.go @@ -87,6 +87,12 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) { loads := append([]string(nil), a.Loads...) if a.Loads == nil && len(m.Tools) > 0 { loads = append([]string(nil), a.Entrypoints...) + // A bundle compiled to a binary has no entrypoints: the binary is what it is, and what + // the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served + // as Go — the runtime execs it — exactly as a TypeScript one is through its launcher. + if bin := BinaryOf(a); bin != "" { + loads = []string{bin} + } } // **Kept, never routed** (ADR 0155): the builder publishes to the store at the address // it reached it by, and a manifest carrying that address names an installation — @@ -242,6 +248,11 @@ func (b *Build) problems(module string) []string { for _, e := range a.Entrypoints { found = found || e == load } + // A bundle compiled to a binary is one executable: the runtime loads that or nothing + // (novox/hq ADR 0193). + if bin := BinaryOf(a); bin != "" { + found = load == bin + } if !found { problems = append(problems, fmt.Sprintf( "%s: %q says the runtime loads %q, which is not among its entrypoints — "+ @@ -436,3 +447,33 @@ func BinaryOf(a Artifact) string { } return a.Name } + +// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq +// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says +// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or +// it is the runtime itself. One reached by none of them was built, recorded and pushed as success, +// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here, +// naming the field that would deliver it. +func undeliveredBundles(m Manifest) []string { + if m.Build == nil || m.Module == RuntimeModule { + return nil + } + named := map[string]bool{} + for _, r := range m.Resources { + if a, ok := r["artifact"].(string); ok && a != "" { + named[a] = true + } + } + var problems []string + for _, a := range m.Build.Artifacts { + if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 { + continue + } + problems = append(problems, fmt.Sprintf( + "%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+ + "unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+ + "module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)", + m.Module, a.Name)) + } + return problems +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 01686d5..aef8497 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -1373,6 +1373,7 @@ func ParseManifest(raw []byte) (Manifest, error) { } } problems = append(problems, m.Build.problems(m.Module)...) + problems = append(problems, undeliveredBundles(m)...) // **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029). // // Building publishes to the store, and the builder will not start without one. So a module diff --git a/internal/catalogue/runtime_test.go b/internal/catalogue/runtime_test.go index fa4dc43..5d2b9f6 100644 --- a/internal/catalogue/runtime_test.go +++ b/internal/catalogue/runtime_test.go @@ -389,3 +389,71 @@ func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) { t.Errorf("the Go runtime is not told what to serve or whose it is: %v %v", env, process["user"]) } } + +// novox/hq 04-ISSUES/216: a bundle nothing loads, runs or unpacks is refused at registration; saying +// `loads`, listing `tools`, or a resource naming it admits it. +func TestABundleNothingDeliversIsRefused(t *testing.T) { + base := func() Manifest { + return Manifest{Module: "baserow", Version: "1", Build: &Build{Artifacts: []Artifact{ + {Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}} + } + if p := undeliveredBundles(base()); len(p) != 1 || !strings.Contains(p[0], "never reach a machine") { + t.Fatalf("a bundle nothing delivers was admitted: %v", p) + } + loads := base() + loads.Build.Artifacts[0].Loads = []string{"tools/index.js"} + tools := base() + tools.Tools = []string{"baserow_list_rows"} + run := base() + run.Resources = []map[string]any{{"id": "daemon", "type": "process", "artifact": "tools", "run": []any{"node", "tools/index.js"}}} + runtime := base() + runtime.Module = RuntimeModule + for name, m := range map[string]Manifest{"loads": loads, "tools": tools, "a process": run, "the runtime": runtime} { + if p := undeliveredBundles(m); len(p) != 0 { + t.Errorf("a bundle delivered by %s was refused: %v", name, p) + } + } +} + +// novox/hq ADR 0193: a Go tools bundle is served — its binary is what the runtime starts, delivered +// like any tools bundle, named to the runtime where a TypeScript bundle names its launcher. +func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} + lamp := Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, + Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go", + System: "arch", From: "cmd/lamp-tools"}}}} + if p := lamp.Build.problems("lamp"); len(p) != 0 { + t.Fatalf("a Go tools bundle was refused: %v", p) + } + lamp, err := lamp.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "lamp/tools/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(lamp.Bundles[0].Loads) != "[lamp-tools]" { + t.Fatalf("the runtime loads %v from a Go bundle, want its binary", lamp.Bundles[0].Loads) + } + out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{lamp, theRuntime(t)}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + if fileNamed(out, "lamp."+BundleID("tools")) == nil { + t.Errorf("the Go bundle is not delivered: %v", ids(out)) + } + env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string) + if env[RuntimeToolModules] != "lamp="+BundlePath("lamp", "tools")+"/lamp-tools" { + t.Errorf("the runtime is told %q, want the binary", env[RuntimeToolModules]) + } + + // An artifact may say it explicitly; naming anything but the binary is refused. + said := Manifest{Module: "lamp", Version: "1", Build: &Build{Artifacts: []Artifact{{Name: "tools", + Kind: ArtifactBundle, Language: "go", System: "arch", Binary: "lamp", Loads: []string{"lamp"}}}}} + if p := said.Build.problems("lamp"); len(p) != 0 { + t.Errorf("loads naming the binary was refused: %v", p) + } + said.Build.Artifacts[0].Loads = []string{"tools/index.js"} + if p := said.Build.problems("lamp"); len(p) == 0 { + t.Error("a Go bundle loading a file it does not contain was admitted") + } +} diff --git a/internal/inventory/people_test.go b/internal/inventory/people_test.go index a3a4ac6..7df4b15 100644 --- a/internal/inventory/people_test.go +++ b/internal/inventory/people_test.go @@ -44,8 +44,15 @@ func TestAPersonMayCallToolsAndNothingElse(t *testing.T) { } // The one tool, both ways it is addressed (novox/hq ADR 0159): to whichever instance // answers, and to the instance on one machine. Nothing else. - if len(perms.Publish) != 2 || perms.Publish[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" || - perms.Publish[1] != "mesh.mod.mesh-catalog.tool.catalog_tools.*" { + // And asking what answers (novox/hq ADR 0197), which claims nothing and calls nothing. + var tools []string + for _, s := range perms.Publish { + if !strings.HasPrefix(s, "$SRV.") { + tools = append(tools, s) + } + } + if len(tools) != 2 || tools[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" || + tools[1] != "mesh.mod.mesh-catalog.tool.catalog_tools.*" { t.Errorf("ada may publish %v, which should be the one tool, both ways addressed, and nothing else", perms.Publish) } for _, s := range perms.Publish {