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 {