diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 083139ea..6f1c0cd5 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,21 @@ 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. + 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) + } // **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. @@ -678,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. @@ -719,6 +744,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..053639de --- /dev/null +++ b/cmd/mesh-controller/build_source.go @@ -0,0 +1,427 @@ +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" + "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") + +// 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(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 +// 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 +} + +// 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, 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 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): + 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 0, err + } + 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)) + } + } + 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 { + 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 { + 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 + } + 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. +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..69fc5d8a --- /dev/null +++ b/cmd/mesh-controller/build_source_test.go @@ -0,0 +1,345 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "hash/fnv" + "os" + "os/exec" + "slices" + "strings" + "sync" + "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, path string, atTerminal bool) { + t.Helper() + if err := inv.RecordBuildRequest(t.Context(), inventory.BuildRequest{ID: id, Repository: repository, Seat: "git", + Path: path, 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, b.Path, 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, 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) { + 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) + } + 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) + } +} + +// 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 +} + +// 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", "modules/sudo", 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) + } +} + +// 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/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.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/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/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/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: 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..650a8cf8 --- /dev/null +++ b/internal/inventory/migrations/0084-a-build-asked-at-the-terminal-says-so.sql @@ -0,0 +1,18 @@ +-- 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; + +-- 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 e9d82f57..0e761e31 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,31 @@ 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 } +// 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 BuildRequest{}, false, nil + } + return a, err == nil, 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 {