Merge pull request 'Phase 5 (2/4): judge every pull request against the mesh that runs, before it merges (hq ADR 0237)' (#96) from feat/merge-gate into main

This commit was merged in pull request #96.
This commit is contained in:
2026-10-06 19:19:34 +00:00
22 changed files with 2320 additions and 6 deletions
+29 -1
View File
@@ -237,7 +237,21 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
npmrc, err := packagesFrom()
var built builder.Result
if err == nil {
if request.Check != nil {
// **A pull request's merge check, not a build** (novox/hq to-be 45 §9): nothing is built,
// published or registered; the verdict is the outcome.
result.Checked = request.Check
registry := ""
if r, ok := publisher.(builder.Registry); ok {
registry = r.Address
}
var v builder.CheckVerdict
v, err = builder.Check(building, builder.Command, checkSpecOf(request), workspace, registry, forgeFrom(), say)
if err == nil {
result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report,
Took: v.Took.Round(time.Second).String()}
}
} else if err == nil {
// The package-registry credential is a build input, so it is resolved before the clone: a
// build that could not have resolved its dependencies is refused in front of the reason, not
// after a clone that then fails at npm ci.
@@ -263,6 +277,8 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
// builder that is not running, and those want completely different responses.
result.Failed = err.Error()
say("failed", err.Error())
} else if request.Check != nil {
say("checked", result.Check.Verdict+": "+result.Check.Summary)
} else {
manifest, marshalErr := json.Marshal(built.Manifest)
if marshalErr != nil {
@@ -312,6 +328,18 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
}
}
// checkSpecOf is a check request as the builder runs it.
func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
c := request.Check
spec := builder.CheckSpec{ID: request.ID, Repository: request.Repository, Ref: request.Ref,
Owner: c.Owner, Repo: c.Repo, Number: c.Number, Paths: c.Paths, Beside: map[string]builder.Beside{},
Toolchain: builder.ToolchainOf(request.Held)}
for dir, b := range c.Beside {
spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref}
}
return spec
}
// packagesFrom is where a build resolves the mesh's own published packages — the SDK above all
// (novox/hq ADR 0076, issue 053).
//
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// A pull request's merge check (novox/hq to-be 45 §9): the forge announces a pull request's new head,
// the controller asks the build seat to check it, and says the verdict as `checked`, which the forge's
// holder sets as the pull request's status. **Before merge, never after**: every check the mesh had ran
// after a merge, on a machine.
//
// What is checked is decided here and run there. Here: whether the mesh builds anything from the
// repository into that branch — a repository it builds nothing from is not its to judge — and what the
// check reads beside it: the controller the mesh runs (its judge, for a catalogue change: a manifest
// that controller cannot read fails, which is version skew caught), the catalogue the mesh holds, the
// host it runs. There: the repository's own merge-check.sh, or the merge gate alone for a repository
// that declares none (internal/builder/check.go).
// checkTimeout is how long one check may run on the build seat. Said here so the ask's watchdog (S6)
// and the builder agree on what late means.
const checkTimeout = 45 * time.Minute
// PullUpdated asks for a pull request's merge check.
func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
inv := f.open.inventory
entries, err := inv.Catalogued(ctx)
if err != nil {
return err
}
moved := link.SourceMoved{Owner: p.Owner, Repo: p.Repo, Base: p.Base, CloneURL: p.CloneURL}
var from *inventory.Entry
for i, e := range entries {
if !e.Provided && sourceIs(e.Source, moved) {
from = &entries[i]
break
}
}
if from == nil {
fmt.Printf("%s/%s#%d (%.8s): the mesh builds nothing from it into %s, so it is not the mesh's to check\n",
p.Owner, p.Repo, p.Number, p.Commit, p.Base)
return nil
}
request, err := checkRequestFor(ctx, f.open, p, *from, entries)
if err != nil {
return err
}
seat := buildSeatHeld(ctx)
ask, err := askOverOn(seat)
if err != nil {
return err
}
defer ask.Close()
if err := ask.Ask(ctx, request); err != nil {
return err
}
fmt.Printf("%s/%s#%d (%.8s): asked %s to check it before it merges, as %s\n", p.Owner, p.Repo, p.Number,
p.Commit, seat, request.ID)
return nil
}
// checkRequestFor is the ask for one pull request's head: the repository as the mesh clones it, the head,
// and what is read beside it.
func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, from inventory.Entry,
entries []inventory.Entry) (link.BuildRequest, error) {
shelf := map[string]catalogue.Manifest{}
for _, e := range entries {
shelf[e.Manifest.Module] = e.Manifest
}
world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "")
if err != nil {
return link.BuildRequest{}, err
}
clone := func(s inventory.Source) (string, error) {
if s.Seat == "" {
return s.Repository, nil
}
return clonedFromSeat(world, s.Seat, s.Repository)
}
repository, err := clone(from.Source)
if err != nil {
return link.BuildRequest{}, err
}
current, err := open.inventory.CurrentBuilds(ctx)
if err != nil {
return link.BuildRequest{}, err
}
// Beside it, at what the mesh runs: each core repository by the module the mesh builds from it.
beside := map[string]link.CheckedOut{}
byModule := map[string]string{"mesh-controller": "mesh-controller", "mesh-host": "mesh-host",
"node-tools": "mesh-tools", "nats": "mesh-catalog"}
for _, e := range entries {
dir, core := byModule[e.Manifest.Module]
if !core || e.Provided || e.Source.Repository == "" {
continue
}
url, err := clone(e.Source)
if err != nil {
return link.BuildRequest{}, err
}
ref := current[e.Manifest.Module].Commit
if dir == "mesh-catalog" {
// The catalogue the mesh runs is in the snapshot, every manifest as it holds it; its checkout
// beside is what tests read its files from, so its main — what the next merge builds from.
ref = "main"
}
beside[dir] = link.CheckedOut{Repository: url, Ref: ref}
if dir == "mesh-controller" {
// And its main, for a judge the running controller predates (Phase 5 rolling out).
beside["mesh-controller-main"] = link.CheckedOut{Repository: url, Ref: "main"}
// And the lab, whose replays of what the mesh runs every check runs; on the same forge.
if e.Source.Seat != "" {
if lab, err := clone(inventory.Source{Seat: e.Source.Seat, Repository: siblingOf(e.Source.Repository,
"mesh-lab")}); err == nil {
beside["mesh-lab"] = link.CheckedOut{Repository: lab, Ref: "main"}
}
}
}
}
return link.BuildRequest{
ID: link.NewBuildID(time.Now()),
Repository: repository,
Ref: p.Commit,
Held: heldBy(ctx),
Seats: seatBases(ctx),
Source: sourceOnSeat(from.Source),
Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
Paths: p.Paths, Beside: beside},
}, nil
}
// siblingOf is another repository of the same owner: novox/mesh-controller → novox/mesh-lab.
func siblingOf(repository, name string) string {
if cut := strings.LastIndex(repository, "/"); cut >= 0 {
return repository[:cut+1] + name
}
return name
}
// sourceOnSeat is a source's seat form, nil for one on no seat.
func sourceOnSeat(s inventory.Source) *link.SourceOnSeat {
if s.Seat == "" {
return nil
}
return &link.SourceOnSeat{Seat: s.Seat, Repository: s.Repository}
}
// checkEvents is where the serving controller says a check's verdict; nil in a command.
var checkEvents link.Bus
// maxCheckReport is how much of a check's report travels in its verdict: enough for the failures and
// the machines, never a log.
const maxCheckReport = 60 << 10
// checked says a merge check's verdict as the controller's `checked`. Nothing is recorded or
// registered: a check builds nothing (issue 240's rule for a dry run, kept for a check).
func checked(ctx context.Context, result link.BuildResult) {
c := link.Checked{ID: result.ID, On: result.On, Commit: result.Ref}
if result.Checked != nil {
c.Owner, c.Repo, c.Number = result.Checked.Owner, result.Checked.Repo, result.Checked.Number
}
switch {
case result.Check != nil:
c.Verdict, c.Summary, c.Report = result.Check.Verdict, result.Check.Summary, result.Check.Report
case result.Failed != "":
// The check could not run: an error, never read as a pass.
c.Verdict, c.Summary = "error", "the check could not run: "+firstLine(result.Failed)
default:
c.Verdict, c.Summary = "error", "the build seat answered the check with no verdict"
}
if c.Verdict == "" {
c.Verdict = "error"
}
if len(c.Report) > maxCheckReport {
c.Report = "…" + c.Report[len(c.Report)-maxCheckReport:]
}
fmt.Printf("%s: %s/%s#%d at %.8s checked on %s: %s — %s\n", result.ID, c.Owner, c.Repo, c.Number, c.Commit,
orSomewhere(c.On), strings.ToUpper(c.Verdict), c.Summary)
if checkEvents == nil {
return
}
body, err := json.Marshal(c)
if err != nil {
return
}
stating, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer stop()
if err := checkEvents.PublishSeatEvent(stating, link.MeshControllerSeat, link.KeyChecked, body); err != nil {
fmt.Printf("%s: the verdict could not be said, so the pull request is not told it: %v\n", result.ID, err)
}
}
+9
View File
@@ -117,6 +117,9 @@ func run() error {
// The facts snapshot a merge check is fed (novox/hq to-be 45 §9).
case "facts":
return factsCommand(ctx, args[1:])
// The merge gate: every machine of the snapshot composed with a change (novox/hq to-be 45 §9).
case "merge-gate":
return mergeGateCommand(ctx, args[1:])
case "upgrade":
return upgradeCommand(ctx, args[1:])
// The bus as a planned step (novox/hq to-be 45 §8, ADR 0236).
@@ -335,6 +338,12 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) {
// registered, the same as the waiting command does. Said either way, so the daemon's log tells what
// became of a build nobody was watching.
func (b builds) Built(ctx context.Context, result link.BuildResult) error {
// **A merge check builds nothing** (novox/hq to-be 45 §9): its verdict is said, and nothing of it is
// recorded or registered.
if result.Check != nil || result.Checked != nil {
checked(ctx, result)
return nil
}
// **A dry run is looked at, never taken in** (novox/hq issue 240). On 2026-10-04 a dry run of an
// unmerged branch was heard here like any build, registered, and its definition reached a machine
// before anyone had reviewed it.
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
snapshot "github.com/novox/mesh-controller/internal/facts"
"github.com/novox/mesh-controller/internal/inventory"
)
// The merge gate (novox/hq to-be 45 §9): a change judged against every machine of the snapshot, by the
// incidents it is written from. Each case raises a mesh, takes its snapshot, and judges a pull request's
// tree against it in throwaway stores of its own.
// catalogueMesh is two machines and a catalogue repository: a resolver and an object store on the
// anchor, and on the laptop a network manager requiring the resolver, an album requiring the object
// store, and a login manager. Every module is registered from novox/mesh-catalog, as the mesh's are.
func catalogueMesh(t *testing.T) (snapshot.Facts, map[string]string) {
t.Helper()
open := aMesh(t)
ctx := t.Context()
manifests := map[string]string{
"objects": `{"module":"objects","version":"1",
"provides":[{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}],
"receives":{"s3-bucket":"/var/lib/mesh/objects/mesh.json"}}`,
"resolver": `{"module":"resolver","version":"1",
"provides":[{"name":"wildcard-resolution","scope":"mesh","identity":false}]}`,
"networkmanager": `{"module":"networkmanager","version":"1","requires":["wildcard-resolution"]}`,
"album": `{"module":"album","version":"1","requires":["s3-bucket"]}`,
"lemurs": `{"module":"lemurs","version":"1","capabilities":["systemd"],
"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`,
}
for name, raw := range manifests {
m, err := catalogue.ParseManifest([]byte(raw))
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog",
Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil {
t.Fatal(err)
}
}
for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "resolver"}, {"laptop", "networkmanager"},
{"laptop", "album"}, {"laptop", "lemurs"}} {
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
t.Fatalf("assign %s %s: %v", a[0], a[1], err)
}
}
f, err := gatherFacts(ctx, open, "2.11.17")
if err != nil {
t.Fatal(err)
}
return f, manifests
}
// aTree is a checkout of the catalogue repository holding these manifests.
func aTree(t *testing.T, manifests map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, raw := range manifests {
at := filepath.Join(dir, "modules", name)
if err := os.MkdirAll(at, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(at, "module.json"), []byte(raw), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}
func gateJudged(t *testing.T, f snapshot.Facts, tree string, changed ...string) mergeVerdict {
t.Helper()
admin := os.Getenv("MESH_TEST_POSTGRES")
in := mergeCheckInput{facts: f, admin: admin, changed: changed}
if tree != "" {
in.repository, in.tree = "novox/mesh-catalog", tree
}
v, err := judgeChange(t.Context(), in)
if err != nil {
t.Fatal(err)
}
return v
}
func withEdit(manifests map[string]string, name, raw string) map[string]string {
out := map[string]string{}
for k, v := range manifests {
out[k] = v
}
if raw == "" {
delete(out, name)
} else {
out[name] = raw
}
return out
}
// The mesh as it is passes: every machine composes in the gate's store as the controller composed it.
func TestTheMeshAsItIsPassesTheGate(t *testing.T) {
f, manifests := catalogueMesh(t)
for _, m := range f.Machines {
if !m.Declaration.Composes {
t.Fatalf("the mesh itself does not compose: %+v", m.Declaration)
}
}
v := gateJudged(t, f, aTree(t, manifests))
if v.Verdict != "pass" {
t.Fatalf("an unchanged catalogue does not pass:\n%s", v.Report())
}
for _, m := range v.Machines {
if !m.Base.Composes || !m.Change.Composes {
t.Errorf("%s does not compose in the gate's store as it does on the mesh: %+v / %+v", m.Described, m.Base, m.Change)
}
}
}
// **Issue 263**: a change makes the network manager require the object store's provision; on a machine
// whose name is six characters its identity is 26 against a bound of 20. Refused in the pull request,
// naming the module, and not on the anchor after it merged.
func TestIssue263AnIdentityARealMachineNameOverflowsFailsThePullRequest(t *testing.T) {
f, manifests := catalogueMesh(t)
tree := aTree(t, withEdit(manifests, "networkmanager",
`{"module":"networkmanager","version":"1","requires":["wildcard-resolution","s3-bucket"]}`))
v := gateJudged(t, f, tree)
if v.Verdict != "fail" {
t.Fatalf("the overflow passed the gate:\n%s", v.Report())
}
report := v.Report()
if !strings.Contains(report, "networkmanager") || !strings.Contains(report, "s3-bucket") {
t.Errorf("the refusal does not name the module and the provision:\n%s", report)
}
}
// **Issue 236**: a login manager's service that omits its state passed the catalogue check and was
// refused whole by the node-engine on the first machine. A change to a module a machine runs, and a
// module nobody runs yet, are both refused before merge, naming the machine and the module.
func TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest(t *testing.T) {
f, manifests := catalogueMesh(t)
broken := `{"module":"lemurs","version":"1","capabilities":["systemd"],
"resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}`
v := gateJudged(t, f, aTree(t, withEdit(manifests, "lemurs", broken)))
if v.Verdict != "fail" || !strings.Contains(v.Report(), "lemurs") {
t.Fatalf("a service the node-engine refuses passed the gate:\n%s", v.Report())
}
laptop := snapshot.Pseudonym("machine", "laptop")
if !strings.Contains(v.Report(), laptop) {
t.Errorf("the refusal does not name the machine it would be refused on:\n%s", v.Report())
}
// And as a new module, which no machine runs: tried on one that could.
newcomer := strings.ReplaceAll(broken, `"lemurs"`, `"greeter"`)
newcomer = strings.ReplaceAll(newcomer, "lemurs.service", "greeter.service")
v = gateJudged(t, f, aTree(t, withEdit(manifests, "greeter", newcomer)))
if v.Verdict != "fail" || !strings.Contains(v.Report(), "greeter, assigned to") {
t.Fatalf("a new module the node-engine would refuse passed the gate:\n%s", v.Report())
}
}
// **Version skew**: a manifest the controller judging it cannot read — the one the mesh runs, for a
// catalogue change — fails here, not at registration after the merge.
func TestAManifestTheRunningControllerCannotReadFailsThePullRequest(t *testing.T) {
f, manifests := catalogueMesh(t)
v := gateJudged(t, f, aTree(t, withEdit(manifests, "album",
`{"module":"album","version":"1","requires":["s3-bucket"],"a-field-of-a-newer-controller":true}`)))
if v.Verdict != "fail" || !strings.Contains(v.Report(), "refuses it") {
t.Fatalf("a manifest this controller cannot read passed:\n%s", v.Report())
}
}
// A module a machine runs, removed from its source, fails until it is unassigned (ADR 0236).
func TestAModuleAMachineRunsRemovedFromItsSourceFails(t *testing.T) {
f, manifests := catalogueMesh(t)
v := gateJudged(t, f, aTree(t, withEdit(manifests, "album", "")))
if v.Verdict != "fail" || !strings.Contains(v.Report(), "album is removed") {
t.Fatalf("removing a module a machine runs passed:\n%s", v.Report())
}
}
// **Issue 278**: a file of a module nobody holds read as shared code, and the merge rebuilt the
// catalogue. The gate says how wide a merge's rebuild is, and warns when shared code makes it wide.
func TestIssue278AWideRebuildIsSaidBeforeTheMerge(t *testing.T) {
f, manifests := catalogueMesh(t)
was := wideRebuild
wideRebuild = 2
t.Cleanup(func() { wideRebuild = was })
v := gateJudged(t, f, aTree(t, manifests), "modules/showcase/index.ts")
if v.Width == nil || len(v.Width.Modules) < 5 || len(v.Width.Shared) != 1 {
t.Fatalf("the width reads %+v", v.Width)
}
if v.Verdict != "warning" || !strings.Contains(v.Report(), "shared") {
t.Fatalf("a rebuild of everything for one shared file is not said:\n%s", v.Report())
}
// The same file, with the reference module's definition in the tree: its directory is a module, held
// or not, and the file is its business alone (the fix of 278, read from the tree as the announcer does).
withShowcase := withEdit(manifests, "showcase", `{"module":"showcase","version":"1"}`)
v = gateJudged(t, f, aTree(t, withShowcase), "modules/showcase/index.ts")
if v.Width == nil || len(v.Width.Modules) != 0 || len(v.Width.Shared) != 0 {
t.Fatalf("a file of a module nobody holds still reads as shared: %+v", v.Width)
}
v = gateJudged(t, f, aTree(t, manifests), "modules/album/module.json")
if v.Width == nil || strings.Join(v.Width.Modules, ",") != "album" || v.Verdict != "pass" {
t.Fatalf("a change to one module's directory reads %+v, %s", v.Width, v.Verdict)
}
}
+6
View File
@@ -210,6 +210,12 @@ func serve(ctx context.Context) (err error) {
handActConn = bus.Conn
// And says when it replaced a value given by hand (novox/hq ADR 0228).
givenEvents = bus
// And a pull request's merge check, asked when the forge announces its head and said when judged
// (novox/hq to-be 45 §9).
checkEvents = bus
if err := server.Checks(following{open}); err != nil {
return err
}
// Composed now and kept current, before the verb that answers from it is served.
go statusFrom.keep(ctx)
// The facts snapshot a merge check is fed (novox/hq to-be 45 §9): kept current while this
+2
View File
@@ -30,6 +30,8 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
states = append(states, link.KeyHealerActed)
// And a build put back after its gate failed (novox/hq ADR 0236).
states = append(states, link.KeyRolledBack)
// And a pull request's merge check, judged (novox/hq to-be 45 §9).
states = append(states, link.KeyChecked)
for _, event := range states {
if !slices.Contains(broker.ControllerStates, event) {
t.Errorf("the mesh states %q and its account may not publish it", event)
+8 -1
View File
@@ -215,7 +215,10 @@ var ControllerStates = []string{"applied", "refused", "built-before",
// made by itself is said like one a person made, never quietly.
"healer-acted",
// And a build put back after its gate failed on its first machine (novox/hq ADR 0236, to-be 45 §8).
"rolled-back"}
"rolled-back",
// And a pull request's merge check, judged (novox/hq to-be 45 §9): what the forge's holder sets as
// the pull request's status, and anybody else may read.
"checked"}
// BusAdvisories are what the bus server says about the mesh's own account that the controller
// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it
@@ -261,6 +264,10 @@ var ControllerFollows = []string{
// for a person, re-enabled, deleted — a provider's third word, from whichever module provides.
// Appended, because the index is a name.
moduleEventSubject("*", ProvisionerRetirement),
// **A pull request's head, announced** (novox/hq to-be 45 §9): what the controller asks the build
// seat to check before it merges — every machine of the facts snapshot composed with the change.
// Appended, because the index is a name.
moduleEventSubject("gitea", "pull.updated"),
}
// The provider standing events, by their local names. Written here as well as in the catalogue
+2 -2
View File
@@ -24,8 +24,8 @@ accounts {
jetstream: enabled
users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*"] }
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*"] }
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
+6
View File
@@ -116,6 +116,12 @@ var WritersTable = []WriterRow{
Shared: "every machine holding the build seat answers the asks it took; each outcome names its ask"},
{State: "a merge announced", Writer: "one announcer per forge (the hook, or the poll when the hook is absent — never both)",
KeptIn: "the bus", Others: "—", Subjects: []string{"mesh.mod.*.event.pull.merged"}, Writes: ownModule},
// A pull request's head, before it merges (novox/hq to-be 45 §9): the same one announcer.
{State: "a pull request's head announced", Writer: "the forge's announcer, the one that announces its merges",
KeptIn: "the bus", Others: "the controller asks the build seat to check it", Subjects: []string{"mesh.mod.*.event.pull.updated"},
Writes: ownModule},
{State: "a pull request's merge check", Writer: "the build seat's holder that ran it, said as the controller's `checked`",
KeptIn: "the bus", Others: "the forge's holder sets it as the pull request's status"},
{State: "a provider's standing", Writer: "the provider", KeptIn: "the provider's events",
Others: "the controller keeps the newest word as a condition",
Subjects: []string{"mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered",
+2
View File
@@ -25,6 +25,8 @@ var designRows = []string{
"stream definitions and bus permissions",
"builds and their outcomes",
"a merge announced",
"a pull request's head announced",
"a pull request's merge check",
"a provider's standing",
"the operator-channel's open messages",
"the facts snapshot",
+437
View File
@@ -0,0 +1,437 @@
package builder
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"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).
//
// **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.
//
// 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 — 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. builds the judge — the controller the mesh runs, or its main while the running one predates the
// merge gate — and runs the repository's own merge-check.sh, or the merge gate alone for a
// repository that declares none. **In the mesh's Go toolchain, in a container of its own**, never in
// the build machine's: 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;
// 5. answers 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
// Toolchain is the image a check's Go runs in: the mesh's own Go toolchain, as it holds it.
Toolchain string
}
// ToolchainOf is the Go toolchain image among what the mesh holds, empty when it holds none.
func ToolchainOf(held map[string]string) string {
for _, chain := range toolchains {
if chain.Language == "go" {
return held[chain.Base+"/"+chain.Artifact]
}
}
return ""
}
// Beside is one repository cloned next to the one checked.
type Beside struct {
Repository string
Ref string
}
// CheckVerdict is what came of one check.
type CheckVerdict struct {
Verdict string
Summary string
Report string
Took time.Duration
}
// CheckScript is what a repository declares its 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"
)
// 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
// 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 == "" {
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
}
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))
}
// 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")
}
inToolchain := func(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", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), "--env", "HOME=" + workspace}
for _, e := range env {
args = append(args, "--env", e)
}
return append(append(args, spec.Toolchain), command...)
}
// The judge: the controller the mesh runs, or its main while the running one has no merge gate.
gate := ""
if name != "mesh-controller" {
gate, err = judge(ctx, labelled, run, root, inToolchain, say)
if err != nil {
return CheckVerdict{}, err
}
}
verdictFile := filepath.Join(root, "verdict.json")
env := append([]string{},
EnvFacts+"="+factsFile, EnvGate+"="+gate, EnvGateStore+"="+storeURL, EnvTestStore+"="+storeURL,
EnvTestBus+"=nats://"+bus, EnvRepository+"="+spec.Owner+"/"+spec.Repo,
EnvChanged+"="+strings.Join(spec.Paths, ","), EnvVerdict+"="+verdictFile, EnvBeside+"="+root,
"GOCACHE="+filepath.Join(workspace, "go-cache"), "GOMODCACHE="+filepath.Join(workspace, "go-modules"))
tree := filepath.Join(root, name)
var command []string
if _, err := os.Stat(filepath.Join(tree, CheckScript)); err == nil {
say("check", "running its %s in the mesh's Go toolchain", CheckScript)
command = []string{"sh", CheckScript}
} else {
if gate == "" {
return CheckVerdict{}, fmt.Errorf("%s declares no %s and there is no judge to run", name, CheckScript)
}
say("check", "it declares no %s: the merge gate alone", CheckScript)
command = []string{"sh", "-c", `"$MESH_GATE" merge-gate --facts "$MESH_FACTS" --store "$MESH_GATE_POSTGRES" ` +
`--repository "$MESH_CHECK_REPOSITORY" --tree . --changed "$MESH_CHECK_CHANGED" --json > "$MESH_CHECK_VERDICT"`}
}
cmd := exec.CommandContext(ctx, "docker", LabelledArgs("docker", inToolchain(tree, env, command...), spec.ID)...)
inItsOwnGroup(cmd)
var out tail
cmd.Stdout, cmd.Stderr = &out, &out
runErr := cmd.Run()
// **And 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.
var replayErr error
if lab := filepath.Join(root, "mesh-lab", "replays"); runErr == nil && 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 script above.
args = append([]string{args[0], "--volume", "/var/run/docker.sock:/var/run/docker.sock"}, args[1:]...)
replays := exec.CommandContext(ctx, "docker", LabelledArgs("docker", args, spec.ID)...)
inItsOwnGroup(replays)
replays.Stdout, replays.Stderr = &out, &out
replayErr = replays.Run()
}
}
v := CheckVerdict{Report: out.String(), Took: time.Since(began)}
var gateSaid struct {
Verdict string `json:"verdict"`
Summary string `json:"summary"`
}
if raw, err := os.ReadFile(verdictFile); err == nil {
_ = json.Unmarshal(raw, &gateSaid)
}
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
v.Verdict, v.Summary = "error", fmt.Sprintf("the check ran past %s and was ended", CheckTimeout)
case ctx.Err() != nil:
return v, ctx.Err()
case runErr != nil:
v.Verdict = "fail"
v.Summary = gateSaid.Summary
if v.Summary == "" || gateSaid.Verdict != "fail" {
v.Summary = "the merge check failed: " + lastLine(out.String())
}
case replayErr != nil:
v.Verdict, v.Summary = "fail", "a replay of a core incident fails with this change: "+lastLine(out.String())
default:
v.Verdict, v.Summary = "pass", "the merge check passed"
if gateSaid.Verdict == "warning" || gateSaid.Verdict == "pass" {
v.Verdict, v.Summary = gateSaid.Verdict, gateSaid.Summary
}
}
say("check", "%s — %s (%s)", strings.ToUpper(v.Verdict), v.Summary, v.Took.Round(time.Second))
return v, nil
}
// 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.
func judge(ctx context.Context, run, plain Runner, root string, inToolchain func(string, []string, ...string) []string,
say func(step, format string, args ...any)) (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, 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, 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")
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
return strings.TrimSpace(lines[len(lines)-1])
}
+152
View File
@@ -0,0 +1,152 @@
package builder
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/artifacts"
"github.com/novox/mesh-controller/internal/facts"
)
// A merge check on the build seat (novox/hq to-be 45 §9), against a real container runtime and a real
// registry: the repository's own merge-check.sh runs with the facts the controller keeps, beside a
// throwaway store and bus of **the versions the mesh runs**, and everything raised is removed.
//
// MESH_TEST_DOCKER=1 MESH_TEST_REGISTRY=127.0.0.1:15000 go test ./internal/builder -run Check
func TestTheStoreAndBusAChecksStandsOnAreTheOnesTheMeshRuns(t *testing.T) {
if got := StoreImage("17.11"); got != "postgres:17-alpine" {
t.Errorf("a store at 17.11 is checked against %s", got)
}
if got := StoreImage("16.4 (Debian 16.4-1.pgdg120+1)"); got != "postgres:16-alpine" {
t.Errorf("a store at 16.4 is checked against %s", got)
}
if got := BusImage("2.11.17"); got != "nats:2.11.17-alpine" {
t.Errorf("a bus at 2.11.17 is checked against %s", got)
}
}
// aRepository is a git repository holding these files, committed, and its head.
func aCheckedRepository(t *testing.T, files map[string]string) (string, string) {
t.Helper()
dir := t.TempDir()
git := func(args ...string) string {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.org",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.org")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
git("init", "--quiet", "-b", "main")
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
git("add", "-A")
git("commit", "--quiet", "-m", "x")
return dir, git("rev-parse", "HEAD")
}
func checkEnvironment(t *testing.T) string {
t.Helper()
if os.Getenv("MESH_TEST_DOCKER") != "1" || os.Getenv("MESH_TEST_REGISTRY") == "" {
t.Skip("MESH_TEST_DOCKER=1 and MESH_TEST_REGISTRY: a check raises containers and reads the registry")
}
registry := os.Getenv("MESH_TEST_REGISTRY")
body, err := json.Marshal(facts.Facts{Format: facts.Format, Taken: time.Now().UTC(),
Versions: facts.Versions{Bus: "2.11.17", Store: "17.11"},
Machines: []facts.Machine{{Name: "abcdef", Length: 6}}})
if err != nil {
t.Fatal(err)
}
if _, err := (artifacts.Store{Address: registry}).PutTagged(t.Context(), facts.Repository, facts.Tag,
facts.MediaType, body); err != nil {
t.Fatal(err)
}
return registry
}
// goToolchain is the Go image a check's script runs in here: the base the controller's own image is built on.
const goToolchain = "golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"
func labelled(id string) []string {
out, _ := exec.Command("docker", "ps", "-aq", "--filter", "label="+BuildLabel+"="+id).Output()
return strings.Fields(string(out))
}
func TestACheckRunsTheRepositorysOwnScriptBesideTheMeshsVersionsAndLeavesNothing(t *testing.T) {
registry := checkEnvironment(t)
// The script proves what it was given: the facts, a store that answers, a bus that answers, and
// writes the gate's verdict where it is told to.
script := `set -e
test -s "$MESH_FACTS"
grep -q '"bus": "2.11.17"' "$MESH_FACTS" || grep -q '"bus":"2.11.17"' "$MESH_FACTS"
case "$MESH_TEST_POSTGRES" in postgres://*127.0.0.1:*) ;; *) echo "no store: $MESH_TEST_POSTGRES"; exit 1;; esac
case "$MESH_TEST_NATS" in nats://127.0.0.1:*) ;; *) echo "no bus: $MESH_TEST_NATS"; exit 1;; esac
test "$MESH_CHECK_REPOSITORY" = "novox/mesh-controller"
test "$MESH_CHECK_CHANGED" = "a.go,b.go"
test ! -S /var/run/docker.sock || { echo "the check holds the container runtime's socket"; exit 1; }
echo '{"verdict":"warning","summary":"a merge rebuilds 14 module(s)"}' > "$MESH_CHECK_VERDICT"
echo checked
`
repo, head := aCheckedRepository(t, map[string]string{CheckScript: script})
id := fmt.Sprintf("check-test-%d", time.Now().UnixNano())
v, err := Check(t.Context(), Command, CheckSpec{ID: id, Repository: repo, Ref: head, Owner: "novox",
Repo: "mesh-controller", Number: 7, Paths: []string{"a.go", "b.go"}, Toolchain: goToolchain}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Verdict != "warning" || v.Summary != "a merge rebuilds 14 module(s)" || !strings.Contains(v.Report, "checked") {
t.Fatalf("the check answered %+v", v)
}
if left := labelled(id); len(left) > 0 {
t.Errorf("the check left %d container(s) behind", len(left))
}
}
func TestAFailingCheckFailsAndOneThatCannotRunIsNeverAPass(t *testing.T) {
registry := checkEnvironment(t)
repo, head := aCheckedRepository(t, map[string]string{CheckScript: "echo 'resource \"x.service\": refused'; exit 3\n"})
v, err := Check(t.Context(), Command, CheckSpec{ID: fmt.Sprintf("check-fail-%d", time.Now().UnixNano()),
Repository: repo, Ref: head, Owner: "novox", Repo: "mesh-controller", Toolchain: goToolchain}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Verdict != "fail" || !strings.Contains(v.Summary, "refused") {
t.Fatalf("a failing script answered %+v", v)
}
// Past its bound: an error, not a pass.
was := CheckTimeout
CheckTimeout = 25 * time.Second
t.Cleanup(func() { CheckTimeout = was })
repo, head = aCheckedRepository(t, map[string]string{CheckScript: "sleep 120\n"})
v, err = Check(context.Background(), Command, CheckSpec{ID: fmt.Sprintf("check-slow-%d", time.Now().UnixNano()),
Repository: repo, Ref: head, Owner: "novox", Repo: "mesh-controller", Toolchain: goToolchain}, t.TempDir(), registry, GitCredential{}, nil)
if err == nil && v.Verdict == "pass" {
t.Fatalf("a check past its bound passed: %+v", v)
}
if err == nil && v.Verdict != "error" {
t.Fatalf("a check past its bound answered %+v", v)
}
// No facts: it cannot run, and says so.
repo, head = aCheckedRepository(t, map[string]string{CheckScript: "exit 0\n"})
_, err = Check(t.Context(), Command, CheckSpec{ID: "check-nofacts", Repository: repo, Ref: head, Owner: "novox",
Repo: "mesh-controller", Toolchain: goToolchain}, t.TempDir(), "127.0.0.1:1", GitCredential{}, nil)
if err == nil || !strings.Contains(err.Error(), "facts snapshot") {
t.Fatalf("a check with no facts said %v", err)
}
}
+3 -1
View File
@@ -91,7 +91,9 @@ var defaultSeats = append([]Seat{
// Every act a healer takes (novox/hq to-be 45 §7).
"healer-acted",
// A build put back after its gate failed (novox/hq ADR 0236, to-be 45 §8).
"rolled-back"},
"rolled-back",
// A pull request's merge check, judged (novox/hq to-be 45 §9).
"checked"},
Serves: ControllerVerbs},
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
// served by whichever module holds the seat with tools of these names.
+41
View File
@@ -87,6 +87,42 @@ type BuildRequest struct {
// builder echoes it, and whoever hears the outcome takes nothing in — no record, no registration,
// no plan, nothing a push could send.
DryRun bool `json:"dry-run,omitempty"`
// Check makes this ask a pull request's merge check rather than a build (novox/hq to-be 45 §9): the
// builder checks the repository out at Ref with the repositories it is checked beside, reads the
// facts snapshot, raises the throwaway stores the check needs, runs the repository's own
// merge-check.sh and answers its verdict. Nothing is built, published or registered.
Check *CheckRequest `json:"check,omitempty"`
}
// CheckRequest is what a merge check needs beyond the repository and its head.
type CheckRequest struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number,omitempty"`
Base string `json:"base,omitempty"`
// Paths are the files the pull request changes, for the width of its rebuild.
Paths []string `json:"paths,omitempty"`
// Beside are the repositories the check reads next to this one, each cloned at the ref given — the
// controller the mesh runs, the catalogue it holds, the host it runs — keyed by the directory name the
// check finds it under.
Beside map[string]CheckedOut `json:"beside,omitempty"`
}
// CheckedOut is a repository cloned beside a check, at a ref.
type CheckedOut struct {
Repository string `json:"repository"`
Ref string `json:"ref,omitempty"`
}
// CheckOutcome is a merge check's verdict.
type CheckOutcome struct {
// Verdict is pass, warning, fail, or error: the check could not run, which is never a pass.
Verdict string `json:"verdict"`
Summary string `json:"summary"`
// Report is the check's own account, its last lines, bounded.
Report string `json:"report,omitempty"`
// Took is how long it ran.
Took string `json:"took,omitempty"`
}
// SourceOnSeat names a repository by the seat whose holder serves it and its path there.
@@ -151,6 +187,11 @@ type BuildResult struct {
// DryRun is the request's, echoed: an outcome nobody may take in (novox/hq issue 240).
DryRun bool `json:"dry-run,omitempty"`
// Check is a merge check's verdict, for an ask that was one; the request's Check is echoed in
// Checked so whoever hears it knows which pull request it judged.
Check *CheckOutcome `json:"check-outcome,omitempty"`
Checked *CheckRequest `json:"check,omitempty"`
}
// ReadRepository is a repository a build read source from besides the module's own, at the branch,
+3
View File
@@ -40,6 +40,9 @@ var Contracts = map[string]Contract{
Tests: []string{"TestAnEnrolmentMetByAHeldTokenIsAskedToTryAgain"}},
KindModuleMoved: {Unordered: "the catalogue saying a module's current build moved: acted on by reading the " +
"catalogue's record, which is the order, so a late one reads the same record"},
KindPullUpdated: {Unordered: "a pull request's head, asked to be checked: each head is its own commit, and its " +
"verdict is set on that commit alone, so a head heard late is checked and judged as itself and never " +
"stands for a newer one (novox/hq to-be 45 §9)"},
KindCatchUp: {Unordered: "a catalogue asking what it missed: answered from the record, whenever asked"},
KindProvisioner: {Unordered: "a provider's newest word about a consumer, said again every fifteen minutes " +
"while it holds (ADR 0224): the condition keeps the last observed, and S8 says when the words stop. " +
+37
View File
@@ -73,6 +73,9 @@ const (
// KeyRolledBack: a build failed its gate on its first machine and was put back there, or could not
// be (novox/hq ADR 0236, to-be 45 §8); or a witness on a machine put a core component back.
KeyRolledBack = "rolled-back"
// KeyChecked: a pull request's merge check was judged (novox/hq to-be 45 §9) — the verdict, its
// summary and its report, for the forge's holder to set as the pull request's status.
KeyChecked = "checked"
)
// Applied is what a machine now runs, as the mesh states it.
@@ -203,6 +206,40 @@ type SourceMoved struct {
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
}
// PullUpdated is what the forge announces when an open pull request's head moves — opened, or pushed
// to (novox/hq to-be 45 §9): what the controller asks the build seat to check before it merges.
type PullUpdated struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Title string `json:"title,omitempty"`
Base string `json:"base"`
Head string `json:"head"`
Commit string `json:"head_sha"`
CloneURL string `json:"clone_url"`
HTMLURL string `json:"html_url,omitempty"`
// Paths are the files the pull request changes; PathsTruncated says there were more.
Paths []string `json:"paths,omitempty"`
PathsTruncated bool `json:"paths_truncated,omitempty"`
}
// Checked is a pull request's merge check, judged: what the controller says as `checked`.
type Checked struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number,omitempty"`
Commit string `json:"commit"`
// Verdict is pass, warning, fail, or error — the check could not be run, which is not the change's
// fault and is never read as a pass.
Verdict string `json:"verdict"`
Summary string `json:"summary"`
// Report is the check's own account, bounded.
Report string `json:"report,omitempty"`
// ID is the ask, and On the machine that ran it.
ID string `json:"id"`
On string `json:"on,omitempty"`
}
type Upgraded struct {
Module string `json:"module"`
Commit string `json:"commit"`
+3
View File
@@ -39,6 +39,9 @@ const (
// KindProvisioner is a provider saying a consumer has failed for minutes, or recovered
// (novox/hq ADR 0224).
KindProvisioner = "provisioner"
// KindPullUpdated is the forge announcing a pull request's new head: checked before it merges
// (novox/hq to-be 45 §9).
KindPullUpdated = "pull-updated"
)
// Control is one thing a node or a module said, as the controller must act on it.
+3 -1
View File
@@ -53,7 +53,7 @@ func Nats(js *broker.JetStream) Inbound {
// whatever was asked for — and not at all when nothing was.
func (n *natsInbound) Also(kind string) error {
switch kind {
case KindModuleMoved, KindCatchUp, KindSourceMoved, KindProvisioner:
case KindModuleMoved, KindCatchUp, KindSourceMoved, KindProvisioner, KindPullUpdated:
n.follows[kind] = true
return nil
default:
@@ -248,6 +248,8 @@ func kindOfSubject(subject string) (string, bool) {
return KindCatchUp, true
case broker.ControllerFollows[3]:
return KindSourceMoved, true
case PullUpdatedSubject:
return KindPullUpdated, true
case BuildOutcome(), BuildOutcomeOf(TheBuildMachineBefore):
// A build's outcome is the role's event now, so it arrives on the events stream rather than
// the control branch — and is acted on by the same handler, because what the controller does
+41
View File
@@ -63,6 +63,15 @@ type Upgrader interface {
SourceMoved(ctx context.Context, m SourceMoved) error
}
// Checker is what the controller does when the forge says a pull request's head moved: ask for it to be
// checked before it merges (novox/hq to-be 45 §9).
type Checker interface {
PullUpdated(ctx context.Context, p PullUpdated) error
}
// PullUpdatedSubject is where the forge's pull requests land: the controller's own follow of them.
var PullUpdatedSubject = broker.ControllerFollows[len(broker.ControllerFollows)-1]
// Server acts on what nodes and modules say.
//
// **It holds no transport.** What arrives comes through Inbound and what it publishes goes through
@@ -83,6 +92,8 @@ type Server struct {
standings Standings
// retirements keeps what providers say about consumers the mesh stopped asking for (ADR 0230).
retirements Retirements
// checker asks for a pull request's merge check (novox/hq to-be 45 §9).
checker Checker
log *log.Logger
// giveUp is how long one message is held for the store; zero means GiveUpAfter.
@@ -116,6 +127,15 @@ func (s *Server) Follows(u Upgrader) error {
return nil
}
// Checks says what to do about a pull request's new head, and asks for them to be delivered.
func (s *Server) Checks(c Checker) error {
if err := s.inbound.Also(KindPullUpdated); err != nil {
return err
}
s.checker = c
return nil
}
// Answers says what to do about a catalogue's catch-up request, and asks for them to be delivered.
func (s *Server) Answers(r Replayer) error {
if err := s.inbound.Also(KindCatchUp); err != nil {
@@ -184,6 +204,8 @@ func (s *Server) act(ctx context.Context, m Control) {
s.catchingUp(ctx, m)
case KindProvisioner:
s.provisioner(ctx, m)
case KindPullUpdated:
s.pullUpdated(ctx, m)
default:
// Dropped: a message nothing understands will not be understood on the next attempt
// either, and asking for it again would spin.
@@ -611,6 +633,25 @@ func (s *Server) sourceMoved(ctx context.Context, m Control) {
_ = m.Took()
}
// pullUpdated asks for a pull request's merge check. Taken whatever happens: a check that could not be
// asked is said here, and the next push to the pull request asks again.
func (s *Server) pullUpdated(ctx context.Context, m Control) {
var p PullUpdated
if err := json.Unmarshal(m.Body(), &p); err != nil || p.Commit == "" || p.Repo == "" {
s.log.Printf("a pull request's announcement could not be read or named no repository or head; ignored")
_ = m.Took()
return
}
if s.checker == nil {
_ = m.Took()
return
}
if err := s.checker.PullUpdated(ctx, p); err != nil {
s.log.Printf("%s/%s#%d at %.8s could not be asked to be checked: %v", p.Owner, p.Repo, p.Number, p.Commit, err)
}
_ = m.Took()
}
// saysWhatItDid states what a machine now runs, or what it would not take, as a fact on the bus
// (novox/hq ADR 0134).
//
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# The merge check of the controller (novox/hq to-be 45 §9), run by the build seat on every pull request
# before it merges — and by hand: `MESH_FACTS=facts.json MESH_GATE_POSTGRES=… MESH_TEST_POSTGRES=…
# MESH_TEST_NATS=… sh merge-check.sh`.
#
# The build seat clones this repository with the catalogue and the host beside it (the tests read them
# there), reads the facts snapshot the controller keeps, and raises a throwaway store and bus of the
# versions the mesh runs; this says what is judged with them:
#
# 1. formatted and vetted;
# 2. the merge gate, judged by THIS change's controller: every machine of the snapshot composed with
# it and validated by the node-engine's own validator, against the mesh as it is;
# 3. the whole suite, the replays among it, against that store and that bus, one package at a time
# because the live tests share one bus's fixed names.
#
# Fails on the first that fails. The gate's verdict is written where MESH_CHECK_VERDICT says, so the
# pull request is told the gate's own words.
set -eu
export GOFLAGS=-mod=vendor GOPROXY=off CGO_ENABLED=0
unformatted=$(gofmt -l cmd internal examples)
if [ -n "$unformatted" ]; then
echo "not gofmt'd:"
echo "$unformatted"
exit 1
fi
go vet ./...
judge="${MESH_CHECK_BESIDE:-${TMPDIR:-/tmp}}/bin/judge"
go build -o "$judge" ./cmd/mesh-controller
"$judge" merge-gate --facts "$MESH_FACTS" --store "$MESH_GATE_POSTGRES" \
--repository "${MESH_CHECK_REPOSITORY:-novox/mesh-controller}" --tree . \
--changed "${MESH_CHECK_CHANGED:-}" --json > "${MESH_CHECK_VERDICT:-/dev/null}"
go test -p 1 -timeout 25m ./...
BIN
View File
Binary file not shown.