A check asked by the controller before its own build of the issue 285 fix was registered was judged by the controller the mesh ran then, which passed 0 of 4 composing. The judge is the running controller by design, so the rule is read where the verdict is taken too: from the machines the verdict lists.
922 lines
36 KiB
Go
922 lines
36 KiB
Go
package builder
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/artifacts"
|
|
"github.com/novox/mesh-controller/internal/facts"
|
|
)
|
|
|
|
// A pull request's merge check, run on the build seat (novox/hq to-be 45 §9, ADR 0237).
|
|
//
|
|
// **The build machine already has what a check needs**: the repositories, a container runtime, the
|
|
// artifact store where the controller keeps the facts snapshot, and a Go toolchain. So a check is one
|
|
// more kind of work on the build seat's queue rather than a CI the mesh would have to run beside itself.
|
|
//
|
|
// **Two layers, each its own status on the pull request** (ADR 0237 as amended, 2026-10-06):
|
|
//
|
|
// - **the gate** (`mesh/merge-gate`) runs when the change touches a module of the mesh's graph — the
|
|
// controller, which holds the graph, says which (Modules) and which directories it adds a module in
|
|
// (New). The touched manifests through `module check`; every machine of the facts snapshot composed
|
|
// with the change and validated by the node-engine's own validator; then mesh-lab's replays. The
|
|
// judge is the controller the mesh runs — or, for a change to the controller, the change's own
|
|
// controller, and for a change to the node-engine, the running controller with the change's validator
|
|
// in place of the one it vendors. The graph decides whether this runs, never the repository.
|
|
// - **the repository's own check** (`mesh/repo-check`): its merge-check.sh, its unit tests and code
|
|
// quality, run when present in the toolchain it declares (`# mesh-check-toolchain: go|typescript`
|
|
// among its first lines; go when it declares none). A repository that reaches the build seat with
|
|
// none is said as a warning: it is the mesh's, and nothing of its own is tested before it merges.
|
|
//
|
|
// One check:
|
|
//
|
|
// 1. clones the repository at the pull request's head, and beside it the repositories its check
|
|
// reads — the controller the mesh runs, the catalogue, the host, the lab — each at the ref asked;
|
|
// 2. reads the facts snapshot the controller keeps, and with it **the versions the mesh runs**: the
|
|
// store a check's tests stand on is the store's own release, and the bus the bus's;
|
|
// 3. raises a throwaway PostgreSQL and a throwaway bus of those versions, labelled with the ask so a
|
|
// kill or a crash leaves nothing behind;
|
|
// 4. runs the gate, then the repository's script — **each in a toolchain container of its own**, never
|
|
// in the build machine: a pull request is code nobody has approved yet, and the build machine holds
|
|
// the container runtime's socket; the check's container holds none, and reaches only the throwaway
|
|
// store and bus on loopback. Only the replays — mesh-lab's main, reviewed code — get the socket;
|
|
// 5. answers each layer pass, warning or fail from what ran, and error — never pass — when it could
|
|
// not run.
|
|
|
|
// CheckSpec is one check, as the controller asks it.
|
|
type CheckSpec struct {
|
|
ID string
|
|
Repository string
|
|
Ref string
|
|
Owner string
|
|
Repo string
|
|
Number int
|
|
Paths []string
|
|
// Beside are the repositories cloned next to it, by the directory they are found under.
|
|
Beside map[string]Beside
|
|
// Modules are the modules of the mesh's graph the change touches, New the directories it adds a
|
|
// module in, and Manifests the manifests among them in the change's tree: the gate runs when Modules
|
|
// or New is not empty.
|
|
Modules []string
|
|
New []string
|
|
Manifests []string
|
|
// Base is the branch the pull request merges into: what the gate compares the change against.
|
|
Base string
|
|
// Judge is who judges the gate: "" the controller the mesh runs, "self" the change's own, "validator"
|
|
// the running one with the change's validator.
|
|
Judge string
|
|
// Toolchain is the image a check's Go runs in: the mesh's own Go toolchain, as it holds it.
|
|
// Toolchains is every toolchain the mesh holds, by language, for a script that declares another.
|
|
Toolchain string
|
|
Toolchains map[string]string
|
|
// User is who a check's containers run as, uid:gid: the builder's own when empty — on the build seat,
|
|
// the user its service runs as. A check run by hand (`mesh-controller check-here`) says the seat's.
|
|
User string
|
|
// Group are a delivery group's other heads (novox/hq ADR 0239), each cloned beside this one at its head
|
|
// and composed with it by the gate as one future state. The repository's own check is not run for a
|
|
// group: each member's pull request runs its own.
|
|
Group []GroupHead
|
|
}
|
|
|
|
// GroupHead is one other head of a delivery group's composed check.
|
|
type GroupHead struct {
|
|
Owner string
|
|
Repo string
|
|
Repository string
|
|
Ref string
|
|
Paths []string
|
|
}
|
|
|
|
// Gated is whether the change touches the mesh's graph, and so whether the gate runs.
|
|
func (s CheckSpec) Gated() bool { return len(s.Modules)+len(s.New) > 0 }
|
|
|
|
// ToolchainOf is the Go toolchain image among what the mesh holds, empty when it holds none.
|
|
func ToolchainOf(held map[string]string) string {
|
|
return ToolchainsOf(held)["go"]
|
|
}
|
|
|
|
// ToolchainsOf is every toolchain image the mesh holds, by language.
|
|
func ToolchainsOf(held map[string]string) map[string]string {
|
|
out := map[string]string{}
|
|
for _, chain := range toolchains {
|
|
if image := held[chain.Base+"/"+chain.Artifact]; image != "" {
|
|
out[chain.Language] = image
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Beside is one repository cloned next to the one checked.
|
|
type Beside struct {
|
|
Repository string
|
|
Ref string
|
|
}
|
|
|
|
// CheckVerdict is what came of one check. Verdict and Summary are the gate's; Gate and Repo each layer.
|
|
type CheckVerdict struct {
|
|
Verdict string
|
|
Summary string
|
|
Report string
|
|
Took time.Duration
|
|
Gate *Layer
|
|
Repo *Layer
|
|
}
|
|
|
|
// Layer is one layer of a check, judged.
|
|
type Layer struct {
|
|
Verdict string
|
|
Summary string
|
|
Modules []string
|
|
}
|
|
|
|
// CheckScript is what a repository declares its own merge check as: run from its root, with the
|
|
// environment below.
|
|
const CheckScript = "merge-check.sh"
|
|
|
|
// Where a check finds what the builder raised and read for it.
|
|
const (
|
|
EnvFacts = "MESH_FACTS"
|
|
EnvGate = "MESH_GATE"
|
|
EnvGateStore = "MESH_GATE_POSTGRES"
|
|
EnvTestStore = "MESH_TEST_POSTGRES"
|
|
EnvTestBus = "MESH_TEST_NATS"
|
|
EnvRepository = "MESH_CHECK_REPOSITORY"
|
|
EnvChanged = "MESH_CHECK_CHANGED"
|
|
EnvVerdict = "MESH_CHECK_VERDICT"
|
|
EnvBeside = "MESH_CHECK_BESIDE"
|
|
EnvModules = "MESH_CHECK_MODULES"
|
|
// EnvGroup is a delivery group's other heads, as JSON, for the gate (novox/hq ADR 0239); empty for a
|
|
// pull request checked alone.
|
|
EnvGroup = "MESH_CHECK_GROUP"
|
|
)
|
|
|
|
// CheckTimeout bounds one check; a check that runs past it is an error, not a pass.
|
|
var CheckTimeout = 45 * time.Minute
|
|
|
|
// reportLines is how much of what a check printed travels in its verdict.
|
|
const reportLines = 200
|
|
|
|
// noModule is the gate's word for a change that touches nothing of the mesh's graph: a fact, not a
|
|
// missing check, so a pass.
|
|
const noModule = "the change touches no module of the mesh's graph"
|
|
|
|
// noScript is the repository layer's word for a repository with no merge-check.sh of its own.
|
|
const noScript = "the repository declares no " + CheckScript + ": none of its own tests run before it merges"
|
|
|
|
// toolchainLine is how a merge-check.sh declares the toolchain it runs in.
|
|
var toolchainLine = regexp.MustCompile(`^#\s*mesh-check-toolchain:\s*([a-z0-9-]+)\s*$`)
|
|
|
|
// ScriptToolchain is the language a merge-check.sh declares it runs in, among its first twenty lines;
|
|
// go when it declares none.
|
|
func ScriptToolchain(script []byte) string {
|
|
lines := bufio.NewScanner(bytes.NewReader(script))
|
|
for i := 0; i < 20 && lines.Scan(); i++ {
|
|
if m := toolchainLine.FindStringSubmatch(strings.TrimSpace(lines.Text())); m != nil {
|
|
return m[1]
|
|
}
|
|
}
|
|
return "go"
|
|
}
|
|
|
|
// Check runs one merge check. An error is that it could not run; the verdict is then "error".
|
|
func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry string, forge GitCredential,
|
|
log Log) (CheckVerdict, error) {
|
|
say := logging(log)
|
|
began := time.Now()
|
|
ctx, stop := context.WithTimeout(ctx, CheckTimeout)
|
|
defer stop()
|
|
|
|
root := filepath.Join(workspace, "check")
|
|
if err := os.RemoveAll(root); err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
defer os.RemoveAll(root)
|
|
credentials := ""
|
|
if forge.URL != "" {
|
|
credentials = filepath.Join(workspace, "git-credentials")
|
|
if err := os.WriteFile(credentials, []byte(forge.URL+"\n"), 0o600); err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
}
|
|
clone := func(repository, ref, dir string) error {
|
|
if _, err := run(ctx, root, "git", cloneWith(credentials, "clone", "--quiet", repository, dir)...); err != nil {
|
|
return fmt.Errorf("cannot clone %s: %w", repository, err)
|
|
}
|
|
if ref != "" {
|
|
if _, err := run(ctx, filepath.Join(root, dir), "git", "checkout", "--quiet", ref); err != nil {
|
|
return fmt.Errorf("%s has no %s: %w", repository, ref, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
name := spec.Repo
|
|
if name == "" || !safeName.MatchString(name) {
|
|
name = "checked"
|
|
}
|
|
say("check", "%s/%s#%d at %s", spec.Owner, spec.Repo, spec.Number, short(spec.Ref))
|
|
if err := clone(spec.Repository, spec.Ref, name); err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
tree := filepath.Join(root, name)
|
|
script, scriptErr := os.ReadFile(filepath.Join(tree, CheckScript))
|
|
hasScript := scriptErr == nil
|
|
gated := spec.Gated()
|
|
if !gated && !hasScript {
|
|
// Nothing to run: said, never passed silently.
|
|
v := CheckVerdict{Verdict: "pass", Summary: noModule, Took: time.Since(began),
|
|
Gate: &Layer{Verdict: "pass", Summary: noModule}, Repo: &Layer{Verdict: "warning", Summary: noScript}}
|
|
say("check", "%s; %s", noModule, noScript)
|
|
return v, nil
|
|
}
|
|
for dir, b := range spec.Beside {
|
|
if dir == name || !safeName.MatchString(dir) {
|
|
continue
|
|
}
|
|
if err := clone(b.Repository, b.Ref, dir); err != nil {
|
|
return CheckVerdict{}, fmt.Errorf("beside it, %w", err)
|
|
}
|
|
say("check", "beside it %s at %s", dir, short(b.Ref))
|
|
}
|
|
|
|
// A delivery group's other heads (novox/hq ADR 0239), each at its head, for the gate to compose with this.
|
|
groupFile := ""
|
|
if len(spec.Group) > 0 {
|
|
var heads []map[string]any
|
|
for i, g := range spec.Group {
|
|
dir := fmt.Sprintf("group-%d", i)
|
|
if g.Repo != "" && safeName.MatchString(g.Repo) {
|
|
dir = "group-" + g.Repo
|
|
}
|
|
if err := clone(g.Repository, g.Ref, dir); err != nil {
|
|
return CheckVerdict{}, fmt.Errorf("a head of the group, %w", err)
|
|
}
|
|
say("check", "with it, of its group, %s/%s at %s", g.Owner, g.Repo, short(g.Ref))
|
|
heads = append(heads, map[string]any{"repository": g.Owner + "/" + g.Repo,
|
|
"tree": filepath.Join(root, dir), "changed": g.Paths})
|
|
}
|
|
raw, err := json.Marshal(heads)
|
|
if err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
groupFile = filepath.Join(root, "group.json")
|
|
if err := os.WriteFile(groupFile, raw, 0o644); err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
}
|
|
|
|
// The facts, and the versions they say the mesh runs.
|
|
if registry == "" {
|
|
return CheckVerdict{}, errors.New("no artifact store to read the facts snapshot from")
|
|
}
|
|
body, digest, err := (artifacts.Store{Address: registry}).GetTagged(ctx, facts.Repository, facts.Tag)
|
|
if err != nil {
|
|
return CheckVerdict{}, fmt.Errorf("the facts snapshot cannot be read, and a check without it judges nothing: %w", err)
|
|
}
|
|
f, err := facts.Decode(body)
|
|
if err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
factsFile := filepath.Join(root, "facts.json")
|
|
if err := os.WriteFile(factsFile, body, 0o644); err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
say("check", "the facts of %s (%s): %d machine(s), the bus at %s, the store at %s", f.Taken.Format(time.RFC3339),
|
|
short(strings.TrimPrefix(digest, "sha256:")), len(f.Machines), f.Versions.Bus, f.Versions.Store)
|
|
|
|
// The throwaway store and bus, of the versions the mesh runs, removed whatever happens.
|
|
defer func() {
|
|
removing, done := context.WithTimeout(context.Background(), time.Minute)
|
|
defer done()
|
|
if n, err := RemoveContainersOf(removing, run, spec.ID); err == nil && n > 0 {
|
|
say("check", "removed %d throwaway container(s)", n)
|
|
}
|
|
}()
|
|
labelled := Labelled(run, spec.ID)
|
|
store, err := throwaway(ctx, labelled, run, spec.ID+"-store", StoreImage(f.Versions.Store), 5432,
|
|
[]string{"-e", "POSTGRES_PASSWORD=check"}, nil)
|
|
if err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
storeURL := "postgres://postgres:check@" + store + "/postgres?sslmode=disable"
|
|
if err := waitFor(ctx, run, spec.ID+"-store", []string{"pg_isready", "-U", "postgres"}); err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
bus, err := throwaway(ctx, labelled, run, spec.ID+"-bus", BusImage(f.Versions.Bus), 4222, nil, []string{"-js"})
|
|
if err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
if err := dialable(ctx, bus); err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
say("check", "a throwaway store (%s) and bus (%s) of the versions the mesh runs", StoreImage(f.Versions.Store),
|
|
BusImage(f.Versions.Bus))
|
|
|
|
if spec.Toolchain == "" {
|
|
return CheckVerdict{}, errors.New("the mesh holds no Go toolchain to run a check in")
|
|
}
|
|
user := spec.User
|
|
if user == "" {
|
|
user = fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())
|
|
}
|
|
in := func(image, dir string, env []string, command ...string) []string {
|
|
// As the builder itself: what a check writes into the workspace is the builder's to remove.
|
|
args := []string{"run", "--rm", "--network", "host", "--volume", workspace + ":" + workspace, "--workdir", dir,
|
|
"--user", user, "--env", "HOME=" + workspace,
|
|
// The check's own checkouts, whoever cloned them: git in a container of another user than the
|
|
// one that cloned refuses a repository it does not own ("dubious ownership"), and Go's build
|
|
// stamps the version from git — a judge that would not build for want of it.
|
|
"--env", "GIT_CONFIG_COUNT=1", "--env", "GIT_CONFIG_KEY_0=safe.directory", "--env", "GIT_CONFIG_VALUE_0=*"}
|
|
for _, e := range env {
|
|
args = append(args, "--env", e)
|
|
}
|
|
return append(append(args, image), command...)
|
|
}
|
|
inToolchain := func(dir string, env []string, command ...string) []string {
|
|
return in(spec.Toolchain, dir, env, command...)
|
|
}
|
|
var out tail
|
|
// running runs one container of the check, its output into the report, in a process group of its own.
|
|
running := func(args []string) error {
|
|
cmd := exec.CommandContext(ctx, "docker", LabelledArgs("docker", args, spec.ID)...)
|
|
inItsOwnGroup(cmd)
|
|
cmd.Stdout, cmd.Stderr = &out, &out
|
|
return cmd.Run()
|
|
}
|
|
|
|
// The judge. Its failing to build is the change's fault when the change is the judge or its validator,
|
|
// and the check's when it is the controller the mesh runs.
|
|
gate, judgeFault, err := judgeFor(ctx, labelled, run, spec, root, tree, inToolchain, say)
|
|
if err != nil {
|
|
return CheckVerdict{}, err
|
|
}
|
|
verdictFile := filepath.Join(root, "verdict.json")
|
|
env := []string{EnvFacts + "=" + factsFile, EnvGate + "=" + gate, EnvGateStore + "=" + storeURL,
|
|
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
|
|
EnvChanged + "=" + strings.Join(spec.Paths, ","), EnvBeside + "=" + root,
|
|
EnvModules + "=" + strings.Join(append(append([]string{}, spec.Modules...), spec.New...), ","),
|
|
EnvGroup + "=" + groupFile,
|
|
"GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")}
|
|
|
|
v := CheckVerdict{}
|
|
modules := append(append([]string{}, spec.Modules...), prefixed("new:", spec.New)...)
|
|
timedOut := func() bool { return errors.Is(ctx.Err(), context.DeadlineExceeded) }
|
|
|
|
// **The gate**, when the graph says the change touches it.
|
|
if !gated {
|
|
v.Gate = &Layer{Verdict: "pass", Summary: noModule}
|
|
} else {
|
|
v.Gate = &Layer{Modules: modules}
|
|
switch {
|
|
case judgeFault != "":
|
|
v.Gate.Verdict, v.Gate.Summary = "fail", judgeFault
|
|
default:
|
|
say("check", "the gate: %d module(s) of the graph touched — %s", len(modules), strings.Join(modules, ", "))
|
|
fmt.Fprintf(&out, "--- the gate: %s\n", strings.Join(modules, ", "))
|
|
v.Gate.Verdict, v.Gate.Summary = gateLayer(ctx, spec, tree, root, gate, verdictFile, env, inToolchain,
|
|
running, &out, bus, workspace, name, say)
|
|
}
|
|
if timedOut() {
|
|
v.Gate.Verdict, v.Gate.Summary = "error", fmt.Sprintf("the check ran past %s and was ended", CheckTimeout)
|
|
}
|
|
}
|
|
if ctx.Err() != nil && !timedOut() {
|
|
return v, ctx.Err()
|
|
}
|
|
|
|
// **The repository's own check**, in the toolchain it declares.
|
|
switch {
|
|
case len(spec.Group) > 0:
|
|
// A group's composed check judges the heads together; each member's own tests are its pull request's.
|
|
v.Repo = &Layer{Verdict: "pass", Summary: "a group's composed check: each member's own " + CheckScript +
|
|
" runs on its pull request"}
|
|
case !hasScript:
|
|
v.Repo = &Layer{Verdict: "warning", Summary: noScript}
|
|
case timedOut():
|
|
v.Repo = &Layer{Verdict: "error", Summary: fmt.Sprintf("the check ran past %s before its %s ran", CheckTimeout, CheckScript)}
|
|
default:
|
|
language := ScriptToolchain(script)
|
|
image := spec.Toolchains[language]
|
|
if language == "go" && image == "" {
|
|
image = spec.Toolchain
|
|
}
|
|
if image == "" {
|
|
v.Repo = &Layer{Verdict: "error", Summary: fmt.Sprintf("its %s runs in the %s toolchain, which the mesh does "+
|
|
"not hold", CheckScript, language)}
|
|
break
|
|
}
|
|
say("check", "running its %s in the mesh's %s toolchain", CheckScript, language)
|
|
fmt.Fprintf(&out, "--- its %s (%s toolchain)\n", CheckScript, language)
|
|
var own tail
|
|
cmd := exec.CommandContext(ctx, "docker", LabelledArgs("docker", in(image, tree, env, "sh", CheckScript), spec.ID)...)
|
|
inItsOwnGroup(cmd)
|
|
w := io.MultiWriter(&out, &own)
|
|
cmd.Stdout, cmd.Stderr = w, w
|
|
switch err := cmd.Run(); {
|
|
case timedOut():
|
|
v.Repo = &Layer{Verdict: "error", Summary: fmt.Sprintf("its %s ran past %s and was ended", CheckScript, CheckTimeout)}
|
|
case ctx.Err() != nil:
|
|
return v, ctx.Err()
|
|
case err != nil:
|
|
v.Repo = &Layer{Verdict: "fail", Summary: "its " + CheckScript + " failed: " + whatFailed(own.String())}
|
|
default:
|
|
v.Repo = &Layer{Verdict: "pass", Summary: "its " + CheckScript + " passed"}
|
|
}
|
|
}
|
|
|
|
v.Verdict, v.Summary = v.Gate.Verdict, v.Gate.Summary
|
|
v.Report, v.Took = out.String(), time.Since(began)
|
|
say("check", "gate %s — %s; repository %s — %s (%s)", strings.ToUpper(v.Gate.Verdict), v.Gate.Summary,
|
|
strings.ToUpper(v.Repo.Verdict), v.Repo.Summary, v.Took.Round(time.Second))
|
|
return v, nil
|
|
}
|
|
|
|
// gateLayer runs the gate: the touched manifests through `module check`, every machine composed with the
|
|
// change, and the replays of what the mesh runs. It answers the gate's verdict and summary.
|
|
func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFile string, env []string,
|
|
inToolchain func(string, []string, ...string) []string, running func([]string) error, out *tail, bus, workspace,
|
|
name string, say func(step, format string, args ...any)) (string, string) {
|
|
// 1. The manifests the change touches, as the judge reads them: a manifest it cannot read, or one with a
|
|
// problem the change brings, fails here. **What was already so on the base branch is said and fails
|
|
// nothing** — the gate's own rule: a module whose manifest the running controller already finds fault
|
|
// with would otherwise fail every pull request that touches it, for a fault none of them made.
|
|
var manifests []string
|
|
for _, m := range spec.Manifests {
|
|
if _, err := os.Stat(filepath.Join(tree, m)); err == nil {
|
|
manifests = append(manifests, m)
|
|
}
|
|
}
|
|
if len(manifests) > 0 {
|
|
checked := func(dir string) (string, error) {
|
|
var own tail
|
|
cmd := exec.CommandContext(ctx, "docker", LabelledArgs("docker",
|
|
inToolchain(dir, env, append([]string{gate, "module", "check"}, manifests...)...), spec.ID)...)
|
|
inItsOwnGroup(cmd)
|
|
w := io.MultiWriter(out, &own)
|
|
cmd.Stdout, cmd.Stderr = w, w
|
|
err := cmd.Run()
|
|
return own.String(), err
|
|
}
|
|
said, err := checked(tree)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return "error", "the check was ended during the module check"
|
|
}
|
|
// The same manifests as the base branch has them, beside the change.
|
|
was := ""
|
|
if spec.Base != "" {
|
|
base := filepath.Join(root, "base-manifests")
|
|
for _, m := range manifests {
|
|
body, err := gitShow(ctx, tree, "origin/"+spec.Base, m)
|
|
if err != nil {
|
|
continue // new on this branch: nothing was so before it
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(base, m)), 0o755); err == nil {
|
|
_ = os.WriteFile(filepath.Join(base, m), body, 0o644)
|
|
}
|
|
}
|
|
fmt.Fprintf(out, "--- the same manifests on %s\n", spec.Base)
|
|
if _, err := os.Stat(base); err == nil {
|
|
was, _ = checked(base)
|
|
}
|
|
}
|
|
brought := newProblems(said, was)
|
|
if len(brought) > 0 {
|
|
return "fail", "a manifest the change touches fails the module check: " + brought[0]
|
|
}
|
|
fmt.Fprintf(out, "the module check's problems were all so on %s already: said, not the change's\n", spec.Base)
|
|
}
|
|
}
|
|
|
|
// 2. Every machine composed with the change.
|
|
if err := running(inToolchain(tree, append(env, EnvVerdict+"="+verdictFile), "sh", "-c",
|
|
`"$MESH_GATE" merge-gate --facts "$MESH_FACTS" --store "$MESH_GATE_POSTGRES" `+
|
|
`--repository "$MESH_CHECK_REPOSITORY" --tree . --changed "$MESH_CHECK_CHANGED" `+
|
|
`${MESH_CHECK_GROUP:+--group "$MESH_CHECK_GROUP"} --json > "$MESH_CHECK_VERDICT"`)); err != nil {
|
|
if ctx.Err() != nil {
|
|
return "error", "the check was ended during the merge gate"
|
|
}
|
|
var said struct {
|
|
Verdict string `json:"verdict"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
if raw, err := os.ReadFile(verdictFile); err == nil && json.Unmarshal(raw, &said) == nil {
|
|
switch said.Verdict {
|
|
case "fail":
|
|
return "fail", said.Summary
|
|
case "error":
|
|
// The gate could not raise the mesh as it is (novox/hq issue 282): said in its own words.
|
|
return "error", said.Summary
|
|
}
|
|
}
|
|
// The gate could not judge: not the change's fault, and never a pass.
|
|
return "error", "the merge gate could not judge the change: " + lastLine(out.String())
|
|
}
|
|
raw, err := os.ReadFile(verdictFile)
|
|
if err != nil {
|
|
return "error", "the merge gate said no verdict"
|
|
}
|
|
said, ok := readGateVerdict(raw)
|
|
if !ok {
|
|
return "error", "the merge gate said no verdict"
|
|
}
|
|
if verdict, summary, raised := gateRaisedTheMesh(said); !raised {
|
|
return verdict, summary
|
|
}
|
|
|
|
// 3. **The replays of what the mesh runs** (to-be 45 §9, M9): mesh-lab's, from its main — reviewed code,
|
|
// so given the container runtime the resolver replay raises containers with — against the bus of the
|
|
// release the mesh runs and the change's own catalogue when the change is to the catalogue.
|
|
if lab := filepath.Join(root, "mesh-lab", "replays"); ctx.Err() == nil {
|
|
if _, err := os.Stat(lab); err == nil {
|
|
catalogue := filepath.Join(root, "mesh-catalog")
|
|
if name == "mesh-catalog" {
|
|
catalogue = tree
|
|
}
|
|
say("check", "the replays of what the mesh runs, from mesh-lab")
|
|
fmt.Fprintln(out, "--- the replays (mesh-lab replays/)")
|
|
args := inToolchain(lab, []string{EnvTestBus + "=nats://" + bus, "MESH_REPLAY_CATALOGUE=" + catalogue,
|
|
"GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")},
|
|
"go", "test", "-count=1", "./...")
|
|
// The socket goes to the replays alone, never to the change's own code above.
|
|
args = append([]string{args[0], "--volume", "/var/run/docker.sock:/var/run/docker.sock"}, args[1:]...)
|
|
if err := running(args); err != nil {
|
|
if ctx.Err() != nil {
|
|
return "error", "the check was ended during the replays"
|
|
}
|
|
return "fail", "a replay of a core incident fails with this change: " + lastLine(out.String())
|
|
}
|
|
}
|
|
}
|
|
if said.Verdict == "pass" || said.Verdict == "warning" || said.Verdict == "fail" {
|
|
return said.Verdict, said.Summary
|
|
}
|
|
return "error", "the merge gate said " + said.Verdict
|
|
}
|
|
|
|
// judgeFor builds the judge of a check: the change's own controller (a change to the controller), the
|
|
// running controller built with the change's validator (a change to the node-engine), or the controller
|
|
// the mesh runs. It answers the judge's path; or, when the change makes its own judge unbuildable, why —
|
|
// the change's fault, a failing gate; or an error when the check cannot build a judge at all. A check
|
|
// whose gate does not run builds a judge only to hand its scripts one, and goes on without when it cannot.
|
|
func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, tree string,
|
|
inToolchain func(string, []string, ...string) []string, say func(step, format string, args ...any)) (string, string, error) {
|
|
gated := spec.Gated()
|
|
switch spec.Judge {
|
|
case "self":
|
|
bin := filepath.Join(root, "bin", "judge-of-itself")
|
|
if _, err := labelled(ctx, root, "docker", inToolchain(tree,
|
|
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
|
|
"go", "build", "-o", bin, "./cmd/mesh-controller")...); err != nil {
|
|
return "", "the change's controller, which judges itself, does not build: " + firstLine(err.Error()), nil
|
|
}
|
|
say("check", "judged by the change's own controller")
|
|
return bin, "", nil
|
|
}
|
|
bin, judgeTree, err := judge(ctx, labelled, run, root, inToolchain, say)
|
|
if err != nil {
|
|
if !gated {
|
|
say("check", "no judge for its script: %v", err)
|
|
return "", "", nil
|
|
}
|
|
return "", "", err
|
|
}
|
|
if spec.Judge != "validator" {
|
|
return bin, "", nil
|
|
}
|
|
// The node-engine's change: its validator in place of the one the judge vendors.
|
|
vendored := filepath.Join(root, judgeTree, "vendor", "github.com", "novox", "mesh-host")
|
|
for _, pkg := range []string{"validate", filepath.Join("internal", "declaration")} {
|
|
if err := replaceGoFiles(filepath.Join(tree, pkg), filepath.Join(vendored, pkg)); err != nil {
|
|
return "", "", fmt.Errorf("the change's validator could not be put in the judge: %w", err)
|
|
}
|
|
}
|
|
withValidator := filepath.Join(root, "bin", "judge-with-this-validator")
|
|
if _, err := labelled(ctx, root, "docker", inToolchain(filepath.Join(root, judgeTree),
|
|
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
|
|
"go", "build", "-o", withValidator, "./cmd/mesh-controller")...); err != nil {
|
|
return "", "the controller the mesh runs does not build with this change's validator: " + firstLine(err.Error()), nil
|
|
}
|
|
say("check", "judged by the controller the mesh runs, with this change's validator")
|
|
return withValidator, "", nil
|
|
}
|
|
|
|
// replaceGoFiles puts a package's Go files — never its tests — in place of another copy's.
|
|
func replaceGoFiles(from, into string) error {
|
|
old, err := filepath.Glob(filepath.Join(into, "*.go"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, f := range old {
|
|
if err := os.Remove(f); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := os.MkdirAll(into, 0o755); err != nil {
|
|
return err
|
|
}
|
|
files, err := filepath.Glob(filepath.Join(from, "*.go"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(files) == 0 {
|
|
return fmt.Errorf("%s holds no Go files", from)
|
|
}
|
|
for _, f := range files {
|
|
if strings.HasSuffix(f, "_test.go") {
|
|
continue
|
|
}
|
|
body, err := os.ReadFile(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(into, filepath.Base(f)), body, 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// problemLines are the lines of a module check's output that name a problem: not a manifest found ok, not
|
|
// the count, not the closing note.
|
|
func problemLines(s string) []string {
|
|
var out []string
|
|
for _, line := range strings.Split(s, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
switch {
|
|
case line == "", strings.Contains(line, ": ok"), strings.Contains(line, "problem(s) in"),
|
|
strings.Contains(line, "manifest(s) checked"):
|
|
continue
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// newProblems are the problems a module check said of the change that it did not say of the base.
|
|
func newProblems(change, base string) []string {
|
|
was := map[string]bool{}
|
|
for _, p := range problemLines(base) {
|
|
was[p] = true
|
|
}
|
|
var out []string
|
|
for _, p := range problemLines(change) {
|
|
if !was[p] {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
if len(out) == 0 && len(problemLines(change)) == 0 {
|
|
// It failed and named nothing: not readable as already so.
|
|
out = append(out, lastLine(change))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// gateVerdict is what of the gate's verdict the seat reads: the verdict, and every machine — whether it
|
|
// composes on the mesh and whether it composed in the gate's store without the change.
|
|
type gateVerdict struct {
|
|
Verdict string `json:"verdict"`
|
|
Summary string `json:"summary"`
|
|
Machines []struct {
|
|
Described string `json:"described"`
|
|
Live bool `json:"live-composes"`
|
|
Base struct {
|
|
Composes bool `json:"composes"`
|
|
} `json:"base"`
|
|
} `json:"machines"`
|
|
}
|
|
|
|
// readGateVerdict reads the verdict the gate wrote, from its first line that opens a JSON document: a
|
|
// judge from before the verdict was alone on its output printed what composition said ahead of it.
|
|
func readGateVerdict(raw []byte) (gateVerdict, bool) {
|
|
var v gateVerdict
|
|
text := string(raw)
|
|
if i := strings.Index(text, "\n{"); i >= 0 && !strings.HasPrefix(strings.TrimSpace(text), "{") {
|
|
text = text[i+1:]
|
|
}
|
|
if json.Unmarshal([]byte(text), &v) != nil || v.Verdict == "" {
|
|
return gateVerdict{}, false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
// gateRaisedTheMesh is the seat's own reading of a verdict that passes (novox/hq issue 285): **a machine
|
|
// the mesh composes that did not compose in the gate's store without the change makes the gate an error,
|
|
// never a pass** — the change was judged against a machine that is not the mesh's, broken against broken.
|
|
// The judge says so itself since issue 285, but the judge is the controller the mesh runs, and one from
|
|
// before it passed such a verdict; read here too, the rule holds whatever judged. It answers false, with
|
|
// the verdict to report, when the gate did not raise the mesh.
|
|
func gateRaisedTheMesh(v gateVerdict) (string, string, bool) {
|
|
if v.Verdict != "pass" && v.Verdict != "warning" {
|
|
return "", "", true
|
|
}
|
|
var unraised []string
|
|
for _, m := range v.Machines {
|
|
if m.Live && !m.Base.Composes {
|
|
unraised = append(unraised, m.Described)
|
|
}
|
|
}
|
|
if len(unraised) == 0 {
|
|
return "", "", true
|
|
}
|
|
return "error", fmt.Sprintf("the mesh as it is could not be raised, so the change cannot be judged against it: "+
|
|
"%d of %d machines compose on the mesh and not in the gate (the first: %s) — novox/hq issue 285",
|
|
len(unraised), len(v.Machines), unraised[0]), false
|
|
}
|
|
|
|
// gitShow is a file as a ref has it.
|
|
func gitShow(ctx context.Context, dir, ref, file string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, "git", "show", ref+":"+filepath.ToSlash(file))
|
|
cmd.Dir = dir
|
|
return cmd.Output()
|
|
}
|
|
|
|
func prefixed(prefix string, items []string) []string {
|
|
out := make([]string, 0, len(items))
|
|
for _, i := range items {
|
|
out = append(out, prefix+i)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstLine(s string) string {
|
|
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
|
|
return line
|
|
}
|
|
|
|
// safeName is a directory a repository beside a check may be cloned under.
|
|
var safeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
|
|
|
// StoreImage is the store a check stands on: the major release the mesh's store runs (`17.11` → 17),
|
|
// on Alpine as the store module runs it.
|
|
func StoreImage(version string) string {
|
|
major, _, _ := strings.Cut(strings.TrimSpace(version), ".")
|
|
if major == "" || strings.ContainsAny(major, " (") {
|
|
major = "17"
|
|
}
|
|
return "postgres:" + major + "-alpine"
|
|
}
|
|
|
|
// BusImage is the bus a check stands on: the release the mesh's bus server runs.
|
|
func BusImage(version string) string {
|
|
v := strings.TrimPrefix(strings.TrimSpace(version), "v")
|
|
if v == "" {
|
|
v = "2.11"
|
|
}
|
|
return "nats:" + v + "-alpine"
|
|
}
|
|
|
|
// throwaway starts a container publishing one port on loopback, and answers where it is reached.
|
|
func throwaway(ctx context.Context, run, plain Runner, name, image string, port int, opts, args []string) (string, error) {
|
|
invocation := []string{"run", "-d", "--rm", "--name", name, "-p", fmt.Sprintf("127.0.0.1::%d", port)}
|
|
invocation = append(invocation, opts...)
|
|
invocation = append(invocation, image)
|
|
invocation = append(invocation, args...)
|
|
if _, err := run(ctx, "", "docker", invocation...); err != nil {
|
|
return "", fmt.Errorf("a throwaway %s could not be raised: %w", image, err)
|
|
}
|
|
out, err := plain(ctx, "", "docker", "port", name, fmt.Sprintf("%d/tcp", port))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
|
if strings.HasPrefix(line, "127.0.0.1:") {
|
|
return strings.TrimSpace(line), nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("%s published %d nowhere on loopback: %q", name, port, out)
|
|
}
|
|
|
|
// waitFor runs a readiness command in a container until it answers, for a minute.
|
|
func waitFor(ctx context.Context, run Runner, name string, ready []string) error {
|
|
for i := 0; i < 60; i++ {
|
|
if _, err := run(ctx, "", "docker", append([]string{"exec", name}, ready...)...); err == nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(time.Second):
|
|
}
|
|
}
|
|
return fmt.Errorf("%s never became ready", name)
|
|
}
|
|
|
|
// dialable waits for an address to take a connection, for a minute.
|
|
func dialable(ctx context.Context, address string) error {
|
|
for i := 0; i < 60; i++ {
|
|
if c, err := net.DialTimeout("tcp", address, time.Second); err == nil {
|
|
c.Close()
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(time.Second):
|
|
}
|
|
}
|
|
return fmt.Errorf("nothing took a connection at %s", address)
|
|
}
|
|
|
|
// judge builds the controller that judges a change: the one the mesh runs, beside the check as
|
|
// mesh-controller — or, when that one predates the merge gate, the controller's main, said. It answers
|
|
// the binary and the directory it was built from.
|
|
func judge(ctx context.Context, run, plain Runner, root string, inToolchain func(string, []string, ...string) []string,
|
|
say func(step, format string, args ...any)) (string, string, error) {
|
|
bin := filepath.Join(root, "bin", "mesh-controller")
|
|
build := func(dir string) error {
|
|
_, err := run(ctx, root, "docker", inToolchain(filepath.Join(root, dir),
|
|
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
|
|
"go", "build", "-o", bin, "./cmd/mesh-controller")...)
|
|
return err
|
|
}
|
|
// Static, so it runs where the builder does.
|
|
hasGate := func() bool {
|
|
out, _ := plain(ctx, root, bin, "merge-gate")
|
|
return strings.Contains(out, "merge-gate --facts")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "mesh-controller")); err == nil {
|
|
if err := build("mesh-controller"); err != nil {
|
|
return "", "", fmt.Errorf("the controller the mesh runs does not build: %w", err)
|
|
}
|
|
if hasGate() {
|
|
say("check", "judged by the controller the mesh runs")
|
|
return bin, "mesh-controller", nil
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "mesh-controller-main")); err != nil {
|
|
return "", "", errors.New("no controller beside the check to judge it with")
|
|
}
|
|
if err := build("mesh-controller-main"); err != nil {
|
|
return "", "", fmt.Errorf("the controller's main does not build: %w", err)
|
|
}
|
|
say("check", "judged by the controller's main: the one the mesh runs predates the merge gate")
|
|
return bin, "mesh-controller-main", nil
|
|
}
|
|
|
|
// tail keeps the last lines written to it.
|
|
type tail struct{ buf bytes.Buffer }
|
|
|
|
func (t *tail) Write(p []byte) (int, error) {
|
|
t.buf.Write(p)
|
|
if t.buf.Len() > 1<<20 {
|
|
keep := t.buf.Bytes()[t.buf.Len()-(512<<10):]
|
|
t.buf = *bytes.NewBuffer(append([]byte(nil), keep...))
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (t *tail) String() string {
|
|
lines := strings.Split(strings.TrimRight(t.buf.String(), "\n"), "\n")
|
|
if len(lines) > reportLines {
|
|
lines = lines[len(lines)-reportLines:]
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// whatFailed is the line of a failed script's output that says what failed, for the status a pull request
|
|
// shows: the first failing test, the first failing package, the files not formatted — a bare "FAIL" or a
|
|
// file's name said nothing a reader could act on (novox/hq issue 283) — and the last line otherwise.
|
|
func whatFailed(s string) string {
|
|
lines := strings.Split(strings.TrimSpace(s), "\n")
|
|
for i, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "not gofmt'd:") {
|
|
var files []string
|
|
for _, f := range lines[i+1:] {
|
|
if f = strings.TrimSpace(f); f != "" {
|
|
files = append(files, f)
|
|
}
|
|
}
|
|
return "not gofmt'd by the toolchain's gofmt: " + strings.Join(files, ", ")
|
|
}
|
|
}
|
|
for _, prefix := range []string{"--- FAIL:", "FAIL\t", "panic:"} {
|
|
for _, line := range lines {
|
|
if line = strings.TrimSpace(line); strings.HasPrefix(line, prefix) {
|
|
return line
|
|
}
|
|
}
|
|
}
|
|
return lastLine(s)
|
|
}
|
|
|
|
func lastLine(s string) string {
|
|
lines := strings.Split(strings.TrimSpace(s), "\n")
|
|
return strings.TrimSpace(lines[len(lines)-1])
|
|
}
|