Merge pull request 'Register a module only from the repository the catalogue builds it from (hq ADR 0266 §7, route A)' (#174) from fix/a-build-registers-only-from-its-modules-source into main
This commit was merged in pull request #174.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user