Files
mesh-controller/cmd/mesh-controller/try.go
T
jschoubben e419d0182f Delete the builder, the build asks, the build queue and the merge gate: the builder's keys are the only way a module is built (issues 1851, 1299, 1318; C2b-C5, C10)
The controller asks no build of anybody (ADR 0501 §5, ADR 0525 clause 6, ADR 0503 §3). Every module of a walk is taken from the
builder's done key; a key not done yet waits, named in the plan, and never fails it.

- C2b/C2c: internal/builder and cmd/mesh-controller's asks go (askABuild, onlyTheBuildersOwn, buildOneAsked, takeIn from an
  outcome, buildAgentsOwn, the builder's say and its fallback, the daemon hearing an outcome). The server follows the retired
  seats' built subjects only to let go of them, because the installer's carried user list names them.
- C2d: replay, queue, cancel, clear, kill, pause, resume, delivery-check, check-here and merge-gate are refused by name.
- C3: the check queues (CHECKS_READY, CHECKS_OTHER) and their workers, the checks-running and plans-building buckets and
  their grants, the build queue and its hold (plans_building.go, queue.go, link/holds.go, builds_nats.go, checks_nats.go).
- C4: merge_gate*.go, the pull request's repo-check path, the Checked event and its layers; a pull request asks the
  controller nothing but to tell mesh-config what its head declares.
- C5: the builds bucket is written only by key_takein.
- C10 (help and refusals): the terminal's help lists only what is served.

Integration tests of the plan's tiers, sends, stops and retries now run on a stand-in builder with a done key for every
module (walk_keys_scaffold_integration_test.go); the tests of the asks, the queue, the merge gate and the replays are deleted.
2026-10-23 15:55:15 +02:00

293 lines
12 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/novox/mesh-controller/internal/link"
)
// A branch is tried before its pull request (novox/hq issue 686), by the checker (novox/hq ADR 0501 §5; issue 1851).
//
// A repository's store tests skip without a store, and no agent may start a database on its machine. So a builder
// pushed, a reviewer read, and only the pull request's merge check ran those tests for the first time: each failure
// there cost a round of fifteen to thirty minutes (mesh-controller #283, #310, #312).
//
// `try <owner>/<repository> <ref>` asks the checker (mesh-delivery-checker's `checker_try`) to judge the commit the ref
// names merged onto the trunk, as it judges a pull request's head: the build modules' fetch, digest and test steps,
// the unit level and then the integration level, each level's kept verdicts taken. From R4e build-agent is asked no
// try. It is a check and nothing more:
//
// - **no pull request, so no status**: the checker owes the forge nothing for a try;
// - **nothing registered, nothing built for delivery**: a try writes no verdict of a group and no preview;
// - **bounded**: one try of a head at a time — a second ask answers the first's id — and at most four open, the
// checker's own rule; a repository the checker judges no level of (hq) is refused by the checker, and said.
//
// A check outlasts a seat verb's one answer, so the ask answers its id at once and `try --id <id>` reads what came of
// it from the checker (`checker_try_read`), holding its answer up to tryWait for a verdict, like `plans wait`.
// tryWait is how long reading a try holds its answer for the verdict. A variable so a test need not wait.
var tryWait = 20 * time.Second
// A ref as the forge is asked for it — a branch or a commit, never an option — and a `go test -run` pattern.
var (
tryID = regexp.MustCompile(`^try-[0-9]+$`)
tryRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
tryTests = regexp.MustCompile(`^[A-Za-z0-9_^$|().*+?/\[\]-]+$`)
)
// tryAnswer is what `try` answers, asking or reading.
type tryAnswer struct {
ID string `json:"id"`
// State is asked (just now), waiting (nothing said of it yet: in the build queue), running, or the verdict:
// pass, warning (the repository declares no merge-check.sh), fail, or error (it could not run).
State string `json:"state"`
Said string `json:"said"`
Repository string `json:"repository,omitempty"`
Ref string `json:"ref,omitempty"`
Commit string `json:"commit,omitempty"`
Base string `json:"base,omitempty"`
Trunk string `json:"trunk,omitempty"`
On string `json:"on,omitempty"`
Took string `json:"took,omitempty"`
Failed []string `json:"failed,omitempty"`
// Last is the last thing the build seat said of one still running.
Last string `json:"last,omitempty"`
}
// checkerModule is the module whose tools judge a try (mesh-delivery-checker).
const checkerModule = "mesh-delivery-checker"
// checkerTry is the checker's answer to a try's ask: the try's id.
type checkerTry struct {
ID string `json:"id"`
}
// checkerTryRead is the checker's word on a try (`checker_try_read`): its state queued, running, pass, fail or error
// (could not run), what it said, the head and trunk judged, how long it took, what failed, and the machines it ran on.
type checkerTryRead struct {
ID string `json:"id"`
Repository string `json:"repository"`
Head string `json:"head"`
Trunk string `json:"trunk"`
Branch string `json:"branch,omitempty"`
State string `json:"state"`
Said string `json:"said"`
Took string `json:"took,omitempty"`
Failed []string `json:"failed,omitempty"`
Machines []string `json:"machines,omitempty"`
}
// askChecker asks one of the checker's tools and reads its result into into. A variable so a test needs no checker.
var askChecker = func(ctx context.Context, tool string, args map[string]any, into any) error {
js, err := aBus()
if err != nil {
return err
}
defer js.Close()
raw, _ := json.Marshal(args)
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, checkerModule, tool, raw, 30*time.Second)
if err != nil {
return fmt.Errorf("%s.%s: %w", checkerModule, tool, err)
}
if answer.Error != "" {
return fmt.Errorf("%s.%s: %s", checkerModule, tool, answer.Error)
}
var wrapped struct {
Result json.RawMessage `json:"result"`
}
if err := json.Unmarshal(answer.Result, &wrapped); err == nil && len(wrapped.Result) > 0 {
return json.Unmarshal(wrapped.Result, into)
}
return json.Unmarshal(answer.Result, into)
}
// tryOfChecker is the checker's word on a try as `try` answers it, today's answer unchanged (novox/hq issue 686): queued
// is waiting, and the machines it ran on are where.
func tryOfChecker(r checkerTryRead) tryAnswer {
a := tryAnswer{ID: r.ID, State: r.State, Said: r.Said, Repository: r.Repository, Commit: r.Head, Trunk: r.Trunk,
Took: r.Took, Failed: r.Failed, On: strings.Join(r.Machines, ", "), Base: r.Branch}
switch r.State {
case "queued":
a.State = "waiting"
if a.Said == "" {
a.Said = "it waits for the checker; ask again"
}
case "running":
if a.Said == "" {
a.Said = "still running; ask again"
}
}
return a
}
// forgeCommit asks the forge, through its module's tools on the bus, which commit a branch or commit of a
// repository is, and the repository's default branch. A variable so a test needs no forge.
var forgeCommit = func(ctx context.Context, owner, repo, ref string) (commit, trunk string, err error) {
js, err := aBus()
if err != nil {
return "", "", err
}
defer js.Close()
bus := link.OverNATS{Conn: js.Conn()}
ask := func(path string, into any) error {
raw, _ := json.Marshal(map[string]any{"path": path})
answer, err := link.Ask(ctx, bus, "gitea", "gitea_api", raw, 30*time.Second)
if err != nil {
return err
}
if answer.Error != "" {
return errors.New(answer.Error)
}
return json.Unmarshal(answer.Result, into)
}
var commits struct {
Result []struct {
SHA string `json:"sha"`
} `json:"result"`
}
if err := ask("/repos/"+owner+"/"+repo+"/commits?limit=1&stat=false&verification=false&files=false&sha="+
url.QueryEscape(ref), &commits); err != nil {
return "", "", err
}
if len(commits.Result) == 0 || commits.Result[0].SHA == "" {
return "", "", errors.New("the forge named no commit for it")
}
var found struct {
Result struct {
DefaultBranch string `json:"default_branch"`
} `json:"result"`
}
if err := ask("/repos/"+owner+"/"+repo, &found); err != nil {
return "", "", err
}
return commits.Result[0].SHA, found.Result.DefaultBranch, nil
}
// tryCommand is `try <owner>/<repository> <ref> [--base <branch>] [--tests <pattern>] [--store-only]`, and
// `try --id <id>` to read what came of one.
func tryCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("try", flag.ContinueOnError)
base := set.String("base", "", "the branch it is merged into (the repository's default branch when none)")
tests := set.String("tests", "", "only the tests this `go test -run` pattern names")
storeOnly := set.Bool("store-only", false, "only the repository's store tests")
id := set.String("id", "", "a try's id: what came of it")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
usage := errors.New("try <owner>/<repository> <branch or commit> [--base <branch>] [--tests <pattern>] " +
"[--store-only], or try --id <id>")
if *id != "" {
if len(positionals) != 0 {
return usage
}
// An id, never a pattern: the checker's `try-<n>`.
if !tryID.MatchString(*id) {
return fmt.Errorf("%q is not a try's id, try-<number>", *id)
}
return tryRead(ctx, *id)
}
if len(positionals) != 2 {
return usage
}
owner, repo, isPath := strings.Cut(positionals[0], "/")
ref := positionals[1]
switch {
case !isPath || !tryRef.MatchString(owner) || !tryRef.MatchString(repo) || strings.Contains(repo, "/"):
return fmt.Errorf("%q is not a repository on the forge, owner/name; nothing was asked", positionals[0])
case !tryRef.MatchString(ref):
return fmt.Errorf("%q is not a branch or a commit; nothing was asked", ref)
case *base != "" && !tryRef.MatchString(*base):
return fmt.Errorf("%q is not a branch; nothing was asked", *base)
case *tests != "" && !tryTests.MatchString(*tests):
return fmt.Errorf("%q is not a `go test -run` pattern of test names; nothing was asked", *tests)
}
// **An unknown ref is refused here, loudly** — not sent to a machine to fail at its clone minutes later.
commit, trunk, err := forgeCommit(ctx, owner, repo, ref)
if err != nil {
return fmt.Errorf("the forge holds no branch or commit %s in %s/%s — is it pushed? — so nothing was asked: %w",
ref, owner, repo, err)
}
if *base != "" {
trunk = *base
}
if trunk == "" {
return fmt.Errorf("the forge named no default branch for %s/%s, and no --base says what %s is merged into; "+
"nothing was asked", owner, repo, ref)
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
entries, read, edges, err := theGraph(ctx, open.inventory)
if err != nil {
return err
}
p := link.PullUpdated{Owner: owner, Repo: repo, Base: trunk, Commit: commit}
// Whose repository it is, as a pull request's check reckons it: only the mesh's is tried, the code the
// build seat runs for every pull request already.
mesh := pullScope(p, entries, read, edges)
if !mesh.Mesh {
return fmt.Errorf("%s/%s is no repository of the mesh — no module is built from it and its owner is not "+
"the core's — and only the mesh's are tried on the build seat; nothing was asked", owner, repo)
}
// **Asked of the checker, never of build-agent** (ADR 0501 §5): the head merged onto the trunk, as a pull request's.
askArgs := map[string]any{"repository": strings.ToLower(owner + "/" + repo), "head": commit, "trunk": trunk}
if *tests != "" {
askArgs["tests"] = *tests
}
if *storeOnly {
askArgs["level"] = "integration"
}
var asked checkerTry
if err := askChecker(ctx, "checker_try", askArgs, &asked); err != nil {
return fmt.Errorf("the checker did not take a try of %s/%s at %.12s; nothing was asked: %w", owner, repo, commit, err)
}
return printTry(tryAnswer{ID: asked.ID, State: "asked", Repository: owner + "/" + repo, Ref: ref, Commit: commit,
Base: trunk, On: checkerModule, Said: fmt.Sprintf("asked %s to try %s/%s at %.12s (%s) merged into %s: the head "+
"judged as a pull request's, unit level then integration level; no status is set and nothing is registered. "+
"Read it with try id=%s", checkerModule, owner, repo, commit, ref, trunk, asked.ID)})
}
// tryRead answers what came of a try, holding the answer up to tryWait for its verdict.
func tryRead(ctx context.Context, id string) error {
until := time.Now().Add(tryWait)
for {
var r checkerTryRead
if err := askChecker(ctx, "checker_try_read", map[string]any{"id": id}, &r); err != nil {
return printTry(tryAnswer{ID: id, State: "error", Said: "the checker's word on it cannot be read: " + err.Error()})
}
a := tryOfChecker(r)
if (a.State != "waiting" && a.State != "running") || !time.Now().Add(2*time.Second).Before(until) {
return printTry(a)
}
if err := sleepOrDone(ctx, 2*time.Second); err != nil {
return err
}
}
}
func printTry(a tryAnswer) error {
fmt.Fprintln(os.Stderr, a.Said)
for _, l := range a.Failed {
fmt.Fprintln(os.Stderr, " "+l)
}
out, err := json.MarshalIndent(a, "", " ")
if err != nil {
return err
}
fmt.Println(string(out))
return nil
}