The registration holds the controller's one receive loop. Checking the bound only between asks let an ask in flight hold it near two minutes, the deaf-controller limit. The whole wait now runs under a 60 s deadline, still says it waited when that deadline ends it, and a tool that did not answer in time is not asked again: it has already held the loop its whole wait.
548 lines
22 KiB
Go
548 lines
22 KiB
Go
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.
|
|
//
|
|
// **And a line mesh-cli asked as the controller's terminal** (novox/hq ADR 0272 §4): the serving controller runs it
|
|
// without the served mark and without a verb, names its caller, and marks it with cliTerminalVar — a mark only the
|
|
// mesh-cli path sets and every other command line the controller runs is stripped of (commandEnvironment).
|
|
func startedAtTheTerminal() bool {
|
|
if os.Getenv(servedVar) != "" || os.Getenv(verbVar) != "" {
|
|
return false
|
|
}
|
|
return os.Getenv(link.CallerVar) == "" || os.Getenv(cliTerminalVar) != ""
|
|
}
|
|
|
|
// cliTerminalVar marks a command line the serving controller runs as the controller's terminal for mesh-cli.
|
|
const cliTerminalVar = "MESH_CLI_TERMINAL"
|
|
|
|
// 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, waiting out a forge not served for a moment (novox/hq issue 486). A variable so a test needs no
|
|
// forge.
|
|
var askTheForge = func(ctx context.Context, owner, repo, branch string) (forgeFacts, error) {
|
|
return forgeWaitedOut(ctx, forgeWait, owner+"/"+repo, func(ctx context.Context) (forgeFacts, error) {
|
|
return askTheForgeOnce(ctx, owner, repo, branch)
|
|
})
|
|
}
|
|
|
|
// waitOut bounds how long a question is asked again while its answer is "not now": a pause of first, doubling
|
|
// to most, for at most bound in all. now and sleep are the clock, so a test waits in no real time.
|
|
type waitOut struct {
|
|
bound, first, most time.Duration
|
|
now func() time.Time
|
|
sleep func(context.Context, time.Duration) error
|
|
}
|
|
|
|
// forgeWait is the registration's wait for the forge (novox/hq issue 486). On 2026-10-11 a build's registration
|
|
// asked the forge while node-tools on the control-node re-served after a rollout of the tool runner, heard
|
|
// "nothing serves gitea.gitea_api", and failed its plan; the forge answered again seconds later. A re-serve takes
|
|
// about 10 s, so 60 s waits one out with room to spare. It is a cap on the whole wait, an ask in flight included:
|
|
// the registration holds the controller's one receive loop while it waits, and a loop held two minutes reads as
|
|
// deaf. The pauses are those the build machine waits the registry with (novox/hq issue 457).
|
|
var forgeWait = waitOut{bound: 60 * time.Second, first: time.Second, most: 10 * time.Second,
|
|
now: time.Now, sleep: sleepOrDone}
|
|
|
|
// sleepOrDone pauses for d, or less when ctx ends first.
|
|
func sleepOrDone(ctx context.Context, d time.Duration) error {
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-t.C:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// forgeNotNow is an answer that says the forge's tool is not served now — the tool runner re-serving — not that
|
|
// the forge refused. Only that is asked again: a refusal, an unreadable answer or a repository with no id is the
|
|
// forge's word, and a tool that did not answer in time has already held the receive loop its whole wait.
|
|
func forgeNotNow(err error) bool { return errors.Is(err, link.ErrNothingServes) }
|
|
|
|
// forgeWaitedOut asks the forge about repository, asking again while it answers "not served now", all of it
|
|
// within w's bound, an ask in flight included (novox/hq issue 486). Past the bound it fails, saying it waited,
|
|
// for how long, and what the forge last said; when the caller's own context ends, it says that instead.
|
|
func forgeWaitedOut(ctx context.Context, w waitOut, repository string,
|
|
ask func(context.Context) (forgeFacts, error)) (forgeFacts, error) {
|
|
capped, cancel := context.WithTimeout(ctx, w.bound)
|
|
defer cancel()
|
|
began := w.now()
|
|
pause := w.first
|
|
for asked := 1; ; asked++ {
|
|
facts, err := ask(capped)
|
|
if err == nil {
|
|
if asked > 1 {
|
|
fmt.Printf("the forge answered about %s again, after %s (novox/hq issue 486)\n", repository,
|
|
w.now().Sub(began).Round(time.Second))
|
|
}
|
|
return facts, nil
|
|
}
|
|
if ctx.Err() != nil {
|
|
return forgeFacts{}, err // the caller's own context ended: no wait of ours to speak of
|
|
}
|
|
gaveUp := func() (forgeFacts, error) {
|
|
return forgeFacts{}, fmt.Errorf("waited %s for the forge to answer about %s, asking %d times, and it "+
|
|
"did not (novox/hq issue 486): %w", w.now().Sub(began).Round(time.Second), repository, asked, err)
|
|
}
|
|
if capped.Err() != nil {
|
|
return gaveUp() // the bound ended an ask in flight
|
|
}
|
|
if !forgeNotNow(err) {
|
|
return forgeFacts{}, err
|
|
}
|
|
if w.now().Sub(began)+pause > w.bound {
|
|
return gaveUp()
|
|
}
|
|
fmt.Printf("the forge cannot be asked about %s now (%v); asking again in %s, for at most %s in all "+
|
|
"(novox/hq issue 486)\n", repository, err, pause, w.bound)
|
|
if serr := w.sleep(capped, pause); serr != nil {
|
|
if ctx.Err() != nil {
|
|
return forgeFacts{}, fmt.Errorf("stopped waiting for the forge to answer about %s: %w", repository,
|
|
ctx.Err())
|
|
}
|
|
return gaveUp()
|
|
}
|
|
pause = min(2*pause, w.most)
|
|
}
|
|
}
|
|
|
|
// askTheForgeOnce asks the forge once, through its module's tools on the bus.
|
|
func askTheForgeOnce(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 []protectionRule `json:"rules"`
|
|
}
|
|
if err := ask("gitea_branch_protection_get", map[string]any{"owner": owner, "repo": repo}, &rules); err != nil {
|
|
return forgeFacts{}, err
|
|
}
|
|
facts := judgedRule(rules.Rules, branch)
|
|
facts.ID = found.Result.ID
|
|
return facts, nil
|
|
}
|
|
|
|
// protectionRule is a branch protection rule as the forge's tool summarises it.
|
|
type protectionRule struct {
|
|
Rule string `json:"rule"`
|
|
Push bool `json:"push"`
|
|
RequiredStatuses []string `json:"required_statuses"`
|
|
AdminMayOverride bool `json:"admin_may_override"`
|
|
}
|
|
|
|
// judgedRule judges the one rule the forge applies to a branch, as the forge picks it: the rule named for the
|
|
// branch, else the first glob rule, in the forge's order, that covers it (gitea's first matching rule). Never a
|
|
// later rule that happens to be stronger: the forge does not read it.
|
|
func judgedRule(rules []protectionRule, branch string) forgeFacts {
|
|
var applied *protectionRule
|
|
for i := range rules {
|
|
if rules[i].Rule == branch {
|
|
applied = &rules[i]
|
|
break
|
|
}
|
|
}
|
|
if applied == nil {
|
|
for i := range rules {
|
|
if ruleCovers(rules[i].Rule, branch) {
|
|
applied = &rules[i]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
switch r := applied; {
|
|
case r == nil:
|
|
return forgeFacts{Why: fmt.Sprintf("no protection rule covers %s", branch)}
|
|
case r.Push:
|
|
return forgeFacts{Why: fmt.Sprintf("the rule %s lets a person push to %s directly", r.Rule, branch)}
|
|
case len(r.RequiredStatuses) == 0:
|
|
return forgeFacts{Why: fmt.Sprintf("the rule %s requires no status before a merge into %s", r.Rule, branch)}
|
|
case r.AdminMayOverride:
|
|
return forgeFacts{Why: fmt.Sprintf("the rule %s lets an administrator merge into %s past a status", r.Rule, branch)}
|
|
}
|
|
return forgeFacts{Guarded: true}
|
|
}
|
|
|
|
// 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)
|
|
}
|