An agent could make a repository of its own, or fork one the mesh builds from, commit a module.json naming sudo or mesh-host, and ask the build verb for it: the outcome was registered under that name, and the next push made whoever wrote it root on every node (novox/hq ADR 0266 §7, the review of 2026-10-09). The trunk rule checked the trunk of the repository built, which was the agent's. The take-in, which every outcome reaches whichever verb asked it, now registers a module only from its registered repository, and a new module only from a repository the catalogue already builds from (a merge adding one); anything else only when the build request was kept as asked at the controller's terminal (migration 0084). Through a verb, a build of a repository the catalogue builds nothing from is not asked at all.
198 lines
7.9 KiB
Go
198 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// Where a build may register a module from (novox/hq ADR 0266).
|
|
//
|
|
// A build's outcome registers its module, and a registered module is what the next push sends. Before this,
|
|
// an outcome registered whatever its manifest named, from whichever repository it was built from: an agent
|
|
// that made a repository of its own, committed `modules/sudo/module.json` granting itself a rule without a
|
|
// password, and asked the `build` verb for it had its repository registered as the module `sudo` — whose next
|
|
// push made the agent root on every node. A fork of the node-engine did the same everywhere. The trunk rule
|
|
// (ADR 0238) did not stop it: the trunk it checked was the trunk of the repository built, which was the
|
|
// agent's own.
|
|
//
|
|
// So **an outcome registers a module only from the repository the catalogue already builds that module
|
|
// from**; and a module new to the catalogue only from a repository the catalogue already builds another
|
|
// module from — whose trunk takes a reviewed, approved merge, which is how a merge adds a module (novox/hq
|
|
// issue 300). Anything else — a module moved to another repository, a module from a repository the
|
|
// catalogue has never built — is the operator's, at the controller's terminal: allowed only when the build
|
|
// request was kept as asked there. Judged at the take-in, which every outcome reaches whoever hears it and
|
|
// whichever verb asked it (`build`, `rebuild`, `replay --register`, `assign` with build), and before the ask
|
|
// for a call through a verb, so an agent cannot have a build node run a repository the catalogue does not
|
|
// build from at all.
|
|
|
|
// errNotItsSource is an outcome refused for where it was built from.
|
|
var errNotItsSource = errors.New("not built from the repository the catalogue builds it from")
|
|
|
|
// startedAtTheTerminal says this process was started at the controller's terminal: neither a verb nor a seat call
|
|
// started it. runVerb sets both for every command a verb runs (seatverbs.go), and a verb is the only way an
|
|
// agent reaches the controller.
|
|
func startedAtTheTerminal() bool {
|
|
return os.Getenv(verbVar) == "" && os.Getenv(link.CallerVar) == ""
|
|
}
|
|
|
|
// sourceForms compares sources however each is spelled: a path on a seat's holder (ADR 0111) or a URL — a
|
|
// build asked of a seat's path is registered with the URL composed from it when its outcome does not echo
|
|
// the seat. A seat's path is composed into its URL where the seat's holder is known, so both spellings of
|
|
// one repository are one; where it is not, a seat's path matches only the same seat's same path.
|
|
type sourceForms struct {
|
|
bases map[string]string // the seat's clone base, `scheme://host:port`, by seat; "" where it is not known
|
|
base func(seat string) string
|
|
}
|
|
|
|
// newSourceForms reads the seats' bases from the mesh once, when first needed.
|
|
func newSourceForms(ctx context.Context, inv *inventory.Inventory) *sourceForms {
|
|
f := &sourceForms{bases: map[string]string{}}
|
|
var world *catalogue.World
|
|
f.base = func(seat string) string {
|
|
if b, known := f.bases[seat]; known {
|
|
return b
|
|
}
|
|
if world == nil {
|
|
w := catalogue.World{}
|
|
if shelf, err := inv.Catalogue(ctx); err == nil {
|
|
if read, err := theRestOfTheMesh(ctx, inv, shelf, ""); err == nil {
|
|
w = read
|
|
}
|
|
}
|
|
world = &w
|
|
}
|
|
b, err := seatBase(*world, seat)
|
|
if err != nil {
|
|
b = ""
|
|
}
|
|
f.bases[seat] = b
|
|
return b
|
|
}
|
|
return f
|
|
}
|
|
|
|
// canonical is one spelling of a repository: lower case, no `.git`, no trailing slash, and a seat's path as
|
|
// the URL its holder serves it at where that is known.
|
|
func (f *sourceForms) canonical(repository, seat string) string {
|
|
trim := func(s string) string {
|
|
s = strings.TrimSpace(strings.ToLower(s))
|
|
s = strings.TrimRight(s, "/")
|
|
return strings.TrimRight(strings.TrimSuffix(s, ".git"), "/")
|
|
}
|
|
if seat == "" {
|
|
return trim(repository)
|
|
}
|
|
if b := f.base(seat); b != "" {
|
|
return trim(b + "/" + strings.Trim(repository, "/"))
|
|
}
|
|
return "seat:" + seat + ":" + trim(strings.Trim(repository, "/"))
|
|
}
|
|
|
|
// same says two sources are one repository.
|
|
func (f *sourceForms) same(aRepository, aSeat, bRepository, bSeat string) bool {
|
|
if aRepository == "" || bRepository == "" {
|
|
return false
|
|
}
|
|
return f.canonical(aRepository, aSeat) == f.canonical(bRepository, bSeat)
|
|
}
|
|
|
|
// buildsFrom says the catalogue builds some module from this repository.
|
|
func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat string) bool {
|
|
for _, e := range entries {
|
|
if e.Provided || e.Source.Repository == "" {
|
|
continue
|
|
}
|
|
if f.same(e.Source.Repository, e.Source.Seat, repository, seat) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// mayRegisterFrom says whether a build's outcome may register module from the source it was built from
|
|
// (novox/hq ADR 0266): from the module's registered repository; for a module new to the catalogue, from a
|
|
// repository the catalogue builds another module from; else only when the build was asked at the terminal.
|
|
func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source,
|
|
buildID string) error {
|
|
forms := newSourceForms(ctx, inv)
|
|
was, err := inv.SourceOf(ctx, module)
|
|
isNew := errors.Is(err, inventory.ErrNoSuchModule)
|
|
if err != nil && !isNew {
|
|
return err
|
|
}
|
|
var why string
|
|
switch {
|
|
case !isNew && forms.same(was.Repository, was.Seat, built.Repository, built.Seat):
|
|
return nil
|
|
case !isNew:
|
|
registered := was.Repository
|
|
if registered == "" {
|
|
registered = "no repository (it was handed over by hand)"
|
|
}
|
|
why = fmt.Sprintf("%s is built from %s, and this build is of %s", module, sourceWords(registered, was.Seat),
|
|
sourceWords(built.Repository, built.Seat))
|
|
default:
|
|
entries, err := inv.Catalogued(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if forms.buildsFrom(entries, built.Repository, built.Seat) {
|
|
return nil
|
|
}
|
|
why = fmt.Sprintf("%s is new to the catalogue, and %s is no repository the catalogue builds a module from",
|
|
module, sourceWords(built.Repository, built.Seat))
|
|
}
|
|
terminal, err := inv.AskedAtTheTerminal(ctx, buildID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if terminal {
|
|
fmt.Printf("%s: %s — registered, as asked at the controller's terminal\n", buildID, why)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%w: %s. A module is registered from another repository only by a build asked at the "+
|
|
"controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+
|
|
"may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why)
|
|
}
|
|
|
|
// sourceWords is a source as a person reads it.
|
|
func sourceWords(repository, seat string) string {
|
|
if seat == "" {
|
|
return repository
|
|
}
|
|
return buildSource{Repository: repository, Seat: seat}.String()
|
|
}
|
|
|
|
// verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from
|
|
// (novox/hq ADR 0266): the build node would run what an agent wrote, and its outcome could never be
|
|
// registered anyway. url is the repository as it is cloned. At the terminal anything may be asked.
|
|
func verbMayAsk(ctx context.Context, source buildSource, url string) error {
|
|
if startedAtTheTerminal() {
|
|
return nil
|
|
}
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
entries, err := open.inventory.Catalogued(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
forms := newSourceForms(ctx, open.inventory)
|
|
if forms.buildsFrom(entries, source.Repository, source.Seat) || forms.buildsFrom(entries, url, "") {
|
|
return nil
|
|
}
|
|
return terminalRefusal("%s is no repository the catalogue builds a module from, and a build of any other is "+
|
|
"asked at the controller's terminal only, never through a verb: a build node runs what the repository "+
|
|
"says, and its outcome would register a module the next push sends — whoever may call a verb includes "+
|
|
"agents (novox/hq ADR 0266). Nothing was asked", source)
|
|
}
|