On the control node the store-bound packages of the controller's suite ran five to seven times slower than on any other holder, and every controller check that landed there ran past the suite's thirty minutes: a throwaway store flushing to a disk the mesh's own store, bus and forge keep busy. A store that lives for one check needs no crash safety. A holder recreated mid-check left the check's store and bus running, and the redelivery went to another machine, so nothing removed them: eleven pairs across four machines. A starting holder has taken nothing, so every container labelled with an ask of the seat is an earlier holder's.
1009 lines
41 KiB
Go
1009 lines
41 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.
|
|
// **A repository in two languages declares the other part too** (`# mesh-check-also: <toolchain>
|
|
// <script>`, one line each, among the same first lines): each named script is run from the root in
|
|
// its own toolchain's container, after merge-check.sh, and the layer passes only when every part
|
|
// does (novox/hq issue 302 — the lab's Go replays went unchecked because its script runs in the
|
|
// TypeScript toolchain, which holds no Go compiler).
|
|
//
|
|
// 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*$`)
|
|
|
|
// alsoLine is how a merge-check.sh declares a further part of the repository's check, run in a toolchain
|
|
// of its own: `# mesh-check-also: <toolchain> <script>`.
|
|
var alsoLine = regexp.MustCompile(`^#\s*mesh-check-also:\s*([a-z0-9-]+)\s+(\S+)\s*$`)
|
|
|
|
// ScriptPart is one part of a repository's own check: a script, run from the repository's root, and the
|
|
// toolchain it runs in.
|
|
type ScriptPart struct {
|
|
Toolchain string
|
|
Script string
|
|
}
|
|
|
|
// ScriptParts is every part of a repository's own check: its merge-check.sh in the toolchain it declares,
|
|
// then each further part it declares among its first twenty lines (`# mesh-check-also: <toolchain>
|
|
// <script>`), in the order declared. A declared script that is not a path inside the repository is
|
|
// answered as a part all the same, for the check to refuse by name rather than to drop.
|
|
func ScriptParts(script []byte) []ScriptPart {
|
|
parts := []ScriptPart{{Toolchain: ScriptToolchain(script), Script: CheckScript}}
|
|
lines := bufio.NewScanner(bytes.NewReader(script))
|
|
for i := 0; i < 20 && lines.Scan(); i++ {
|
|
if m := alsoLine.FindStringSubmatch(strings.TrimSpace(lines.Text())); m != nil {
|
|
parts = append(parts, ScriptPart{Toolchain: m[1], Script: m[2]})
|
|
}
|
|
}
|
|
return parts
|
|
}
|
|
|
|
// 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)
|
|
|
|
// **What an earlier delivery of this same ask left is removed first** (novox/hq issue 285): an ask is
|
|
// redelivered when the holder that took it stopped mid-check — the build agent itself updated by the
|
|
// rollout it is checking — and its containers, named by the ask's id, are still there. Raising the
|
|
// store again under that name was refused, and the check said it could not run.
|
|
if n, err := RemoveContainersOf(ctx, run, spec.ID); err == nil && n > 0 {
|
|
say("check", "removed %d throwaway container(s) an earlier delivery of this check left", n)
|
|
}
|
|
// 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"}, StoreSettings)
|
|
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:
|
|
v.Repo = ownCheck(ctx, spec, ScriptParts(script), tree, &out, timedOut, func(image string, script string) *exec.Cmd {
|
|
return exec.CommandContext(ctx, "docker", LabelledArgs("docker", in(image, tree, env, "sh", script), spec.ID)...)
|
|
}, say)
|
|
if v.Repo == nil {
|
|
return v, ctx.Err()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ownCheck runs the repository's own check, part by part, each in its toolchain's own container: the
|
|
// layer's verdict. Nil when the check was ended from outside (not past its bound), which the caller
|
|
// answers as the context's error.
|
|
func ownCheck(ctx context.Context, spec CheckSpec, parts []ScriptPart, tree string, out *tail, timedOut func() bool,
|
|
command func(image, script string) *exec.Cmd, say func(step, format string, args ...any)) *Layer {
|
|
var ran []string
|
|
for i, part := range parts {
|
|
named := part.Script
|
|
if i > 0 {
|
|
named = CheckScript + "'s part " + part.Script
|
|
}
|
|
image := spec.Toolchains[part.Toolchain]
|
|
if part.Toolchain == "go" && image == "" {
|
|
image = spec.Toolchain
|
|
}
|
|
if image == "" {
|
|
return &Layer{Verdict: "error", Summary: fmt.Sprintf("%sits %s runs in the %s toolchain, which the mesh does "+
|
|
"not hold", passedSoFar(ran), named, part.Toolchain)}
|
|
}
|
|
if i > 0 && !filepath.IsLocal(part.Script) {
|
|
return &Layer{Verdict: "fail", Summary: fmt.Sprintf("%s declares a part %q that is not a path inside the "+
|
|
"repository", CheckScript, part.Script)}
|
|
}
|
|
if i > 0 {
|
|
if info, err := os.Stat(filepath.Join(tree, part.Script)); err != nil || info.IsDir() {
|
|
return &Layer{Verdict: "fail", Summary: fmt.Sprintf("%s declares a part %s, which the repository does "+
|
|
"not hold", CheckScript, part.Script)}
|
|
}
|
|
}
|
|
say("check", "running its %s in the mesh's %s toolchain", part.Script, part.Toolchain)
|
|
fmt.Fprintf(out, "--- its %s (%s toolchain)\n", part.Script, part.Toolchain)
|
|
var own tail
|
|
cmd := command(image, part.Script)
|
|
inItsOwnGroup(cmd)
|
|
w := io.MultiWriter(out, &own)
|
|
cmd.Stdout, cmd.Stderr = w, w
|
|
switch err := cmd.Run(); {
|
|
case timedOut():
|
|
return &Layer{Verdict: "error", Summary: fmt.Sprintf("its %s ran past %s and was ended", part.Script, CheckTimeout)}
|
|
case ctx.Err() != nil:
|
|
return nil
|
|
case err != nil:
|
|
return &Layer{Verdict: "fail", Summary: passedSoFar(ran) + "its " + part.Script + " failed: " + whatFailed(own.String())}
|
|
}
|
|
ran = append(ran, fmt.Sprintf("%s (%s)", part.Script, part.Toolchain))
|
|
}
|
|
if len(ran) == 1 {
|
|
return &Layer{Verdict: "pass", Summary: "its " + CheckScript + " passed"}
|
|
}
|
|
return &Layer{Verdict: "pass", Summary: "its " + CheckScript + " passed, each part in its toolchain: " + strings.Join(ran, ", ")}
|
|
}
|
|
|
|
// passedSoFar is what of a check's parts passed before the one that did not, said first.
|
|
func passedSoFar(ran []string) string {
|
|
if len(ran) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(ran, ", ") + " passed; "
|
|
}
|
|
|
|
// 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 285): 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"
|
|
}
|
|
|
|
// StoreSettings are the throwaway store's server settings: **no durability** (novox/hq issue 306). The
|
|
// store lives for one check and is removed after it, so nothing it writes has to survive a crash —
|
|
// and a test suite that creates a database per test asks the disk to flush thousands of times. On a
|
|
// machine whose disk the mesh's own store, bus and forge already keep busy — the control node — each
|
|
// flush waited its turn: the store-bound packages of the controller's suite ran five to seven times
|
|
// slower there than on any other holder, while those that touch no store ran alike, and its check ran
|
|
// past the suite's thirty minutes every time it landed there. The release stays the mesh's; only what
|
|
// a crash would need is switched off.
|
|
var StoreSettings = []string{"-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"}
|
|
|
|
// 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 286) — 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])
|
|
}
|