From a5e8baf6da51d3b2b3c8822f2a37dd6e23f3064a Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:39:52 +0200 Subject: [PATCH 1/3] Register a module only from the repository the catalogue builds it from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent could make a repository of its own, or fork one the mesh builds from, commit a module.json naming sudo or mesh-host, and ask the build verb for it: the outcome was registered under that name, and the next push made whoever wrote it root on every node (novox/hq ADR 0266 §7, the review of 2026-10-09). The trunk rule checked the trunk of the repository built, which was the agent's. The take-in, which every outcome reaches whichever verb asked it, now registers a module only from its registered repository, and a new module only from a repository the catalogue already builds from (a merge adding one); anything else only when the build request was kept as asked at the controller's terminal (migration 0084). Through a verb, a build of a repository the catalogue builds nothing from is not asked at all. --- cmd/mesh-controller/build.go | 18 +- cmd/mesh-controller/build_source.go | 197 ++++++++++++++++++ cmd/mesh-controller/build_source_test.go | 186 +++++++++++++++++ cmd/mesh-controller/build_test.go | 8 +- cmd/mesh-controller/gate_test.go | 3 +- cmd/mesh-controller/push_recreates_test.go | 2 +- cmd/mesh-controller/recorded_kept_test.go | 2 +- cmd/mesh-controller/replays_test.go | 4 +- cmd/mesh-controller/same_source_test.go | 4 +- ...-a-build-asked-at-the-terminal-says-so.sql | 11 + internal/inventory/pending.go | 23 +- 11 files changed, 443 insertions(+), 15 deletions(-) create mode 100644 cmd/mesh-controller/build_source.go create mode 100644 cmd/mesh-controller/build_source_test.go create mode 100644 internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 083139ea..a69d8379 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -451,6 +451,10 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa if err != nil { return "", err } + // Through a verb, only a repository the catalogue builds from (novox/hq ADR 0266). + if err := verbMayAsk(ctx, source, repository); err != nil { + return "", err + } ident, err := openIdentity(ctx) if err != nil { @@ -500,8 +504,10 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa // never before, so an ask that failed never reads as a build in flight. A dry run registers nothing, and // is not kept. keep := !dryRun && asker != "" + // Asked at the terminal is what lets its outcome register a module from a repository the catalogue does + // not build it from (novox/hq ADR 0266); never through a verb. asked := inventory.BuildRequest{ID: request.ID, Repository: source.Repository, Seat: source.Seat, Path: path, - Ref: ref, For: asker} + Ref: ref, For: asker, AtTerminal: startedAtTheTerminal()} if wait == 0 { // Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the @@ -631,6 +637,13 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu 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) } + // **Only from the repository the catalogue builds the module from** (novox/hq ADR 0266): else the trunk + // below is the trunk of whatever repository was built, which may be one an agent made — and a module named + // `sudo` from it would be what the next push sends. Another repository is the operator's, at the terminal. + if err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID); err != nil { + return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, + manifest.Module, short(result.Commit), err) + } // **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's // default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay // --register` of one — is for checking, and is never a module's version; nothing could then send it. @@ -719,6 +732,9 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai if err != nil { return err } + if err := verbMayAsk(ctx, source, repository); err != nil { + return err + } ident, err := openIdentity(ctx) if err != nil { return err diff --git a/cmd/mesh-controller/build_source.go b/cmd/mesh-controller/build_source.go new file mode 100644 index 00000000..2d8b0ae5 --- /dev/null +++ b/cmd/mesh-controller/build_source.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// Where a build may register a module from (novox/hq ADR 0266). +// +// A build's outcome registers its module, and a registered module is what the next push sends. Before this, +// an outcome registered whatever its manifest named, from whichever repository it was built from: an agent +// that made a repository of its own, committed `modules/sudo/module.json` granting itself a rule without a +// password, and asked the `build` verb for it had its repository registered as the module `sudo` — whose next +// push made the agent root on every node. A fork of the node-engine did the same everywhere. The trunk rule +// (ADR 0238) did not stop it: the trunk it checked was the trunk of the repository built, which was the +// agent's own. +// +// So **an outcome registers a module only from the repository the catalogue already builds that module +// from**; and a module new to the catalogue only from a repository the catalogue already builds another +// module from — whose trunk takes a reviewed, approved merge, which is how a merge adds a module (novox/hq +// issue 300). Anything else — a module moved to another repository, a module from a repository the +// catalogue has never built — is the operator's, at the controller's terminal: allowed only when the build +// request was kept as asked there. Judged at the take-in, which every outcome reaches whoever hears it and +// whichever verb asked it (`build`, `rebuild`, `replay --register`, `assign` with build), and before the ask +// for a call through a verb, so an agent cannot have a build node run a repository the catalogue does not +// build from at all. + +// errNotItsSource is an outcome refused for where it was built from. +var errNotItsSource = errors.New("not built from the repository the catalogue builds it from") + +// startedAtTheTerminal says this process was started at the controller's terminal: neither a verb nor a seat call +// started it. runVerb sets both for every command a verb runs (seatverbs.go), and a verb is the only way an +// agent reaches the controller. +func startedAtTheTerminal() bool { + return os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == "" +} + +// sourceForms compares sources however each is spelled: a path on a seat's holder (ADR 0111) or a URL — a +// build asked of a seat's path is registered with the URL composed from it when its outcome does not echo +// the seat. A seat's path is composed into its URL where the seat's holder is known, so both spellings of +// one repository are one; where it is not, a seat's path matches only the same seat's same path. +type sourceForms struct { + bases map[string]string // the seat's clone base, `scheme://host:port`, by seat; "" where it is not known + base func(seat string) string +} + +// newSourceForms reads the seats' bases from the mesh once, when first needed. +func newSourceForms(ctx context.Context, inv *inventory.Inventory) *sourceForms { + f := &sourceForms{bases: map[string]string{}} + var world *catalogue.World + f.base = func(seat string) string { + if b, known := f.bases[seat]; known { + return b + } + if world == nil { + w := catalogue.World{} + if shelf, err := inv.Catalogue(ctx); err == nil { + if read, err := theRestOfTheMesh(ctx, inv, shelf, ""); err == nil { + w = read + } + } + world = &w + } + b, err := seatBase(*world, seat) + if err != nil { + b = "" + } + f.bases[seat] = b + return b + } + return f +} + +// canonical is one spelling of a repository: lower case, no `.git`, no trailing slash, and a seat's path as +// the URL its holder serves it at where that is known. +func (f *sourceForms) canonical(repository, seat string) string { + trim := func(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + s = strings.TrimRight(s, "/") + return strings.TrimRight(strings.TrimSuffix(s, ".git"), "/") + } + if seat == "" { + return trim(repository) + } + if b := f.base(seat); b != "" { + return trim(b + "/" + strings.Trim(repository, "/")) + } + return "seat:" + seat + ":" + trim(strings.Trim(repository, "/")) +} + +// same says two sources are one repository. +func (f *sourceForms) same(aRepository, aSeat, bRepository, bSeat string) bool { + if aRepository == "" || bRepository == "" { + return false + } + return f.canonical(aRepository, aSeat) == f.canonical(bRepository, bSeat) +} + +// buildsFrom says the catalogue builds some module from this repository. +func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat string) bool { + for _, e := range entries { + if e.Provided || e.Source.Repository == "" { + continue + } + if f.same(e.Source.Repository, e.Source.Seat, repository, seat) { + return true + } + } + return false +} + +// mayRegisterFrom says whether a build's outcome may register module from the source it was built from +// (novox/hq ADR 0266): from the module's registered repository; for a module new to the catalogue, from a +// repository the catalogue builds another module from; else only when the build was asked at the terminal. +func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source, + buildID string) error { + forms := newSourceForms(ctx, inv) + was, err := inv.SourceOf(ctx, module) + isNew := errors.Is(err, inventory.ErrNoSuchModule) + if err != nil && !isNew { + return err + } + var why string + switch { + case !isNew && forms.same(was.Repository, was.Seat, built.Repository, built.Seat): + return nil + case !isNew: + registered := was.Repository + if registered == "" { + registered = "no repository (it was handed over by hand)" + } + why = fmt.Sprintf("%s is built from %s, and this build is of %s", module, sourceWords(registered, was.Seat), + sourceWords(built.Repository, built.Seat)) + default: + entries, err := inv.Catalogued(ctx) + if err != nil { + return err + } + if forms.buildsFrom(entries, built.Repository, built.Seat) { + return nil + } + why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from", + module, sourceWords(built.Repository, built.Seat)) + } + terminal, err := inv.AskedAtTheTerminal(ctx, buildID) + if err != nil { + return err + } + if terminal { + fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID, why) + return nil + } + return fmt.Errorf("%w: %s. A module is registered from another repository only by a build asked at the "+ + "controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+ + "may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why) +} + +// sourceWords is a source as a person reads it. +func sourceWords(repository, seat string) string { + if seat == "" { + return repository + } + return buildSource{Repository: repository, Seat: seat}.String() +} + +// verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from +// (novox/hq ADR 0266): the build node would run what an agent wrote, and its outcome could never be +// registered anyway. url is the repository as it is cloned. At the terminal anything may be asked. +func verbMayAsk(ctx context.Context, source buildSource, url string) error { + if startedAtTheTerminal() { + return nil + } + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + entries, err := open.inventory.Catalogued(ctx) + if err != nil { + return err + } + forms := newSourceForms(ctx, open.inventory) + if forms.buildsFrom(entries, source.Repository, source.Seat) || forms.buildsFrom(entries, url, "") { + return nil + } + return terminalRefusal("%s is no repository the catalogue builds a module from, and a build of any other is "+ + "asked at the controller's terminal only, never through a verb: a build node runs what the repository "+ + "says, and its outcome would register a module the next push sends — whoever may call a verb includes "+ + "agents (novox/hq ADR 0266). Nothing was asked", source) +} diff --git a/cmd/mesh-controller/build_source_test.go b/cmd/mesh-controller/build_source_test.go new file mode 100644 index 00000000..b49ba7bf --- /dev/null +++ b/cmd/mesh-controller/build_source_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// The routes to root a review of ADR 0266 found (novox/hq ADR 0266 §7): an agent makes a repository of its +// own — or forks one the mesh builds from — commits a module.json naming a module the mesh runs everywhere +// (`sudo`, granting itself a rule without a password; `mesh-host`, the node-engine), and asks the `build` verb +// for it. Its outcome was registered under that name from the agent's repository, and the next push sent it. + +// onTrunk is an outcome of a commit on its repository's trunk, as the build seat says it. +func onTrunk(id, repository, seat, path string, manifest map[string]any) link.BuildResult { + raw, _ := json.Marshal(manifest) + r := link.BuildResult{ID: id, Repository: "http://forge.internal:20000/" + repository + ".git", Path: path, + Ref: "main", On: "anchor", Commit: "c0ffee0123456789", Manifest: raw, + Trunk: "main", OnTrunk: true, Branches: []string{"main"}} + if seat != "" { + r.Source = &link.SourceOnSeat{Seat: seat, Repository: repository} + } + return r +} + +// keptAsked keeps a build request as the asker would: through a verb, or at the terminal. +func keptAsked(t *testing.T, inv *inventory.Inventory, id, repository string, atTerminal bool) { + t.Helper() + if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: id, Repository: repository, Seat: "git", + For: "build", AtTerminal: atTerminal}); err != nil { + t.Fatal(err) + } +} + +// theCatalogue is a mesh whose sudo is built from the catalogue repository and whose node-engine from its own. +func theCatalogue(t *testing.T) *stores { + t.Helper() + open := aMesh(t) + ctx := t.Context() + for _, b := range []link.BuildResult{ + onTrunk("build-sudo", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "1"}), + onTrunk("build-host", "novox/mesh-host", "git", "", map[string]any{"module": "mesh-host", "version": "1"}), + } { + keptAsked(t, open.inventory, b.ID, b.Source.Repository, true) + if _, _, err := takeIn(ctx, open.inventory, b); err != nil { + t.Fatal(err) + } + } + return open +} + +func TestABuildFromAnAgentsRepositoryIsNotRegisteredAsAModuleTheMeshHolds(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + for _, c := range []struct { + name, repository, path, module string + }{ + {"its own repository naming sudo", "agent/sudo", "modules/sudo", "sudo"}, + {"a fork of the catalogue", "agent/mesh-catalog", "modules/sudo", "sudo"}, + {"a fork of the node-engine", "agent/mesh-host", "", "mesh-host"}, + } { + t.Run(c.name, func(t *testing.T) { + id := "build-" + strings.ReplaceAll(c.repository, "/", "-") + keptAsked(t, open.inventory, id, c.repository, false) // through the build verb + evil := onTrunk(id, c.repository, "git", c.path, map[string]any{"module": c.module, "version": "evil"}) + _, _, err := takeIn(ctx, open.inventory, evil) + if !errors.Is(err, errNotItsSource) { + t.Fatalf("a build of %s was taken in as %s: %v", c.repository, c.module, err) + } + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { + t.Fatal(err) + } + if got := shelf[c.module].Version; got != "1" { + t.Fatalf("%s is now %q, from %s", c.module, got, c.repository) + } + if _, found, _ := open.inventory.BuildByID(ctx, id); !found { + t.Errorf("the refused build %s is not recorded", id) + } + }) + } +} + +func TestANewModuleFromARepositoryTheCatalogueDoesNotBuildFromIsNotRegistered(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + keptAsked(t, open.inventory, "build-new", "agent/tools", false) + _, _, err := takeIn(ctx, open.inventory, onTrunk("build-new", "agent/tools", "git", "", + map[string]any{"module": "agent-tools", "version": "1"})) + if !errors.Is(err, errNotItsSource) { + t.Fatalf("a new module from an agent's repository was taken in: %v", err) + } + // And one asked of nobody here — an outcome on the bus no request was kept for — the same. + _, _, err = takeIn(ctx, open.inventory, onTrunk("build-unasked", "agent/tools", "git", "", + map[string]any{"module": "agent-tools", "version": "1"})) + if !errors.Is(err, errNotItsSource) { + t.Fatalf("an outcome nobody asked for was taken in: %v", err) + } + if shelf, _ := open.inventory.Catalogue(ctx); shelf["agent-tools"].Module != "" { + t.Fatal("the refused module is in the catalogue") + } +} + +// The operator at the terminal may still move a module, or add one from a new repository. +func TestAtTheTerminalAnotherRepositoryIsRegistered(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + keptAsked(t, open.inventory, "build-moved", "novox/sudo", true) + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-moved", "novox/sudo", "git", "", + map[string]any{"module": "sudo", "version": "2"})); err != nil { + t.Fatalf("a move the operator asked for at the terminal was refused: %v", err) + } + if src, _ := open.inventory.SourceOf(ctx, "sudo"); src.Repository != "novox/sudo" { + t.Fatalf("sudo is built from %q", src.Repository) + } + keptAsked(t, open.inventory, "build-external", "someone/app", true) + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-external", "someone/app", "", "", + map[string]any{"module": "app", "version": "1"})); err != nil { + t.Fatalf("a new module the operator asked for at the terminal was refused: %v", err) + } +} + +// The delivery's flow is untouched: a merge's rebuild of a module from its own repository, and a merge adding +// a module to a repository the catalogue builds from (novox/hq issue 300), are registered with no terminal. +func TestADeliveryFromTheRegisteredRepositoryIsRegistered(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + rebuilt := onTrunk("build-plan", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "2"}) + if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: rebuilt.ID, + Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/sudo", For: "plan"}); err != nil { + t.Fatal(err) + } + if _, _, err := takeIn(ctx, open.inventory, rebuilt); err != nil { + t.Fatalf("a plan's build of the module's own repository was refused: %v", err) + } + added := onTrunk("build-merge", "novox/mesh-catalog", "git", "modules/zram", map[string]any{"module": "zram", "version": "1"}) + if _, _, err := takeIn(ctx, open.inventory, added); err != nil { + t.Fatalf("a module a merge added to the catalogue repository was refused: %v", err) + } + shelf, _ := open.inventory.Catalogue(ctx) + if shelf["sudo"].Version != "2" || shelf["zram"].Module == "" { + t.Fatalf("not registered: sudo %q, zram %q", shelf["sudo"].Version, shelf["zram"].Module) + } +} + +// Through a verb, a build of a repository the catalogue builds nothing from is not even asked: the build node +// would run what the agent wrote. +func TestAVerbAsksNoBuildOfARepositoryTheCatalogueDoesNotBuildFrom(t *testing.T) { + theCatalogue(t) + ctx := t.Context() + t.Setenv(verbVar, "build") + t.Setenv(link.CallerVar, "node-tools.anchor, through the mesh-controller seat") + err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git") + var policy *heldAtTheTerminal + if !errors.As(err, &policy) { + t.Fatalf("a verb's build of an agent's repository was asked: %v", err) + } + if err := verbMayAsk(ctx, buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, + "http://forge.internal:20000/novox/mesh-catalog.git"); err != nil { + t.Fatalf("a verb's build of the catalogue repository was refused: %v", err) + } + t.Setenv(verbVar, "") + t.Setenv(link.CallerVar, "") + if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, ""); err != nil { + t.Fatalf("the terminal was refused: %v", err) + } +} + +// asTheOperator keeps a build as asked at the controller's terminal, as the operator's first build of a module +// from a repository the catalogue does not yet build from is (novox/hq ADR 0266), and hands it back. +func asTheOperator(t *testing.T, inv *inventory.Inventory, b link.BuildResult) link.BuildResult { + t.Helper() + repository, seat := b.Repository, "" + if b.Source != nil { + repository, seat = b.Source.Repository, b.Source.Seat + } + if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: b.ID, Repository: repository, Seat: seat, + Path: b.Path, For: "build", AtTerminal: true}); err != nil { + t.Fatal(err) + } + return b +} diff --git a/cmd/mesh-controller/build_test.go b/cmd/mesh-controller/build_test.go index a7b0c695..aa70ff81 100644 --- a/cmd/mesh-controller/build_test.go +++ b/cmd/mesh-controller/build_test.go @@ -19,11 +19,11 @@ func TestABuildHeardIsRecordedAndRegistered(t *testing.T) { open := aMesh(t) ctx := t.Context() manifest, _ := json.Marshal(map[string]any{"module": "shop", "version": "3"}) - m, _, err := takeIn(ctx, open.inventory, link.BuildResult{ + m, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, link.BuildResult{ ID: "b-1", Repository: "http://forge.internal:20000/novox/shop.git", Path: "modules/shop", Ref: "main", On: "anchor", Commit: "abcdef0123", Manifest: manifest, Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/shop"}, - }) + })) if err != nil { t.Fatal(err) } @@ -78,7 +78,7 @@ func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) { 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 { + if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, 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 { @@ -117,7 +117,7 @@ func TestAnOlderBuildHeardLaterDoesNotReplaceTheNewer(t *testing.T) { Path: "modules/postgres", Ref: "main", On: "anchor", Commit: "efff5415", Manifest: manifest, Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}} } - if _, _, err := takeIn(ctx, open.inventory, result(newer, "4bcd5f73")); err != nil { + if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, result(newer, "4bcd5f73"))); err != nil { t.Fatal(err) } _, _, err := takeIn(ctx, open.inventory, result(older, "0ab07fa9")) diff --git a/cmd/mesh-controller/gate_test.go b/cmd/mesh-controller/gate_test.go index e8d55ce2..9bbd46d3 100644 --- a/cmd/mesh-controller/gate_test.go +++ b/cmd/mesh-controller/gate_test.go @@ -221,7 +221,8 @@ func TestABuildThatFailsItsGateIsRolledBackOnItsFirstMachineAndGoesNoFurther(t * t.Fatalf("sent again after the rollback: %v", g.sent) } _, _, err = takeIn(ctx, inv, link.BuildResult{ID: "build-2", Repository: "novox/mesh-catalog", Path: "modules/app", - Commit: "c2", Manifest: mustJSON(t, catalogue.Manifest{Module: "app", Version: "c2"})}) + Commit: "c2", Manifest: mustJSON(t, catalogue.Manifest{Module: "app", Version: "c2"}), + Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}) if err == nil || !strings.Contains(err.Error(), "failed its gate") { t.Fatalf("the failed build was registered again: %v", err) } diff --git a/cmd/mesh-controller/push_recreates_test.go b/cmd/mesh-controller/push_recreates_test.go index be91f5c1..1fc486a0 100644 --- a/cmd/mesh-controller/push_recreates_test.go +++ b/cmd/mesh-controller/push_recreates_test.go @@ -26,7 +26,7 @@ func TestAPushSaysWhatItRecreates(t *testing.T) { aContainerBuild(t, "postgres", "c1111111", "", start.Add(time.Second), map[string][2]string{"server": {image("e"), ""}}), } { - if _, _, err := takeIn(ctx, inv, b); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil { t.Fatal(err) } } diff --git a/cmd/mesh-controller/recorded_kept_test.go b/cmd/mesh-controller/recorded_kept_test.go index bbd8e192..07bce8bb 100644 --- a/cmd/mesh-controller/recorded_kept_test.go +++ b/cmd/mesh-controller/recorded_kept_test.go @@ -71,7 +71,7 @@ func TestARecordedBuildIsCarriedOnlyByAPersonsPush(t *testing.T) { aContainerBuild(t, "mailu", "c1111111", "", start.Add(time.Second), map[string][2]string{"smtp": {mailImage, ""}, "imap": {mailImage, ""}}), } { - if _, _, err := takeIn(ctx, inv, b); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil { t.Fatal(err) } } diff --git a/cmd/mesh-controller/replays_test.go b/cmd/mesh-controller/replays_test.go index b1e5f495..138f0180 100644 --- a/cmd/mesh-controller/replays_test.go +++ b/cmd/mesh-controller/replays_test.go @@ -539,8 +539,8 @@ func TestReplay301APersonsPushOfAHeldRecordedBuildIsNoRepair(t *testing.T) { inv := open.inventory start := time.Now().Add(-time.Hour) image := "registry.invalid:5000/resolver/server@sha256:" + strings.Repeat("e", 64) - if _, _, err := takeIn(ctx, inv, aContainerBuild(t, "resolver", "c1111111", catalogue.PolicyRecord, start, - map[string][2]string{"server": {image, ""}})); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, aContainerBuild(t, "resolver", "c1111111", catalogue.PolicyRecord, start, + map[string][2]string{"server": {image, ""}}))); err != nil { t.Fatal(err) } for _, node := range []string{"anchor", "laptop"} { diff --git a/cmd/mesh-controller/same_source_test.go b/cmd/mesh-controller/same_source_test.go index 9f1949a1..be2dc8c2 100644 --- a/cmd/mesh-controller/same_source_test.go +++ b/cmd/mesh-controller/same_source_test.go @@ -54,7 +54,7 @@ func TestARebuildOfAnUnchangedSourceKeepsItsArtifacts(t *testing.T) { // Another module's merge rebuilt it: a new commit, a new image digest, the same source. anImageBuild(t, "app", "", "c2bbbbbb", strings.Repeat("b", 64), "src1:same", start.Add(time.Minute)), } { - if _, _, err := takeIn(ctx, inv, b); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil { t.Fatalf("build %d: %v", i, err) } } @@ -114,7 +114,7 @@ func TestABusRebuiltFromAnUnchangedSourceDemandsNoBusStep(t *testing.T) { b.Manifest = manifest return b } - if _, _, err := takeIn(ctx, inv, bus("", "n1111111", strings.Repeat("a", 64), "src1:bus", start)); err != nil { + if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, bus("", "n1111111", strings.Repeat("a", 64), "src1:bus", start))); err != nil { t.Fatal(err) } if _, err := inv.Assign(ctx, "anchor", "nats"); err != nil { diff --git a/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql new file mode 100644 index 00000000..78a8d98c --- /dev/null +++ b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql @@ -0,0 +1,11 @@ +-- A build asked at the controller's terminal says so (novox/hq ADR 0266). +-- +-- A build's outcome registers its module, and a module is what the next push sends: a module named `sudo` +-- built from a repository an agent made would grant whoever wrote it root on every node it is assigned. +-- So an outcome may register a module only from the repository the catalogue already builds it from — or, +-- for a module new to the catalogue, from a repository the catalogue already builds another module from. +-- Anything else — a module moved to another repository, a new module from a new repository — is the +-- operator's, at the controller's terminal. This column is how the take-in tells: true only for a build +-- request kept by a `build` or `replay --register` run at the terminal, never through a verb (whoever may +-- call a verb includes agents). False for every request kept before this column existed. +alter table build_request add column at_terminal boolean not null default false; diff --git a/internal/inventory/pending.go b/internal/inventory/pending.go index e9d82f57..9cb5dd38 100644 --- a/internal/inventory/pending.go +++ b/internal/inventory/pending.go @@ -38,6 +38,9 @@ type BuildRequest struct { // unknown. Read as in flight until its outcome or its bound. OutcomeUnknown string At time.Time + // AtTerminal says the request was asked at the controller's terminal, never through a verb (novox/hq ADR + // 0266): what lets its outcome register a module from a repository the catalogue does not build it from. + AtTerminal bool } // Name is the module this request is expected to register, read from its directory: the last element of @@ -73,16 +76,30 @@ func (i *Inventory) RecordBuildRequest(ctx context.Context, a BuildRequest) erro notAsked = &a.NotAsked } if _, err := i.store.Pool().Exec(ctx, - `insert into build_request (id, repository, seat, source_path, ref, commit_hash, asked_for, not_asked, asked_at) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `insert into build_request (id, repository, seat, source_path, ref, commit_hash, asked_for, not_asked, asked_at, + at_terminal) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) on conflict (id) do nothing`, - a.ID, a.Repository, a.Seat, strings.Trim(a.Path, "/"), a.Ref, a.Commit, a.For, notAsked, at); err != nil { + a.ID, a.Repository, a.Seat, strings.Trim(a.Path, "/"), a.Ref, a.Commit, a.For, notAsked, at, + a.AtTerminal); err != nil { return err } _, err := i.store.Pool().Exec(ctx, `delete from build_request where asked_at < $1`, time.Now().Add(-KeptFor)) return err } +// AskedAtTheTerminal says whether the build of this id was kept as asked at the controller's terminal (novox/hq +// ADR 0266). False for a build no request was kept for — a check, a dry run, an outcome nobody here asked for — +// and for every request asked through a verb. +func (i *Inventory) AskedAtTheTerminal(ctx context.Context, id string) (bool, error) { + var at bool + err := i.store.Pool().QueryRow(ctx, `select at_terminal from build_request where id = $1`, id).Scan(&at) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return at, err +} + // MarkNotAsked says a kept build request was never handed over: the words are kept, unless its outcome was // heard first. func (i *Inventory) MarkNotAsked(ctx context.Context, id, why string) error { From 1b780eae3a87f1aad46c83edba7b985e7daff90d Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:41:05 +0200 Subject: [PATCH 2/3] Put back on a failed gate only a build of the module's own repository A build refused for its repository is still recorded, and a fork carries the commit the module was registered at: the rollback's search for the previous build would have found it and registered it by the back door. --- cmd/mesh-controller/build_source_test.go | 36 ++++++++++++++++++++++++ internal/inventory/gate.go | 15 ++++++++++ 2 files changed, 51 insertions(+) diff --git a/cmd/mesh-controller/build_source_test.go b/cmd/mesh-controller/build_source_test.go index b49ba7bf..6a9301fc 100644 --- a/cmd/mesh-controller/build_source_test.go +++ b/cmd/mesh-controller/build_source_test.go @@ -184,3 +184,39 @@ func asTheOperator(t *testing.T, inv *inventory.Inventory, b link.BuildResult) l } return b } + +// A rollback puts back only a build of the module's own repository: an agent's build of the module's name, +// recorded and refused, at the very commit the machine ran before (a fork carries it), is never registered by +// the back door of a failed gate. +func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + inv := open.inventory + fork := onTrunk("build-1791500000000000000", "agent/mesh-catalog", "git", "modules/sudo", + map[string]any{"module": "sudo", "version": "evil"}) + fork.Commit = "c0ffee0123456789" // the commit sudo was registered at + keptAsked(t, inv, fork.ID, "agent/mesh-catalog", false) + if _, _, err := takeIn(ctx, inv, fork); !errors.Is(err, errNotItsSource) { + t.Fatalf("the fork's build was taken in: %v", err) + } + failed := onTrunk("build-1791600000000000000", "novox/mesh-catalog", "git", "modules/sudo", + map[string]any{"module": "sudo", "version": "2"}) + failed.Commit = "badbadbad0123456" + if _, _, err := takeIn(ctx, inv, failed); err != nil { + t.Fatal(err) + } + record, _, err := inv.BuildByID(ctx, failed.ID) + if err != nil { + t.Fatal(err) + } + previous, found, err := inv.PreviousBuild(ctx, "sudo", "c0ffee0123456789", record) + if err != nil { + t.Fatal(err) + } + if found && previous.ID == fork.ID { + t.Fatalf("a rollback would put back the fork's build %s", previous.ID) + } + if !found || previous.ID != "build-sudo" { + t.Fatalf("a rollback puts back %q (found %v), want the registered build-sudo", previous.ID, found) + } +} diff --git a/internal/inventory/gate.go b/internal/inventory/gate.go index 58bc4e80..7baa82e7 100644 --- a/internal/inventory/gate.go +++ b/internal/inventory/gate.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/jackc/pgx/v5" @@ -169,6 +170,12 @@ func (i *Inventory) PreviousBuild(ctx context.Context, module, commit string, fa if b.ID == failed.ID || (commit != "" && b.Commit != commit) { continue } + // Only a build of the repository the failed build was made from — the module's, since its take-in + // registered it (novox/hq ADR 0266): a build of the module's name from another repository is recorded + // and was never registered, and putting it back would register it now. + if failed.Repository != "" && !sameRepositoryAs(b.Repository, failed.Repository) { + continue + } if !failed.AskedOrAt().IsZero() && !b.AskedOrAt().Before(failed.AskedOrAt()) { continue } @@ -190,6 +197,14 @@ func (i *Inventory) PreviousBuild(ctx context.Context, module, commit string, fa return Build{}, false, nil } +// sameRepositoryAs says two recorded repositories are one, however their case or `.git` is spelled. +func sameRepositoryAs(a, b string) bool { + trim := func(s string) string { + return strings.TrimSuffix(strings.TrimRight(strings.ToLower(strings.TrimSpace(s)), "/"), ".git") + } + return trim(a) == trim(b) +} + // RestoreModule puts a module's registered build back to an earlier one: its manifest, the commit it // was built from, and when it was asked — as now, so the build that failed its gate, asked before, can // never register over it again (issue 219's order). The source's head is left where the merge moved it: From 95e7a7120ab00ac4f7a51d3b25a38a01d66fed70 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:23:47 +0200 Subject: [PATCH 3/3] Register only from a protected trunk of the same repository, and mark the serving controller never the terminal A source repository's name is not its identity, and a trunk anyone may push to makes the trunk rule mean nothing (the review of 2026-10-09). Through any verb a module now registers only from a repository on the mesh's forge whose trunk refuses direct pushes, requires a status and lets no administrator merge past one, asked of the forge's own tools; and only from the repository by the forge's id, recorded at registration (migration 0084), so one deleted and made again under the name is refused. The serving controller marks its environment, so nothing it runs or starts reads as the terminal, and a terminal request covers only the repository and path it asked. --- cmd/mesh-controller/build.go | 14 +- cmd/mesh-controller/build_source.go | 274 ++++++++++++++++-- cmd/mesh-controller/build_source_test.go | 139 ++++++++- cmd/mesh-controller/push.go | 2 + internal/inventory/catalogue.go | 19 ++ ...-a-build-asked-at-the-terminal-says-so.sql | 7 + internal/inventory/pending.go | 17 +- 7 files changed, 433 insertions(+), 39 deletions(-) diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index a69d8379..6f1c0cd5 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -640,7 +640,15 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu // **Only from the repository the catalogue builds the module from** (novox/hq ADR 0266): else the trunk // below is the trunk of whatever repository was built, which may be one an agent made — and a module named // `sudo` from it would be what the next push sends. Another repository is the operator's, at the terminal. - if err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID); err != nil { + trunk := result.Trunk + if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" { + trunk = followedBranch(was.Ref) + } + if trunk == "" { + trunk = "main" + } + repoID, err := mayRegisterFrom(ctx, inv, manifest.Module, recorded, result.ID, result.Path, trunk) + if err != nil { return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, manifest.Module, short(result.Commit), err) } @@ -691,6 +699,10 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu } return manifest, kept, err } + // Which repository it is registered from, by the forge's own id (novox/hq ADR 0266). + if err := inv.SetSourceIdentity(ctx, manifest.Module, repoID); err != nil { + return manifest, kept, err + } // The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather // than on a timer of its own: this is the only moment either is true. Never fatal — the build // worked and the module is registered. diff --git a/cmd/mesh-controller/build_source.go b/cmd/mesh-controller/build_source.go index 2d8b0ae5..053639de 100644 --- a/cmd/mesh-controller/build_source.go +++ b/cmd/mesh-controller/build_source.go @@ -2,10 +2,13 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "os" + "regexp" "strings" + "time" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/inventory" @@ -35,11 +38,24 @@ import ( // errNotItsSource is an outcome refused for where it was built from. var errNotItsSource = errors.New("not built from the repository the catalogue builds it from") -// startedAtTheTerminal says this process was started at the controller's terminal: neither a verb nor a seat call -// started it. runVerb sets both for every command a verb runs (seatverbs.go), and a verb is the only way an -// agent reaches the controller. +// servedVar marks every process the serving controller starts — each verb's command, each child — and the serving +// process itself, so none of them can read as the operator at the terminal (novox/hq ADR 0266). Set by serve +// before it answers anything, inherited by every child through os.Environ. +const servedVar = "MESH_SERVED_BY_THE_CONTROLLER" + +// startedAtTheTerminal says this process was started at the controller's terminal: not the serving controller, +// not anything it started, not a verb's command, not a seat call's. The serving controller marks its own +// environment (servedVar), so a build asked in it, or by any process it starts, never reads as the terminal's; +// runVerb also names the verb and the caller. func startedAtTheTerminal() bool { - return os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == "" + return os.Getenv(servedVar) == "" && os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == "" +} + +// markServed marks this process, and so everything it starts, as the serving controller's. +func markServed() { + if err := os.Setenv(servedVar, "1"); err != nil { + panic("the serving controller could not mark its environment: " + err.Error()) + } } // sourceForms compares sources however each is spelled: a path on a seat's holder (ADR 0111) or a URL — a @@ -116,21 +132,166 @@ func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat str return false } -// mayRegisterFrom says whether a build's outcome may register module from the source it was built from -// (novox/hq ADR 0266): from the module's registered repository; for a module new to the catalogue, from a -// repository the catalogue builds another module from; else only when the build was asked at the terminal. +// forgeFacts is what the mesh's forge says of a repository a module is registered from (novox/hq ADR 0266). +type forgeFacts struct { + // ID is the forge's own id of the repository: what tells it from one deleted and made again by its name. + ID int64 + // Guarded is whether the branch is protected as a module's trunk must be: no direct push, at least one + // required status, and no administrator merging past one. Why says what is missing when it is not. + Guarded bool + Why string +} + +// askTheForge asks the forge, through its module's tools on the bus, for a repository's id and its branch's +// protection. A variable so a test needs no forge. +var askTheForge = func(ctx context.Context, owner, repo, branch string) (forgeFacts, error) { + js, err := aBus() + if err != nil { + return forgeFacts{}, err + } + defer js.Close() + bus := link.OverNATS{Conn: js.Conn()} + ask := func(tool string, args map[string]any, into any) error { + raw, _ := json.Marshal(args) + answer, err := link.Ask(ctx, bus, "gitea", tool, raw, 30*time.Second) + if err != nil { + return err + } + if answer.Error != "" { + return fmt.Errorf("gitea.%s: %s", tool, answer.Error) + } + return json.Unmarshal(answer.Result, into) + } + var found struct { + Result struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + } `json:"result"` + } + if err := ask("gitea_api", map[string]any{"path": "/repos/" + owner + "/" + repo}, &found); err != nil { + return forgeFacts{}, err + } + if found.Result.ID == 0 { + return forgeFacts{}, fmt.Errorf("the forge named no id for %s/%s", owner, repo) + } + var rules struct { + Rules []struct { + Rule string `json:"rule"` + Push bool `json:"push"` + RequiredStatuses []string `json:"required_statuses"` + AdminMayOverride bool `json:"admin_may_override"` + } `json:"rules"` + } + if err := ask("gitea_branch_protection_get", map[string]any{"owner": owner, "repo": repo}, &rules); err != nil { + return forgeFacts{}, err + } + facts := forgeFacts{ID: found.Result.ID, Why: fmt.Sprintf("no protection rule covers %s", branch)} + for _, r := range rules.Rules { + if !ruleCovers(r.Rule, branch) { + continue + } + switch { + case r.Push: + facts.Why = fmt.Sprintf("the rule %s lets a person push to %s directly", r.Rule, branch) + case len(r.RequiredStatuses) == 0: + facts.Why = fmt.Sprintf("the rule %s requires no status before a merge into %s", r.Rule, branch) + case r.AdminMayOverride: + facts.Why = fmt.Sprintf("the rule %s lets an administrator merge into %s past a status", r.Rule, branch) + default: + return forgeFacts{ID: facts.ID, Guarded: true}, nil + } + } + return facts, nil +} + +// ruleCovers says a protection rule's name — a branch, or a glob of them — covers a branch, as the forge reads it. +func ruleCovers(rule, branch string) bool { + if rule == branch { + return true + } + if !strings.ContainsAny(rule, "*?[") { + return false + } + var re strings.Builder + re.WriteString("^") + for i := 0; i < len(rule); i++ { + switch c := rule[i]; { + case c == '*' && i+1 < len(rule) && rule[i+1] == '*': + re.WriteString(".*") + i++ + case c == '*': + re.WriteString("[^/]*") + case c == '?': + re.WriteString("[^/]") + default: + re.WriteString(regexp.QuoteMeta(string(c))) + } + } + re.WriteString("$") + ok, _ := regexp.MatchString(re.String(), branch) + return ok +} + +// onTheForge is a source's owner and name on the mesh's own forge (the git seat's holder), and whether it is +// there at all. +func (f *sourceForms) onTheForge(repository, seat string) (owner, name string, ok bool) { + var rest string + switch { + case seat == gitSeat: + rest = strings.Trim(repository, "/") + case seat == "": + base := f.base(gitSeat) + if base == "" { + return "", "", false + } + url, prefix := f.canonical(repository, ""), f.canonical(base, "")+"/" + if !strings.HasPrefix(url, prefix) { + return "", "", false + } + // The case the forge spells it with: the URL as given, past the base. + rest = strings.TrimSuffix(strings.Trim(repository[len(prefix):], "/"), ".git") + default: + return "", "", false + } + parts := strings.Split(rest, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +// mayRegisterFrom says whether a build's outcome may register module from the source it was built from, and the +// forge's id of that repository to record (novox/hq ADR 0266). Through any verb, only: +// +// - from the module's registered repository — the same repository by the forge's own id, not only its name; or, +// for a module new to the catalogue, from a repository the catalogue builds another module from; +// - and from a repository on the mesh's forge whose trunk is protected as a trunk must be: no direct push, at +// least one required status, no administrator merging past one. +// +// Anything else only when the build request was kept as asked at the controller's terminal, for this very +// repository and path. path is the module's directory as built; branch the trunk it is registered from. func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source, - buildID string) error { + buildID, path, branch string) (int64, error) { forms := newSourceForms(ctx, inv) was, err := inv.SourceOf(ctx, module) isNew := errors.Is(err, inventory.ErrNoSuchModule) if err != nil && !isNew { - return err + return 0, err } + terminal, err := askedHereFor(ctx, inv, forms, buildID, built, path) + if err != nil { + return 0, err + } + refuse := func(why string) (int64, error) { + return 0, fmt.Errorf("%w: %s. A module is registered from such a source only by a build asked at the "+ + "controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+ + "may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why) + } + var why string + var alongside []inventory.Entry // the modules a new one's repository already builds switch { case !isNew && forms.same(was.Repository, was.Seat, built.Repository, built.Seat): - return nil case !isNew: registered := was.Repository if registered == "" { @@ -141,25 +302,94 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin default: entries, err := inv.Catalogued(ctx) if err != nil { - return err + return 0, err } - if forms.buildsFrom(entries, built.Repository, built.Seat) { - return nil + for _, e := range entries { + if !e.Provided && e.Source.Repository != "" && + forms.same(e.Source.Repository, e.Source.Seat, built.Repository, built.Seat) { + alongside = append(alongside, e) + } + } + if len(alongside) == 0 { + why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from", + module, sourceWords(built.Repository, built.Seat)) } - why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from", - module, sourceWords(built.Repository, built.Seat)) } - terminal, err := inv.AskedAtTheTerminal(ctx, buildID) + if why != "" && !terminal { + return refuse(why) + } + + owner, name, onForge := forms.onTheForge(built.Repository, built.Seat) + if !onForge { + if terminal { + fmt.Printf("%s: %s is not on the mesh's forge — registered, as asked at the controller's terminal\n", + buildID, sourceWords(built.Repository, built.Seat)) + return 0, nil + } + return refuse(fmt.Sprintf("%s is not on the mesh's forge, so whether its trunk is protected cannot be read", + sourceWords(built.Repository, built.Seat))) + } + facts, err := askTheForge(ctx, owner, name, branch) if err != nil { - return err + if terminal { + fmt.Printf("%s: the forge could not be asked about %s/%s (%v) — registered, as asked at the controller's "+ + "terminal\n", buildID, owner, name, err) + return 0, nil + } + return refuse(fmt.Sprintf("the forge could not say whether %s/%s's %s is protected: %v", owner, name, branch, err)) } if terminal { - fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID, why) - return nil + if why != "" || !facts.Guarded { + fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID, + strings.Trim(why+"; "+facts.Why, "; ")) + } + return facts.ID, nil } - return fmt.Errorf("%w: %s. A module is registered from another repository only by a build asked at the "+ - "controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+ - "may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why) + if !facts.Guarded { + return refuse(fmt.Sprintf("%s/%s's %s is not protected as a module's trunk must be: %s", owner, name, branch, + facts.Why)) + } + // The same repository by the forge's id, not only its name: one deleted and made again is another. + recorded := map[string]int64{} + if !isNew { + id, err := inv.SourceIdentity(ctx, module) + if err != nil { + return 0, err + } + recorded[module] = id + } + for _, e := range alongside { + id, err := inv.SourceIdentity(ctx, e.Manifest.Module) + if err != nil { + return 0, err + } + recorded[e.Manifest.Module] = id + } + for m, id := range recorded { + if id != 0 && id != facts.ID { + return refuse(fmt.Sprintf("%s/%s is not the repository %s was registered from: the forge knows it as "+ + "repository %d, and %s was registered from repository %d — one of that name deleted and made again", + owner, name, m, facts.ID, m, id)) + } + } + return facts.ID, nil +} + +// askedHereFor says the build was asked at the controller's terminal, for this repository and this path: a kept +// request marked so, whose source is the outcome's (novox/hq ADR 0266). +func askedHereFor(ctx context.Context, inv *inventory.Inventory, forms *sourceForms, buildID string, + built inventory.Source, path string) (bool, error) { + r, found, err := inv.BuildRequestByID(ctx, buildID) + if err != nil || !found || !r.AtTerminal { + return false, err + } + if !forms.same(r.Repository, r.Seat, built.Repository, built.Seat) || + strings.Trim(r.Path, "/") != strings.Trim(path, "/") { + fmt.Printf("%s was asked at the terminal of %s at %q, and its outcome is of %s at %q: not the terminal's\n", + buildID, sourceWords(r.Repository, r.Seat), r.Path, sourceWords(built.Repository, built.Seat), path) + return false, nil + } + return true, nil } // sourceWords is a source as a person reads it. diff --git a/cmd/mesh-controller/build_source_test.go b/cmd/mesh-controller/build_source_test.go index 6a9301fc..69fc5d8a 100644 --- a/cmd/mesh-controller/build_source_test.go +++ b/cmd/mesh-controller/build_source_test.go @@ -1,9 +1,15 @@ package main import ( + "context" "encoding/json" "errors" + "hash/fnv" + "os" + "os/exec" + "slices" "strings" + "sync" "testing" "github.com/novox/mesh-controller/internal/inventory" @@ -28,10 +34,10 @@ func onTrunk(id, repository, seat, path string, manifest map[string]any) link.Bu } // keptAsked keeps a build request as the asker would: through a verb, or at the terminal. -func keptAsked(t *testing.T, inv *inventory.Inventory, id, repository string, atTerminal bool) { +func keptAsked(t *testing.T, inv *inventory.Inventory, id, repository, path string, atTerminal bool) { t.Helper() if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: id, Repository: repository, Seat: "git", - For: "build", AtTerminal: atTerminal}); err != nil { + Path: path, For: "build", AtTerminal: atTerminal}); err != nil { t.Fatal(err) } } @@ -45,7 +51,7 @@ func theCatalogue(t *testing.T) *stores { onTrunk("build-sudo", "novox/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "1"}), onTrunk("build-host", "novox/mesh-host", "git", "", map[string]any{"module": "mesh-host", "version": "1"}), } { - keptAsked(t, open.inventory, b.ID, b.Source.Repository, true) + keptAsked(t, open.inventory, b.ID, b.Source.Repository, b.Path, true) if _, _, err := takeIn(ctx, open.inventory, b); err != nil { t.Fatal(err) } @@ -65,7 +71,7 @@ func TestABuildFromAnAgentsRepositoryIsNotRegisteredAsAModuleTheMeshHolds(t *tes } { t.Run(c.name, func(t *testing.T) { id := "build-" + strings.ReplaceAll(c.repository, "/", "-") - keptAsked(t, open.inventory, id, c.repository, false) // through the build verb + keptAsked(t, open.inventory, id, c.repository, c.path, false) // through the build verb evil := onTrunk(id, c.repository, "git", c.path, map[string]any{"module": c.module, "version": "evil"}) _, _, err := takeIn(ctx, open.inventory, evil) if !errors.Is(err, errNotItsSource) { @@ -88,7 +94,7 @@ func TestABuildFromAnAgentsRepositoryIsNotRegisteredAsAModuleTheMeshHolds(t *tes func TestANewModuleFromARepositoryTheCatalogueDoesNotBuildFromIsNotRegistered(t *testing.T) { open := theCatalogue(t) ctx := t.Context() - keptAsked(t, open.inventory, "build-new", "agent/tools", false) + keptAsked(t, open.inventory, "build-new", "agent/tools", "", false) _, _, err := takeIn(ctx, open.inventory, onTrunk("build-new", "agent/tools", "git", "", map[string]any{"module": "agent-tools", "version": "1"})) if !errors.Is(err, errNotItsSource) { @@ -109,7 +115,7 @@ func TestANewModuleFromARepositoryTheCatalogueDoesNotBuildFromIsNotRegistered(t func TestAtTheTerminalAnotherRepositoryIsRegistered(t *testing.T) { open := theCatalogue(t) ctx := t.Context() - keptAsked(t, open.inventory, "build-moved", "novox/sudo", true) + keptAsked(t, open.inventory, "build-moved", "novox/sudo", "", true) if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-moved", "novox/sudo", "git", "", map[string]any{"module": "sudo", "version": "2"})); err != nil { t.Fatalf("a move the operator asked for at the terminal was refused: %v", err) @@ -117,7 +123,10 @@ func TestAtTheTerminalAnotherRepositoryIsRegistered(t *testing.T) { if src, _ := open.inventory.SourceOf(ctx, "sudo"); src.Repository != "novox/sudo" { t.Fatalf("sudo is built from %q", src.Repository) } - keptAsked(t, open.inventory, "build-external", "someone/app", true) + if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-external", + Repository: "http://forge.internal:20000/someone/app.git", For: "build", AtTerminal: true}); err != nil { + t.Fatal(err) + } if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-external", "someone/app", "", "", map[string]any{"module": "app", "version": "1"})); err != nil { t.Fatalf("a new module the operator asked for at the terminal was refused: %v", err) @@ -195,7 +204,7 @@ func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) { fork := onTrunk("build-1791500000000000000", "agent/mesh-catalog", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "evil"}) fork.Commit = "c0ffee0123456789" // the commit sudo was registered at - keptAsked(t, inv, fork.ID, "agent/mesh-catalog", false) + keptAsked(t, inv, fork.ID, "agent/mesh-catalog", "modules/sudo", false) if _, _, err := takeIn(ctx, inv, fork); !errors.Is(err, errNotItsSource) { t.Fatalf("the fork's build was taken in: %v", err) } @@ -220,3 +229,117 @@ func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) { t.Fatalf("a rollback puts back %q (found %v), want the registered build-sudo", previous.ID, found) } } + +// theForge is what the forge says in a test, by owner/name: a repository not named here is protected as a +// trunk must be, with an id of its own. +var theForge sync.Map + +func init() { + askTheForge = func(_ context.Context, owner, repo, _ string) (forgeFacts, error) { + if said, ok := theForge.Load(owner + "/" + repo); ok { + switch f := said.(type) { + case error: + return forgeFacts{}, f + case forgeFacts: + return f, nil + } + } + h := fnv.New32a() + _, _ = h.Write([]byte(owner + "/" + repo)) + return forgeFacts{ID: int64(h.Sum32()), Guarded: true}, nil + } +} + +// A module's trunk the forge does not protect — direct pushes, no required status — or a forge that cannot say, +// registers nothing through a verb: the trunk rule means nothing on a branch anyone pushes to. +func TestATrunkTheForgeDoesNotProtectRegistersNothing(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + theForge.Store("novox/unguarded", forgeFacts{ID: 7, Why: "the rule main lets a person push to main directly"}) + t.Cleanup(func() { theForge.Delete("novox/unguarded") }) + first := onTrunk("build-unguarded-1", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "1"}) + keptAsked(t, open.inventory, first.ID, "novox/unguarded", "", true) + if _, _, err := takeIn(ctx, open.inventory, first); err != nil { + t.Fatalf("at the terminal: %v", err) + } + again := onTrunk("build-unguarded-2", "novox/unguarded", "git", "", map[string]any{"module": "unguarded", "version": "2"}) + if _, _, err := takeIn(ctx, open.inventory, again); !errors.Is(err, errNotItsSource) || + !strings.Contains(err.Error(), "push to main directly") { + t.Fatalf("a rebuild from an unprotected trunk was taken in: %v", err) + } + theForge.Store("novox/unguarded", errors.New("nothing serves gitea.gitea_api")) + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-unguarded-3", "novox/unguarded", "git", "", + map[string]any{"module": "unguarded", "version": "3"})); !errors.Is(err, errNotItsSource) { + t.Fatalf("a forge that could not say was read as a protected trunk: %v", err) + } + if shelf, _ := open.inventory.Catalogue(ctx); shelf["unguarded"].Version != "1" { + t.Fatalf("unguarded is %q", shelf["unguarded"].Version) + } +} + +// A repository deleted and made again under the module's repository's name is another repository: the forge's +// id, recorded at registration, tells them apart. +func TestARepositoryMadeAgainUnderItsNameIsNotTheModulesSource(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + theForge.Store("novox/remade", forgeFacts{ID: 100, Guarded: true}) + t.Cleanup(func() { theForge.Delete("novox/remade") }) + first := onTrunk("build-remade-1", "novox/remade", "git", "", map[string]any{"module": "remade", "version": "1"}) + keptAsked(t, open.inventory, first.ID, "novox/remade", "", true) + if _, _, err := takeIn(ctx, open.inventory, first); err != nil { + t.Fatal(err) + } + if id, _ := open.inventory.SourceIdentity(ctx, "remade"); id != 100 { + t.Fatalf("the forge's id was not recorded: %d", id) + } + theForge.Store("novox/remade", forgeFacts{ID: 101, Guarded: true}) // deleted, and made again by an agent + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-2", "novox/remade", "git", "", + map[string]any{"module": "remade", "version": "evil"})); !errors.Is(err, errNotItsSource) || + !strings.Contains(err.Error(), "made again") { + t.Fatalf("a repository made again under the name was taken in: %v", err) + } + // And a new module from it, beside the one registered from the first, the same. + if _, _, err := takeIn(ctx, open.inventory, onTrunk("build-remade-3", "novox/remade", "git", "modules/other", + map[string]any{"module": "other", "version": "1"})); !errors.Is(err, errNotItsSource) { + t.Fatalf("a new module from a repository made again was taken in: %v", err) + } +} + +// The terminal's mark is the operator's for the repository and path they asked: an outcome of another, under that +// build's id, is not theirs. +func TestATerminalRequestCoversOnlyWhatItAsked(t *testing.T) { + open := theCatalogue(t) + ctx := t.Context() + if err := open.inventory.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-asked", Repository: "novox/app", + Seat: "git", Path: "modules/app", For: "build", AtTerminal: true}); err != nil { + t.Fatal(err) + } + other := onTrunk("build-asked", "agent/sudo", "git", "modules/app", map[string]any{"module": "sudo", "version": "evil"}) + if _, _, err := takeIn(ctx, open.inventory, other); !errors.Is(err, errNotItsSource) { + t.Fatalf("an outcome of another repository under a terminal request's id was taken in: %v", err) + } + elsewhere := onTrunk("build-asked", "novox/app", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "evil"}) + if _, _, err := takeIn(ctx, open.inventory, elsewhere); !errors.Is(err, errNotItsSource) { + t.Fatalf("an outcome of another path under a terminal request's id was taken in: %v", err) + } +} + +// The serving controller, and everything it starts, is never the terminal: a build asked in it reads as asked +// through the mesh even when no verb and no caller is named. +func TestTheServingControllerIsNeverTheTerminal(t *testing.T) { + t.Setenv(verbVar, "") + t.Setenv(link.CallerVar, "") + t.Setenv(servedVar, "") + if !startedAtTheTerminal() { + t.Fatal("a process started by hand is not the terminal") + } + markServed() + if startedAtTheTerminal() { + t.Fatal("the serving controller reads as the terminal") + } + child := exec.Command(os.Args[0], "-test.run=^$") + child.Env = os.Environ() + if !slices.Contains(child.Env, servedVar+"=1") { + t.Fatal("what the serving controller starts does not carry its mark") + } +} diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index a4f54d63..7495ea4e 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -65,6 +65,8 @@ func connectLink(ctx context.Context, inv *inventory.Inventory, enroller link.En } func serve(ctx context.Context) (err error) { + // Nothing this process does, or starts, is the operator at the terminal (novox/hq ADR 0266). + markServed() // The one process whose log is read over time, so the one that says each change to a node's // unmet seat dependencies once (novox/hq ADR 0207). logUnheldChanges = true diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 3112962c..71350a92 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -169,6 +169,25 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr return nil } +// SourceIdentity is the forge's id of the repository a module is registered from, 0 when none is recorded. +func (i *Inventory) SourceIdentity(ctx context.Context, module string) (int64, error) { + var id *int64 + err := i.store.Pool().QueryRow(ctx, `select source_repo_id from module where name = $1`, module).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return 0, fmt.Errorf("%w: %s", ErrNoSuchModule, module) + } + if err != nil || id == nil { + return 0, err + } + return *id, nil +} + +// SetSourceIdentity records the forge's id of the repository a module is registered from; 0 records none. +func (i *Inventory) SetSourceIdentity(ctx context.Context, module string, id int64) error { + _, err := i.store.Pool().Exec(ctx, `update module set source_repo_id = nullif($2::bigint, 0) where name = $1`, module, id) + return err +} + // hasModule is whether the catalogue holds a module of that name. func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) { var one int diff --git a/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql index 78a8d98c..650a8cf8 100644 --- a/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql +++ b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql @@ -9,3 +9,10 @@ -- request kept by a `build` or `replay --register` run at the terminal, never through a verb (whoever may -- call a verb includes agents). False for every request kept before this column existed. alter table build_request add column at_terminal boolean not null default false; + +-- And which repository a module is registered from, by the forge's own id for it (novox/hq ADR 0266): a name +-- is not an identity. A repository deleted and made again under the same name is another repository, with +-- none of the protection the first had until someone sets it; its outcome must not register as the module's. +-- Recorded when a build of it is registered from the mesh's own forge; null until then, and for a source the +-- forge does not hold. +alter table module add column source_repo_id bigint; diff --git a/internal/inventory/pending.go b/internal/inventory/pending.go index 9cb5dd38..0e761e31 100644 --- a/internal/inventory/pending.go +++ b/internal/inventory/pending.go @@ -88,16 +88,17 @@ func (i *Inventory) RecordBuildRequest(ctx context.Context, a BuildRequest) erro return err } -// AskedAtTheTerminal says whether the build of this id was kept as asked at the controller's terminal (novox/hq -// ADR 0266). False for a build no request was kept for — a check, a dry run, an outcome nobody here asked for — -// and for every request asked through a verb. -func (i *Inventory) AskedAtTheTerminal(ctx context.Context, id string) (bool, error) { - var at bool - err := i.store.Pool().QueryRow(ctx, `select at_terminal from build_request where id = $1`, id).Scan(&at) +// BuildRequestByID is the build request kept under this id, and whether one was kept. +func (i *Inventory) BuildRequestByID(ctx context.Context, id string) (BuildRequest, bool, error) { + var a BuildRequest + err := i.store.Pool().QueryRow(ctx, + `select id, repository, seat, source_path, ref, commit_hash, asked_for, at_terminal, asked_at + from build_request where id = $1`, id).Scan(&a.ID, &a.Repository, &a.Seat, &a.Path, &a.Ref, &a.Commit, + &a.For, &a.AtTerminal, &a.At) if errors.Is(err, pgx.ErrNoRows) { - return false, nil + return BuildRequest{}, false, nil } - return at, err + return a, err == nil, err } // MarkNotAsked says a kept build request was never handed over: the words are kept, unless its outcome was